From 93d7d837fa678a95ecfb480af5c5178a79246b2e Mon Sep 17 00:00:00 2001 From: 0xLLVEL Date: Wed, 14 Oct 2026 02:14:00 +0700 Subject: [PATCH] fix(update): separate ownership evidence from product applicability rea update's maintenance scope inspected only product applicability evidence, so when a FreeBuff profile was absent but REA already owned a version-pinned row in the shared ~/.agents/mcp.json, the plan omitted freebuff and left the integration stale. FreeBuff now declares its registration storage as shared, which proves ownership of an owned row but never product applicability, and maintenance requests ownership evidence through that declaration instead of reinterpreting the shared paths. Doctor and setup keep readiness evidence, foreign rows stay out of maintenance, and the Doctor exactOptionalPropertyTypes fix makes the focused test workflow runnable. --- src/application/ClientRegistrationStatus.ts | 35 +++++++++++----- src/application/SupportedClients.ts | 39 ++++++++++++++++-- src/application/UpdateMaintenance.ts | 4 ++ .../freebuffClientConfiguration.test.ts | 15 ++++++- .../process-global/updateMaintenance.test.ts | 40 +++++++++++++++++++ 5 files changed, 120 insertions(+), 13 deletions(-) diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts index 2def9c1ccf..c67040f2fa 100644 --- a/src/application/ClientRegistrationStatus.ts +++ b/src/application/ClientRegistrationStatus.ts @@ -24,6 +24,7 @@ import { } from "./ClientRegistrationIdentity.js"; import { clientEvidencePaths, + clientOwnershipEvidencePaths, manualRegistrationRemediation, supportedClients, } from "./SupportedClients.js"; @@ -86,6 +87,14 @@ interface ClientRegistrationReadOptions { readonly environment?: NodeJS.ProcessEnv; readonly resources?: ArtifactResourceScope; readonly selectedClientIds?: readonly string[]; + /** + * Which evidence authorises inspection. `readiness` inspects a client whose + * product applicability is established, plus clients the caller selected + * explicitly. `ownership` also inspects shared registration storage, because + * a version-pinned registration REA wrote must be refreshed even when the + * product profile is absent. + */ + readonly evidence?: "readiness" | "ownership"; } /** Registration facts shared by diagnostics and update maintenance. */ @@ -133,7 +142,13 @@ export const readClientRegistrationInspections = async ( }); continue; } - if (!(await clientEvidencePresent(client, selected))) continue; + if ( + !(await clientEvidencePresent(client, { + selected, + ownership: options.evidence === "ownership", + })) + ) + continue; const manualRemediation = manualRegistrationRemediation(client.name); if (client.format === "unsupported") { if (manualRemediation !== undefined) @@ -447,15 +462,17 @@ const unavailableStatus = ( const clientEvidencePresent = async ( client: SetupClient, - selected: boolean, + evidence: { readonly selected: boolean; readonly ownership: boolean }, ): Promise => { - const paths = selected - ? [ - ...clientEvidencePaths(client), - client.configPath, - ...(client.configPaths ?? []), - ] - : clientEvidencePaths(client); + const paths = [ + ...clientEvidencePaths(client), + // An explicit selection is its own evidence: the caller named this client, + // so its configured location counts even without product detection. + ...(evidence.selected + ? [client.configPath, ...(client.configPaths ?? [])] + : []), + ...(evidence.ownership ? clientOwnershipEvidencePaths(client) : []), + ]; for (const path of paths) if (await exists(path)) return true; return false; }; diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts index fa9dbc426b..17886dcfbb 100644 --- a/src/application/SupportedClients.ts +++ b/src/application/SupportedClients.ts @@ -31,6 +31,12 @@ export interface SetupClient { /** Same-scope fallback ZCode stops loading after its first native server. */ readonly zcodeFallbackPath?: string; readonly markerPath?: string; + /** + * Registration storage shared with other products. Its presence never proves + * this product is installed; only a recognizable REA-owned row in it proves + * ownership of a registration REA wrote. + */ + readonly sharedRegistration?: boolean; /** MSIX package storage that decides which configuration the client reads. */ readonly msixPackage?: MsixPackageView; /** A running client can rewrite its configuration from memory. */ @@ -99,6 +105,7 @@ interface ClientDefinitionBase { readonly name: string; readonly displayName: string; readonly discoveryPaths?: (context: ClientPathContext) => readonly string[]; + readonly sharedRegistration?: true; readonly format: NonNullable; readonly quitBeforeSetup?: boolean; readonly zcodeFallbackPath?: (context: ClientPathContext) => string; @@ -719,6 +726,9 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ discoveryPaths: (context: ClientPathContext) => [ freebuffProfileDirectory(context), ], + // The same file doubles as ZCode's user-scope fallback, so its presence + // proves neither product is installed; see clientOwnershipEvidencePaths. + sharedRegistration: true, format: "json", }, { @@ -731,9 +741,11 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ ] as const satisfies readonly ClientDefinition[]; /** - * Paths whose presence means doctor and setup should inspect the client. - * Layered sources count: OpenCode still loads its XDG files when a custom - * directory override does not exist. + * Paths whose presence means the product itself is applicable, so doctor and + * setup should inspect the client. Layered sources count: OpenCode still loads + * its XDG files when a custom directory override does not exist. Shared + * registration storage never establishes applicability; see + * {@link clientOwnershipEvidencePaths}. */ export const clientEvidencePaths = ( client: Pick< @@ -752,6 +764,24 @@ export const clientEvidencePaths = ( ), ]; +/** + * Paths where a recognizable REA-owned row proves ownership of a registration + * REA wrote, never that the product is installed: shared storage is created by + * other products and by REA's own skill installs. A client whose registration + * is product-local declares no shared storage, so this stays empty for it. + */ +export const clientOwnershipEvidencePaths = ( + client: Pick< + SetupClient, + "configPath" | "configPaths" | "sharedRegistration" + >, +): readonly string[] => + client.sharedRegistration === true + ? [client.configPath, ...(client.configPaths ?? [])].filter( + (path): path is string => path !== undefined && path.length > 0, + ) + : []; + const resolvePath = ( path: ClientPath, context: ClientPathContext, @@ -819,6 +849,9 @@ export const supportedClients = ( ...(definition.discoveryPaths === undefined ? {} : { discoveryPaths: definition.discoveryPaths(context) }), + ...(definition.sharedRegistration === true + ? { sharedRegistration: true } + : {}), format: definition.format, }; } catch (cause: unknown) { diff --git a/src/application/UpdateMaintenance.ts b/src/application/UpdateMaintenance.ts index fd55c8f230..705bb9e84c 100644 --- a/src/application/UpdateMaintenance.ts +++ b/src/application/UpdateMaintenance.ts @@ -57,6 +57,10 @@ export const existingMaintenanceScope = async ( entryPoint, { environment, + // A registration REA wrote must be refreshed even when the product + // profile is absent, so maintenance reads ownership evidence instead of + // product applicability. Owned rows stay the only ones selected below. + evidence: "ownership", ...(resources === undefined ? {} : { resources }), }, ); diff --git a/tests/boundary/filesystem/freebuffClientConfiguration.test.ts b/tests/boundary/filesystem/freebuffClientConfiguration.test.ts index cadcae9c4a..2d7c3571f2 100644 --- a/tests/boundary/filesystem/freebuffClientConfiguration.test.ts +++ b/tests/boundary/filesystem/freebuffClientConfiguration.test.ts @@ -8,7 +8,11 @@ import { npxRegistrationCommand } from "../../../src/application/ClientRegistrat import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js"; import { detectClients } from "../../../src/application/SetupHost.js"; import { skillDestinations } from "../../../src/application/SetupSkill.js"; -import { supportedClients } from "../../../src/application/SupportedClients.js"; +import { + clientEvidencePaths, + clientOwnershipEvidencePaths, + supportedClients, +} from "../../../src/application/SupportedClients.js"; import { runUninstall, systemUninstallHost, @@ -40,6 +44,7 @@ describe("FreeBuff configuration paths", () => { configPath: join("/home/a", ".agents", "mcp.json"), markerPath: join("/home/a", ".config", "freebuff-desktop"), discoveryPaths: [join("/home/a", ".config", "freebuff-desktop")], + sharedRegistration: true, format: "json", }); expect(freebuffPath("/Users/a", "darwin")?.markerPath).toBe( @@ -78,6 +83,14 @@ describe("FreeBuff configuration paths", () => { ); }); + it("keeps shared registration storage out of applicability evidence", () => { + for (const client of supportedClients("/home/a", "linux", {})) { + if (client.sharedRegistration !== true) continue; + expect(clientEvidencePaths(client)).not.toContain(client.configPath); + expect(clientOwnershipEvidencePaths(client)).toContain(client.configPath); + } + }); + it("shares the user-scope file ZCode loads as its fallback", () => { const clients = supportedClients("/home/a", "linux", {}); const freebuff = clients.find(({ name }) => name === "freebuff"); diff --git a/tests/process-global/updateMaintenance.test.ts b/tests/process-global/updateMaintenance.test.ts index 141cf23ec6..1b6da1160e 100644 --- a/tests/process-global/updateMaintenance.test.ts +++ b/tests/process-global/updateMaintenance.test.ts @@ -315,6 +315,46 @@ describe("existing REA integration maintenance", () => { }); }); +describe("shared registration maintenance", () => { + it("refreshes an owned shared registration without its product profile", async () => { + // FreeBuff's registration lives in the shared user agents file. The row is + // owned, so maintenance must refresh it even though no FreeBuff profile + // exists to establish product applicability. + await writeClient( + "freebuff", + JSON.stringify({ + mcpServers: { + other: { command: "other-server" }, + rea: { command: entryPoint, args: ["mcp"] }, + }, + }), + ); + await expect( + existingMaintenanceScope(home, entryPoint, process.env), + ).resolves.toEqual({ + clients: ["freebuff"], + skill: false, + skillDestinations: [], + }); + }); + + it("leaves a foreign shared registration out of maintenance", async () => { + await writeClient( + "freebuff", + JSON.stringify({ + mcpServers: { rea: { command: "foreign-server", args: [] } }, + }), + ); + await expect( + existingMaintenanceScope(home, entryPoint, process.env), + ).resolves.toEqual({ + clients: [], + skill: false, + skillDestinations: [], + }); + }); +}); + describe("updated executable maintenance planning", () => { it("runs real setup as a noninteractive dry run and creates no new integrations or backups", async () => { const configPath = await writeClient("codex", staleCodex);