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);