fix(setup): keep OpenCode registrations whose V1 fallback REA does not own

Beside a native V2 table, OpenCode loads the V1 mcp.rea member once the
native mcp.servers.rea entry is gone. Uninstall removed an owned native
entry even when that V1 member was foreign, so it activated a launcher
REA does not own and still reported the registration removed. This
already happened on main with one native entry; removing repeated native
entries made it reachable there too.

The shared ownership decision now treats a foreign V1 fallback as
something native removal would reveal: preflight reports why and nothing
is changed or backed up. Owned V1 members are still removed together with
the native entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nikhil Bhagat
2026-10-18 12:45:47 +05:45
parent 939b31bbbd
commit 4cd22d4ec2
3 changed files with 62 additions and 19 deletions
+2
View File
@@ -380,6 +380,8 @@ while being updated stops the remaining removals. JSON clients read the last of
repeated `rea` members, so uninstall removes every repeat in the table the
client reads when REA owns each one; if an earlier repeat is not REA-owned,
removal would activate it, so the operation stops before anything is removed.
The same applies to an OpenCode V1 `mcp.rea` entry that REA does not own,
which OpenCode loads once the native `mcp.servers.rea` entry is gone.
Repeats inside an inactive earlier copy of the server table stay unchanged.
A purge path that is a
symbolic link is retained and reported rather than followed. Uninstall checks
+25 -19
View File
@@ -285,8 +285,8 @@ export const systemUninstallHost = (
read.original,
client,
);
if (removal.shadowsUnownedMember)
return item(client.name, "failed", SHADOWED_UNOWNED_MEMBER_DETAIL);
if (removal.blocked !== undefined)
return item(client.name, "failed", removal.blocked);
if (!removal.removeNative && removal.legacyPath === undefined)
return undefined;
// A removal the running package would revert, or redirect into its
@@ -463,21 +463,17 @@ const clientRegistrationRemoval = (
readonly removeNative: boolean;
readonly legacyPath: readonly string[] | undefined;
/**
* REA's effective entry repeats a member REA does not own. Removing the
* entry would make that duplicate active, so nothing is removed.
* Why nothing is removed: removing REA's entry would activate an entry REA
* does not own, either an earlier repeat or an OpenCode V1 fallback.
*/
readonly shadowsUnownedMember: boolean;
readonly blocked: string | undefined;
} => {
const name = PRODUCT_IDENTITY.mcpServerKey;
if (
parsed.dialect === "dsh" &&
dshDocument(parsed.document).hasUnknownStructure
)
return {
removeNative: false,
legacyPath: undefined,
shadowsUnownedMember: false,
};
return { removeNative: false, legacyPath: undefined, blocked: undefined };
// Removal deletes every repeated member of the name, so each must be owned.
// The repeats are located only once an owned entry needs this check.
let shadowed: ShadowedClientServers | undefined;
@@ -500,22 +496,30 @@ const clientRegistrationRemoval = (
legacyPath === undefined
? "absent"
: removal(parsed.legacyServers, (members) => members.legacyServers);
const shadowsUnownedMember =
native === "shadows_unowned" || legacy === "shadows_unowned";
// OpenCode loads the V1 member once the native entry is gone, so removing
// the native entry must not expose a V1 member REA cannot remove.
const blocked =
native === "shadows_unowned" || legacy === "shadows_unowned"
? SHADOWED_UNOWNED_MEMBER_DETAIL
: native === "removable" && legacy === "foreign"
? UNOWNED_LEGACY_FALLBACK_DETAIL
: undefined;
return {
removeNative:
!shadowsUnownedMember &&
blocked === undefined &&
((parsed.dialect === "dsh" &&
dshDocument(parsed.document).carriesEntry) ||
native === "removable"),
legacyPath:
!shadowsUnownedMember && legacy === "removable" ? legacyPath : undefined,
shadowsUnownedMember,
blocked === undefined && legacy === "removable" ? legacyPath : undefined,
blocked,
};
};
const SHADOWED_UNOWNED_MEMBER_DETAIL = `Configuration repeats the "${PRODUCT_IDENTITY.mcpServerKey}" member, and an earlier duplicate is not REA-owned; removing the registration the client reads would activate an earlier duplicate, so no change was made. Remove the duplicate members manually, then rerun uninstall.`;
const UNOWNED_LEGACY_FALLBACK_DETAIL = `OpenCode falls back to the V1 "mcp.${PRODUCT_IDENTITY.mcpServerKey}" entry, which is not REA-owned; removing REA's native registration would activate it, so no change was made. Remove or replace that V1 entry manually, then rerun uninstall.`;
/** Preserve the bytes uninstall is about to edit and return the backup holding them. */
const backupClientConfiguration = async (
client: SetupClient,
@@ -569,10 +573,12 @@ const removeClient = async (
);
if (!registrationPresent(parsed))
return item(client.name, "skipped", "REA registration is absent.");
const { removeNative, legacyPath, shadowsUnownedMember } =
clientRegistrationRemoval(parsed, original, client);
if (shadowsUnownedMember)
return item(client.name, "failed", SHADOWED_UNOWNED_MEMBER_DETAIL);
const { removeNative, legacyPath, blocked } = clientRegistrationRemoval(
parsed,
original,
client,
);
if (blocked !== undefined) return item(client.name, "failed", blocked);
const removeLegacy = legacyPath !== undefined;
if (!removeNative && !removeLegacy)
return item(
@@ -261,3 +261,38 @@ cliTest(
});
},
);
for (const [layout, servers] of [
["one native entry", `"rea": ${openCodeOwned}`],
[
"repeated native entries",
`"rea": ${openCodeOwned}, "rea": ${openCodeOwned}`,
],
] as const)
cliTest(
`uninstall keeps ${layout} when OpenCode would fall back to a foreign V1 entry`,
async ({ cli }) => {
const home = await createTestTempDirectory("rea-json-duplicate-");
const config = join(home, ".config/opencode/opencode.json");
// Without the native entry, OpenCode loads the V1 `mcp.rea` member.
const original = `{\n "mcp": {\n "servers": {${servers}},\n "rea": ${openCodeForeign}\n }\n}\n`;
await writeConfiguration(config, original);
const removed = await runIn(cli, home)(["uninstall", "--json"]);
expect(removed.exitCode, removed.stdout).toBe(1);
expect(removed.json).toMatchObject({
status: "failed",
items: expect.arrayContaining([
expect.objectContaining({
name: "opencode",
status: "failed",
detail: expect.stringContaining("would activate it"),
}),
]),
});
expect(await readFile(config, "utf8")).toBe(original);
await expect(readFile(`${config}.rea.backup`)).rejects.toMatchObject({
code: "ENOENT",
});
},
);