mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
98ec00f406
* fix(setup): report host-owned cleanup resources alongside the primary's
SetupHost.close documents that it reports the resources a setup host still
owns, but its contract returned only a string. runSetup therefore built the
ProviderCleanupError from the primary failure's cleanupResources alone, so a
resource the host failed to release reached the projection only inside a
free-text reason while the error asks the caller to review the reported local
resources.
Return a SetupCloseReport instead. closeSetupResources absorbs the structured
resources of any cause that reports them, through
ArtifactReaderFailure.cleanupObservation, and leaves opaque failures
message-only so no resource name is invented. runSetup then unions the host's
resources with the primary's before building the typed cleanup error.
Two boundary tests read the close result directly: the skill-cleanup case now
asserts the retained backup path in the report reason rather than in a bare
string, and the shared ownership helper takes an opaque close result because it
only ever awaits undefined from either host it is given.
Validation: RED then green in src/application/Setup.recovery.test.ts, where
the new case previously received only the primary resource; 116 tests across
the seven Setup and cleanup-ownership suites pass; tsc --noEmit over the
changed set, its setup callers, and both boundary tests exits 0; oxlint
reports 0 errors; oxfmt --check is clean; verify:module-boundaries passes.
The two failures in src/setupEnvironment.test.ts reproduce identically on
pristine bded9c628 with this change stashed.
* fix(setup): preserve cleanup ownership through host reports
* fix(setup): bind finalization reports to cleanup owners
* fix(setup): retain live resources in returned cleanup views
* fix(setup): preserve completed cleanup and exact installer resources
* fix(setup): retain delegated cleanup across authority changes
* fix(setup): compose callback and source cleanup authorities
* fix(setup): retain producer cleanup failure reasons
* fix(cli): render structured setup cleanup resources
* fix(setup): separate failure evidence from cleanup ownership
* fix(setup): normalize close failures and unify lease binding
* fix(setup): preserve direct lease resource attribution
---------
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
1456 lines
50 KiB
TypeScript
1456 lines
50 KiB
TypeScript
import { open, readFile, type FileHandle } 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,
|
|
failReadback: true,
|
|
rejectPublicationClose: false,
|
|
publicationCloseFailure: undefined as unknown,
|
|
publicationHandles: [] as FileHandle[],
|
|
beforePublicationOpen: undefined as
|
|
| ((path: string) => Promise<void>)
|
|
| undefined,
|
|
rejectPublicationMode: false,
|
|
rejectStageRemoval: false,
|
|
beforeFirstPublication: undefined as (() => Promise<void>) | undefined,
|
|
afterPublication: undefined as ((path: string) => Promise<void>) | undefined,
|
|
rejectRestore: false,
|
|
removeCopiedRecovery: false,
|
|
afterBackupCopy: undefined as
|
|
| ((source: string, destination: string) => Promise<void>)
|
|
| undefined,
|
|
initialCleanupFailure: undefined as unknown,
|
|
readbackCleanupFailure: undefined as unknown,
|
|
}));
|
|
|
|
afterEach(() => {
|
|
state.readbackFailed = false;
|
|
state.failReadback = true;
|
|
state.rejectPublicationClose = false;
|
|
state.publicationCloseFailure = undefined;
|
|
state.publicationHandles = [];
|
|
state.beforePublicationOpen = undefined;
|
|
state.rejectPublicationMode = false;
|
|
state.rejectStageRemoval = false;
|
|
state.afterPublication = undefined;
|
|
state.beforeFirstPublication = undefined;
|
|
state.rejectRestore = false;
|
|
state.removeCopiedRecovery = false;
|
|
state.afterBackupCopy = undefined;
|
|
state.initialCleanupFailure = undefined;
|
|
state.readbackCleanupFailure = undefined;
|
|
});
|
|
|
|
vi.mock("node:fs/promises", async (importOriginal) => {
|
|
const original = await importOriginal<typeof import("node:fs/promises")>();
|
|
return {
|
|
...original,
|
|
open: async (...arguments_: Parameters<typeof original.open>) => {
|
|
const writable =
|
|
typeof arguments_[1] === "number" && (arguments_[1] & 3) === 2;
|
|
if (writable && typeof arguments_[0] === "string")
|
|
await state.beforePublicationOpen?.(arguments_[0]);
|
|
const handle = await original.open(...arguments_);
|
|
if (typeof arguments_[1] === "number" && (arguments_[1] & 3) === 2) {
|
|
state.publicationHandles.push(handle);
|
|
const write = handle.write.bind(handle);
|
|
handle.write = ((...values: Parameters<typeof handle.write>) => {
|
|
if (state.rejectRestore && state.readbackFailed)
|
|
return Promise.reject(new Error("injected restore write failure"));
|
|
return write(...values);
|
|
}) as typeof handle.write;
|
|
const chmod = handle.chmod.bind(handle);
|
|
handle.chmod = async (mode) => {
|
|
if (state.rejectPublicationMode && mode === 0o400)
|
|
throw new Error("staging mode preparation denied");
|
|
await chmod(mode);
|
|
};
|
|
const close = handle.close.bind(handle);
|
|
handle.close = async () => {
|
|
if (state.rejectPublicationClose)
|
|
throw (
|
|
state.publicationCloseFailure ??
|
|
new Error("injected publication close failure")
|
|
);
|
|
await close();
|
|
};
|
|
}
|
|
return handle;
|
|
},
|
|
rmdir: async (...values: Parameters<typeof original.rmdir>) => {
|
|
if (
|
|
state.rejectStageRemoval &&
|
|
String(values[0]).includes(".rea-config-")
|
|
)
|
|
throw new Error("injected staging directory removal failure");
|
|
return original.rmdir(...values);
|
|
},
|
|
rename: async (...values: Parameters<typeof original.rename>) => {
|
|
const result = await original.rename(...values);
|
|
state.readbackFailed = true;
|
|
await state.afterPublication?.(String(values[1]));
|
|
return result;
|
|
},
|
|
link: async (...values: Parameters<typeof original.link>) => {
|
|
await state.beforeFirstPublication?.();
|
|
const result = await original.link(...values);
|
|
state.readbackFailed = true;
|
|
await state.afterPublication?.(String(values[1]));
|
|
return result;
|
|
},
|
|
copyFile: async (
|
|
...arguments_: Parameters<typeof original.copyFile>
|
|
): Promise<void> => {
|
|
await original.copyFile(...arguments_);
|
|
const source = arguments_[0];
|
|
const destination = arguments_[1];
|
|
if (typeof source === "string" && typeof destination === "string")
|
|
await state.afterBackupCopy?.(source, destination);
|
|
if (
|
|
state.removeCopiedRecovery &&
|
|
typeof destination === "string" &&
|
|
destination.endsWith(".recovery")
|
|
)
|
|
await original.rm(destination);
|
|
},
|
|
};
|
|
});
|
|
|
|
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 && state.failReadback
|
|
? Promise.reject(new Error("injected readback failure"))
|
|
: original.readRegularFileText(...arguments_),
|
|
};
|
|
});
|
|
|
|
import { ClientConfigurationPublication } from "../../src/application/ClientConfigurationPublication.js";
|
|
import {
|
|
configureClientConfiguration,
|
|
ClientConfigurationCleanupRollbackFailure,
|
|
} from "../../src/application/SetupClientConfiguration.js";
|
|
import { RegularFileReadFailure } from "../../src/application/RegularFileRead.js";
|
|
import { retryClientConfigurationCleanup } from "../../src/application/ClientConfigurationCleanup.js";
|
|
import { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js";
|
|
import { RegularFileCleanupFailure } from "../../src/filesystem/OwnedRegularFile.js";
|
|
import { OwnedFileHandle } from "../../src/filesystem/OwnedFileHandle.js";
|
|
import type { DoctorHost } from "../../src/application/Doctor.js";
|
|
import { systemSetupHost } from "../../src/application/SetupHost.js";
|
|
import { AnalysisError } from "../../src/domain/analysisErrorBase.js";
|
|
import { systemUninstallHost } from "../../src/application/Uninstall.js";
|
|
import { ConfigurationError } from "../../src/domain/configurationErrors.js";
|
|
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
|
import { createDeferred } from "../fixtures/binarySession.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.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.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.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.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,
|
|
owner: await ownReadHandle(path),
|
|
outcome: { kind: "completed", value: original },
|
|
cleanupCause: 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("retains first-run configuration for manual recovery without deleting by pathname", async () => {
|
|
state.readbackFailed = false;
|
|
state.rejectRestore = false;
|
|
state.initialCleanupFailure = undefined;
|
|
state.readbackCleanupFailure = undefined;
|
|
const root = await createTestTempDirectory("rea-config-first-run-rollback-");
|
|
const path = join(root, "client.json");
|
|
|
|
const result = await configureClientConfiguration(
|
|
{ name: "cursor", configPath: path, format: "json" },
|
|
{},
|
|
["rea", "mcp"],
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
status: "failed",
|
|
reason: "rollback",
|
|
failure: {
|
|
kind: "restore",
|
|
path,
|
|
detail:
|
|
"First-run configuration recovery requires manual removal; pathname deletion cannot prove ownership",
|
|
},
|
|
});
|
|
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.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,
|
|
outcome: { kind: "completed", value: "" },
|
|
cleanupCause: 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);
|
|
});
|
|
|
|
it.each(["existing", "first-run"] as const)(
|
|
"retains a replacement symlink and its target when %s readback fails",
|
|
async (kind) => {
|
|
const root = await createTestTempDirectory("rea-config-replaced-rollback-");
|
|
const path = join(root, "client.json");
|
|
const replacement = join(root, "replacement.json");
|
|
const original = '{"keep":"original"}\n';
|
|
const foreign = '{"keep":"replacement"}\n';
|
|
const fs =
|
|
await vi.importActual<typeof import("node:fs/promises")>(
|
|
"node:fs/promises",
|
|
);
|
|
await fs.writeFile(replacement, foreign);
|
|
if (kind === "existing") await fs.writeFile(path, original);
|
|
state.afterPublication = async (publishedPath) => {
|
|
await fs.unlink(publishedPath);
|
|
await fs.symlink(replacement, publishedPath);
|
|
};
|
|
|
|
const result = await configureClientConfiguration(
|
|
{ name: "cursor", configPath: path, format: "json" },
|
|
{},
|
|
["rea", "mcp"],
|
|
);
|
|
|
|
expect(await fs.readFile(replacement, "utf8")).toBe(foreign);
|
|
expect((await fs.lstat(path)).isSymbolicLink()).toBe(true);
|
|
expect(result).toMatchObject({ status: "failed", reason: "conflict" });
|
|
if (kind === "existing")
|
|
expect(await fs.readFile(`${path}.rea.backup`, "utf8")).toBe(original);
|
|
},
|
|
);
|
|
|
|
it("retains a replacement regular file when readback fails", async () => {
|
|
const root = await createTestTempDirectory("rea-config-replaced-file-");
|
|
const path = join(root, "client.json");
|
|
const replacement = join(root, "replacement.json");
|
|
const original = '{"keep":"original"}\n';
|
|
const foreign = '{"keep":"replacement"}\n';
|
|
const fs =
|
|
await vi.importActual<typeof import("node:fs/promises")>(
|
|
"node:fs/promises",
|
|
);
|
|
await fs.writeFile(path, original);
|
|
await fs.writeFile(replacement, foreign);
|
|
state.afterPublication = (publishedPath) =>
|
|
fs.rename(replacement, publishedPath);
|
|
|
|
const result = await configureClientConfiguration(
|
|
{ name: "cursor", configPath: path, format: "json" },
|
|
{},
|
|
["rea", "mcp"],
|
|
);
|
|
|
|
expect(await fs.readFile(path, "utf8")).toBe(foreign);
|
|
expect(result).toMatchObject({ status: "failed", reason: "conflict" });
|
|
expect(await fs.readFile(`${path}.rea.backup`, "utf8")).toBe(original);
|
|
});
|
|
|
|
it("retains and retries both readback and publication descriptors after mixed close failures", async () => {
|
|
const root = await createTestTempDirectory("rea-config-two-close-owners-");
|
|
const path = join(root, "client.json");
|
|
const fs =
|
|
await vi.importActual<typeof import("node:fs/promises")>(
|
|
"node:fs/promises",
|
|
);
|
|
await fs.writeFile(path, '{"keep":true}\n');
|
|
const readOwner = await ownReadHandle(path);
|
|
const closeRead = readOwner.handle.close.bind(readOwner.handle);
|
|
let rejectReadClose = true;
|
|
readOwner.handle.close = async () => {
|
|
if (rejectReadClose) throw new Error("injected readback close failure");
|
|
await closeRead();
|
|
};
|
|
state.readbackCleanupFailure = new RegularFileCleanupFailure({
|
|
path,
|
|
owner: readOwner,
|
|
outcome: { kind: "completed", value: "" },
|
|
cleanupCause: new Error("injected readback close failure"),
|
|
});
|
|
state.rejectPublicationClose = true;
|
|
const host = systemSetupHost(
|
|
{ platform: "linux", homeDirectory: root } as DoctorHost,
|
|
{ HOME: root },
|
|
);
|
|
try {
|
|
await expect(
|
|
host.configureClient(
|
|
{ name: "cursor", configPath: path, format: "json" },
|
|
{},
|
|
["rea", "mcp"],
|
|
),
|
|
).rejects.toBeInstanceOf(ConfigurationError);
|
|
expect(state.publicationHandles).toHaveLength(1);
|
|
expect(readOwner.handle.fd).toBeGreaterThanOrEqual(0);
|
|
expect(state.publicationHandles[0]!.fd).toBeGreaterThanOrEqual(0);
|
|
rejectReadClose = false;
|
|
state.rejectPublicationClose = false;
|
|
expect(await host.close?.()).toBeUndefined();
|
|
expect(readOwner.handle.fd).toBe(-1);
|
|
expect(state.publicationHandles[0]!.fd).toBe(-1);
|
|
} finally {
|
|
rejectReadClose = false;
|
|
state.rejectPublicationClose = false;
|
|
await host.close?.();
|
|
await readOwner.close();
|
|
for (const handle of state.publicationHandles)
|
|
if (handle.fd >= 0) await handle.close();
|
|
}
|
|
});
|
|
|
|
it("rejects a replaced publication even when the replacement passes registration readback", async () => {
|
|
const root = await createTestTempDirectory("rea-config-valid-replacement-");
|
|
const path = join(root, "client.json");
|
|
const replacement = join(root, "replacement.json");
|
|
const fs =
|
|
await vi.importActual<typeof import("node:fs/promises")>(
|
|
"node:fs/promises",
|
|
);
|
|
const original = '{"keep":true}\n';
|
|
await fs.writeFile(path, original);
|
|
let foreign = "";
|
|
state.failReadback = false;
|
|
state.afterPublication = async (publishedPath) => {
|
|
foreign = await fs.readFile(publishedPath, "utf8");
|
|
await fs.writeFile(replacement, foreign);
|
|
await fs.rename(replacement, publishedPath);
|
|
};
|
|
expect(
|
|
await configureClientConfiguration(
|
|
{ name: "cursor", configPath: path, format: "json" },
|
|
{},
|
|
["rea", "mcp"],
|
|
),
|
|
).toMatchObject({ status: "failed", reason: "conflict" });
|
|
expect(await fs.readFile(path, "utf8")).toBe(foreign);
|
|
expect(await fs.readFile(`${path}.rea.backup`, "utf8")).toBe(original);
|
|
});
|
|
|
|
it("retains concurrent edits to the published inode rather than overwriting them", async () => {
|
|
const root = await createTestTempDirectory("rea-config-inode-edit-");
|
|
const path = join(root, "client.json");
|
|
const fs =
|
|
await vi.importActual<typeof import("node:fs/promises")>(
|
|
"node:fs/promises",
|
|
);
|
|
const original = '{"keep":true}\n';
|
|
const edited = '{"keep":"concurrent edit"}\n';
|
|
await fs.writeFile(path, original);
|
|
state.afterPublication = (publishedPath) =>
|
|
fs.writeFile(publishedPath, edited);
|
|
expect(
|
|
await configureClientConfiguration(
|
|
{ name: "cursor", configPath: path, format: "json" },
|
|
{},
|
|
["rea", "mcp"],
|
|
),
|
|
).toMatchObject({ status: "failed", reason: "conflict" });
|
|
expect(await fs.readFile(path, "utf8")).toBe(edited);
|
|
expect(await fs.readFile(`${path}.rea.backup`, "utf8")).toBe(original);
|
|
});
|
|
|
|
it("does not adopt a descriptor named by an opaque publication close rejection", async () => {
|
|
const root = await createTestTempDirectory(
|
|
"rea-config-close-cause-ownership-",
|
|
);
|
|
const path = join(root, "client.json");
|
|
const unrelated = join(root, "unrelated.json");
|
|
const fs =
|
|
await vi.importActual<typeof import("node:fs/promises")>(
|
|
"node:fs/promises",
|
|
);
|
|
await fs.writeFile(path, '{"keep":true}\n');
|
|
await fs.writeFile(unrelated, "unrelated");
|
|
const foreignOwner = await ownReadHandle(unrelated);
|
|
state.failReadback = false;
|
|
state.rejectPublicationClose = true;
|
|
state.publicationCloseFailure = new RegularFileCleanupFailure({
|
|
path: unrelated,
|
|
owner: foreignOwner,
|
|
outcome: { kind: "completed", value: "unrelated" },
|
|
cleanupCause: new Error("opaque close rejection"),
|
|
});
|
|
const host = systemSetupHost(
|
|
{ platform: "linux", homeDirectory: root } as DoctorHost,
|
|
{ HOME: root },
|
|
);
|
|
try {
|
|
await expect(
|
|
host.configureClient(
|
|
{ name: "cursor", configPath: path, format: "json" },
|
|
{},
|
|
["rea", "mcp"],
|
|
),
|
|
).rejects.toBeInstanceOf(ConfigurationError);
|
|
state.rejectPublicationClose = false;
|
|
expect(await host.close?.()).toBeUndefined();
|
|
expect(state.publicationHandles[0]!.fd).toBe(-1);
|
|
expect(foreignOwner.handle.fd).toBeGreaterThanOrEqual(0);
|
|
} finally {
|
|
state.rejectPublicationClose = false;
|
|
await host.close?.();
|
|
await foreignOwner.close();
|
|
for (const handle of state.publicationHandles)
|
|
if (handle.fd >= 0) await handle.close();
|
|
}
|
|
});
|
|
|
|
it.each(["symlink", "regular", "valid-readback"] as const)(
|
|
"uninstall retains a %s replacement instead of restoring through its name",
|
|
async (kind) => {
|
|
const root = await createTestTempDirectory(
|
|
"rea-uninstall-replaced-rollback-",
|
|
);
|
|
const path = join(root, "client.json");
|
|
const replacement = join(root, "replacement.json");
|
|
const fs =
|
|
await vi.importActual<typeof import("node:fs/promises")>(
|
|
"node:fs/promises",
|
|
);
|
|
const original =
|
|
'{"mcpServers":{"rea":{"command":"rea","args":["mcp"]}},"keep":"original"}\n';
|
|
const foreign = '{"keep":"replacement"}\n';
|
|
await fs.writeFile(path, original);
|
|
await fs.writeFile(replacement, foreign);
|
|
state.failReadback = kind !== "valid-readback";
|
|
state.afterPublication = async (publishedPath) => {
|
|
state.afterPublication = undefined;
|
|
if (kind === "symlink") {
|
|
await fs.unlink(publishedPath);
|
|
await fs.symlink(replacement, publishedPath);
|
|
} else await fs.rename(replacement, publishedPath);
|
|
};
|
|
const host = systemUninstallHost(root);
|
|
try {
|
|
const result = await host.removeClient({
|
|
name: "cursor",
|
|
configPath: path,
|
|
format: "json",
|
|
});
|
|
expect(await fs.readFile(path, "utf8")).toBe(foreign);
|
|
if (kind === "symlink") {
|
|
expect((await fs.lstat(path)).isSymbolicLink()).toBe(true);
|
|
expect(await fs.readFile(replacement, "utf8")).toBe(foreign);
|
|
}
|
|
expect(result.status).toBe("failed");
|
|
expect(result.detail).not.toContain("The original was restored");
|
|
expect(await fs.readFile(`${path}.rea.backup`, "utf8")).toBe(original);
|
|
} finally {
|
|
await host.close?.();
|
|
}
|
|
},
|
|
);
|
|
|
|
it("uninstall retains both readback and publication owners until cleanup retry", async () => {
|
|
const root = await createTestTempDirectory("rea-uninstall-two-close-owners-");
|
|
const path = join(root, "client.json");
|
|
const fs =
|
|
await vi.importActual<typeof import("node:fs/promises")>(
|
|
"node:fs/promises",
|
|
);
|
|
await fs.writeFile(
|
|
path,
|
|
'{"mcpServers":{"rea":{"command":"rea","args":["mcp"]}}}\n',
|
|
);
|
|
const readOwner = await ownReadHandle(path);
|
|
const closeRead = readOwner.handle.close.bind(readOwner.handle);
|
|
let rejectReadClose = true;
|
|
readOwner.handle.close = async () => {
|
|
if (rejectReadClose) throw new Error("injected readback close failure");
|
|
await closeRead();
|
|
};
|
|
state.readbackCleanupFailure = new RegularFileCleanupFailure({
|
|
path,
|
|
owner: readOwner,
|
|
outcome: { kind: "completed", value: "" },
|
|
cleanupCause: new Error("readback close failed"),
|
|
});
|
|
state.rejectPublicationClose = true;
|
|
const host = systemUninstallHost(root);
|
|
try {
|
|
const result = await host.removeClient({
|
|
name: "cursor",
|
|
configPath: path,
|
|
format: "json",
|
|
});
|
|
expect(result.status).toBe("failed");
|
|
expect(state.publicationHandles).toHaveLength(1);
|
|
expect(readOwner.handle.fd).toBeGreaterThanOrEqual(0);
|
|
expect(state.publicationHandles[0]!.fd).toBeGreaterThanOrEqual(0);
|
|
rejectReadClose = false;
|
|
state.rejectPublicationClose = false;
|
|
expect(await host.close?.()).toBeUndefined();
|
|
expect(readOwner.handle.fd).toBe(-1);
|
|
expect(state.publicationHandles[0]!.fd).toBe(-1);
|
|
} finally {
|
|
rejectReadClose = false;
|
|
state.rejectPublicationClose = false;
|
|
await host.close?.();
|
|
await readOwner.close();
|
|
for (const handle of state.publicationHandles)
|
|
if (handle.fd >= 0) await handle.close();
|
|
}
|
|
});
|
|
|
|
it("uninstall retains the exact owner of a failed initial config read", async () => {
|
|
const root = await createTestTempDirectory(
|
|
"rea-uninstall-initial-read-owner-",
|
|
);
|
|
const path = join(root, "client.json");
|
|
const fs =
|
|
await vi.importActual<typeof import("node:fs/promises")>(
|
|
"node:fs/promises",
|
|
);
|
|
const original = '{"mcpServers":{"rea":{"command":"rea","args":["mcp"]}}}\n';
|
|
await fs.writeFile(path, original);
|
|
const owner = await ownReadHandle(path);
|
|
const close = owner.handle.close.bind(owner.handle);
|
|
let rejectClose = true;
|
|
owner.handle.close = async () => {
|
|
if (rejectClose) throw new Error("initial close denied");
|
|
await close();
|
|
};
|
|
state.initialCleanupFailure = new RegularFileCleanupFailure({
|
|
path,
|
|
owner,
|
|
outcome: { kind: "completed", value: original },
|
|
cleanupCause: new Error("initial close denied"),
|
|
});
|
|
const host = systemUninstallHost(root);
|
|
try {
|
|
expect(
|
|
await host.inspectClient({
|
|
name: "cursor",
|
|
configPath: path,
|
|
format: "json",
|
|
}),
|
|
).toMatchObject({ status: "failed" });
|
|
expect(state.publicationHandles).toHaveLength(0);
|
|
expect(owner.handle.fd).toBeGreaterThanOrEqual(0);
|
|
rejectClose = false;
|
|
expect(await host.close?.()).toBeUndefined();
|
|
expect(owner.handle.fd).toBe(-1);
|
|
expect(await fs.readFile(path, "utf8")).toBe(original);
|
|
await expect(fs.readFile(`${path}.rea.backup`)).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
} finally {
|
|
rejectClose = false;
|
|
await host.close?.();
|
|
await owner.close();
|
|
}
|
|
});
|
|
|
|
it("uninstall close joins a publication already admitted by its host", async () => {
|
|
const root = await createTestTempDirectory(
|
|
"rea-uninstall-close-publication-",
|
|
);
|
|
const path = join(root, "client.json");
|
|
const fs =
|
|
await vi.importActual<typeof import("node:fs/promises")>(
|
|
"node:fs/promises",
|
|
);
|
|
await fs.writeFile(
|
|
path,
|
|
'{"mcpServers":{"rea":{"command":"rea","args":["mcp"]}}}\n',
|
|
);
|
|
const arrived = createDeferred<void>();
|
|
const release = createDeferred<void>();
|
|
state.afterPublication = async () => {
|
|
arrived.resolve();
|
|
await release.promise;
|
|
};
|
|
const host = systemUninstallHost(root);
|
|
const removal = host.removeClient({
|
|
name: "cursor",
|
|
configPath: path,
|
|
format: "json",
|
|
});
|
|
await arrived.promise;
|
|
let closed = false;
|
|
const closing = host.close?.().then((result) => {
|
|
closed = true;
|
|
return result;
|
|
});
|
|
try {
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
expect(closed).toBe(false);
|
|
expect(state.publicationHandles[0]!.fd).toBeGreaterThanOrEqual(0);
|
|
} finally {
|
|
release.resolve();
|
|
}
|
|
expect((await removal).status).toBe("failed");
|
|
expect(await closing).toBeUndefined();
|
|
expect(state.publicationHandles[0]!.fd).toBe(-1);
|
|
await expect(
|
|
host.removeClient({ name: "cursor", configPath: path, format: "json" }),
|
|
).rejects.toThrow("closed");
|
|
});
|
|
|
|
it("preserves physical read origin through failed readback cleanup and rollback", async () => {
|
|
const root = await createTestTempDirectory("rea-config-physical-rollback-");
|
|
const path = join(root, "client.json");
|
|
const other = join(root, "foreign.json");
|
|
const fs =
|
|
await vi.importActual<typeof import("node:fs/promises")>(
|
|
"node:fs/promises",
|
|
);
|
|
await fs.writeFile(path, '{"keep":true}\n');
|
|
await fs.writeFile(other, "foreign bytes");
|
|
const owned = await ownReadHandle(path);
|
|
const foreign = await ownReadHandle(other);
|
|
const opaque = new RegularFileCleanupFailure({
|
|
path: other,
|
|
owner: foreign,
|
|
outcome: { kind: "completed", value: "foreign bytes" },
|
|
cleanupCause: new Error("foreign cleanup-shaped physical rejection"),
|
|
});
|
|
const physical = new RegularFileReadFailure(opaque);
|
|
const close = owned.handle.close.bind(owned.handle);
|
|
let denyClose = true;
|
|
owned.handle.close = async () => {
|
|
if (denyClose) throw new Error("readback close denied");
|
|
await close();
|
|
};
|
|
state.readbackCleanupFailure = new RegularFileCleanupFailure({
|
|
path,
|
|
owner: owned,
|
|
outcome: { kind: "failed", cause: opaque },
|
|
operationFailure: physical,
|
|
cleanupCause: new Error("readback close denied"),
|
|
});
|
|
state.rejectRestore = true;
|
|
const resources = new ArtifactResourceScope();
|
|
try {
|
|
const failure: unknown = await configureClientConfiguration(
|
|
{ name: "cursor", configPath: path, format: "json" },
|
|
{},
|
|
["rea", "mcp"],
|
|
).catch((cause: unknown) => cause);
|
|
expect(failure).toBeInstanceOf(ClientConfigurationCleanupRollbackFailure);
|
|
if (!(failure instanceof ClientConfigurationCleanupRollbackFailure))
|
|
throw new Error("Expected failed readback cleanup and rollback");
|
|
expect(failure.operationFailure).toBe(physical);
|
|
expect(failure.outcome).toEqual({ kind: "failed", cause: opaque });
|
|
expect(failure.configurationFailure).toMatchObject({
|
|
status: "failed",
|
|
reason: "rollback",
|
|
failure: { kind: "restore", detail: "injected restore write failure" },
|
|
});
|
|
const retried = await retryClientConfigurationCleanup(failure, resources);
|
|
expect(retried.primary).toBe(opaque);
|
|
expect(retried.failures).toHaveLength(1);
|
|
expect(retried.cleanup?.resources).toEqual([path]);
|
|
expect(owned.handle.fd).toBeGreaterThanOrEqual(0);
|
|
expect(foreign.handle.fd).toBeGreaterThanOrEqual(0);
|
|
denyClose = false;
|
|
await resources.close();
|
|
expect(owned.handle.fd).toBe(-1);
|
|
expect(foreign.handle.fd).toBeGreaterThanOrEqual(0);
|
|
expect(await fs.readFile(other, "utf8")).toBe("foreign bytes");
|
|
} finally {
|
|
denyClose = false;
|
|
await resources.close();
|
|
await owned.close();
|
|
}
|
|
});
|
|
|
|
it("cleanup retry never adopts an opaque cancellation reason as another owner", async () => {
|
|
const root = await createTestTempDirectory(
|
|
"rea-cleanup-cancellation-authority-",
|
|
);
|
|
const path = join(root, "owned.json");
|
|
const other = join(root, "other.json");
|
|
const fs =
|
|
await vi.importActual<typeof import("node:fs/promises")>(
|
|
"node:fs/promises",
|
|
);
|
|
await fs.writeFile(path, "owned");
|
|
await fs.writeFile(other, "other");
|
|
const owned = await ownReadHandle(path);
|
|
const foreign = await ownReadHandle(other);
|
|
const opaque = new RegularFileCleanupFailure({
|
|
path: other,
|
|
owner: foreign,
|
|
outcome: { kind: "completed", value: "other" },
|
|
cleanupCause: new Error("opaque reason"),
|
|
});
|
|
const { RegularFileOperationCancelled } =
|
|
await import("../../src/filesystem/RegularFile.js");
|
|
const { ArtifactResourceScope } =
|
|
await import("../../src/artifacts/ArtifactResourceScope.js");
|
|
const { retryRegularFileCleanupFailures } =
|
|
await import("../../src/application/RegularFileRead.js");
|
|
const resources = new ArtifactResourceScope();
|
|
const outcome = await retryRegularFileCleanupFailures(
|
|
new RegularFileCleanupFailure({
|
|
path,
|
|
owner: owned,
|
|
outcome: {
|
|
kind: "failed",
|
|
cause: new RegularFileOperationCancelled(opaque),
|
|
},
|
|
cleanupCause: new Error("owned close failed"),
|
|
}),
|
|
resources,
|
|
);
|
|
expect(outcome.primary).toBe(opaque);
|
|
expect(outcome.failures).toHaveLength(1);
|
|
expect(owned.handle.fd).toBe(-1);
|
|
await resources.close();
|
|
expect(foreign.handle.fd).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
it("uninstall progresses when a peer leaves a denied-close descriptor pending", async () => {
|
|
const root = await createTestTempDirectory("rea-uninstall-peer-cleanup-");
|
|
const path = join(root, "client.json");
|
|
const other = join(root, "other.json");
|
|
const fs =
|
|
await vi.importActual<typeof import("node:fs/promises")>(
|
|
"node:fs/promises",
|
|
);
|
|
await fs.writeFile(
|
|
path,
|
|
'{"mcpServers":{"rea":{"command":"rea","args":["mcp"]}}}\n',
|
|
);
|
|
await fs.writeFile(other, "{}");
|
|
const owner = await ownReadHandle(other);
|
|
const close = owner.handle.close.bind(owner.handle);
|
|
let rejectClose = true;
|
|
owner.handle.close = async () => {
|
|
if (rejectClose) throw new Error("peer close denied");
|
|
await close();
|
|
};
|
|
const arrived = createDeferred<void>();
|
|
const release = createDeferred<void>();
|
|
state.failReadback = false;
|
|
state.afterPublication = async () => {
|
|
arrived.resolve();
|
|
await release.promise;
|
|
};
|
|
const host = systemUninstallHost(root);
|
|
const removal = host.removeClient({
|
|
name: "cursor",
|
|
configPath: path,
|
|
format: "json",
|
|
});
|
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
try {
|
|
await arrived.promise;
|
|
state.initialCleanupFailure = new RegularFileCleanupFailure({
|
|
path: other,
|
|
owner,
|
|
outcome: { kind: "completed", value: "{}" },
|
|
cleanupCause: new Error("peer close denied"),
|
|
});
|
|
expect(
|
|
await host.inspectClient({
|
|
name: "other",
|
|
configPath: other,
|
|
format: "json",
|
|
}),
|
|
).toMatchObject({ status: "failed" });
|
|
state.initialCleanupFailure = undefined;
|
|
release.resolve();
|
|
const result = await Promise.race([
|
|
removal,
|
|
new Promise<never>((_resolve, reject) => {
|
|
timeout = setTimeout(
|
|
() =>
|
|
reject(
|
|
new Error("configuration admission deadlocked with file cleanup"),
|
|
),
|
|
5000,
|
|
);
|
|
}),
|
|
]);
|
|
expect(result.status).toBe("failed");
|
|
expect(owner.handle.fd).toBeGreaterThanOrEqual(0);
|
|
rejectClose = false;
|
|
expect(await host.close?.()).toBeUndefined();
|
|
expect(owner.handle.fd).toBe(-1);
|
|
expect(state.publicationHandles[0]!.fd).toBe(-1);
|
|
} finally {
|
|
if (timeout !== undefined) clearTimeout(timeout);
|
|
state.initialCleanupFailure = undefined;
|
|
rejectClose = false;
|
|
release.resolve();
|
|
await owner.close();
|
|
for (const handle of state.publicationHandles)
|
|
if (handle.fd >= 0) await handle.close();
|
|
}
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
|
|
"exclusive staging refuses a preexisting entry without changing its permissions",
|
|
async () => {
|
|
const root = await createTestTempDirectory(
|
|
"rea-uninstall-staging-exclusive-",
|
|
);
|
|
const path = join(root, "client.json");
|
|
const foreign = join(root, "foreign.json");
|
|
const fs =
|
|
await vi.importActual<typeof import("node:fs/promises")>(
|
|
"node:fs/promises",
|
|
);
|
|
const original =
|
|
'{"mcpServers":{"rea":{"command":"rea","args":["mcp"]}}}\n';
|
|
await fs.writeFile(path, original);
|
|
await fs.chmod(path, 0o400);
|
|
await fs.writeFile(foreign, "foreign");
|
|
await fs.chmod(foreign, 0o640);
|
|
let inserted: string | undefined;
|
|
state.beforePublicationOpen = async (stagedPath) => {
|
|
state.beforePublicationOpen = undefined;
|
|
inserted = stagedPath;
|
|
await fs.link(foreign, stagedPath);
|
|
};
|
|
const host = systemUninstallHost(root);
|
|
try {
|
|
expect(
|
|
(
|
|
await host.removeClient({
|
|
name: "cursor",
|
|
configPath: path,
|
|
format: "json",
|
|
})
|
|
).status,
|
|
).toBe("failed");
|
|
expect(await fs.readFile(foreign, "utf8")).toBe("foreign");
|
|
expect((await fs.lstat(foreign)).mode & 0o777).toBe(0o640);
|
|
expect(await fs.readFile(path, "utf8")).toBe(original);
|
|
expect((await fs.lstat(path)).mode & 0o777).toBe(0o400);
|
|
expect(state.publicationHandles).toHaveLength(0);
|
|
expect(await host.close?.()).toContain("cleanup failed");
|
|
} finally {
|
|
if (inserted !== undefined) await fs.unlink(inserted);
|
|
await host.close?.();
|
|
}
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
|
|
"uninstall retains the private stage after mode preparation and descriptor cleanup fail",
|
|
async () => {
|
|
const root = await createTestTempDirectory(
|
|
"rea-uninstall-staging-mode-retry-",
|
|
);
|
|
const path = join(root, "client.json");
|
|
const fs =
|
|
await vi.importActual<typeof import("node:fs/promises")>(
|
|
"node:fs/promises",
|
|
);
|
|
const original =
|
|
'{"mcpServers":{"rea":{"command":"rea","args":["mcp"]}}}\n';
|
|
await fs.writeFile(path, original);
|
|
await fs.chmod(path, 0o400);
|
|
state.rejectPublicationMode = true;
|
|
state.rejectPublicationClose = true;
|
|
const host = systemUninstallHost(root);
|
|
try {
|
|
expect(
|
|
(
|
|
await host.removeClient({
|
|
name: "cursor",
|
|
configPath: path,
|
|
format: "json",
|
|
})
|
|
).status,
|
|
).toBe("failed");
|
|
expect(state.publicationHandles).toHaveLength(1);
|
|
expect(state.publicationHandles[0]!.fd).toBeGreaterThanOrEqual(0);
|
|
expect(await host.close?.()).toContain("cleanup failed");
|
|
state.rejectPublicationMode = false;
|
|
state.rejectPublicationClose = false;
|
|
expect(await host.close?.()).toBeUndefined();
|
|
expect(state.publicationHandles[0]!.fd).toBe(-1);
|
|
expect(await fs.readFile(path, "utf8")).toBe(original);
|
|
expect((await fs.lstat(path)).mode & 0o777).toBe(0o400);
|
|
} finally {
|
|
state.rejectPublicationMode = false;
|
|
state.rejectPublicationClose = false;
|
|
await host.close?.();
|
|
for (const handle of state.publicationHandles)
|
|
if (handle.fd >= 0) await handle.close();
|
|
}
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
|
|
"uninstall restores a read-only configuration through its retained writable descriptor",
|
|
async () => {
|
|
const root = await createTestTempDirectory(
|
|
"rea-uninstall-readonly-restore-",
|
|
);
|
|
const path = join(root, "client.json");
|
|
const fs =
|
|
await vi.importActual<typeof import("node:fs/promises")>(
|
|
"node:fs/promises",
|
|
);
|
|
const original =
|
|
'{"mcpServers":{"rea":{"command":"rea","args":["mcp"]}},"keep":true}\n';
|
|
await fs.writeFile(path, original);
|
|
await fs.chmod(path, 0o400);
|
|
const host = systemUninstallHost(root);
|
|
try {
|
|
const result = await host.removeClient({
|
|
name: "cursor",
|
|
configPath: path,
|
|
format: "json",
|
|
});
|
|
expect(result.status).toBe("failed");
|
|
expect(result.detail).toContain("The original was restored");
|
|
expect(await fs.readFile(path, "utf8")).toBe(original);
|
|
expect((await fs.lstat(path)).mode & 0o777).toBe(0o400);
|
|
expect(await fs.readFile(`${path}.rea.backup`, "utf8")).toBe(original);
|
|
expect(state.publicationHandles[0]!.fd).toBe(-1);
|
|
} finally {
|
|
await host.close?.();
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each(["setup", "uninstall"] as const)(
|
|
"%s refuses a configuration symlink inserted after backup without overwriting its target",
|
|
async (operation) => {
|
|
const root = await createTestTempDirectory(
|
|
"rea-config-prepublication-symlink-",
|
|
);
|
|
const path = join(root, "client.json");
|
|
const foreign = join(root, "foreign.json");
|
|
const fs =
|
|
await vi.importActual<typeof import("node:fs/promises")>(
|
|
"node:fs/promises",
|
|
);
|
|
const original =
|
|
operation === "setup"
|
|
? '{"keep":"original"}\n'
|
|
: '{"mcpServers":{"rea":{"command":"rea","args":["mcp"]}},"keep":"original"}\n';
|
|
const unrelated = '{"keep":"foreign"}\n';
|
|
await fs.writeFile(path, original);
|
|
await fs.writeFile(foreign, unrelated);
|
|
state.failReadback = false;
|
|
state.afterBackupCopy = async (source) => {
|
|
state.afterBackupCopy = undefined;
|
|
await fs.unlink(source);
|
|
await fs.symlink(foreign, source);
|
|
};
|
|
if (operation === "setup") {
|
|
expect(
|
|
(
|
|
await configureClientConfiguration(
|
|
{ name: "cursor", configPath: path, format: "json" },
|
|
{},
|
|
["rea", "mcp"],
|
|
)
|
|
).status,
|
|
).toBe("failed");
|
|
} else {
|
|
const host = systemUninstallHost(root);
|
|
try {
|
|
expect(
|
|
(
|
|
await host.removeClient({
|
|
name: "cursor",
|
|
configPath: path,
|
|
format: "json",
|
|
})
|
|
).status,
|
|
).toBe("failed");
|
|
} finally {
|
|
await host.close?.();
|
|
}
|
|
}
|
|
expect(await fs.readFile(foreign, "utf8")).toBe(unrelated);
|
|
expect((await fs.lstat(path)).isSymbolicLink()).toBe(true);
|
|
expect(await fs.readFile(`${path}.rea.backup`, "utf8")).toBe(original);
|
|
},
|
|
);
|
|
|
|
const runConfigurationChange = async (
|
|
operation: "setup" | "uninstall",
|
|
root: string,
|
|
path: string,
|
|
) => {
|
|
if (operation === "setup")
|
|
return configureClientConfiguration(
|
|
{ name: "cursor", configPath: path, format: "json" },
|
|
{},
|
|
["new-rea", "mcp"],
|
|
);
|
|
const host = systemUninstallHost(root);
|
|
try {
|
|
return await host.removeClient({
|
|
name: "cursor",
|
|
configPath: path,
|
|
format: "json",
|
|
});
|
|
} finally {
|
|
await host.close?.();
|
|
}
|
|
};
|
|
|
|
it.each([
|
|
["setup", "edit"],
|
|
["uninstall", "edit"],
|
|
["setup", "delete"],
|
|
["uninstall", "delete"],
|
|
] as const)(
|
|
"%s preserves a configuration %s after backup",
|
|
async (operation, change) => {
|
|
const root = await createTestTempDirectory("rea-config-backup-change-");
|
|
const path = join(root, "client.json");
|
|
const fs =
|
|
await vi.importActual<typeof import("node:fs/promises")>(
|
|
"node:fs/promises",
|
|
);
|
|
const original =
|
|
'{"mcpServers":{"rea":{"command":"rea","args":["mcp"]}},"keep":true}\n';
|
|
const edited = '{"changed":"by client"}\n';
|
|
await fs.writeFile(path, original);
|
|
state.failReadback = false;
|
|
state.afterBackupCopy = async (source) => {
|
|
state.afterBackupCopy = undefined;
|
|
if (change === "edit") await fs.writeFile(source, edited);
|
|
else await fs.unlink(source);
|
|
};
|
|
expect((await runConfigurationChange(operation, root, path)).status).toBe(
|
|
"failed",
|
|
);
|
|
if (change === "edit") expect(await fs.readFile(path, "utf8")).toBe(edited);
|
|
else await expect(fs.lstat(path)).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect(await fs.readFile(`${path}.rea.backup`, "utf8")).toBe(original);
|
|
expect(
|
|
(await fs.readdir(root)).filter((name) =>
|
|
name.startsWith(".rea-config-"),
|
|
),
|
|
).toEqual([]);
|
|
},
|
|
);
|
|
|
|
it.each(["setup", "uninstall"] as const)(
|
|
"%s refuses an input changed during staging",
|
|
async (operation) => {
|
|
const root = await createTestTempDirectory("rea-config-staging-change-");
|
|
const path = join(root, "client.json");
|
|
const fs =
|
|
await vi.importActual<typeof import("node:fs/promises")>(
|
|
"node:fs/promises",
|
|
);
|
|
const original =
|
|
'{"mcpServers":{"rea":{"command":"rea","args":["mcp"]}},"keep":true}\n';
|
|
const edited = '{"changed":"during staging"}\n';
|
|
await fs.writeFile(path, original);
|
|
state.failReadback = false;
|
|
state.beforePublicationOpen = async () => {
|
|
state.beforePublicationOpen = undefined;
|
|
await fs.writeFile(path, edited);
|
|
};
|
|
expect((await runConfigurationChange(operation, root, path)).status).toBe(
|
|
"failed",
|
|
);
|
|
expect(await fs.readFile(path, "utf8")).toBe(edited);
|
|
expect(await fs.readFile(`${path}.rea.backup`, "utf8")).toBe(original);
|
|
expect(
|
|
(await fs.readdir(root)).filter((name) =>
|
|
name.startsWith(".rea-config-"),
|
|
),
|
|
).toEqual([]);
|
|
},
|
|
);
|
|
|
|
it("first-run setup preserves a destination created during staging", async () => {
|
|
const root = await createTestTempDirectory("rea-config-first-conflict-");
|
|
const path = join(root, "client.json");
|
|
const fs =
|
|
await vi.importActual<typeof import("node:fs/promises")>(
|
|
"node:fs/promises",
|
|
);
|
|
const edited = '{"created":"by client"}\n';
|
|
state.failReadback = false;
|
|
state.beforePublicationOpen = async () => {
|
|
state.beforePublicationOpen = undefined;
|
|
await fs.writeFile(path, edited);
|
|
};
|
|
expect((await runConfigurationChange("setup", root, path)).status).toBe(
|
|
"failed",
|
|
);
|
|
expect(await fs.readFile(path, "utf8")).toBe(edited);
|
|
expect(
|
|
(await fs.readdir(root)).filter((name) => name.startsWith(".rea-config-")),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it.each(["setup", "uninstall"] as const)(
|
|
"%s retains staging directory cleanup for host retry",
|
|
async (operation) => {
|
|
const root = await createTestTempDirectory(
|
|
"rea-config-staging-directory-retry-",
|
|
);
|
|
const path = join(root, "client.json");
|
|
const fs =
|
|
await vi.importActual<typeof import("node:fs/promises")>(
|
|
"node:fs/promises",
|
|
);
|
|
await fs.writeFile(
|
|
path,
|
|
'{"mcpServers":{"rea":{"command":"rea","args":["mcp"]}},"keep":true}\n',
|
|
);
|
|
state.failReadback = false;
|
|
state.rejectStageRemoval = true;
|
|
const host =
|
|
operation === "setup"
|
|
? systemSetupHost(
|
|
{ platform: "linux", homeDirectory: root } as DoctorHost,
|
|
{ HOME: root },
|
|
)
|
|
: systemUninstallHost(root);
|
|
try {
|
|
const client = {
|
|
name: "cursor",
|
|
configPath: path,
|
|
format: "json" as const,
|
|
};
|
|
if (operation === "setup") {
|
|
await expect(
|
|
(host as ReturnType<typeof systemSetupHost>).configureClient(
|
|
client,
|
|
{},
|
|
["new-rea", "mcp"],
|
|
),
|
|
).rejects.toThrow();
|
|
} else {
|
|
expect(
|
|
(
|
|
await (host as ReturnType<typeof systemUninstallHost>).removeClient(
|
|
client,
|
|
)
|
|
).status,
|
|
).toBe("failed");
|
|
}
|
|
const cleanupFailure = await host.close?.();
|
|
if (operation === "setup") {
|
|
expect(cleanupFailure).toBeInstanceOf(AnalysisError);
|
|
const retainedRoots = (await fs.readdir(root))
|
|
.filter((name) => name.startsWith(".rea-config-"))
|
|
.map((name) => join(root, name));
|
|
expect((cleanupFailure as AnalysisError).cleanupResources).toEqual(
|
|
retainedRoots,
|
|
);
|
|
} else expect(cleanupFailure).toContain("cleanup");
|
|
expect(
|
|
(await fs.readdir(root)).filter((name) =>
|
|
name.startsWith(".rea-config-"),
|
|
),
|
|
).toHaveLength(1);
|
|
state.rejectStageRemoval = false;
|
|
expect(await host.close?.()).toBeUndefined();
|
|
if (cleanupFailure instanceof AnalysisError)
|
|
expect(cleanupFailure.cleanupIncomplete).toBe(false);
|
|
expect(
|
|
(await fs.readdir(root)).filter((name) =>
|
|
name.startsWith(".rea-config-"),
|
|
),
|
|
).toEqual([]);
|
|
expect(state.publicationHandles[0]!.fd).toBe(-1);
|
|
} finally {
|
|
state.rejectStageRemoval = false;
|
|
await host.close?.();
|
|
}
|
|
},
|
|
);
|
|
|
|
it("first-run publication exclusively refuses a destination created after its final check", async () => {
|
|
const root = await createTestTempDirectory("rea-config-exclusive-link-");
|
|
const path = join(root, "client.json");
|
|
const fs =
|
|
await vi.importActual<typeof import("node:fs/promises")>(
|
|
"node:fs/promises",
|
|
);
|
|
const edited = '{"created":"after final check"}\n';
|
|
state.beforeFirstPublication = async () => {
|
|
await fs.writeFile(path, edited);
|
|
};
|
|
await expect(
|
|
ClientConfigurationPublication.write(path, "{}", undefined),
|
|
).rejects.toMatchObject({ code: "EEXIST" });
|
|
expect(await fs.readFile(path, "utf8")).toBe(edited);
|
|
expect(
|
|
(await fs.readdir(root)).filter((name) => name.startsWith(".rea-config-")),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("publication keeps its selected canonical parent when a directory symlink is retargeted", async () => {
|
|
const root = await createTestTempDirectory("rea-config-parent-selection-");
|
|
const fs =
|
|
await vi.importActual<typeof import("node:fs/promises")>(
|
|
"node:fs/promises",
|
|
);
|
|
const selected = join(root, "selected");
|
|
const foreign = join(root, "foreign");
|
|
const alias = join(root, "alias");
|
|
await fs.mkdir(selected);
|
|
await fs.mkdir(foreign);
|
|
await fs.symlink(selected, alias, "dir");
|
|
const original = '{"original":true}\n';
|
|
const unrelated = '{"foreign":true}\n';
|
|
await fs.writeFile(join(selected, "client.json"), original);
|
|
await fs.writeFile(join(foreign, "client.json"), unrelated);
|
|
state.beforePublicationOpen = async () => {
|
|
state.beforePublicationOpen = undefined;
|
|
await fs.unlink(alias);
|
|
await fs.symlink(foreign, alias, "dir");
|
|
};
|
|
const publication = await ClientConfigurationPublication.write(
|
|
join(alias, "client.json"),
|
|
"{}",
|
|
original,
|
|
);
|
|
await publication.verify(() => publication.assertCurrent());
|
|
expect(await fs.readFile(join(selected, "client.json"), "utf8")).toBe("{}");
|
|
expect(await fs.readFile(join(foreign, "client.json"), "utf8")).toBe(
|
|
unrelated,
|
|
);
|
|
expect(
|
|
(await fs.readdir(selected)).filter((name) =>
|
|
name.startsWith(".rea-config-"),
|
|
),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it.each(["setup", "uninstall"] as const)(
|
|
"%s reads back the selected canonical parent after its alias is retargeted",
|
|
async (operation) => {
|
|
const root = await createTestTempDirectory(
|
|
"rea-config-adapter-parent-selection-",
|
|
);
|
|
const fs =
|
|
await vi.importActual<typeof import("node:fs/promises")>(
|
|
"node:fs/promises",
|
|
);
|
|
const selected = join(root, "selected");
|
|
const foreign = join(root, "foreign");
|
|
const alias = join(root, "alias");
|
|
await fs.mkdir(selected);
|
|
await fs.mkdir(foreign);
|
|
await fs.symlink(selected, alias, "dir");
|
|
const original =
|
|
'{"mcpServers":{"rea":{"command":"rea","args":["mcp"]}},"keep":true}\n';
|
|
const unrelated = '{"foreign":true}\n';
|
|
await fs.writeFile(join(selected, "client.json"), original);
|
|
await fs.writeFile(join(foreign, "client.json"), unrelated);
|
|
state.failReadback = false;
|
|
state.beforePublicationOpen = async () => {
|
|
state.beforePublicationOpen = undefined;
|
|
await fs.unlink(alias);
|
|
await fs.symlink(foreign, alias, "dir");
|
|
};
|
|
expect(
|
|
(
|
|
await runConfigurationChange(
|
|
operation,
|
|
root,
|
|
join(alias, "client.json"),
|
|
)
|
|
).status,
|
|
).toBe(operation === "setup" ? "configured" : "removed");
|
|
expect(
|
|
JSON.parse(await fs.readFile(join(selected, "client.json"), "utf8")),
|
|
).toMatchObject({ keep: true });
|
|
expect(await fs.readFile(join(foreign, "client.json"), "utf8")).toBe(
|
|
unrelated,
|
|
);
|
|
expect(
|
|
await fs.readFile(join(selected, "client.json.rea.backup"), "utf8"),
|
|
).toBe(original);
|
|
},
|
|
);
|