From 69ad5fc7fb35ffddaef406fbbd6c20f3f1251a61 Mon Sep 17 00:00:00 2001 From: juyaohuidt Date: Sun, 19 Jul 2026 06:18:16 +0800 Subject: [PATCH] fix(wizard): treat not-directory paths as missing in migration snapshots (#109161) * fix(wizard): treat not-directory paths as missing in migration snapshots The setup migration snapshot and recovery paths checked for ENOENT only when catching fs errors. ENOTDIR (returned when a path component that should be a directory is a file) was not recognized, so a not-directory migration root made fs.readdir throw instead of resolving to no recovery record, and interrupted the onboarding migration snapshot hash. Migrate both local isMissingPathError helpers to the shared isNotFoundPathError guard (matches ENOENT and ENOTDIR), following the same migration applied to plugins in #107691. * test(wizard): cover ENOTDIR migration snapshots --------- Co-authored-by: Peter Steinberger --- src/wizard/setup.migration-recovery.test.ts | 41 +++++++++++++++++++++ src/wizard/setup.migration-recovery.ts | 7 +--- src/wizard/setup.migration-snapshot.ts | 9 ++--- 3 files changed, 46 insertions(+), 11 deletions(-) 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; }