From 1f3afd7fc1a8d9f2c847fc5748b28afb3249f616 Mon Sep 17 00:00:00 2001 From: 0xLLVEL Date: Wed, 14 Oct 2026 12:54:11 +0700 Subject: [PATCH] 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. --- src/application/Uninstall.ts | 101 ++++++------------ .../zcodeClientConfiguration.test.ts | 1 + 2 files changed, 32 insertions(+), 70 deletions(-) diff --git a/src/application/Uninstall.ts b/src/application/Uninstall.ts index d0b6ce1f0d..e03469a23c 100644 --- a/src/application/Uninstall.ts +++ b/src/application/Uninstall.ts @@ -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 => { + 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 => { - 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 = 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(); - 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, diff --git a/tests/boundary/filesystem/zcodeClientConfiguration.test.ts b/tests/boundary/filesystem/zcodeClientConfiguration.test.ts index 2687473564..a18261e49b 100644 --- a/tests/boundary/filesystem/zcodeClientConfiguration.test.ts +++ b/tests/boundary/filesystem/zcodeClientConfiguration.test.ts @@ -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"), });