diff --git a/src/wizard/setup.migration-recovery.test.ts b/src/wizard/setup.migration-recovery.test.ts index 356338bbcfc8..143be298832a 100644 --- a/src/wizard/setup.migration-recovery.test.ts +++ b/src/wizard/setup.migration-recovery.test.ts @@ -433,6 +433,15 @@ describe("setup migration recovery", () => { await expect(buildSetupMigrationPlanSourceSnapshot(buildPlan(root))).resolves.not.toBe(initial); }); + it("treats source paths beneath a non-directory as missing", async () => { + const root = await makeTempRoot(); + const plan = buildPlan(root); + const missingSnapshot = await buildSetupMigrationPlanSourceSnapshot(plan); + await fs.writeFile(path.join(root, "hermes"), "not a directory"); + + await expect(buildSetupMigrationPlanSourceSnapshot(plan)).resolves.toBe(missingSnapshot); + }); + it("snapshots meaningful target changes but ignores migration reports", async () => { const stateDir = await makeTempRoot(); const workspaceDir = path.join(stateDir, "workspace"); @@ -491,6 +500,21 @@ describe("setup migration recovery", () => { ).resolves.not.toBe(workspaceChanged); }); + it("treats target paths beneath a non-directory as missing", async () => { + const stateDir = await makeTempRoot(); + const workspaceDir = path.join(stateDir, "workspace"); + const missingSnapshot = await buildSetupMigrationTargetSnapshot({ + config: {}, + stateDir, + workspaceDir, + }); + await fs.writeFile(workspaceDir, "not a directory"); + + await expect( + buildSetupMigrationTargetSnapshot({ config: {}, stateDir, workspaceDir }), + ).resolves.toBe(missingSnapshot); + }); + it("fails closed when the newest recovery record is malformed", async () => { const stateDir = await makeTempRoot(); const reportDir = path.join(stateDir, "migration", "hermes", "2026-07-13T10-00-00Z"); @@ -506,4 +530,21 @@ describe("setup migration recovery", () => { }), ).rejects.toThrow("Invalid onboarding migration recovery record"); }); + + it("treats a not-directory migration root as no recovery record", async () => { + // A file at the provider report path makes readdir return ENOTDIR; recovery + // treats that unavailable child path like a missing report directory. + const stateDir = await makeTempRoot(); + await fs.mkdir(path.join(stateDir, "migration"), { recursive: true }); + await fs.writeFile(path.join(stateDir, "migration", "hermes"), "not a directory"); + + await expect( + resolveSetupMigrationRecovery({ + stateDir, + providerId: "hermes", + workspaceDir: path.join(stateDir, "workspace"), + targetSnapshotHash: BEFORE_HASH, + }), + ).resolves.toEqual({ kind: "none" }); + }); }); diff --git a/src/wizard/setup.migration-recovery.ts b/src/wizard/setup.migration-recovery.ts index 47fd346a2631..27fbbc9404ab 100644 --- a/src/wizard/setup.migration-recovery.ts +++ b/src/wizard/setup.migration-recovery.ts @@ -3,6 +3,7 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { readDurableJsonFile, writeJsonAtomic } from "../infra/json-files.js"; +import { isNotFoundPathError } from "../infra/path-guards.js"; import { summarizeMigrationItems } from "../plugin-sdk/migration.js"; import type { MigrationApplyResult, MigrationItem, MigrationPlan } from "../plugins/types.js"; import { resolveUserPath } from "../utils.js"; @@ -153,10 +154,6 @@ function isSetupMigrationAttempt(value: unknown): value is SetupMigrationAttempt ); } -function isMissingPathError(error: unknown): boolean { - return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT"; -} - export function createSetupMigrationAttempt( params: SetupMigrationIdentity & { plan: MigrationPlan; @@ -285,7 +282,7 @@ async function findLatestSetupMigrationAttempt(params: { try { entries = await fs.readdir(providerReportRoot, { withFileTypes: true }); } catch (error) { - if (isMissingPathError(error)) { + if (isNotFoundPathError(error)) { return undefined; } throw error; diff --git a/src/wizard/setup.migration-snapshot.ts b/src/wizard/setup.migration-snapshot.ts index bd945ad12d1c..c739a5e46abd 100644 --- a/src/wizard/setup.migration-snapshot.ts +++ b/src/wizard/setup.migration-snapshot.ts @@ -5,6 +5,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { withFileLock } from "../infra/file-lock.js"; +import { isNotFoundPathError } from "../infra/path-guards.js"; import type { MigrationPlan } from "../plugins/types.js"; import { resolveUserPath } from "../utils.js"; @@ -25,10 +26,6 @@ const MEANINGFUL_WORKSPACE_ENTRIES = [ ] as const; const MEANINGFUL_STATE_ENTRIES = ["credentials", "sessions", "agents"] as const; -function isMissingPathError(error: unknown): boolean { - return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT"; -} - function canonicalizeJsonValue(value: unknown): unknown { if (Array.isArray(value)) { return value.map(canonicalizeJsonValue); @@ -149,7 +146,7 @@ async function hashTargetPath( try { stat = await fs.lstat(candidate); } catch (error) { - if (isMissingPathError(error)) { + if (isNotFoundPathError(error)) { hash.update(`missing:${snapshotPath}\0`); return; } @@ -187,7 +184,7 @@ async function hashSourcePath( try { stat = await fs.lstat(candidate); } catch (error) { - if (isMissingPathError(error)) { + if (isNotFoundPathError(error)) { hash.update(`missing:${snapshotPath}\0`); return; }