fix(uninstall): merge duplicated host and prove shared ownership from owned rows

Uninstall.ts declared systemUninstallHost twice, preventing module load,
with the exported copy returning only clients and the second copy missing
clientWriteBlocker. Merge into one exported host carrying both blockers.

hasOwnershipEvidence used access() existence, so an unrelated malformed
~/.agents/mcp.json established ownership and blocked every removal.
Ownership now requires a readable, recognizable REA-owned row via the
injected filesystem, so unrelated shared data stays retained.

Also cover ZCode discoveryPaths in its path test.
This commit is contained in:
0xLLVEL committed 2026-10-14 12:54:11 +07:00
1 parent 0b46e59cc3
commit 1f3afd7fc1
2 files changed
+32 -70

No files matched your search

+31 -70
View File
@@ -39,7 +39,6 @@ import {
supportedClients,
type SetupClient,
} from "./SupportedClients.js";
import { access } from "node:fs/promises";
import {
redirectedWriteBlocker,
type MsixPackageView,
@@ -214,6 +213,36 @@ const removeAll = async (
return items;
};
/**
* Ownership is a readable, recognizable REA-owned row, never the shared
* file's existence. An unrelated malformed file must not establish ownership;
* it stays retained without blocking other removals.
*/
const hasOwnershipEvidence = async (
client: UninstallClient,
fileSystem: UninstallFileSystem,
): Promise<boolean> => {
for (const path of clientOwnershipEvidencePaths(client)) {
let original: string;
try {
original = await fileSystem.readText(path);
} catch {
// Missing or unreadable storage proves nothing; keep scanning.
continue;
}
let parsed: ClientConfigurationDocument;
try {
parsed = parseClientConfiguration(original, client.format);
} catch {
// Malformed shared data is retained, never ownership evidence.
continue;
}
const removal = clientRegistrationRemoval(parsed, client);
if (removal.removeNative || removal.legacyPath !== undefined) return true;
}
return false;
};
/** Create uninstall effects contained to detected client configs and REA-owned paths. */
export const systemUninstallHost = (
selectedHome: string | undefined = undefined,
@@ -272,78 +301,10 @@ export const systemUninstallHost = (
}
return clients;
},
};
};
const hasOwnershipEvidence = async (
client: UninstallClient,
fileSystem: UninstallFileSystem,
): Promise<boolean> => {
for (const path of clientOwnershipEvidencePaths(client)) {
try {
await access(path);
return true;
} catch {
// Keep scanning; one missing path does not establish absence.
}
}
return false;
};
const systemUninstallHost = (
selectedHome: string | undefined = undefined,
selectedFileSystem: UninstallFileSystem | undefined = undefined,
environment: Readonly<NodeJS.ProcessEnv> = process.env,
platform: NodeJS.Platform = process.platform,
): UninstallHost => {
const home =
selectedHome ?? homeDirectoryFromEnvironment(environment, platform);
const resources = new ArtifactResourceScope();
const fileSystem = selectedFileSystem ?? systemFileSystem(resources);
const preflightOwnedClients = new WeakSet<UninstallClient>();
let redirection: MsixPackageView | undefined;
const skillWriteBlocker = (path: string): string | undefined =>
redirectedWriteBlocker(redirection, [path], "uninstall");
return {
clients: async () => {
const supported = supportedClients(home, platform, environment);
redirection = appDataRedirection(supported);
const clients: UninstallClient[] = [];
for (const client of supported) {
// Shared configuration alone cannot establish product applicability.
// Missing or unreadable evidence stays unverified; owned rows in a
// readable configuration can still be removed independently.
let applicable = client.discoveryPaths === undefined;
for (const path of client.discoveryPaths ?? []) {
try {
await fileSystem.stat(path);
applicable = true;
break;
} catch {
// Keep the client unverified until one discovery path exists.
}
}
const applicability = applicable
? {}
: { applicability: "unverified" as const };
for (const configPath of uninstallConfigPaths(client))
clients.push({ ...client, configPath, ...applicability });
if (applicable && client.zcodeFallbackPath !== undefined)
clients.push({
...client,
configPath: client.zcodeFallbackPath,
format: "zcode_fallback" as const,
...applicability,
});
// Shared storage is inspected once and the decision is reported once.
// See ClientRegistrationStatus.sharedRegistration for the ownership model.
}
return clients;
},
inspectClient: async (client) => {
const ownershipEstablished =
client.sharedRegistration === true &&
(await clientOwnershipEvidencePaths(client)).length > 0 &&
clientOwnershipEvidencePaths(client).length > 0 &&
(await hasOwnershipEvidence(client, fileSystem));
const read = await readClientConfiguration(
client,
@@ -42,6 +42,7 @@ describe("ZCode configuration paths", () => {
displayName: "ZCode",
configPath: join("/home/a", ".zcode", "cli", "config.json"),
markerPath: join("/home/a", ".zcode", "cli"),
discoveryPaths: [join("/home/a", ".zcode", "cli")],
format: "zcode",
zcodeFallbackPath: join("/home/a", ".agents", "mcp.json"),
});