fix(setup): preserve current recovery snapshots and decoding failures

This commit is contained in:
morluto 2026-10-13 14:51:55 +08:00
parent 5f0f25ad61
commit f6775a3669
13 changed files with 835 additions and 142 deletions

View File

@ -57,6 +57,10 @@ Hopper installation or any file write. Declining or pressing Ctrl-C makes no
changes. Agent configuration writes preserve unrelated entries and comments,
create backups, use atomic replacement, and verify their result. Setup and
uninstall retain an existing `.rea.backup` rather than replacing the first snapshot.
If that snapshot differs from the current configuration, setup previews and
preserves a verified `.rea.backup.<sha256>.recovery` copy of the current bytes.
Both snapshots remain available; a failed rollback reports the current recovery
copy's path and the restore failure.
Setup also retains valid environment overrides in REA's existing server entry,
such as `REA_MCP_INPUT_SCHEMA_PROFILE` and `REA_MCP_MAX_RESPONSE_BYTES`.
Detected provider settings replace only matching environment keys.

View File

@ -19,6 +19,17 @@ export interface RegularFileReadOptions {
readonly resources?: ArtifactResourceScope;
}
export class InvalidUtf8FileError extends TypeError {
readonly _tag = "InvalidUtf8FileError";
constructor(
readonly path: string,
cause: unknown,
) {
super(`File is not valid UTF-8: ${path}`, { cause });
this.name = this._tag;
}
}
export type RegularFileReadOutcome<Value> =
| { readonly kind: "completed"; readonly value: Value }
| { readonly kind: "failed"; readonly cause: unknown };
@ -142,7 +153,34 @@ const withRegularFileOwned = async <Value>(
export const readRegularFile = (
path: string,
options: RegularFileReadOptions = {},
): Promise<Buffer> =>
): Promise<Buffer> => readRegularFileContent(path, (bytes) => bytes, options);
/** Read valid UTF-8 without replacing source bytes or stripping an initial BOM. */
export const readRegularFileText = (
path: string,
options: RegularFileReadOptions = {},
): Promise<string> =>
readRegularFileContent(
path,
(bytes) => {
try {
return new TextDecoder("utf-8", {
fatal: true,
ignoreBOM: true,
}).decode(bytes);
} catch (cause: unknown) {
if (!(cause instanceof TypeError)) throw cause;
throw new InvalidUtf8FileError(path, cause);
}
},
options,
);
const readRegularFileContent = <Value>(
path: string,
decode: (bytes: Buffer) => Value,
options: RegularFileReadOptions,
): Promise<Value> =>
withRegularFile(
path,
async (handle, stats) => {
@ -153,30 +191,7 @@ export const readRegularFile = (
);
if (bytes === undefined || bytes.length !== stats.size)
throw new RegularFileChangedError(path);
return bytes;
return decode(bytes);
},
options,
);
/** Read one admitted regular file as UTF-8 without waiting on a pipe. */
export const readRegularFileText = async (
path: string,
options: RegularFileReadOptions = {},
): Promise<string> => {
try {
return (await readRegularFile(path, options)).toString("utf8");
} catch (cause: unknown) {
if (
cause instanceof RegularFileCleanupFailure &&
cause.outcome.kind === "completed" &&
Buffer.isBuffer(cause.outcome.value)
)
throw new RegularFileCleanupFailure(
cause.path,
cause.owner,
{ kind: "completed", value: cause.outcome.value.toString("utf8") },
cause.cleanupCause,
);
throw cause;
}
};

View File

