fix(uninstall): preserve completed reads as shared ownership evidence

hasOwnershipEvidence caught every read failure and kept scanning, so a handle-close failure after a successful read — a RegularFileCleanupFailure whose completed outcome still contains the exact shared-file text — was discarded as "no proof". FreeBuff's applicability-unverified storage therefore never got its ownership fact, and once the retained handle blocked later reads, readClientConfiguration downgraded them to retained: inspection passed, removals soft-failed, and runUninstall(true) could purge REA data while the active shared registration remained. The catch now parses the completed outcome for the ownership text while missing or unreadable storage and malformed data keep their proves-nothing semantics, so ownership holds and every later read failure stays blocking.

Covers the Codex P2 on Uninstall.ts:252. Validation: the new regression fails on 4dbfdf1a9 (freebuff reports no blocking failure while the poisoned-scope error lands on claude_code) and passes after; setupClientConfigCleanupOwnership 25/25; the other 21 Uninstall-consumer suites are byte-identical to base 4dbfdf1a9 (61 pre-existing Windows-host failures: symlink EPERM, stale-vs-aligned); tsc --noEmit, oxlint (0 errors, same 6 warnings) and oxfmt --check clean.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
This commit is contained in:
0xLLVEL
2026-10-19 14:42:35 +07:00
parent 4dbfdf1a9a
commit 225cb214a4
2 changed files with 66 additions and 4 deletions
+10 -3
View File
@@ -247,9 +247,16 @@ const hasOwnershipEvidence = async (
let original: string;
try {
original = await fileSystem.readText(path);
} catch {
// Missing or unreadable storage proves nothing; keep scanning.
continue;
} catch (cause: unknown) {
// A close failure after a completed read still carries the exact text
// in its retained outcome, and that text is the ownership fact: only a
// read that never completed proves nothing. Preserve it, so later read
// failures stay blocking instead of softening to retained.
if (!(cause instanceof RegularFileCleanupFailure)) continue;
if (cause.outcome.kind !== "completed") continue;
const completed: unknown = cause.outcome.value;
if (typeof completed !== "string") continue;
original = completed;
}
let parsed: ClientConfigurationDocument;
try {
@@ -29,7 +29,10 @@ import {
import { RegularFileCleanupFailure } from "../../../src/application/RegularFileRead.js";
import { configureDetectedClients } from "../../../src/application/SetupClients.js";
import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js";
import { systemUninstallHost } from "../../../src/application/Uninstall.js";
import {
systemUninstallHost,
runUninstall,
} from "../../../src/application/Uninstall.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
vi.mock("node:fs/promises", async (importOriginal) => {
@@ -170,6 +173,58 @@ it("stops uninstall and retains the backup read owner when its close fails", asy
}
});
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);
}
});
describe("ZCode fallback cleanup ownership", () => {
it.each(["preflight", "configure"])(
"retains a failed ZCode fallback close during %s until host cleanup",