Files
reverse-engineering-anything/tests/boundary/filesystem/setupClientConfigCleanupOwnership.test.ts
T
240f08d406 feat(desktop): set up and check a desktop build host in one command (#9199)
* fix(desktop): keep an empty env value empty

An empty assignment is not a path. Resolving it named the working directory
and rewrote the value when that directory's spelling moved.

* fix(desktop): keep a run's font on the run, not the frame's text

A formatting attribute such as Scribus FONT is not the document's words.
Joining it across runs made a frame-wide text check of ItalicBold that
failed when a run was split. The font stays a value check on its own run.

* fix(desktop): do not pair JSON records by a selector-shaped field

A field named @record_id matched the id suffix and became [@record_id=one],
which reads an XML attribute. Records paired while their pointers matched
nothing. Names that start with @ or > are not id fields.

* fix(desktop): count a save-numbered id once when pairing members

xml:id and draw:id values such as id123456 were both generated and
numbered, so the same selector was counted twice and refused. A text
edit of two elements keyed only by those ids now keeps its check.

* fix(desktop): count members of root JSON arrays

* fix(desktop): preserve selected state directory aliases

* docs(desktop): explain tool and skill refresh prerequisites

* fix(desktop): align nested state selections with aliases

* fix(desktop): check build runtime export capability

* fix(desktop): provide complete runtime repack command

* fix(desktop): retain runtime probe failures

* test(desktop): identify capture independently of schema preflight

* fix(desktop): preserve document URLs and value formats

* fix(desktop): bound added checks and omit noisy text guards

* fix(desktop): exclude unseeded locks from task checks

* fix(desktop): preserve draft bounds and launch URLs

* fix(desktop): render draft values using their source format

* fix(desktop): preserve launch URLs and JSON draft values

* fix(desktop): recognize captured file URL documents

* fix(desktop): render captured file URLs for runtime home

* fix(desktop): distinguish phone numbers from save metadata

* fix(desktop): preserve launch URLs and user text

* fix(desktop): preserve captured URLs and user text

* fix(desktop): preserve local file URL authority

* fix(desktop): preserve local file URL authority

* fix(desktop): preserve local file URL authority

* fix(desktop): render captured file URLs at launch

* fix(desktop): classify numeric timestamps using field context

* fix(desktop): classify numeric timestamps using field context

* fix(desktop): classify numeric timestamps using field context

* fix(desktop): retain context for generated draft values

* fix(desktop): retain context for generated draft values

* fix(desktop): retain context for generated draft values

* test(desktop): cover generated IDs in text guards

* test(desktop): cover generated IDs in text guards

* test(desktop): cover generated IDs in text guards

* fix(desktop): isolate launcher URL encoding

* fix(desktop): isolate launcher URL encoding

* fix(desktop): isolate launcher URL encoding

* fix(desktop): isolate launcher URL encoding

* fix(desktop): retain clean text in noisy record fields

* fix(desktop): classify added line values in one pass

* fix(desktop): apply XML quote rules to text drafts

* fix(desktop): distinguish XML attributes from quoted text

* fix(desktop): preserve parsed XML line context

* Format appTaskChecks.ts with the pinned oxfmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(desktop): retain partial XML syntax facts

* fix(desktop): recognize markdown autolinks in drafts

* fix(desktop): reuse export exclusions in task drafts

* fix(desktop): report unchecked client build paths

* docs(desktop): clarify gzip scrub limits

* fix(desktop): reuse export exclusions in task drafts

* docs(desktop): clarify gzip scrub limits

* fix(setup): own desktop build process cleanup

* fix(doctor): compare desktop settings with resolved defaults

* style(desktop): format the noisy-record draft files with the pinned oxfmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(desktop): give the root-array draft test its own describe

The records describe grew past the 150-line lint limit with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(doctor): inspect client-recorded desktop runners

* fix(doctor): reuse matching runner probes

* fix(setup): propagate cancellation to desktop actions

* fix(setup): stop Hopper on cancel, never report a cancelled setup ready, and keep a failed action's client setting

- The Hopper install gets setup's signal: Linux already honoured it, and the
  macOS installer now stops its downloads and stops before mounting or copying.
- runSetup checks the signal after the final doctor and close, so a
  cancellation there raises instead of returning ready.
- A failed pack or agentenv install no longer writes its archive or runner to
  client configs, so the setting the client already had stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(setup): keep the completed setup result in a cancellation after cleanup

A cancellation during the final doctor or host close carried only the applied
actions, dropping each client's result and backup path and the doctor report.
It now carries the whole result as setup_result, as doctor's cancellation
carries its report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(desktop): move launch execution to boundary suite

* fix(desktop): redact build schema failure output

* refactor(desktop): normalize schema failure once

* fix(desktop): omit unsafe schema parser snippets

* fix(desktop): preserve draft change and selected path evidence

* test(desktop): group retained dconf note regressions

* fix(desktop): respect archive entry format in draft checks

* test(desktop): separate unowned-file draft note cases

* fix(desktop): preserve explicitly selected journal documents

* fix(xml): avoid scanning selected ancestors per tag

* fix(desktop): exclude covered text runs from draft counts

* fix: preserve text when drafting desktop checks

* fix: restore nested desktop state aliases

* refactor: share UUID recognition across draft checks

* fix: retain multiline XML attribute context

* fix: retain relationship identifiers in task drafts

* docs: clarify desktop draft identifier handling

* test(desktop): type formatting run cases as pairs

* fix(desktop): carry document format into selector evaluation

* test(desktop): separate markup boundary regression group

* fix(desktop): propagate formats through draft selectors

* fix(desktop): switch selector format within archive entries

* test(desktop): remove duplicate seed filename case

* fix(setup): retain diagnostics when cancellation overlaps failure

* refactor(setup): keep command cancellation at its owner

* test(setup): verify cancellation at the application boundary

* test(desktop): pass JSON format in exported index parity

* docs(desktop): clarify task checks before scratch export

* fix(desktop): respell selected paths in file URLs

* test(desktop): group captured document spelling cases

* fix(desktop): bound draft preview serialization

* fix(desktop): restore selected state aliases

* fix(desktop): respect entry format in draft noise

* test(desktop): match exported alias evidence at its JSON boundary

* fix(desktop): retain app state logs in exported seeds

* fix: align desktop numeric seed comparisons

* test(desktop): keep one draft exclusion fixture import

* test(desktop): retain log eligibility across draft test groups

* fix(doctor): resolve empty registered desktop runner

* style: fix desktop launch test formatting

* style: format native command path schema

* fix(desktop): map selected external launch paths

* style(desktop): format seed document regressions

* test(desktop): group launch URL regressions

* fix(desktop): omit redundant nested state aliases

* fix(desktop): compare added free text as a whole

* fix(desktop): check recompressed seeds as shipped

* docs(desktop): clarify recovered seed format

* test(desktop): group shipped gzip seed checks

* fix: retain quoted identifier text in task drafts

* fix: recognize Unicode XML in desktop state readers

* fix(desktop): bound nested draft checks

* fix(desktop): explain missing arrays in truncated views

* fix(desktop): find XML type references in archives

* test(desktop): remove duplicated capture assertions

* fix(desktop): retain nested omitted text boundaries

* style(desktop): format draft value imports

* test(desktop): group model and draft value checks

* fix(desktop): map captured launch paths once

* refactor: remove unused whole-text guard state

* refactor: stop passing unused text children

* test: align free-text checks with whole-note policy

* fix: validate recompressed desktop archive seeds

* style(desktop): format archive seed regression

* fix(desktop): retain placed-item sibling guards

* fix(desktop): preserve archive paths and bound doctor reads

* fix(doctor): refuse non-files before descriptor admission

* fix(setup): track remaining cleanup resources across retries

* test(desktop): specify XML format in heap pairing fixture

* fix(doctor): query agentenv for model endpoint

* fix(doctor): preserve uncertainty for empty endpoint override

* fix(desktop): redact runtime schema errors

* fix(desktop): normalize captured file URLs

* fix(desktop): report seeded files by capture path

* fix(desktop): retain startup readiness failure stage

* fix(desktop): retain JSON date checks

* fix(desktop): preserve archive formatting checks

* fix(desktop): preserve explicitly selected journal files

* fix(desktop): report build command transport limits

* docs(desktop): clarify unreadable dconf versions

* test(desktop): remove duplicate history regression

* fix(desktop): align XML names with exported reader

* fix(desktop): create selected state alias targets

* fix(desktop): inspect supported runtime archives

* fix(desktop): retain record identity in draft checks

* fix(desktop): scrub XML independently of reader compatibility

* fix(desktop): preserve file aliases and reader diagnostics

* style(desktop): format review fixes

* style(desktop): format capture review fixes

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 23:02:31 +08:00

1024 lines
36 KiB
TypeScript

import {
lstat as fsLstat,
mkdir,
open as fsOpen,
readFile,
realpath as fsRealpath,
rename,
writeFile,
type FileHandle,
} from "node:fs/promises";
import { dirname, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
readClientRegistrationInspections,
readClientRegistrationStatuses,
} from "../../../src/application/ClientRegistrationStatus.js";
import {
existingMaintenanceScope,
planIntegrationMaintenance,
} from "../../../src/application/UpdateMaintenance.js";
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
import { ok } from "../../../src/domain/result.js";
import type { DoctorHost } from "../../../src/application/Doctor.js";
import { ConfigurationError } from "../../../src/domain/configurationErrors.js";
import {
filterClientsNeedingConfigure,
systemSetupHost,
} from "../../../src/application/SetupHost.js";
import { RegularFileCleanupFailure } from "../../../src/filesystem/OwnedRegularFile.js";
import { configureDetectedClients } from "../../../src/application/SetupClients.js";
import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js";
import {
systemUninstallHost,
runUninstall,
} from "../../../src/application/Uninstall.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js";
import { withVerifierCleanup } from "../../../scripts/lib/verifier-cleanup.mjs";
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return {
...actual,
open: vi.fn(actual.open),
lstat: vi.fn(actual.lstat),
realpath: vi.fn(actual.realpath),
};
});
const openMock = vi.mocked(fsOpen);
const lstatMock = vi.mocked(fsLstat);
const realpathMock = vi.mocked(fsRealpath);
let actualFs: typeof import("node:fs/promises");
beforeEach(async () => {
actualFs =
await vi.importActual<typeof import("node:fs/promises")>(
"node:fs/promises",
);
openMock.mockImplementation((...args) => actualFs.open(...args));
lstatMock.mockImplementation((...args) => actualFs.lstat(...args));
realpathMock.mockImplementation((...args) => actualFs.realpath(...args));
});
afterEach(() => {
openMock.mockReset();
lstatMock.mockReset();
realpathMock.mockReset();
});
const holdReadHandle = (
path: string,
reason: string,
initiallyHeld = true,
maxFailures = Number.POSITIVE_INFINITY,
) => {
const closeAttempts: FileHandle[] = [];
let released = false;
let held = initiallyHeld;
let remainingFailures = maxFailures;
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 (held && !released && remainingFailures > 0) {
remainingFailures -= 1;
throw new Error(reason);
}
await nativeClose();
});
return handle;
});
return {
closeAttempts,
hold: () => {
held = true;
},
release: () => {
released = true;
},
};
};
/** Linux setup host with its VS Code client configuration directory prepared. */
const preparedVsCodeSetupHost = async (prefix: string) => {
const home = await createTestTempDirectory(prefix);
const host = systemSetupHost(
{ platform: "linux", homeDirectory: home } as DoctorHost,
{ HOME: home, XDG_CONFIG_HOME: join(home, "xdg") },
);
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 });
return { home, host, client };
};
/** Release the held read handle and verify host cleanup retries that same owner. */
const expectCloseReclaimsHandle = async (
host: { close?: () => Promise<unknown> },
closeAttempts: readonly object[],
release: () => void,
) => {
const attemptsBeforeClose = closeAttempts.length;
release();
expect(await host.close?.()).toBeUndefined();
expect(closeAttempts).toHaveLength(attemptsBeforeClose + 1);
expect(closeAttempts.at(-1)).toBe(closeAttempts[0]);
};
/** Give up on the assertion path: release the handle and let cleanup close it. */
const cleanupHeldHandle = async (
host: { close?: () => Promise<unknown> },
release: () => void,
) => {
release();
await host.close?.();
};
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(Buffer.compare(await readFile(path), original)).toBe(0);
expect(closeAttempts).toHaveLength(3);
expect(closeAttempts.every((handle) => handle === closeAttempts[0])).toBe(
true,
);
await expectCloseReclaimsHandle(host, closeAttempts, release);
} finally {
await cleanupHeldHandle(host, release);
}
});
it("stops uninstall and retains the backup read owner when its close fails", async () => {
const home = await createTestTempDirectory("rea-uninstall-backup-close-");
const path = join(home, "client.json");
const client = { name: "cursor", format: "json", configPath: path } as const;
await writeFile(path, '{"keep":"before setup"}\n');
expect(
await configureClientConfiguration(client, {}, ["rea", "mcp"]),
).toMatchObject({ status: "configured" });
const configured = await readFile(path, "utf8");
const { closeAttempts, release } = holdReadHandle(
`${path}.rea.backup`,
"held backup handle",
);
const host = systemUninstallHost(home, undefined, { HOME: home }, "linux");
try {
expect(await host.removeClient(client)).toEqual({
name: "cursor",
status: "failed",
detail: expect.stringMatching(
/could not be backed up.*no change.*permissions.*uninstall/i,
),
});
expect(await readFile(path, "utf8")).toBe(configured);
expect(closeAttempts).toHaveLength(2);
await expectCloseReclaimsHandle(host, closeAttempts, release);
} finally {
await cleanupHeldHandle(host, release);
}
});
it("keeps shared-ownership proof when the ownership read completes but its close fails", async () => {
// FreeBuff without a profile marker leaves its shared storage applicability-
// unverified. The completed ownership read is the proof that later read
// failures are blocking; discarding it softens every failure to retained,
// so purge runs while the active shared registration remains.
const home = await createTestTempDirectory("rea-freebuff-ownership-close-");
const setupHost = systemSetupHost(
{ platform: "linux", homeDirectory: home } as DoctorHost,
{ HOME: home },
);
const client = (await setupHost.supportedClients()).find(
({ name }) => name === "freebuff",
);
if (client === undefined) throw new Error("freebuff client missing");
await mkdir(dirname(client.configPath), { recursive: true });
await writeFile(
client.configPath,
JSON.stringify({ mcpServers: { other: { command: "other" } } }),
);
expect(
await configureClientConfiguration(client, {}, ["rea", "mcp"]),
).toMatchObject({ status: "configured" });
const state = join(home, ".rea", "state.json");
await mkdir(dirname(state), { recursive: true });
await writeFile(state, "{}\n");
const { release } = holdReadHandle(
client.configPath,
"held ownership read handle",
);
const host = systemUninstallHost(home, undefined, { HOME: home }, "linux");
try {
const result = await runUninstall(true, host);
expect(result.status).toBe("failed");
expect(result.items).toContainEqual(
expect.objectContaining({ name: "freebuff", status: "failed" }),
);
expect(result.items).toContainEqual(
expect.objectContaining({
name: "uninstall",
status: "skipped",
detail: expect.stringContaining(
"No REA registration, skill, or data was removed",
),
}),
);
// Data must not be purged while the shared registration is still active.
await expect(readFile(state, "utf8")).resolves.toBe("{}\n");
} finally {
await cleanupHeldHandle(host, release);
}
});
it("keeps shared-ownership proof when removal refuses a shadowed duplicate", async () => {
// The effective row is REA-owned while an earlier duplicate is foreign, so
// removal refuses the edit. That refusal must not erase ownership: without
// the positive row fact, recurring close failures soften to retained and
// purge runs while the owned row remains active.
const home = await createTestTempDirectory("rea-freebuff-shadowed-close-");
const setupHost = systemSetupHost(
{ platform: "linux", homeDirectory: home } as DoctorHost,
{ HOME: home },
);
const client = (await setupHost.supportedClients()).find(
({ name }) => name === "freebuff",
);
if (client === undefined) throw new Error("freebuff client missing");
await mkdir(dirname(client.configPath), { recursive: true });
await writeFile(
client.configPath,
'{"mcpServers":{"rea":{"command":"foreign-server","args":[]},"rea":{"command":"rea","args":["mcp"]}}}',
);
const state = join(home, ".rea", "state.json");
await mkdir(dirname(state), { recursive: true });
await writeFile(state, "{}\n");
const { release } = holdReadHandle(
client.configPath,
"held ownership read handle",
);
const host = systemUninstallHost(home, undefined, { HOME: home }, "linux");
try {
const result = await runUninstall(true, host);
expect(result.status).toBe("failed");
expect(result.items).toContainEqual(
expect.objectContaining({ name: "freebuff", status: "failed" }),
);
expect(result.items).toContainEqual(
expect.objectContaining({
name: "uninstall",
status: "skipped",
detail: expect.stringContaining(
"No REA registration, skill, or data was removed",
),
}),
);
await expect(readFile(state, "utf8")).resolves.toBe("{}\n");
} finally {
await cleanupHeldHandle(host, release);
}
});
it("stops removal when a completed reread leaves its handle unreleased", async () => {
// Greptile P1: preflight and reread both complete with a foreign-only row,
// but the reread handle cannot be closed even by the cleanup retry. REA
// still owns that open handle, so removal must stop before purge: serving
// the completed text would let removeAll delete managed data while the
// incomplete cleanup is only reported later, by close().
const home = await createTestTempDirectory("rea-freebuff-reread-leak-");
const setupHost = systemSetupHost(
{ platform: "linux", homeDirectory: home } as DoctorHost,
{ HOME: home },
);
const client = (await setupHost.supportedClients()).find(
({ name }) => name === "freebuff",
);
if (client === undefined) throw new Error("freebuff client missing");
await mkdir(dirname(client.configPath), { recursive: true });
await writeFile(
client.configPath,
'{"mcpServers":{"rea":{"command":"foreign-server","args":[]}}}',
);
const state = join(home, ".rea", "state.json");
await mkdir(dirname(state), { recursive: true });
await writeFile(state, "{}\n");
const { hold, release } = holdReadHandle(
client.configPath,
"held removal reread handle",
false,
);
const base = systemUninstallHost(home, undefined, { HOME: home }, "linux");
const host = {
...base,
inspectSkill: async () => {
hold();
return base.inspectSkill?.();
},
};
try {
const result = await runUninstall(true, host);
expect(result.status).toBe("failed");
expect(result.items).toContainEqual(
expect.objectContaining({
name: "freebuff",
status: "failed",
detail: expect.stringContaining("its resources could not be released"),
}),
);
expect(result.items).toContainEqual(
expect.objectContaining({
name: "uninstall",
status: "skipped",
detail: expect.stringContaining("Uninstall stopped at this failure"),
}),
);
// Managed data must survive an unreleased read handle.
await expect(readFile(state, "utf8")).resolves.toBe("{}\n");
} finally {
await cleanupHeldHandle(host, release);
}
});
it("keeps the shared registration fail-closed when ZCode's probe is unresolved", async () => {
// A denied profile probe leaves ZCode's applicability unresolved, never
// absent: the shared file stays fail-closed so its row is inspected and
// removed instead of leaving an installed ZCode wired to purged data.
// Definitive absence still leaves the file to FreeBuff, ZCode's owner.
const home = await createTestTempDirectory("rea-zcode-fallback-unresolved-");
const fallback = join(home, ".agents", "mcp.json");
const profile = join(home, ".zcode", "cli");
lstatMock.mockImplementation((path) =>
String(path) === profile
? Promise.reject(
Object.assign(new Error("EACCES: permission denied, lstat"), {
code: "EACCES",
}),
)
: actualFs.lstat(path),
);
const unresolved = systemUninstallHost(
home,
undefined,
{ HOME: home },
"linux",
);
try {
const rows = (await unresolved.clients()).filter(
(candidate) => candidate.configPath === fallback,
);
// One physical file, one owner: FreeBuff edits the shared file once and
// ZCode reports that same outcome instead of racing it to "absent".
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ name: "freebuff", sharedWith: ["zcode"] });
// Fail-closed: the unresolved reader carries no unverified softening, so a
// failed read blocks removal instead of retaining around the purge.
expect(rows[0]?.applicability).toBeUndefined();
} finally {
await unresolved.close?.();
}
lstatMock.mockImplementation((...args) => actualFs.lstat(...args));
const absent = systemUninstallHost(home, undefined, { HOME: home }, "linux");
try {
const rows = (await absent.clients()).filter(
(candidate) => candidate.configPath === fallback,
);
expect(rows).toHaveLength(1);
// Definitive absence keeps ZCode out of another product's primary storage.
expect(rows[0]?.sharedWith).toBeUndefined();
} finally {
await absent.close?.();
}
});
it("removes a shared registration once and reports that outcome to both clients", async () => {
// ZCode and FreeBuff both load ~/.agents/mcp.json. Queuing it per client
// removed the row as ZCode and then reported FreeBuff as "absent", losing
// the outcome for the client that owns the file, and it read and edited one
// shared resource twice.
const home = await createTestTempDirectory("rea-shared-removal-once-");
const shared = join(home, ".agents", "mcp.json");
await mkdir(join(home, ".zcode", "cli"), { recursive: true });
await mkdir(join(home, ".config", "freebuff-desktop"), { recursive: true });
expect(
await configureClientConfiguration(
{ name: "freebuff", format: "json", configPath: shared },
{},
["rea", "mcp"],
),
).toMatchObject({ status: "configured" });
const host = systemUninstallHost(home, undefined, { HOME: home }, "linux");
try {
const result = await runUninstall(false, host);
expect(result.status).toBe("complete");
expect(result.items).toContainEqual(
expect.objectContaining({
name: "freebuff",
status: "removed",
configuration_path: shared,
}),
);
// The fallback reader reports the owner's removal, never "absent".
const zcode = result.items.find(({ name }) => name === "zcode");
expect(zcode?.actions).toContainEqual(
expect.objectContaining({
status: "removed",
configuration_path: shared,
}),
);
expect(zcode?.actions).not.toContainEqual(
expect.objectContaining({
status: "skipped",
configuration_path: shared,
}),
);
expect(JSON.parse(await readFile(shared, "utf8"))).toEqual({
mcpServers: {},
});
} finally {
await host.close?.();
}
});
it("keeps one row per client when OpenCode is pointed at FreeBuff's file", async () => {
// OPENCODE_CONFIG can make OpenCode and FreeBuff edit one file with different
// tables (`mcp.rea` and `mcpServers.rea`). Sharing one decision by filename
// would remove only OpenCode's row and report it for FreeBuff too, leaving
// FreeBuff registered while uninstall reports complete.
const home = await createTestTempDirectory("rea-shared-dialects-");
const shared = join(home, ".agents", "mcp.json");
await mkdir(dirname(shared), { recursive: true });
await mkdir(join(home, ".config", "freebuff-desktop"), { recursive: true });
expect(
await configureClientConfiguration(
{ name: "freebuff", format: "json", configPath: shared },
{},
["rea", "mcp"],
),
).toMatchObject({ status: "configured" });
const host = systemUninstallHost(
home,
undefined,
{ HOME: home, OPENCODE_CONFIG: shared },
"linux",
);
try {
const rows = (await host.clients()).filter(
(candidate) => candidate.configPath === shared,
);
expect(rows.map(({ name }) => name).sort()).toEqual([
"freebuff",
"opencode",
]);
expect(await runUninstall(false, host)).toMatchObject({
status: "complete",
});
expect(JSON.parse(await readFile(shared, "utf8"))).toEqual({
mcpServers: {},
});
} finally {
await host.close?.();
}
});
it.each([true, false])(
"projects only the shared table's outcome to ZCode (shared row owned: %s)",
async (sharedOwned) => {
const home = await createTestTempDirectory("rea-shared-table-outcome-");
const shared = join(home, ".agents", "mcp.json");
const zcodePath = join(home, ".zcode", "cli", "config.json");
await mkdir(dirname(shared), { recursive: true });
await mkdir(dirname(zcodePath), { recursive: true });
await writeFile(zcodePath, '{"mcp":{"servers":{}}}');
await writeFile(
shared,
JSON.stringify({
mcp: {
rea: {
type: "local",
command: [sharedOwned ? "foreign-server" : "rea", "mcp"],
},
},
mcpServers: {
rea: {
command: sharedOwned ? "rea" : "foreign-server",
args: ["mcp"],
},
},
}),
);
const host = systemUninstallHost(
home,
undefined,
{ HOME: home, OPENCODE_CONFIG: shared },
"linux",
);
try {
const result = await runUninstall(false, host);
expect(result.status).toBe("complete");
const zcode = result.items.find(({ name }) => name === "zcode");
expect(zcode?.status).toBe(sharedOwned ? "removed" : "retained");
expect(
zcode?.actions?.filter(
({ configuration_path }) => configuration_path === shared,
),
).toEqual([
expect.objectContaining({
status: sharedOwned ? "removed" : "retained",
}),
]);
const remaining = JSON.parse(await readFile(shared, "utf8"));
expect(remaining.mcp).toEqual(
sharedOwned
? { rea: { type: "local", command: ["foreign-server", "mcp"] } }
: {},
);
expect(remaining.mcpServers).toEqual(
sharedOwned
? {}
: { rea: { command: "foreign-server", args: ["mcp"] } },
);
} finally {
await host.close?.();
}
},
);
it("does not treat a dangling FreeBuff profile link as installed", async () => {
// Setup and doctor follow ~/.config/freebuff-desktop and reject a link whose
// target is gone. Discovery must agree: counting the leftover link as an
// installed product turns an unrelated malformed shared file into a blocking
// failure that stops every other REA removal.
const home = await createTestTempDirectory("rea-freebuff-dangling-profile-");
const shared = join(home, ".agents", "mcp.json");
const profile = join(home, ".config", "freebuff-desktop");
const cursor = join(home, ".cursor", "mcp.json");
await mkdir(dirname(shared), { recursive: true });
await writeFile(shared, "{ not json");
await mkdir(dirname(cursor), { recursive: true });
await writeFile(cursor, '{"keep":"before setup"}\n');
expect(
await configureClientConfiguration(
{ name: "cursor", format: "json", configPath: cursor },
{},
["rea", "mcp"],
),
).toMatchObject({ status: "configured" });
lstatMock.mockImplementation((path) =>
String(path) === profile
? Promise.resolve({
isFile: () => false,
isDirectory: () => false,
isSymbolicLink: () => true,
} as unknown as Awaited<ReturnType<typeof fsLstat>>)
: actualFs.lstat(path),
);
realpathMock.mockImplementation((path) =>
String(path) === profile
? Promise.reject(
Object.assign(
new Error("ENOENT: no such file or directory, realpath"),
{ code: "ENOENT" },
),
)
: actualFs.realpath(path),
);
const host = systemUninstallHost(home, undefined, { HOME: home }, "linux");
try {
const result = await runUninstall(false, host);
expect(result.status).toBe("complete");
// The unrelated registration is removed instead of blocked.
expect(result.items).toContainEqual(
expect.objectContaining({ name: "cursor", status: "removed" }),
);
expect(JSON.parse(await readFile(cursor, "utf8"))).not.toHaveProperty(
"mcpServers.rea",
);
expect(result.items).toContainEqual(
expect.objectContaining({ name: "freebuff", status: "retained" }),
);
} finally {
await host.close?.();
}
});
it("serves a completed removal reread when its close retry releases the handle", async () => {
// Preflight saw no owned row; another writer adds one before removal. The
// reread's first close fails but its cleanup retry releases the handle:
// the completed text is the ownership evidence that drives removal of the
// concurrently added row instead of softening to retained and leaving it
// active while data is purged.
const home = await createTestTempDirectory("rea-freebuff-reread-close-");
const setupHost = systemSetupHost(
{ platform: "linux", homeDirectory: home } as DoctorHost,
{ HOME: home },
);
const client = (await setupHost.supportedClients()).find(
({ name }) => name === "freebuff",
);
if (client === undefined) throw new Error("freebuff client missing");
await mkdir(dirname(client.configPath), { recursive: true });
await writeFile(
client.configPath,
'{"mcpServers":{"rea":{"command":"foreign-server","args":[]}}}',
);
const cacheOwned = join(home, ".rea", "cache", "owned");
await mkdir(dirname(cacheOwned), { recursive: true });
await writeFile(cacheOwned, "owned\n");
const { hold, release } = holdReadHandle(
client.configPath,
"held removal reread handle",
false,
1,
);
const base = systemUninstallHost(home, undefined, { HOME: home }, "linux");
const host = {
...base,
inspectSkill: async () => {
// Another writer registers REA between preflight and removal.
await writeFile(
client.configPath,
'{"mcpServers":{"rea":{"command":"rea","args":["mcp"]}}}',
);
hold();
return base.inspectSkill?.();
},
};
try {
const result = await runUninstall(true, host);
expect(result.status).toBe("complete");
expect(result.items).toContainEqual(
expect.objectContaining({ name: "freebuff", status: "removed" }),
);
expect(await readFile(client.configPath, "utf8")).not.toContain("rea");
await expect(readFile(cacheOwned)).rejects.toMatchObject({
code: "ENOENT",
});
} finally {
await cleanupHeldHandle(host, release);
}
});
describe("ZCode fallback cleanup ownership", () => {
it.each(["preflight", "configure"])(
"retains a failed ZCode fallback close during %s until host cleanup",
async (operation) => {
const home = await createTestTempDirectory("rea-zcode-fallback-close-");
const host = systemSetupHost(
{ platform: "linux", homeDirectory: home } as DoctorHost,
{ HOME: home },
);
const client = (await host.supportedClients()).find(
({ name }) => name === "zcode",
);
if (client?.zcodeFallbackPath === undefined)
throw new Error("ZCode fallback path missing");
const fallback = client.zcodeFallbackPath;
await mkdir(dirname(fallback), { recursive: true });
const original = '{"mcpServers":{"other":{"command":"other"}}}\n';
await writeFile(fallback, original);
const { closeAttempts, release } = holdReadHandle(
fallback,
"held fallback handle",
);
try {
const action =
operation === "preflight"
? host.inspectClientConfiguration?.(client, {}, ["rea", "mcp"])
: host.configureClient(client, {}, ["rea", "mcp"]);
if (action === undefined) throw new Error("setup preflight missing");
await expect(action).rejects.toMatchObject({
_tag: "ConfigurationError",
cause: expect.any(RegularFileCleanupFailure),
cleanup: { resources: [fallback] },
});
expect(closeAttempts).toHaveLength(2);
expect(closeAttempts[0]).toBe(closeAttempts[1]);
expect(await readFile(fallback, "utf8")).toBe(original);
await expect(readFile(client.configPath)).rejects.toMatchObject({
code: "ENOENT",
});
await expectCloseReclaimsHandle(host, closeAttempts, release);
} finally {
await cleanupHeldHandle(host, release);
}
},
);
});
describe("setup client configuration cleanup ownership", () => {
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 { host, client } = await preparedVsCodeSetupHost(
"rea-setup-config-close-",
);
await writeFile(client.configPath, original);
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],
{ providerEnvironment: {} },
["rea", "mcp"],
)
.then((detectedClients) =>
configureDetectedClients({
host,
detectedClients,
providerEnvironment: {},
command: ["rea", "mcp"],
clients: {},
appliedActions: [],
}),
)
.then(
() => undefined,
(cause: unknown) => cause,
);
expect(failure).toBeInstanceOf(ConfigurationError);
if (!(failure instanceof ConfigurationError))
throw new Error(
"Expected the configuration failure to retain cleanup authority",
);
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(
Buffer.compare(await readFile(client.configPath), original),
).toBe(0);
await expectCloseReclaimsHandle(host, closeAttempts, release);
expect(failure.cleanupIncomplete).toBe(false);
expect(failure.cleanupResources).toEqual([]);
} finally {
await cleanupHeldHandle(host, release);
}
},
);
it("waits for an admitted configuration read and rejects later admissions", async () => {
const { host, client } = await preparedVsCodeSetupHost(
"rea-setup-config-inflight-",
);
await writeFile(client.configPath, '{"servers": {}}\n');
let beginRead!: () => void;
const readStarted = new Promise<void>((resolve) => {
beginRead = resolve;
});
let unblockRead!: () => void;
const readGate = new Promise<void>((resolve) => {
unblockRead = resolve;
});
let heldRead = false;
openMock.mockImplementation(async (...args) => {
const handle = await actualFs.open(...args);
if (String(args[0]) !== client.configPath || heldRead) return handle;
heldRead = true;
const nativeRead = handle.read.bind(handle);
vi.spyOn(handle, "read").mockImplementation(async (...readArgs) => {
beginRead();
await readGate;
return nativeRead(...readArgs);
});
return handle;
});
let hostCloseFinished = false;
const alignment = host.clientNeedsConfigure(client, {}, ["rea", "mcp"]);
let closeResult: ReturnType<NonNullable<typeof host.close>> | undefined;
await withVerifierCleanup(async () => {
await awaitAcknowledgement(readStarted, alignment, "configuration read");
const close = host.close;
if (close === undefined) throw new Error("Setup host close is missing");
closeResult = close().then((result) => {
hostCloseFinished = true;
return result;
});
const lateAdmission = await host
.clientNeedsConfigure(client, {}, ["rea", "mcp"])
.then(
() => undefined,
(cause: unknown) => cause,
);
expect(lateAdmission).toBeInstanceOf(ConfigurationError);
expect(lateAdmission).toMatchObject({
message: expect.stringContaining("Artifact resource scope is closed"),
});
expect(hostCloseFinished).toBe(false);
unblockRead();
await expect(alignment).resolves.toBe(true);
expect(await closeResult).toBeUndefined();
expect(hostCloseFinished).toBe(true);
}, [
unblockRead,
async () => {
await alignment;
},
async () => {
await closeResult;
},
async () => {
await host.close?.();
},
]);
});
});
const unscopedRegistrationReaders: readonly {
readonly name: string;
readonly read: (
home: string,
environment: NodeJS.ProcessEnv,
execute: Parameters<typeof planIntegrationMaintenance>[3],
) => Promise<unknown>;
}[] = [
{
name: "inspections",
read: (home, environment) =>
readClientRegistrationInspections(home, "/current/rea", { environment }),
},
{
name: "statuses",
read: (home, environment) =>
readClientRegistrationStatuses(home, "/current/rea", { environment }),
},
{
name: "maintenance scope",
read: (home, environment) =>
existingMaintenanceScope(home, "/current/rea", environment),
},
{
name: "maintenance plan",
read: (home, environment, execute) =>
planIntegrationMaintenance(home, "/current/rea", environment, execute),
},
];
it.each(
unscopedRegistrationReaders.flatMap((reader) =>
["configuration", "policy"].flatMap((source) =>
["completed", "failed"].map((readOutcome) => ({
...reader,
source,
readOutcome,
})),
),
),
)(
"$name transfers the exact $source owner after a $readOutcome read without a supplied scope",
async ({ read, source, readOutcome }) => {
const home = await createTestTempDirectory(
"rea-registration-unscoped-close-",
);
const config = join(
home,
source === "policy" ? ".gemini/settings.json" : ".cursor/mcp.json",
);
await mkdir(dirname(config), { recursive: true });
const registration =
'{"mcpServers":{"rea":{"command":"rea","args":["mcp"]}}}\n';
await writeFile(config, registration);
const heldPath = source === "policy" ? join(home, "system.json") : config;
const heldText = source === "policy" ? "{}\n" : registration;
await writeFile(
heldPath,
readOutcome === "failed" ? Buffer.from([0xff]) : heldText,
);
const environment = {
HOME: home,
GEMINI_CLI_SYSTEM_SETTINGS_PATH: join(home, "system.json"),
GEMINI_CLI_SYSTEM_DEFAULTS_PATH: join(home, "absent-defaults.json"),
};
const held = holdReadHandle(
heldPath,
"held standalone registration descriptor",
);
const execute = vi.fn(async () =>
ok('{"status":"planned","plannedActions":[]}'),
);
try {
const outcome = await read(home, environment, execute).then(
(value) => ({ kind: "returned" as const, value }),
(cause: unknown) => ({ kind: "threw" as const, cause }),
);
expect(outcome).toMatchObject({
kind: "threw",
cause: expect.any(RegularFileCleanupFailure),
});
if (
outcome.kind !== "threw" ||
!(outcome.cause instanceof RegularFileCleanupFailure)
)
throw new Error("Read lost its failed cleanup owner");
const failure = outcome.cause;
expect(failure.path).toBe(heldPath);
if (readOutcome === "completed")
expect(failure.outcome).toEqual({ kind: "completed", value: heldText });
else
expect(failure.outcome).toMatchObject({
kind: "failed",
cause: { _tag: "InvalidUtf8FileError", path: heldPath },
});
expect(failure.owner.handle).toBe(held.closeAttempts[0]);
await expect(failure.owner.close()).rejects.toThrow("held standalone");
await rename(heldPath, `${heldPath}.retired`);
await writeFile(heldPath, "replacement");
held.release();
await failure.owner.close();
expect(failure.owner.handle.fd).toBe(-1);
expect(new Set(held.closeAttempts).size).toBe(1);
expect(await readFile(heldPath, "utf8")).toBe("replacement");
expect(execute).not.toHaveBeenCalled();
} finally {
held.release();
for (const handle of new Set(held.closeAttempts)) await handle.close();
}
},
);
it("keeps a supplied registration scope responsible for diagnostics and cleanup retry", async () => {
const home = await createTestTempDirectory("rea-registration-scoped-close-");
const config = join(home, ".cursor/mcp.json");
await mkdir(dirname(config), { recursive: true });
await writeFile(
config,
'{"mcpServers":{"rea":{"command":"rea","args":["mcp"]}}}',
);
const held = holdReadHandle(config, "held scoped registration descriptor");
const resources = new ArtifactResourceScope();
try {
expect(
(
await readClientRegistrationStatuses(home, "/current/rea", {
environment: { HOME: home },
resources,
})
).find((status) => status.client === "cursor"),
).toMatchObject({ state: "invalid", command: [] });
expect(resources.hasPending).toBe(true);
await expect(resources.close()).rejects.toThrow();
held.release();
await resources.close();
expect(resources.hasPending).toBe(false);
expect(new Set(held.closeAttempts).size).toBe(1);
expect(held.closeAttempts[0]!.fd).toBe(-1);
} finally {
held.release();
await resources.close();
}
});