@ -54,6 +54,40 @@ describe("setup workflow", () => {
expect(result.remediation).toContain("could not be updated");
});
it("reports a failed config rollback with its destination and recovery backup", async () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.clients = [{ name: "codex", configPath: "/fixture/codex.json" }];
host.clientResults.set("codex", {
status: "failed",
reason: "rollback",
backupPath: "/fixture/codex.json.rea.backup",
failure: {
kind: "restore",
path: "/fixture/codex.json",
detail: "permission denied",
},
});
const result = await runSetup(
{ ...options(true), clientIds: ["codex"] },
host,
);
expect(result.clients.codex).toEqual({
status: "failed",
reason: "rollback",
backupPath: "/fixture/codex.json.rea.backup",
failure: {
kind: "restore",
path: "/fixture/codex.json",
detail: "permission denied",
},
});
expect(result.remediation).toContain("permission denied");
expect(result.remediation).toContain("/fixture/codex.json.rea.backup");
});
it("explains skill installation recovery", async () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";

View File

@ -19,6 +19,7 @@ import {
} from "./ClientConfigurationDocument.js";
import { dshDocument, DshCordisDocument } from "./DshCordisPatch.js";
import { constants as fsConstants } from "node:fs";
import { createHash } from "node:crypto";
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import writeFileAtomic from "write-file-atomic";
@ -29,6 +30,7 @@ import { npxRegistrationCommand } from "./ClientRegistrationIdentity.js";
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
import {
readRegularFile,
readRegularFileText,
RegularFileCleanupFailure,
} from "./RegularFileRead.js";
@ -40,6 +42,26 @@ import type {
} from "./SetupTypes.js";
import type { SetupClient } from "./SupportedClients.js";
export class ClientConfigurationCleanupRollbackFailure extends RegularFileCleanupFailure<string> {
constructor(
failure: RegularFileCleanupFailure<string>,
readonly configurationFailure: Extract<
ClientConfigurationResult,
{ readonly status: "failed"; readonly reason: "rollback" }
>,
) {
super(
failure.path,
failure.owner,
failure.outcome,
new AggregateError(
[failure.cleanupCause, new Error(configurationFailure.failure.detail)],
`Configuration readback cleanup and rollback failed: ${configurationFailure.failure.path}`,
),
);
}
}
/** Configure one supported client's native stdio MCP registration shape. */
export const configureClientConfiguration = (
client: SetupClient,
@ -167,11 +189,23 @@ const persistClientConfiguration = async ({
}: ClientConfigurationUpdate): Promise<ClientConfigurationResult> => {
const backupPath =
original === undefined ? undefined : `${client.configPath}.rea.backup`;
if (
backupPath !== undefined &&
!(await preserveConfigBackup(transactionPath, backupPath))
)
return { status: "failed", reason: "backup" };
let recoveryBackupPath: string | undefined;
if (backupPath !== undefined && original !== undefined) {
const selection = await selectCurrentRecoveryBackup(backupPath, original);
if (!selection.ok) return { status: "failed", reason: "backup" };
if (
!(await preserveConfigBackup(transactionPath, selection.value.backupPath))
)
return { status: "failed", reason: "backup" };
const verified = await selectCurrentRecoveryBackup(backupPath, original);
if (
!verified.ok ||
verified.value.backupPath !== selection.value.backupPath ||
!verified.value.present
)
return { status: "failed", reason: "backup" };
recoveryBackupPath = verified.value.backupPath;
}
// A native OpenCode V2 entry replaces REA's legacy V1 entry, which would
// otherwise conflict with it.
const legacyPath = legacyClientServerPath(
@ -239,19 +273,39 @@ const persistClientConfiguration = async ({
!clientConfigurationValuesEqual(readback.servers[name], entry),
)
) {
await restoreConfig(transactionPath, original);
return { status: "failed", reason: "readback" };
return rollbackOrReadbackFailure(
transactionPath,
original,
recoveryBackupPath,
);
}
} catch (cause: unknown) {
if (cause instanceof RegularFileCleanupFailure) throw cause;
if (cause instanceof RegularFileCleanupFailure) {
const rollback = await restoreConfig(transactionPath, original);
if (!rollback.ok)
throw new ClientConfigurationCleanupRollbackFailure(cause, {
status: "failed",
reason: "rollback",
...(recoveryBackupPath === undefined
? {}
: { backupPath: recoveryBackupPath }),
failure: rollback.error,
});
throw cause;
}
// Readback failure restores the transaction before reporting.
void cause;
await restoreConfig(transactionPath, original);
return { status: "failed", reason: "readback" };
return rollbackOrReadbackFailure(
transactionPath,
original,
recoveryBackupPath,
);
}
return {
status: "configured",
...(backupPath === undefined ? {} : { backupPath }),
...(recoveryBackupPath === undefined
? {}
: { backupPath: recoveryBackupPath }),
};
};
@ -406,9 +460,18 @@ export const inspectClientConfiguration = async (
"The existing configuration is malformed. Repair it before setup applies any other changes.",
};
}
const backupSelection = await selectCurrentRecoveryBackup(
`${client.configPath}.rea.backup`,
original,
);
if (!backupSelection.ok)
return {
status: "invalid",
remediation: backupSelection.error.detail,
};
return {
status: "update",
backupPath: `${client.configPath}.rea.backup`,
backupPath: backupSelection.value.backupPath,
};
};
@ -473,19 +536,101 @@ const preserveConfigBackup = async (
}
};
interface RecoveryBackupSelection {
readonly backupPath: string;
readonly present: boolean;
}
interface RecoveryBackupSelectionFailure {
readonly kind: "unreadable" | "occupied";
readonly path: string;
readonly detail: string;
}
const selectCurrentRecoveryBackup = async (
firstBackupPath: string,
original: string,
): Promise<Result<RecoveryBackupSelection, RecoveryBackupSelectionFailure>> => {
const expected = Buffer.from(original, "utf8");
let firstBackup: Buffer | undefined;
try {
firstBackup = await readRegularFile(firstBackupPath, {
symlinks: "reject",
});
} catch (cause: unknown) {
if (cause instanceof RegularFileCleanupFailure) throw cause;
if (!isMissing(cause))
return err({
kind: "unreadable",
path: firstBackupPath,
detail: `The recovery backup could not be checked: ${errorDetail(cause)}`,
});
}
if (firstBackup === undefined)
return ok({ backupPath: firstBackupPath, present: false });
if (firstBackup.equals(expected))
return ok({ backupPath: firstBackupPath, present: true });
const digest = createHash("sha256").update(original, "utf8").digest("hex");
const recoveryPath = `${firstBackupPath}.${digest}.recovery`;
let recovery: Buffer | undefined;
try {
recovery = await readRegularFile(recoveryPath, { symlinks: "reject" });
} catch (cause: unknown) {
if (cause instanceof RegularFileCleanupFailure) throw cause;
if (!isMissing(cause))
return err({
kind: "unreadable",
path: recoveryPath,
detail: `The recovery backup could not be checked: ${errorDetail(cause)}`,
});
}
if (recovery !== undefined && !recovery.equals(expected))
return err({
kind: "occupied",
path: recoveryPath,
detail: `The recovery path already contains different data: ${recoveryPath}`,
});
return ok({ backupPath: recoveryPath, present: recovery !== undefined });
};
const restoreConfig = async (
path: string,
original: string | undefined,
): Promise<void> => {
): Promise<
Result<
void,
{ readonly kind: "restore"; readonly path: string; readonly detail: string }
>
> => {
try {
if (original === undefined) await rm(path, { force: true });
else await writeFile(path, original, { encoding: "utf8", mode: 0o600 });
return ok(undefined);
} catch (cause: unknown) {
// The backup remains available for the remediation reported by setup.
void cause;
return err({ kind: "restore", path, detail: errorDetail(cause) });
}
};
const rollbackOrReadbackFailure = async (
targetPath: string,
original: string | undefined,
backupPath: string | undefined,
): Promise<ClientConfigurationResult> => {
const rollback = await restoreConfig(targetPath, original);
return rollback.ok
? { status: "failed", reason: "readback" }
: {
status: "failed",
reason: "rollback",
...(backupPath === undefined ? {} : { backupPath }),
failure: rollback.error,
};
};
const errorDetail = (cause: unknown): string =>
cause instanceof Error ? cause.message : String(cause);
/** Whether REA's entry matches, no legacy entry remains, and no client disable list suppresses it. */
const registrationCurrent = (
parsed: ClientConfigurationDocument,

View File

@ -6,15 +6,19 @@ import type {
} from "./SetupTypes.js";
import type { SetupClient } from "./SupportedClients.js";
const failedConfigurationMessage = (
reason: "path" | "backup" | "write" | "readback",
export const failedConfigurationMessage = (
result: Extract<ClientConfigurationResult, { readonly status: "failed" }>,
): string => {
if (reason === "path")
if (result.reason === "path")
return "Agent configuration path could not be safely verified. Check its permissions and, if it is a symbolic link, verify that the link resolves to a regular file owned by the current user, then rerun setup.";
if (reason === "backup")
if (result.reason === "backup")
return "Agent configuration could not be backed up, so no change was made. Check file permissions, then rerun setup.";
if (reason === "write")
if (result.reason === "write")
return "Agent configuration could not be updated. Check file permissions, then rerun setup.";
if (result.reason === "rollback")
return result.backupPath === undefined
? `Agent configuration rollback failed at ${result.failure.path}: ${result.failure.detail}; the failed first-run file may remain. Remove or repair it before rerunning setup.`
: `Agent configuration rollback failed at ${result.failure.path}: ${result.failure.detail}. Inspect the preserved backup at ${result.backupPath} and restore the intended configuration, then rerun setup.`;
return "Agent configuration could not be verified after writing. Repair the configuration file or restore its `.rea.backup`, then rerun setup.";
};
@ -40,12 +44,12 @@ export const configureDetectedClients = async (options: {
);
options.clients[client.name] = result;
if (result.status === "failed") {
failure ??= failedConfigurationMessage(result.reason);
failure ??= failedConfigurationMessage(result);
options.onProgress?.({
actionId,
label,
state: "failed",
detail: failedConfigurationMessage(result.reason),
detail: failedConfigurationMessage(result),
});
} else if (result.status === "configured") {
options.appliedActions.push(`configured_${client.name}`);

View File

@ -33,9 +33,11 @@ import {
} from "./SetupSkill.js";
import {
clientConfigurationAligned,
ClientConfigurationCleanupRollbackFailure,
configureClientConfiguration,
inspectClientConfiguration,
} from "./SetupClientConfiguration.js";
import { failedConfigurationMessage } from "./SetupClients.js";
import { setupInstallFailure } from "./SetupInstallFailure.js";
import {
RegularFileCleanupFailure,
@ -275,8 +277,12 @@ const runClientConfigurationOperation = async <Value>(
cleanup.outcome.kind === "failed"
? cleanup.outcome.cause
: cause.cleanupCause;
const rollbackMessage =
cause instanceof ClientConfigurationCleanupRollbackFailure
? ` ${failedConfigurationMessage(cause.configurationFailure)}`
: "";
throw configurationCleanupError(
`Could not safely access client configuration at ${cause.path}: ${errorMessage(primary)}`,
`Could not safely access client configuration at ${cause.path}: ${errorMessage(primary)}.${rollbackMessage}`,
cause,
cleanup.cleanup,
);

View File

@ -320,5 +320,5 @@ const clientConfigurationDetail = (
? " If this plan installs Hopper, REA will add HOPPER_LAUNCHER_PATH using the launcher path reported by that installation."
: " The HOPPER_LAUNCHER_PATH value shown above is the current fallback; if this plan installs Hopper, REA will replace it with the launcher path reported by that installation."
: "";
return `Add the REA MCP registration for ${client}; preserve unrelated configuration. Command: ${command.map((part) => JSON.stringify(part)).join(" ")}.${environmentDetail}${hopperDetail}`;
return `Add the REA MCP registration for ${client}; preserve unrelated configuration. The backup path reflects the configuration inspected for this plan; if that file changes before execution, setup will select a recovery path for its current contents. Command: ${command.map((part) => JSON.stringify(part)).join(" ")}.${environmentDetail}${hopperDetail}`;
};

View File

@ -26,6 +26,16 @@ export type ClientConfigurationResult =
| {
readonly status: "failed";
readonly reason: "path" | "backup" | "write" | "readback";
}
| {
readonly status: "failed";
readonly reason: "rollback";
readonly backupPath?: string;
readonly failure: {
readonly kind: "restore";
readonly path: string;
readonly detail: string;
};
};
/** Read-only state used to block unsafe setup plans before any mutation. */

View File

@ -25,7 +25,11 @@ import {
import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
import { dshDocument } from "./DshCordisPatch.js";
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
import { readRegularFileText } from "./RegularFileRead.js";
import {
InvalidUtf8FileError,
RegularFileCleanupFailure,
readRegularFileText,
} from "./RegularFileRead.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import {
manualRegistrationRemediation,
@ -269,13 +273,17 @@ const readClientConfiguration = async (
try {
original = await fileSystem.readText(transactionPath);
} catch (cause: unknown) {
const readFailure =
cause instanceof RegularFileCleanupFailure ? cause.cause : cause;
return itemRead(
isMissing(cause)
? item(client.name, "skipped", "Configuration does not exist.")
: item(
client.name,
"failed",
"Configuration could not be read. Check file permissions, then rerun uninstall.",
readFailure instanceof InvalidUtf8FileError
? "Configuration is not valid UTF-8 and was not changed. Repair it, then rerun uninstall."
: "Configuration could not be read. Check file permissions, then rerun uninstall.",
),
);
}

View File

@ -6,6 +6,17 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
const cases = [
{
client: "cursor",
path: ".cursor/mcp.json",
text: "malformed UTF-8",
bytes: Buffer.concat([
Buffer.from('{"label":"'),
Buffer.from([0xff]),
Buffer.from('"}'),
]),
diagnostic: "not valid UTF-8",
},
{
client: "cursor",
path: ".cursor/mcp.json",
@ -92,7 +103,9 @@ for (const scenario of cases)
);
const config = join(home, scenario.path);
await mkdir(dirname(config), { recursive: true });
await writeFile(config, scenario.text);
const original =
"bytes" in scenario ? scenario.bytes : Buffer.from(scenario.text);
await writeFile(config, original);
const environment = {
HOME: home,
USERPROFILE: home,
@ -146,7 +159,7 @@ for (const scenario of cases)
});
if (!("syntax" in scenario))
expect(uninstall.stdout).not.toContain("not valid JSON");
expect(await readFile(config, "utf8")).toBe(scenario.text);
expect(await readFile(config)).toEqual(original);
expect(await readdir(dirname(config))).toEqual([
scenario.path.split("/").at(-1),
]);

View File

@ -1,13 +1,91 @@
import { chmod, readFile, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { parse as parseToml } from "smol-toml";
import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js";
import {
configureClientConfiguration,
inspectClientConfiguration,
} from "../../../src/application/SetupClientConfiguration.js";
import { systemUninstallHost } from "../../../src/application/Uninstall.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
it("accepts a literal UTF-8 replacement character without changing its value", async () => {
const root = await createTestTempDirectory("rea-client-config-utf8-");
const configPath = join(root, "config.json");
const original = Buffer.concat([
Buffer.from('{"label":"'),
Buffer.from("\uFFFD"),
Buffer.from('"}'),
]);
await writeFile(configPath, original);
const result = await configureClientConfiguration(
{ name: "cursor", format: "json", configPath },
{},
["rea", "mcp"],
);
expect(result.status).toBe("configured");
expect(await readFile(`${configPath}.rea.backup`)).toEqual(original);
expect(JSON.parse(await readFile(configPath, "utf8")).label).toBe("\uFFFD");
});
it("previews and creates a current recovery backup while preserving the first backup", async () => {
const root = await createTestTempDirectory("rea-client-current-recovery-");
const configPath = join(root, "config.json");
const firstOriginal = '{"keep":"first"}\n';
const currentOriginal = '{"keep":"current"}\n';
const client = { name: "cursor", format: "json", configPath } as const;
await writeFile(configPath, currentOriginal);
await writeFile(`${configPath}.rea.backup`, firstOriginal);
const recoveryPath = `${configPath}.rea.backup.${createHash("sha256")
.update(currentOriginal, "utf8")
.digest("hex")}.recovery`;
expect(await inspectClientConfiguration(client, {}, ["rea", "mcp"])).toEqual({
status: "update",
backupPath: recoveryPath,
});
expect(
await configureClientConfiguration(client, {}, ["rea", "mcp"]),
).toEqual({ status: "configured", backupPath: recoveryPath });
expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(
firstOriginal,
);
expect(await readFile(recoveryPath, "utf8")).toBe(currentOriginal);
});
it("blocks configuration when the planned content-addressed backup is occupied", async () => {
const root = await createTestTempDirectory("rea-client-recovery-conflict-");
const configPath = join(root, "config.json");
const firstOriginal = '{"keep":"first"}\n';
const currentOriginal = '{"keep":"current"}\n';
const client = { name: "cursor", format: "json", configPath } as const;
await writeFile(configPath, currentOriginal);
await writeFile(`${configPath}.rea.backup`, firstOriginal);
const recoveryPath = `${configPath}.rea.backup.${createHash("sha256")
.update(currentOriginal, "utf8")
.digest("hex")}.recovery`;
await writeFile(recoveryPath, "occupied by other data");
expect(
await inspectClientConfiguration(client, {}, ["rea", "mcp"]),
).toMatchObject({
status: "invalid",
remediation: expect.stringContaining(recoveryPath),
});
expect(
await configureClientConfiguration(client, {}, ["rea", "mcp"]),
).toEqual({ status: "failed", reason: "backup" });
expect(await readFile(configPath, "utf8")).toBe(currentOriginal);
expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(
firstOriginal,
);
expect(await readFile(recoveryPath, "utf8")).toBe("occupied by other data");
});
describe("client configuration write failures", () => {
it.skipIf(process.platform === "win32" || process.getuid?.() === 0).each([
["json", configureClientConfiguration],

View File

@ -11,6 +11,7 @@ import {
} from "../../../src/application/SetupHost.js";
import { RegularFileCleanupFailure } from "../../../src/application/RegularFileRead.js";
import { configureDetectedClients } from "../../../src/application/SetupClients.js";
import { systemUninstallHost } from "../../../src/application/Uninstall.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
vi.mock("node:fs/promises", async (importOriginal) => {
@ -31,6 +32,61 @@ beforeEach(async () => {
afterEach(() => openMock.mockReset());
const holdReadHandle = (path: string, reason: string) => {
const closeAttempts: object[] = [];
let released = false;
openMock.mockImplementation(async (...args) => {
const handle = await actualFs.open(...args);
if (String(args[0]) !== path) return handle;
const nativeClose = handle.close.bind(handle);
vi.spyOn(handle, "close").mockImplementation(async () => {
closeAttempts.push(handle);
if (!released) throw new Error(reason);
await nativeClose();
});
return handle;
});
return {
closeAttempts,
release: () => {
released = true;
},
};
};
it("retains invalid UTF-8 diagnostics and the uninstall read owner when close fails", async () => {
const home = await createTestTempDirectory("rea-uninstall-utf8-close-");
const path = join(home, "client.json");
const original = Buffer.from([0xff]);
await writeFile(path, original);
const { closeAttempts, release } = holdReadHandle(
path,
"held uninstall handle",
);
const host = systemUninstallHost(home, undefined, { HOME: home }, "linux");
try {
expect(
await host.removeClient({
name: "cursor",
format: "json",
configPath: path,
}),
).toMatchObject({
status: "failed",
detail: expect.stringContaining("not valid UTF-8"),
});
expect(await readFile(path)).toEqual(original);
expect(closeAttempts).toHaveLength(2);
release();
expect(await host.close?.()).toBeUndefined();
expect(closeAttempts).toHaveLength(3);
expect(closeAttempts[2]).toBe(closeAttempts[0]);
} finally {
release();
await host.close?.();
}
});
describe("ZCode fallback cleanup ownership", () => {
it.each(["preflight", "configure"])(
"retains a failed ZCode fallback close during %s until host cleanup",
@ -49,19 +105,10 @@ describe("ZCode fallback cleanup ownership", () => {
await mkdir(dirname(fallback), { recursive: true });
const original = '{"mcpServers":{"other":{"command":"other"}}}\n';
await writeFile(fallback, original);
const closeAttempts: object[] = [];
let allowClose = false;
openMock.mockImplementation(async (...args) => {
const handle = await actualFs.open(...args);
if (String(args[0]) !== fallback) return handle;
const nativeClose = handle.close.bind(handle);
vi.spyOn(handle, "close").mockImplementation(async () => {
closeAttempts.push(handle);
if (!allowClose) throw new Error("held fallback handle");
await nativeClose();
});
return handle;
});
const { closeAttempts, release } = holdReadHandle(
fallback,
"held fallback handle",
);
try {
const action =
operation === "preflight"
@ -79,12 +126,12 @@ describe("ZCode fallback cleanup ownership", () => {
await expect(readFile(client.configPath)).rejects.toMatchObject({
code: "ENOENT",
});
allowClose = true;
release();
expect(await host.close?.()).toBeUndefined();
expect(closeAttempts).toHaveLength(3);
expect(closeAttempts[2]).toBe(closeAttempts[0]);
} finally {
allowClose = true;
release();
await host.close?.();
}
},
@ -92,83 +139,87 @@ describe("ZCode fallback cleanup ownership", () => {
});
describe("setup client configuration cleanup ownership", () => {
it("stops client configuration and retries the same alignment handle from host close", async () => {
const home = await createTestTempDirectory("rea-setup-config-close-");
const environment = {
HOME: home,
XDG_CONFIG_HOME: join(home, "xdg"),
};
const host = systemSetupHost(
{ platform: "linux", homeDirectory: home } as DoctorHost,
environment,
);
const client = (await host.supportedClients()).find(
({ name }) => name === "vscode",
);
if (client === undefined) throw new Error("VS Code setup client missing");
await mkdir(dirname(client.configPath), { recursive: true });
const original = Buffer.from('{"servers": {"other": {}}}\n');
await writeFile(client.configPath, original);
it.each([
["valid UTF-8", Buffer.from('{"servers": {"other": {}}}\n')],
["malformed UTF-8", Buffer.from([0xff])],
] as const)(
"stops configuration and retains the %s read owner until host close",
async (encoding, original) => {
const home = await createTestTempDirectory("rea-setup-config-close-");
const environment = {
HOME: home,
XDG_CONFIG_HOME: join(home, "xdg"),
};
const host = systemSetupHost(
{ platform: "linux", homeDirectory: home } as DoctorHost,
environment,
);
const client = (await host.supportedClients()).find(
({ name }) => name === "vscode",
);
if (client === undefined) throw new Error("VS Code setup client missing");
await mkdir(dirname(client.configPath), { recursive: true });
await writeFile(client.configPath, original);
const closeAttempts: object[] = [];
let allowClose = false;
openMock.mockImplementation(async (...args) => {
const handle = await actualFs.open(...args);
if (String(args[0]) !== client.configPath) return handle;
const nativeClose = handle.close.bind(handle);
vi.spyOn(handle, "close").mockImplementation(async () => {
closeAttempts.push(handle);
if (!allowClose) throw new Error("test holds the config handle open");
await nativeClose();
});
return handle;
});
const configure = vi.spyOn(host, "configureClient");
const { closeAttempts, release } = holdReadHandle(
client.configPath,
"test holds the config handle open",
);
const configure = vi.spyOn(host, "configureClient");
try {
const failure = await filterClientsNeedingConfigure(host, [client], {}, [
"rea",
"mcp",
])
.then((detectedClients) =>
configureDetectedClients({
host,
detectedClients,
providerEnvironment: {},
command: ["rea", "mcp"],
clients: {},
appliedActions: [],
}),
try {
const failure = await filterClientsNeedingConfigure(
host,
[client],
{},
["rea", "mcp"],
)
.then(
() => undefined,
(cause: unknown) => cause,
);
expect(failure).toBeInstanceOf(ConfigurationError);
expect(failure).toMatchObject({
_tag: "ConfigurationError",
cause: expect.any(RegularFileCleanupFailure),
cleanup: {
resources: [client.configPath],
reason: expect.stringContaining("test holds the config handle open"),
},
});
expect(configure).not.toHaveBeenCalled();
expect(closeAttempts).toHaveLength(2);
expect(closeAttempts[0]).toBe(closeAttempts[1]);
expect(await readFile(client.configPath)).toEqual(original);
.then((detectedClients) =>
configureDetectedClients({
host,
detectedClients,
providerEnvironment: {},
command: ["rea", "mcp"],
clients: {},
appliedActions: [],
}),
)
.then(
() => undefined,
(cause: unknown) => cause,
);
expect(failure).toBeInstanceOf(ConfigurationError);
if (encoding === "malformed UTF-8")
expect(failure).toMatchObject({
cause: {
outcome: { kind: "failed", cause: expect.any(TypeError) },
},
});
expect(failure).toMatchObject({
_tag: "ConfigurationError",
cause: expect.any(RegularFileCleanupFailure),
cleanup: {
resources: [client.configPath],
reason: expect.stringContaining(
"test holds the config handle open",
),
},
});
expect(configure).not.toHaveBeenCalled();
expect(closeAttempts).toHaveLength(2);
expect(closeAttempts[0]).toBe(closeAttempts[1]);
expect(await readFile(client.configPath)).toEqual(original);
allowClose = true;
const close = host.close;
if (close === undefined) throw new Error("Setup host close is missing");
expect(await close()).toBeUndefined();
expect(closeAttempts).toHaveLength(3);
expect(closeAttempts[2]).toBe(closeAttempts[0]);
} finally {
allowClose = true;
await host.close?.();
}
});
release();
expect(await host.close?.()).toBeUndefined();
expect(closeAttempts).toHaveLength(3);
expect(closeAttempts[2]).toBe(closeAttempts[0]);
} finally {
release();
await host.close?.();
}
},
);
it("waits for an admitted configuration read and rejects later admissions", async () => {
const home = await createTestTempDirectory("rea-setup-config-inflight-");

View File

@ -0,0 +1,325 @@
import { open, readFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { join } from "node:path";
import { afterEach, expect, it, onTestFinished, vi } from "vitest";
const state = vi.hoisted(() => ({
readbackFailed: false,
rejectRestore: false,
rejectRemove: false,
removeCopiedRecovery: false,
initialCleanupFailure: undefined as unknown,
readbackCleanupFailure: undefined as unknown,
}));
afterEach(() => {
state.readbackFailed = false;
state.rejectRestore = false;
state.rejectRemove = false;
state.removeCopiedRecovery = false;
state.initialCleanupFailure = undefined;
state.readbackCleanupFailure = undefined;
});
vi.mock("node:fs/promises", async (importOriginal) => {
const original = await importOriginal<typeof import("node:fs/promises")>();
return {
...original,
rm: (
...arguments_: Parameters<typeof original.rm>
): ReturnType<typeof original.rm> => {
if (state.rejectRemove && state.readbackFailed)
return Promise.reject(new Error("injected restore remove failure"));
return original.rm(...arguments_);
},
writeFile: (
...arguments_: Parameters<typeof original.writeFile>
): ReturnType<typeof original.writeFile> => {
if (state.rejectRestore && state.readbackFailed)
return Promise.reject(new Error("injected restore write failure"));
return original.writeFile(...arguments_);
},
copyFile: async (
...arguments_: Parameters<typeof original.copyFile>
): Promise<void> => {
await original.copyFile(...arguments_);
const destination = arguments_[1];
if (
state.removeCopiedRecovery &&
typeof destination === "string" &&
destination.endsWith(".recovery")
)
await original.rm(destination);
},
};
});
vi.mock("write-file-atomic", async (importOriginal) => {
const original = await importOriginal<{
default: typeof import("write-file-atomic");
}>();
return {
...original,
default: async (...arguments_: Parameters<typeof original.default>) => {
if (state.initialCleanupFailure !== undefined)
throw new Error("unexpected config write before initial read");
const result = await original.default(...arguments_);
state.readbackFailed = true;
return result;
},
};
});
vi.mock("../../src/application/RegularFileRead.js", async (importOriginal) => {
const original =
await importOriginal<
typeof import("../../src/application/RegularFileRead.js")
>();
return {
...original,
readRegularFileText: (
...arguments_: Parameters<typeof original.readRegularFileText>
) =>
state.initialCleanupFailure !== undefined
? Promise.reject(state.initialCleanupFailure)
: state.readbackFailed && state.readbackCleanupFailure !== undefined
? Promise.reject(state.readbackCleanupFailure)
: state.readbackFailed
? Promise.reject(new Error("injected readback failure"))
: original.readRegularFileText(...arguments_),
};
});
import { configureClientConfiguration } from "../../src/application/SetupClientConfiguration.js";
import { RegularFileCleanupFailure } from "../../src/application/RegularFileRead.js";
import { OwnedFileHandle } from "../../src/filesystem/OwnedFileHandle.js";
import type { DoctorHost } from "../../src/application/Doctor.js";
import { systemSetupHost } from "../../src/application/SetupHost.js";
import { ConfigurationError } from "../../src/domain/configurationErrors.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
const ownReadHandle = async (path: string) => {
const owner = new OwnedFileHandle(await open(path, "r"));
onTestFinished(() => owner.close());
return owner;
};
it("reports a failed config rollback and retains its backup path", async () => {
state.readbackFailed = false;
state.rejectRestore = false;
state.rejectRemove = false;
state.initialCleanupFailure = undefined;
state.readbackCleanupFailure = undefined;
const root = await createTestTempDirectory("rea-config-rollback-");
const path = join(root, "client.json");
const original = '{"keep":true}\n';
await import("node:fs/promises").then(({ writeFile }) =>
writeFile(path, original),
);
state.rejectRestore = true;
const result = await configureClientConfiguration(
{ name: "cursor", configPath: path, format: "json" },
{},
["rea", "mcp"],
);
expect(result).toEqual({
status: "failed",
reason: "rollback",
backupPath: `${path}.rea.backup`,
failure: {
kind: "restore",
path,
detail: "injected restore write failure",
},
});
expect(await readFile(`${path}.rea.backup`, "utf8")).toBe(original);
expect(JSON.parse(await readFile(path, "utf8"))).toMatchObject({
mcpServers: { rea: { command: "rea", args: ["mcp"] } },
});
});
it("preserves the first backup and reports a verified backup of the current original", async () => {
state.readbackFailed = false;
state.rejectRestore = false;
state.rejectRemove = false;
state.initialCleanupFailure = undefined;
state.readbackCleanupFailure = undefined;
const root = await createTestTempDirectory("rea-config-stale-backup-");
const path = join(root, "client.json");
const firstOriginal = '{"keep":"first"}\n';
const currentOriginal = '{"keep":"current"}\n';
await import("node:fs/promises").then(async ({ writeFile }) => {
await writeFile(path, currentOriginal);
await writeFile(`${path}.rea.backup`, firstOriginal);
});
state.rejectRestore = true;
const result = await configureClientConfiguration(
{ name: "cursor", configPath: path, format: "json" },
{},
["rea", "mcp"],
);
state.rejectRestore = false;
const recoveryPath = `${path}.rea.backup.${createHash("sha256")
.update(currentOriginal, "utf8")
.digest("hex")}.recovery`;
expect(result).toMatchObject({
status: "failed",
reason: "rollback",
backupPath: recoveryPath,
failure: {
kind: "restore",
path,
detail: "injected restore write failure",
},
});
expect(await readFile(`${path}.rea.backup`, "utf8")).toBe(firstOriginal);
expect(await readFile(recoveryPath, "utf8")).toBe(currentOriginal);
});
it("does not write config when a recovery copy disappears before verification", async () => {
state.readbackFailed = false;
state.rejectRestore = false;
state.rejectRemove = false;
state.removeCopiedRecovery = false;
state.initialCleanupFailure = undefined;
state.readbackCleanupFailure = undefined;
const root = await createTestTempDirectory("rea-config-vanished-recovery-");
const path = join(root, "client.json");
const firstOriginal = '{"keep":"first"}\n';
const currentOriginal = '{"keep":"current"}\n';
await import("node:fs/promises").then(async ({ writeFile }) => {
await writeFile(path, currentOriginal);
await writeFile(`${path}.rea.backup`, firstOriginal);
});
state.removeCopiedRecovery = true;
const result = await configureClientConfiguration(
{ name: "cursor", configPath: path, format: "json" },
{},
["rea", "mcp"],
);
state.removeCopiedRecovery = false;
expect(result).toEqual({ status: "failed", reason: "backup" });
expect(await readFile(path, "utf8")).toBe(currentOriginal);
expect(await readFile(`${path}.rea.backup`, "utf8")).toBe(firstOriginal);
});
it("does not roll back or remove config when its initial read cleanup fails", async () => {
state.readbackFailed = false;
state.rejectRestore = false;
state.rejectRemove = false;
state.readbackCleanupFailure = undefined;
const root = await createTestTempDirectory("rea-config-initial-cleanup-");
const path = join(root, "client.json");
const original = '{"keep":true}\n';
await import("node:fs/promises").then(({ writeFile }) =>
writeFile(path, original),
);
const failure = new RegularFileCleanupFailure(
path,
await ownReadHandle(path),
{ kind: "completed", value: original },
new Error("injected initial close failure"),
);
state.initialCleanupFailure = failure;
await expect(
configureClientConfiguration(
{ name: "cursor", configPath: path, format: "json" },
{},
["rea", "mcp"],
),
).rejects.toBe(failure);
expect(await readFile(path, "utf8")).toBe(original);
});
it("reports first-run rollback removal failure without a backup", async () => {
state.readbackFailed = false;
state.rejectRestore = false;
state.rejectRemove = false;
state.initialCleanupFailure = undefined;
state.readbackCleanupFailure = undefined;
const root = await createTestTempDirectory("rea-config-first-run-rollback-");
const path = join(root, "client.json");
state.rejectRemove = true;
const result = await configureClientConfiguration(
{ name: "cursor", configPath: path, format: "json" },
{},
["rea", "mcp"],
);
state.rejectRemove = false;
expect(result).toMatchObject({
status: "failed",
reason: "rollback",
failure: {
kind: "restore",
path,
detail: "injected restore remove failure",
},
});
expect(result).not.toHaveProperty("backupPath");
expect(JSON.parse(await readFile(path, "utf8"))).toMatchObject({
mcpServers: { rea: { command: "rea", args: ["mcp"] } },
});
});
it("retains readback cleanup ownership when rollback also fails", async () => {
state.readbackFailed = false;
state.rejectRestore = false;
state.rejectRemove = false;
state.initialCleanupFailure = undefined;
const root = await createTestTempDirectory("rea-config-readback-cleanup-");
const path = join(root, "client.json");
const original = '{"keep":true}\n';
await import("node:fs/promises").then(({ writeFile }) =>
writeFile(path, original),
);
const owner = await ownReadHandle(path);
const cleanupFailure = new RegularFileCleanupFailure(
path,
owner,
{ kind: "completed", value: "" },
new Error("injected readback close failure"),
);
state.readbackCleanupFailure = cleanupFailure;
state.rejectRestore = true;
const host = systemSetupHost(
{ platform: "linux", homeDirectory: root } as DoctorHost,
{ HOME: root },
);
let caught: unknown;
try {
await host.configureClient(
{ name: "cursor", configPath: path, format: "json" },
{},
["rea", "mcp"],
);
} catch (cause: unknown) {
caught = cause;
} finally {
await host.close?.();
}
if (!(caught instanceof ConfigurationError)) throw caught;
expect(caught.message).toContain("injected restore write failure");
expect(caught.message).toContain(`${path}.rea.backup`);
const cleanupOwnerError = caught.cause;
if (!(cleanupOwnerError instanceof RegularFileCleanupFailure))
throw new Error("Configuration failure lost its read owner");
expect(cleanupOwnerError).toMatchObject({
path,
outcome: { kind: "completed", value: "" },
cleanupCause: expect.any(AggregateError),
});
expect(cleanupOwnerError.owner).toBe(owner);
expect(await readFile(`${path}.rea.backup`, "utf8")).toBe(original);
});