Merge pull request #1177 from morluto/fix/uninstall-preflight-client-config

fix(setup): stop uninstall before removals when a client config is unsafe
This commit is contained in:
Kaoru0822 2026-10-08 20:22:34 -07:00 committed by GitHub
commit 4fb2f3e6ed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 225 additions and 33 deletions

View File

@ -573,8 +573,11 @@ rea uninstall --purge-data
Uninstall preserves Hopper, Node.js, Evidence files, captures, unrelated skills
and other MCP servers. Purging removes only REA's cache and state under
`~/.rea`; malformed client configuration or purge-path symlinks stop the
operation. See the [CLI guide](cli.md#output-and-exit-status) for exit statuses.
`~/.rea`. A client configuration that is malformed, unreadable, or at an unsafe
path stops the operation before anything is removed, and a client that fails
while being updated stops the remaining removals. A purge path that is a
symbolic link is retained and reported rather than followed. See the
[CLI guide](cli.md#output-and-exit-status) for exit statuses.
## MCP Registry

View File

@ -81,21 +81,40 @@ export const isUninstallFailure = (result: UninstallResult): boolean => {
/** Filesystem boundary used by the contained uninstall workflow. */
export interface UninstallHost {
clients(): Promise<readonly SetupClient[]>;
/** Report a configuration that cannot be read safely, before any removal. */
inspectClient(client: SetupClient): Promise<UninstallItem | undefined>;
removeClient(client: SetupClient): Promise<UninstallItem>;
removeSkill(): Promise<UninstallItem>;
purgeData(): Promise<readonly UninstallItem[]>;
}
/** Remove only REA-owned registrations and managed files, optionally including local state. */
/**
* Remove only REA-owned registrations and managed files, optionally including
* local state. A client configuration that cannot be read safely stops the
* operation before anything is removed; one that fails during removal stops
* the remaining removals.
*/
export const runUninstall = async (
purgeData: boolean,
host: UninstallHost = systemUninstallHost(),
): Promise<UninstallResult> => {
const items: UninstallItem[] = [];
for (const client of await host.clients())
items.push(await host.removeClient(client));
items.push(await host.removeSkill());
if (purgeData) items.push(...(await host.purgeData()));
const clients = await host.clients();
const blocked: UninstallItem[] = [];
for (const client of clients) {
const blocking = await host.inspectClient(client);
if (blocking !== undefined) blocked.push(blocking);
}
const items: UninstallItem[] =
blocked.length > 0
? [
...blocked,
item(
"uninstall",
"skipped",
"No REA registration, skill, or data was removed. Repair each failed configuration, then rerun uninstall.",
),
]
: await removeAll(clients, purgeData, host);
items.push({
name: "analysis_engine",
status: "retained",
@ -109,12 +128,46 @@ export const runUninstall = async (
};
};
/**
* Each client is reread when it is edited, so a concurrent change is never
* overwritten. A client that fails then still stops the remaining removals.
*/
const removeAll = async (
clients: readonly SetupClient[],
purgeData: boolean,
host: UninstallHost,
): Promise<UninstallItem[]> => {
const items: UninstallItem[] = [];
for (const client of clients) {
const removed = await host.removeClient(client);
items.push(removed);
if (removed.status === "failed")
return [
...items,
item(
"uninstall",
"skipped",
"Uninstall stopped at this failure; the items above report what changed. Repair the failed configuration, then rerun uninstall.",
),
];
}
items.push(await host.removeSkill());
if (purgeData) items.push(...(await host.purgeData()));
return items;
};
/** Create uninstall effects contained to detected client configs and REA-owned paths. */
export const systemUninstallHost = (
home = homedir(),
fileSystem: UninstallFileSystem = systemFileSystem,
): UninstallHost => ({
clients: () => Promise.resolve(supportedClients(home)),
inspectClient: async (client) => {
const read = await readClientConfiguration(client, fileSystem);
return read.kind === "item" && read.item.status === "failed"
? read.item
: undefined;
},
removeClient: (client) => removeClient(client, fileSystem),
removeSkill: () => removeManagedSkills(home, fileSystem),
purgeData: async () => [
@ -123,43 +176,72 @@ export const systemUninstallHost = (
],
});
const removeClient = async (
type ClientConfigurationRead =
| { readonly kind: "item"; readonly item: UninstallItem }
| {
readonly kind: "configuration";
readonly transactionPath: string;
readonly original: string;
readonly parsed: ClientConfigurationDocument;
};
const readClientConfiguration = async (
client: SetupClient,
fileSystem: UninstallFileSystem,
): Promise<UninstallItem> => {
): Promise<ClientConfigurationRead> => {
if (client.format === "unsupported")
return item(
client.name,
"skipped",
manualRegistrationRemediation(client.name) ??
"This client has no documented local MCP configuration boundary.",
return itemRead(
item(
client.name,
"skipped",
manualRegistrationRemediation(client.name) ??
"This client has no documented local MCP configuration boundary.",
),
);
const resolved = await resolveUninstallConfigPath(client, fileSystem);
if (typeof resolved !== "string") return resolved;
if (typeof resolved !== "string") return itemRead(resolved);
const transactionPath = resolved;
let original: string;
try {
original = await fileSystem.readText(transactionPath);
} catch (cause: unknown) {
return isMissing(cause)
? item(client.name, "skipped", "Configuration does not exist.")
: item(
client.name,
"failed",
"Configuration could not be read. Check file permissions, then rerun uninstall.",
);
}
let parsed: ClientConfigurationDocument;
try {
parsed = parseClientConfiguration(original, client.format);
} catch (cause: unknown) {
void cause;
return item(
client.name,
"failed",
`Configuration is not valid ${client.format === "toml" || client.format === "grok" ? "TOML" : client.format === "opencode" ? "JSONC" : "JSON"} and was not changed. Repair it, then rerun uninstall.`,
return itemRead(
isMissing(cause)
? item(client.name, "skipped", "Configuration does not exist.")
: item(
client.name,
"failed",
"Configuration could not be read. Check file permissions, then rerun uninstall.",
),
);
}
try {
const parsed = parseClientConfiguration(original, client.format);
return { kind: "configuration", transactionPath, original, parsed };
} catch (cause: unknown) {
void cause;
return itemRead(
item(
client.name,
"failed",
`Configuration is not valid ${client.format === "toml" || client.format === "grok" ? "TOML" : client.format === "opencode" ? "JSONC" : "JSON"} and was not changed. Repair it, then rerun uninstall.`,
),
);
}
};
const itemRead = (result: UninstallItem): ClientConfigurationRead => ({
kind: "item",
item: result,
});
const removeClient = async (
client: SetupClient,
fileSystem: UninstallFileSystem,
): Promise<UninstallItem> => {
const read = await readClientConfiguration(client, fileSystem);
if (read.kind === "item") return read.item;
const { transactionPath, original, parsed } = read;
const name = PRODUCT_IDENTITY.mcpServerKey;
// Beside a native OpenCode V2 table, OpenCode also loads a V1 `mcp.rea`.
const legacyPath = legacyClientServerPath(parsed, name);

View File

@ -145,6 +145,7 @@ describe("CLI command outcome classification", () => {
it("returns nonzero when the real uninstall outcome contains a failed action", async () => {
const host: UninstallHost = {
clients: () => Promise.resolve([]),
inspectClient: () => Promise.resolve(undefined),
removeClient: () =>
Promise.resolve({ name: "client", status: "failed", detail: "write" }),
removeSkill: () =>

View File

@ -1418,6 +1418,112 @@ describe("client configuration filesystem removal", () => {
);
expect(await readFile(config, "utf8")).toBe("not-json");
});
it("stops before any removal when one client configuration is malformed", async () => {
const home = await createTestTempDirectory("rea-uninstall-stop-");
roots.push(home);
const codex = join(home, ".codex/config.toml");
const cursor = join(home, ".cursor/mcp.json");
const managed = [
join(home, ".agents/skills/reverse-engineer-anything/marker.txt"),
join(home, ".rea/cache/marker.txt"),
join(home, ".rea/state/marker.txt"),
];
const cursorConfig = JSON.stringify({
mcpServers: {
rea: { command: "npx", args: ["-y", "rea-agents@6.1.0", "mcp"] },
other: { command: "echo", args: ["hello"] },
},
});
for (const path of [codex, cursor, ...managed])
await mkdir(dirname(path), { recursive: true });
await writeFile(codex, "[mcp_servers.rea]\ncommand = [broken\n");
await writeFile(cursor, cursorConfig);
for (const path of managed) await writeFile(path, "marker");
const stopped = await runUninstall(true, systemUninstallHost(home));
expect(stopped.status).toBe("failed");
expect(stopped.items.map(({ name, status }) => [name, status])).toEqual([
["codex", "failed"],
["uninstall", "skipped"],
["analysis_engine", "retained"],
]);
expect(await readFile(codex, "utf8")).toBe(
"[mcp_servers.rea]\ncommand = [broken\n",
);
expect(await readFile(cursor, "utf8")).toBe(cursorConfig);
for (const path of managed)
expect(await readFile(path, "utf8")).toBe("marker");
await writeFile(codex, "");
const repaired = await runUninstall(true, systemUninstallHost(home));
expect(repaired.status).toBe("complete");
expect(JSON.parse(await readFile(cursor, "utf8"))).toEqual({
mcpServers: { other: { command: "echo", args: ["hello"] } },
});
for (const path of managed)
await expect(readFile(path, "utf8")).rejects.toThrow();
});
});
describe("client configuration changed during uninstall", () => {
it("stops the remaining removals when a configuration fails after preflight", async () => {
const home = await createTestTempDirectory("rea-uninstall-race-");
roots.push(home);
const codex = join(home, ".codex/config.toml");
const cursor = join(home, ".cursor/mcp.json");
const skill = join(
home,
".agents/skills/reverse-engineer-anything/SKILL.md",
);
const cursorConfig = JSON.stringify({
mcpServers: { rea: { command: "rea", args: ["mcp"] } },
});
for (const path of [codex, cursor, skill])
await mkdir(dirname(path), { recursive: true });
await writeFile(
codex,
'[mcp_servers.rea]\ncommand = "rea"\nargs = ["mcp"]\n',
);
await writeFile(cursor, cursorConfig);
await writeFile(skill, "managed");
let codexReads = 0;
const fileSystem: UninstallFileSystem = {
...testFileSystem,
// The preflight read succeeds; the removal reread finds a broken file.
readText: (path) => {
if (path !== codex) return readFile(path, "utf8");
codexReads += 1;
return codexReads === 1
? readFile(path, "utf8")
: Promise.resolve("[mcp_servers.rea]\ncommand = [broken\n");
},
};
const result = await runUninstall(
false,
systemUninstallHost(home, fileSystem),
);
expect(result.status).toBe("failed");
// Clients ordered before Codex have no configuration in this home.
expect(
result.items
.filter(({ status }) => status !== "skipped")
.map(({ name, status }) => [name, status]),
).toEqual([
["codex", "failed"],
["analysis_engine", "retained"],
]);
expect(result.items.map(({ name }) => name)).not.toContain("cursor");
expect(result.items.at(-2)).toMatchObject({
name: "uninstall",
status: "skipped",
});
expect(await readFile(cursor, "utf8")).toBe(cursorConfig);
expect(await readFile(skill, "utf8")).toBe("managed");
});
});
describe("client configuration filesystem failure reporting", () => {