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 <steipete@gmail.com>
This commit is contained in:
juyaohuidt
2026-07-19 06:18:16 +08:00
committed by GitHub
parent c9aa849936
commit 69ad5fc7fb
3 changed files with 46 additions and 11 deletions
@@ -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" });
});
});
+2 -5
View File
@@ -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;
+3 -6
View File
@@ -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;
}