From 3e4e178cccac0f9b01a5c8b6dca609fcfc89bc71 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 15 Nov 2026 21:31:35 +0800 Subject: [PATCH] refactor(setup): take the platform in client configuration readers and the setup write path (#11120) * refactor(setup): take the platform in client configuration readers and the setup write path ClientRegistrationEnvironment, ClientConfigurationFiles and SupportedClients take a required platform (before defaulted parameters). Setup's configure, inspect and alignment entry points take the setup host's platform and pass it through every layer read, ownership check and the Cordis serializer, which now aliases Windows environment names by the selected platform instead of the running host. Host file operations in ClientConfigPath, ClientConfigurationPublication, JsonFiles and rea mcp add read HOST_PLATFORM explicitly. * fix(setup): keep private-profile modes on the host when setup selects another platform --- .../verify/architecture-guard-baselines.json | 6 - src/application/ClientConfigPath.ts | 3 +- ...ientConfigurationDocument.capacity.test.ts | 39 +++-- ...onfigurationDocument.jsonRetention.test.ts | 53 ++++--- ...figurationDocument.repeatedMembers.test.ts | 5 +- ...ConfigurationDocument.tomlComments.test.ts | 7 +- .../ClientConfigurationDocument.ts | 12 +- src/application/ClientConfigurationFiles.ts | 26 ++-- .../ClientConfigurationPublication.ts | 3 +- src/application/ClientRegistration.test.ts | 2 + .../ClientRegistrationEnvironment.ts | 14 +- src/application/DshCordisPatch.ts | 30 ++-- .../HermesConfigurationYaml.test.ts | 26 +++- src/application/JsonFiles.ts | 3 +- .../McpClientRegistration.layers.test.ts | 2 + .../McpRegistrationConfiguration.ts | 8 +- src/application/SetupClientConfiguration.ts | 147 +++++++++++++----- src/application/SetupHost.ts | 5 + src/application/SupportedClients.ts | 6 +- src/application/Uninstall.ts | 35 +++-- src/doctorRuntime.ts | 2 +- src/mcpStartupPolicy.test.ts | 2 + .../clientConfigurationDialects.test.ts | 10 +- .../clientConfigurationEnvironment.test.ts | 84 +++++++--- ...entConfigurationLayeredEnvironment.test.ts | 37 ++++- .../clientConfigurationOverrideOrigin.test.ts | 17 +- .../filesystem/clientLifecycle.test.ts | 6 +- .../filesystem/dshClientConfiguration.test.ts | 78 +++++++++- .../hermesClientConfiguration.test.ts | 21 ++- .../filesystem/kiloClientLocation.test.ts | 6 +- .../kimiClientConfiguration.test.ts | 7 +- .../filesystem/mcpAddClientLifecycle.test.ts | 26 ++++ .../opencodeLayeredUninstall.test.ts | 3 + .../qwenClientConfiguration.test.ts | 4 +- .../skillSelectionProvenance.test.ts | 16 +- .../zcodeClientConfiguration.test.ts | 5 +- .../process-global/updateMaintenance.test.ts | 4 +- 37 files changed, 548 insertions(+), 212 deletions(-) diff --git a/scripts/verify/architecture-guard-baselines.json b/scripts/verify/architecture-guard-baselines.json index 7f5d636fba..31509da61e 100644 --- a/scripts/verify/architecture-guard-baselines.json +++ b/scripts/verify/architecture-guard-baselines.json @@ -283,12 +283,6 @@ "src/apktool/ApktoolConfiguration.ts": 4, "src/apktool/ApktoolProvider.ts": 2, "src/application/BinaryTargetResolver.ts": 1, - "src/application/ClientConfigPath.ts": 1, - "src/application/ClientConfigurationFiles.ts": 4, - "src/application/ClientConfigurationPublication.ts": 1, - "src/application/ClientRegistrationEnvironment.ts": 4, - "src/application/JsonFiles.ts": 1, - "src/application/SupportedClients.ts": 2, "src/application/binary/AnalysisSnapshotCache.ts": 1, "src/application/binaryDiagnostics/ByteEntropyService.ts": 1, "src/application/desktop/DesktopBuildComputers.ts": 1, diff --git a/src/application/ClientConfigPath.ts b/src/application/ClientConfigPath.ts index b2490ae1bf..03478ae5ed 100644 --- a/src/application/ClientConfigPath.ts +++ b/src/application/ClientConfigPath.ts @@ -1,3 +1,4 @@ +import { HOST_PLATFORM } from "../platform/HostPlatform.js"; import { lstat, realpath } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; @@ -75,7 +76,7 @@ const canonicalRegularConfigPath = async ( if (fileSystem.realpath === undefined) return requestedPath; let ancestor = absoluteCallerPath(requestedPath); const directoryRequired = - process.platform !== "win32" && ancestor.endsWith("/"); + HOST_PLATFORM.platform !== "win32" && ancestor.endsWith("/"); const missing: string[] = []; while (true) { try { diff --git a/src/application/ClientConfigurationDocument.capacity.test.ts b/src/application/ClientConfigurationDocument.capacity.test.ts index 2fdb58ed5f..b873539c8f 100644 --- a/src/application/ClientConfigurationDocument.capacity.test.ts +++ b/src/application/ClientConfigurationDocument.capacity.test.ts @@ -1,3 +1,4 @@ +import { HOST_PLATFORM } from "../platform/HostPlatform.js"; import { describe, expect, it } from "vitest"; import { @@ -33,10 +34,14 @@ describe("TOML configuration source capacity", () => { newline, ); const parsed = parseClientConfiguration(original, "toml"); - const desired = withClientServers(parsed, { - ...parsed.servers, - rea: registration, - }); + const desired = withClientServers( + parsed, + { + ...parsed.servers, + rea: registration, + }, + HOST_PLATFORM.platform, + ); const configured = serializedText(desired, "toml", original); expect( configured.startsWith("\uFEFF" + prefix.replaceAll("\n", newline)), @@ -45,7 +50,7 @@ describe("TOML configuration source capacity", () => { desired, ); const removed = serializedText( - withClientServers(parsed, {}), + withClientServers(parsed, {}, HOST_PLATFORM.platform), "toml", configured, ); @@ -61,10 +66,14 @@ describe("TOML configuration source capacity", () => { const note = `first\n${"x\n".repeat(500_000)}last`; const original = `mcp_servers = { other = {command="other",env={NOTE="""${note}"""}}}\n`; const parsed = parseClientConfiguration(original, "toml"); - const desired = withClientServers(parsed, { - ...parsed.servers, - rea: registration, - }); + const desired = withClientServers( + parsed, + { + ...parsed.servers, + rea: registration, + }, + HOST_PLATFORM.platform, + ); const configured = serializedText(desired, "toml", original); expect(configured).toContain(`NOTE="""${note}"""`); expect(parseClientConfiguration(configured, "toml").document).toEqual( @@ -83,10 +92,14 @@ describe("TOML configuration source capacity", () => { `disabled_mcp_servers = [\n"rea",\n${otherNames}]\n` + '[mcp_servers.other]\ncommand = "other"\n'; const parsed = parseClientConfiguration(original, "grok"); - const desired = withClientServers(parsed, { - ...parsed.servers, - rea: registration, - }); + const desired = withClientServers( + parsed, + { + ...parsed.servers, + rea: registration, + }, + HOST_PLATFORM.platform, + ); const configured = serializedText(desired, "grok", original); const result = parseClientConfiguration(configured, "grok"); expect(result.document.disabled_mcp_servers).toEqual( diff --git a/src/application/ClientConfigurationDocument.jsonRetention.test.ts b/src/application/ClientConfigurationDocument.jsonRetention.test.ts index e1f2044152..6cd268bcb7 100644 --- a/src/application/ClientConfigurationDocument.jsonRetention.test.ts +++ b/src/application/ClientConfigurationDocument.jsonRetention.test.ts @@ -1,3 +1,4 @@ +import { HOST_PLATFORM } from "../platform/HostPlatform.js"; import { expect, it } from "vitest"; import { @@ -13,10 +14,14 @@ const replaceLauncher = (source: string, format: ClientConfigurationFormat) => { const existing = parsed.servers.rea; if (typeof existing !== "object" || existing === null) throw new Error("fixture registration missing"); - const document = withClientServers(parsed, { - ...parsed.servers, - rea: { ...existing, command: "new-rea" }, - }); + const document = withClientServers( + parsed, + { + ...parsed.servers, + rea: { ...existing, command: "new-rea" }, + }, + HOST_PLATFORM.platform, + ); const result = serializeClientConfiguration(document, format, source, [ clientServerPath(parsed, "rea"), ]); @@ -67,13 +72,17 @@ it("retains nested array tokens while changing siblings, shrinking and extending const source = `{"mcpServers":{"rea":{"command":"rea","items":[{"opaque":9007199254740993,"label":"old"},2,3],"grow":[9007199254740993]}}}`; const parsed = parseClientConfiguration(source, "json"); const result = serializeClientConfiguration( - withClientServers(parsed, { - rea: { - command: "rea", - items: [{ opaque: 9007199254740992, label: "new" }], - grow: [9007199254740992, 4], + withClientServers( + parsed, + { + rea: { + command: "rea", + items: [{ opaque: 9007199254740992, label: "new" }], + grow: [9007199254740992, 4], + }, }, - }), + HOST_PLATFORM.platform, + ), "json", source, [clientServerPath(parsed, "rea")], @@ -90,7 +99,11 @@ it("retains source tokens when the caller supplies a whole edited document", () const source = `{"opaque":9007199254740993,"mcpServers":{"rea":{"command":"old"}}}`; const parsed = parseClientConfiguration(source, "fx"); const result = serializeClientConfiguration( - withClientServers(parsed, { rea: { command: "new" } }), + withClientServers( + parsed, + { rea: { command: "new" } }, + HOST_PLATFORM.platform, + ), "fx", source, ); @@ -103,13 +116,17 @@ it("removes all repeated nested fields without activating an earlier value", () const source = `{"mcpServers":{"rea":{"remove":1,"remove":2,"retain":9007199254740993,"command":"old"}}}`; const parsed = parseClientConfiguration(source, "json"); const result = serializeClientConfiguration( - withClientServers(parsed, { - rea: { - retain: 9007199254740992, - command: "new", - added: { enabled: true }, + withClientServers( + parsed, + { + rea: { + retain: 9007199254740992, + command: "new", + added: { enabled: true }, + }, }, - }), + HOST_PLATFORM.platform, + ), "json", source, [clientServerPath(parsed, "rea")], @@ -132,7 +149,7 @@ it.each([ const source = `{"mcpServers":{"rea":{"items":[1,2,],"old":3,}}}`; const parsed = parseClientConfiguration(source, "fx"); const result = serializeClientConfiguration( - withClientServers(parsed, { rea }), + withClientServers(parsed, { rea }, HOST_PLATFORM.platform), "fx", source, [clientServerPath(parsed, "rea")], diff --git a/src/application/ClientConfigurationDocument.repeatedMembers.test.ts b/src/application/ClientConfigurationDocument.repeatedMembers.test.ts index d79cedaf70..ab8c119b29 100644 --- a/src/application/ClientConfigurationDocument.repeatedMembers.test.ts +++ b/src/application/ClientConfigurationDocument.repeatedMembers.test.ts @@ -1,3 +1,4 @@ +import { HOST_PLATFORM } from "../platform/HostPlatform.js"; import { describe, expect, it } from "vitest"; import { parse as parseJsonc, type ParseError } from "jsonc-parser"; @@ -21,7 +22,7 @@ const removeRea = ( const parsed = parseClientConfiguration(text, format); const { rea: _removed, ...remaining } = parsed.servers; return serializeClientConfiguration( - withClientServers(parsed, remaining), + withClientServers(parsed, remaining, HOST_PLATFORM.platform), format, text, [clientServerPath(parsed, "rea")], @@ -72,7 +73,7 @@ describe("JSON client configuration repeated-member edits", () => { it("does not apply repeat-removal authority to a different path", () => { const text = `{"mcpServers":{"rea":1,"rea":2,"peer":3,"peer":4}}`; const parsed = parseClientConfiguration(text, "json"); - const document = withClientServers(parsed, {}); + const document = withClientServers(parsed, {}, HOST_PLATFORM.platform); const result = serializeClientConfiguration( document, "json", diff --git a/src/application/ClientConfigurationDocument.tomlComments.test.ts b/src/application/ClientConfigurationDocument.tomlComments.test.ts index 0905e4c7a8..3bb3852425 100644 --- a/src/application/ClientConfigurationDocument.tomlComments.test.ts +++ b/src/application/ClientConfigurationDocument.tomlComments.test.ts @@ -1,3 +1,4 @@ +import { HOST_PLATFORM } from "../platform/HostPlatform.js"; import { expect, it } from "vitest"; import { @@ -38,7 +39,7 @@ for (const format of ["toml", "grok"] as const) ? { ...peers, rea: { command: "new", args: [] } } : peers; const result = serializeClientConfiguration( - withClientServers(parsed, servers), + withClientServers(parsed, servers, HOST_PLATFORM.platform), format, source, ); @@ -80,7 +81,7 @@ it("retains comments from dotted REA assignments and nested inline values", () = const { rea: removed, ...servers } = parsed.servers; expect(removed).toBeDefined(); const result = serializeClientConfiguration( - withClientServers(parsed, servers), + withClientServers(parsed, servers, HOST_PLATFORM.platform), "toml", source, ); @@ -111,7 +112,7 @@ it.each(["toml", "grok"] as const)( rea: { ...previous, command: "new" }, }; const result = serializeClientConfiguration( - withClientServers(parsed, servers), + withClientServers(parsed, servers, HOST_PLATFORM.platform), format, source, ); diff --git a/src/application/ClientConfigurationDocument.ts b/src/application/ClientConfigurationDocument.ts index cdcccf309d..272e1c9c43 100644 --- a/src/application/ClientConfigurationDocument.ts +++ b/src/application/ClientConfigurationDocument.ts @@ -532,22 +532,24 @@ export const clientServerForEdit = ( /** * Return `parsed.document` with `servers` at its server table. A legacy OpenCode - * V1 member named in `removeLegacy` is removed beside a native V2 table. + * V1 member named in `removeLegacy` is removed beside a native V2 table. The + * platform names the environment identity a Cordis entry is written with. */ export const withClientServers = ( parsed: ClientConfigurationDocument, servers: Record, + platform: NodeJS.Platform, removeLegacy?: string, ): Record => { // The Harness patch list upserts or removes by entry id, not by table. if (parsed.dialect === "dsh") return Object.hasOwn(servers, PRODUCT_IDENTITY.mcpServerKey) - ? editDshDocument( - parsed, - clientConfigurationRecordSchema.parse( + ? editDshDocument(parsed, { + desired: clientConfigurationRecordSchema.parse( servers[PRODUCT_IDENTITY.mcpServerKey], ), - ) + platform, + }) : editDshDocument(parsed); if (parsed.serversPath.length === 0) return { ...servers }; const [key = "", nested] = parsed.serversPath; diff --git a/src/application/ClientConfigurationFiles.ts b/src/application/ClientConfigurationFiles.ts index 0844784c7e..07503baccf 100644 --- a/src/application/ClientConfigurationFiles.ts +++ b/src/application/ClientConfigurationFiles.ts @@ -123,8 +123,8 @@ export const readConfigurationFile = async ( /** Read each layer; setup may validate its writable layer with proposed overrides. */ export const readClientConfigurationFiles = async ( client: SetupClient, - resources?: ArtifactResourceScope, - platform: NodeJS.Platform = process.platform, + resources: ArtifactResourceScope | undefined, + platform: NodeJS.Platform, writeOverrides?: Readonly>, ): Promise< Result @@ -156,9 +156,9 @@ export const readClientConfigurationFiles = async ( const environment = clientRegistrationEnvironment( document.value, client, + platform, path === client.configPath ? writeOverrides : undefined, "fragment", - platform, ); if (!environment.ok) return err( @@ -182,9 +182,9 @@ export const readClientConfigurationFiles = async ( const environment = clientRegistrationEnvironment( effectiveClientConfiguration(documents, undefined, platform), client, + platform, writeOverrides, "effective", - platform, ); if (!environment.ok) return err( @@ -249,11 +249,13 @@ export const clientConfigurationForWrite = ( /** Project a client's effective user registration without modifying its source files. */ export const effectiveClientConfiguration = ( sources: readonly ClientConfigurationSource[], - target?: { - readonly client: Pick; - readonly document: ClientConfigurationDocument | undefined; - }, - platform: NodeJS.Platform = process.platform, + target: + | { + readonly client: Pick; + readonly document: ClientConfigurationDocument | undefined; + } + | undefined, + platform: NodeJS.Platform, ): ClientConfigurationDocument | undefined => { const documents = target === undefined @@ -389,7 +391,7 @@ export const launchedRegistrationEntry = ( /** Merge admitted layers without re-reading or changing their source documents. */ export const mergeClientConfigurationDocuments = ( documents: readonly ClientConfigurationDocument[], - platform: NodeJS.Platform = process.platform, + platform: NodeJS.Platform, ): ClientConfigurationDocument | undefined => { const last = documents.at(-1); if (last === undefined) return undefined; @@ -439,8 +441,8 @@ export const mergeClientConfigurationDocuments = ( /** Read the configuration ZCode will load, preserving its same-scope fallback rule. */ export const readEffectiveClientRegistrationConfiguration = async ( client: SetupClient, - resources?: ArtifactResourceScope, - platform: NodeJS.Platform = process.platform, + resources: ArtifactResourceScope | undefined, + platform: NodeJS.Platform, ): Promise< Result< { diff --git a/src/application/ClientConfigurationPublication.ts b/src/application/ClientConfigurationPublication.ts index 6b91af1e99..07f97a70cf 100644 --- a/src/application/ClientConfigurationPublication.ts +++ b/src/application/ClientConfigurationPublication.ts @@ -1,3 +1,4 @@ +import { HOST_PLATFORM } from "../platform/HostPlatform.js"; import { constants, type Stats } from "node:fs"; import { link, lstat, mkdtemp, open, realpath, rename } from "node:fs/promises"; import { basename, dirname, join, resolve, sep } from "node:path"; @@ -124,7 +125,7 @@ export class ClientConfigurationPublication { if (selection?.requiredMode !== undefined) { await owner.handle.chmod(selection.requiredMode); if ( - process.platform !== "win32" && + HOST_PLATFORM.platform !== "win32" && ((await owner.handle.stat()).mode & 0o777) !== selection.requiredMode ) throw new ClientConfigurationWriteError( diff --git a/src/application/ClientRegistration.test.ts b/src/application/ClientRegistration.test.ts index 86350707c1..db8b713172 100644 --- a/src/application/ClientRegistration.test.ts +++ b/src/application/ClientRegistration.test.ts @@ -1,3 +1,4 @@ +import { HOST_PLATFORM } from "../platform/HostPlatform.js"; import { expect, it } from "vitest"; import { @@ -215,6 +216,7 @@ it.each([ name: "goose", registrationProfile: supportedClientRegistrationProfile("goose"), }, + HOST_PLATFORM.platform, ); expect(result.ok).toBe(false); if (result.ok) throw new Error("Null Goose env alias was admitted"); diff --git a/src/application/ClientRegistrationEnvironment.ts b/src/application/ClientRegistrationEnvironment.ts index 13ba7b1d02..f43bcc26a6 100644 --- a/src/application/ClientRegistrationEnvironment.ts +++ b/src/application/ClientRegistrationEnvironment.ts @@ -99,16 +99,16 @@ const expressionSettings = ( export const clientRegistrationEnvironment = ( parsed: ClientConfigurationDocument | undefined, client: Pick | undefined, + platform: NodeJS.Platform, overrides: Readonly> = {}, validation: "fragment" | "effective" = "effective", - platform: NodeJS.Platform = process.platform, ): Result>, ConfigurationError> => { const details = clientRegistrationEnvironmentDetails( parsed, client, + platform, overrides, validation, - platform, ); return details.ok ? ok(details.value.values) : details; }; @@ -117,9 +117,9 @@ export const clientRegistrationEnvironment = ( export const clientRegistrationEnvironmentDetails = ( parsed: ClientConfigurationDocument | undefined, client: Pick | undefined, + platform: NodeJS.Platform, overrides: Readonly> = {}, validation: "fragment" | "effective" = "effective", - platform: NodeJS.Platform = process.platform, ): Result => { if (parsed?.dialect === "dsh") { const environment = dshDocument(parsed.document).environment( @@ -207,7 +207,7 @@ export const recordedProviderSettings = ( parsed: ClientConfigurationDocument, client: Pick | undefined, selected: Readonly>, - platform: NodeJS.Platform = process.platform, + platform: NodeJS.Platform, owns?: OwnedProviderSetting, observe?: ProviderSettingObserver, ): RecordedProviderSettings | undefined => { @@ -219,9 +219,9 @@ export const recordedProviderSettings = ( const details = clientRegistrationEnvironmentDetails( parsed, client, + platform, {}, "fragment", - platform, ); if (!details.ok) return { unrecorded: [], differing: [], unresolved: names }; const recorded = snapshotEnvironment(details.value.values, platform); @@ -273,16 +273,16 @@ export const clientRegistrationEnvironmentDetailsWithBudget = async ( overrides: Readonly>, validation: "fragment" | "effective", resolveProviderRequestBudget: ProviderRequestBudgetResolver | undefined, - platform: NodeJS.Platform = process.platform, + platform: NodeJS.Platform, ): Promise< Result > => { const details = clientRegistrationEnvironmentDetails( parsed, client, + platform, overrides, validation, - platform, ); if (!details.ok) return details; const unresolved = [...details.value.unresolvedSettings].filter( diff --git a/src/application/DshCordisPatch.ts b/src/application/DshCordisPatch.ts index 4cfffeb6f2..b56afc52a0 100644 --- a/src/application/DshCordisPatch.ts +++ b/src/application/DshCordisPatch.ts @@ -1,5 +1,4 @@ import { validateRegistrationEnvironment } from "./RegistrationEnvironmentValidation.js"; -import { HOST_PLATFORM } from "../platform/HostPlatform.js"; import { isMap, isSeq, type Node, type YAMLMap } from "yaml"; import type { Environment } from "../config/environment.js"; import { ConfigurationError } from "../domain/configurationErrors.js"; @@ -282,7 +281,8 @@ export class DshCordisDocument { } /** Plan every replacement from source snapshots before changing an output tree. */ - serialize(desired?: Record): string { + serialize(edit?: DshCordisRegistration): string { + const desired = edit?.desired; if ( this.#editabilityError !== undefined && (this.#unknownStructure || desired !== undefined) @@ -319,9 +319,11 @@ export class DshCordisDocument { const owned = new Set(op.ownedRows); inserted.items = inserted.items.flatMap((entry, index) => { if (!owned.has(index)) return [entry]; - if (carried || desired === undefined) return []; + if (carried || edit === undefined) return []; carried = true; - return [this.#entry(desired, invalidated)] as typeof inserted.items; + return [ + this.#entry(edit.desired, edit.platform, invalidated), + ] as typeof inserted.items; }); if (inserted.items.length === 0) copy.delete("insert"); // An emptied operation keeps caller fields unless it would patch REA's row. @@ -356,6 +358,7 @@ export class DshCordisDocument { #entry( desired: Record, + platform: NodeJS.Platform, invalidated: ReadonlySet, ): YAMLMap { const entry = this.#yaml.detach(this.#entryNode, invalidated); @@ -395,9 +398,10 @@ export class DshCordisDocument { this.#yaml.renameKey(nextEnv, pair, key); continue; } - // Serialization rewrites this host's file, as setup's write path does. + // Windows environment names match case-insensitively, so a caller's + // spelling of a desired name is kept rather than duplicated. const alias = - HOST_PLATFORM.platform === "win32" + platform === "win32" ? Object.keys(desiredEnv).find( (name) => name.toUpperCase() === key.toUpperCase(), ) @@ -415,10 +419,16 @@ export class DshCordisDocument { } } +/** REA's desired entry and the platform whose environment names it uses. */ +export interface DshCordisRegistration { + readonly desired: Record; + readonly platform: NodeJS.Platform; +} + class DshCordisEdit { constructor( readonly source: DshCordisDocument, - readonly desired?: Record, + readonly registration?: DshCordisRegistration, ) {} } export const dshDocument = ( @@ -443,16 +453,16 @@ export const parseDshCordisDocument = ( }; export const editDshDocument = ( parsed: ClientConfigurationDocument, - desired?: Record, + registration?: DshCordisRegistration, ): Record => ({ - cordis: new DshCordisEdit(dshDocument(parsed.document), desired), + cordis: new DshCordisEdit(dshDocument(parsed.document), registration), }); export const serializeDshDocument = ( document: Record, ): string => { if (!(document.cordis instanceof DshCordisEdit)) throw new ConfigurationError("Missing Cordis edit plan"); - return document.cordis.source.serialize(document.cordis.desired); + return document.cordis.source.serialize(document.cordis.registration); }; export const dshClientRegistrationEntry = ( command: readonly string[], diff --git a/src/application/HermesConfigurationYaml.test.ts b/src/application/HermesConfigurationYaml.test.ts index 1b07b013eb..2dccd2a153 100644 --- a/src/application/HermesConfigurationYaml.test.ts +++ b/src/application/HermesConfigurationYaml.test.ts @@ -1,3 +1,4 @@ +import { HOST_PLATFORM } from "../platform/HostPlatform.js"; import { describe, expect, it } from "vitest"; import { parse } from "yaml"; import { @@ -626,10 +627,14 @@ it("retains exact integer settings through a parsed Hermes launcher refresh", () const parsed = parseClientConfiguration(source, "hermes"); const previous = parsed.servers.rea as Record; const result = serializeClientConfiguration( - withClientServers(parsed, { - ...parsed.servers, - rea: { ...previous, command: "rea" }, - }), + withClientServers( + parsed, + { + ...parsed.servers, + rea: { ...previous, command: "rea" }, + }, + HOST_PLATFORM.platform, + ), "hermes", source, ); @@ -812,9 +817,10 @@ describe("Hermes registration refresh through inherited merges", () => { it("keeps an inherited stdio transport through a launcher refresh", () => { const source = "defaults: &defaults\n transport: stdio\n command: rea\n args: [mcp]\nmcp_servers:\n rea:\n <<: *defaults\n"; - const client = supportedClients("/home/rea-test").find( - ({ name }) => name === "hermes", - )!; + const client = supportedClients( + "/home/rea-test", + HOST_PLATFORM.platform, + ).find(({ name }) => name === "hermes")!; const parsed = parseClientConfiguration(source, "hermes"); const registration = refreshClientRegistration( client, @@ -830,7 +836,11 @@ describe("Hermes registration refresh through inherited merges", () => { ); if (!registration.ok) throw registration.error; const written = serializeClientConfiguration( - withClientServers(parsed, { ...parsed.servers, rea: registration.value }), + withClientServers( + parsed, + { ...parsed.servers, rea: registration.value }, + HOST_PLATFORM.platform, + ), "hermes", source, ); diff --git a/src/application/JsonFiles.ts b/src/application/JsonFiles.ts index f5143f99e8..25549a1ef5 100644 --- a/src/application/JsonFiles.ts +++ b/src/application/JsonFiles.ts @@ -1,3 +1,4 @@ +import { HOST_PLATFORM } from "../platform/HostPlatform.js"; import { link, lstat, @@ -378,7 +379,7 @@ const writeTextPartsOwned = async ( const requestedPath = absoluteCallerPath(path); try { assertWriteActive(cancellation); - if (process.platform !== "win32" && requestedPath.endsWith("/")) + if (HOST_PLATFORM.platform !== "win32" && requestedPath.endsWith("/")) return err( new EvidenceFileError("write", "not-file", { path: requestedPath }), ); diff --git a/src/application/McpClientRegistration.layers.test.ts b/src/application/McpClientRegistration.layers.test.ts index ed7351c817..2420e750a2 100644 --- a/src/application/McpClientRegistration.layers.test.ts +++ b/src/application/McpClientRegistration.layers.test.ts @@ -1,3 +1,4 @@ +import { HOST_PLATFORM } from "../platform/HostPlatform.js"; import { lstat, mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { afterEach, beforeEach, expect, it } from "vitest"; @@ -50,6 +51,7 @@ const effective = ( ) => { const parsed = mergeClientConfigurationDocuments( texts.map((text) => parseClientConfiguration(text, format)), + HOST_PLATFORM.platform, ); if (parsed === undefined) throw new Error("Fixture has no configuration documents"); diff --git a/src/application/McpRegistrationConfiguration.ts b/src/application/McpRegistrationConfiguration.ts index 444fe9381e..22c73e1820 100644 --- a/src/application/McpRegistrationConfiguration.ts +++ b/src/application/McpRegistrationConfiguration.ts @@ -1,6 +1,7 @@ import { ConfigurationError } from "../domain/configurationErrors.js"; import type { ClientRegistrationCommand } from "../domain/clientRegistrationCommand.js"; import { PRODUCT_IDENTITY } from "../identity.js"; +import { HOST_PLATFORM } from "../platform/HostPlatform.js"; import { clientConfigurationValuesEqual, clientServerPath, @@ -23,6 +24,9 @@ import { admitClientStdioConnection } from "./ClientRegistrationConnection.js"; import type { McpRegistrationClient } from "./McpRegistrationCatalog.js"; import { readRegularFileTextSnapshot } from "./RegularFileRead.js"; +// `rea mcp add` writes and verifies this host's client files. +const { platform } = HOST_PLATFORM; + const isMissing = (cause: unknown): boolean => cause instanceof Error && "code" in cause && cause.code === "ENOENT"; @@ -53,6 +57,7 @@ export const prepareRegistration = async ( const targetIndex = paths.lastIndexOf(client.configPath); const inherited = mergeClientConfigurationDocuments( layers.slice(0, targetIndex), + platform, ); const desired = replaceClientRegistrationLauncher( { dialect: parsed.dialect, profile: client.registrationProfile }, @@ -84,6 +89,7 @@ export const prepareRegistration = async ( ...parsed.servers, [PRODUCT_IDENTITY.mcpServerKey]: desired.value, }, + platform, PRODUCT_IDENTITY.mcpServerKey, ); const legacy = legacyClientServerPath(parsed, PRODUCT_IDENTITY.mcpServerKey); @@ -162,7 +168,7 @@ const assertEffectiveLauncher = ( layers: readonly ClientConfigurationDocument[], launcher: ClientRegistrationCommand, ): void => { - const effective = mergeClientConfigurationDocuments(layers); + const effective = mergeClientConfigurationDocuments(layers, platform); const admitted = admitClientStdioConnection( effective === undefined ? undefined diff --git a/src/application/SetupClientConfiguration.ts b/src/application/SetupClientConfiguration.ts index ae1fb19953..4aa9b916cf 100644 --- a/src/application/SetupClientConfiguration.ts +++ b/src/application/SetupClientConfiguration.ts @@ -109,6 +109,8 @@ export const configureClientConfiguration = async ( environment: SetupProviderEnvironment = {}, command: readonly string[] = currentClientRegistrationCommand(), resolveProviderRequestBudget?: ProviderRequestBudgetResolver, + // Setup writes the running host's client files unless its host selects one. + platform: NodeJS.Platform = HOST_PLATFORM.platform, ): Promise => { if (command.length === 0) return { @@ -127,7 +129,7 @@ export const configureClientConfiguration = async ( try { return await withClientConfigurationTransaction(transactionPath, () => configureClientDocument( - { client, transactionPath }, + { client, transactionPath, platform }, environment, command, resolveProviderRequestBudget, @@ -148,7 +150,12 @@ const configureClientDocument = async ( { client, transactionPath, - }: { readonly client: SetupClient; readonly transactionPath: string }, + platform, + }: { + readonly client: SetupClient; + readonly transactionPath: string; + readonly platform: NodeJS.Platform; + }, environment: SetupProviderEnvironment, command: readonly string[], resolveProviderRequestBudget?: ProviderRequestBudgetResolver, @@ -159,7 +166,7 @@ const configureClientDocument = async ( const files = await readClientConfigurationFiles( client, undefined, - undefined, + platform, environment, ); if (!files.ok) { @@ -204,6 +211,7 @@ const configureClientDocument = async ( parsed, original ?? "", files.value, + platform, ); if (ownershipBlocker !== undefined) return { @@ -220,6 +228,7 @@ const configureClientDocument = async ( command, parsed, files.value, + platform, resolveProviderRequestBudget, ), ); @@ -235,7 +244,7 @@ const configureClientDocument = async ( void cause; return { status: "failed", reason: "readback" }; } - const fallback = await readZcodeFallbackServers(client, parsed); + const fallback = await readZcodeFallbackServers(client, platform, parsed); if (!fallback.ok) { if (fallback.error.kind !== "registration_ownership") throwCleanupFailure(fallback.error); @@ -256,11 +265,17 @@ const configureClientDocument = async ( servers: { ...fallback.value, ...parsed.servers }, }, files.value, + platform, resolveProviderRequestBudget, ); // The settings written now can differ from the ones inspection admitted, // for example a Hopper path set by an installation earlier in this setup. - const override = inlineOverrideBlocker(client, files.value, desired); + const override = inlineOverrideBlocker( + client, + files.value, + desired, + platform, + ); if (override !== undefined) return { status: "failed", reason: "read_only_override", detail: override }; if ( @@ -277,6 +292,7 @@ const configureClientDocument = async ( parsed, desired, fallbackServers: fallback.value, + platform, }); }; @@ -291,6 +307,7 @@ interface ClientConfigurationUpdate { readonly parsed: ClientConfigurationDocument; readonly desired: unknown; readonly fallbackServers: Readonly>; + readonly platform: NodeJS.Platform; } const persistClientConfiguration = async ({ @@ -302,6 +319,7 @@ const persistClientConfiguration = async ({ parsed, desired, fallbackServers, + platform, }: ClientConfigurationUpdate): Promise => { // The plan chose this file; an MSIX package may have switched to its // private copy since, for example when it quit. @@ -335,6 +353,7 @@ const persistClientConfiguration = async ({ ...parsed.servers, [PRODUCT_IDENTITY.mcpServerKey]: desired, }, + platform, PRODUCT_IDENTITY.mcpServerKey, ); // OMP's denylist would hide the registration; keep every other listed name. @@ -409,6 +428,7 @@ const persistClientConfiguration = async ({ ? { mode: PRIVATE_PROFILE_MODES.directory } : {}), }); + // File modes are this host's, whatever platform the registration targets. if ( client.privateProfile !== undefined && HOST_PLATFORM.platform !== "win32" @@ -519,20 +539,22 @@ const readClientRefresh = async ( providerEnvironment: SetupProviderEnvironment, command: readonly string[], { + platform, resolveProviderRequestBudget, resources, currentCommandPath, }: { + readonly platform: NodeJS.Platform; readonly resolveProviderRequestBudget?: ProviderRequestBudgetResolver; readonly resources?: ArtifactResourceScope; /** The entry point whose registrations setup owns; update names the new one. */ readonly currentCommandPath?: string; - } = {}, + }, ): Promise> => { const files = await readClientConfigurationFiles( client, resources, - undefined, + platform, providerEnvironment, ); if (!files.ok) { @@ -561,6 +583,7 @@ const readClientRefresh = async ( command, parsed, files.value, + platform, resolveProviderRequestBudget, ); const target = @@ -570,6 +593,7 @@ const readClientRefresh = async ( files.value, parsed, original, + platform, currentCommandPath, ); if (target !== undefined) @@ -584,7 +608,7 @@ const readClientRefresh = async ( client, files.value, desired, - HOST_PLATFORM.platform, + platform, ); if (overrideOrigin !== undefined) return ok({ @@ -626,6 +650,8 @@ export const clientConfigurationAligned = async ( providerEnvironment: SetupProviderEnvironment, command: readonly string[], resolveProviderRequestBudget?: ProviderRequestBudgetResolver, + // Setup reads the running host's client files unless its host selects one. + platform: NodeJS.Platform = HOST_PLATFORM.platform, ): Promise => { if (client.configPathError !== undefined) return false; try { @@ -634,8 +660,8 @@ export const clientConfigurationAligned = async ( providerEnvironment, command, resolveProviderRequestBudget === undefined - ? {} - : { resolveProviderRequestBudget }, + ? { platform } + : { platform, resolveProviderRequestBudget }, ); return ( refresh.ok && @@ -698,6 +724,7 @@ export const clientRefreshRefusal = async ( providerEnvironment, command, { + platform, currentCommandPath, ...(resources === undefined ? {} : { resources }), }, @@ -782,6 +809,7 @@ const discoveredInlineSettings = async ( command, refresh.parsed, refresh.sources, + platform, ); return inlineRegistrationOverride( client, @@ -799,6 +827,8 @@ export const inspectClientConfiguration = async ( providerEnvironment: SetupProviderEnvironment, command: readonly string[], resolveProviderRequestBudget?: ProviderRequestBudgetResolver, + // Setup reads the running host's client files unless its host selects one. + platform: NodeJS.Platform = HOST_PLATFORM.platform, ): Promise => { let inspection: ClientConfigurationInspection; try { @@ -806,6 +836,7 @@ export const inspectClientConfiguration = async ( client, providerEnvironment, command, + platform, resolveProviderRequestBudget, ); } catch (cause: unknown) { @@ -828,6 +859,7 @@ const inspectClientDocument = async ( client: SetupClient, providerEnvironment: SetupProviderEnvironment, command: readonly string[], + platform: NodeJS.Platform, resolveProviderRequestBudget?: ProviderRequestBudgetResolver, ): Promise => { if (client.configPathError !== undefined) @@ -836,7 +868,7 @@ const inspectClientDocument = async ( const files = await readClientConfigurationFiles( client, undefined, - undefined, + platform, providerEnvironment, ); if (!files.ok) { @@ -869,6 +901,7 @@ const inspectClientDocument = async ( files.value, undefined, undefined, + platform, ); if (ownershipBlocker !== undefined) return { status: "invalid", remediation: ownershipBlocker }; @@ -878,9 +911,15 @@ const inspectClientDocument = async ( command, undefined, files.value, + platform, resolveProviderRequestBudget, ); - const override = inlineOverrideBlocker(client, files.value, desired); + const override = inlineOverrideBlocker( + client, + files.value, + desired, + platform, + ); if (override !== undefined) return { status: "invalid", remediation: override }; // This read establishes absence; do not reuse an earlier user-file policy. @@ -894,7 +933,7 @@ const inspectClientDocument = async ( } if (policy.value !== undefined) return { status: "invalid", remediation: policy.value }; - const fallback = await readZcodeFallbackServers(client); + const fallback = await readZcodeFallbackServers(client, platform); if (!fallback.ok) { if (fallback.error.kind !== "registration_ownership") throwCleanupFailure(fallback.error); @@ -917,6 +956,7 @@ const inspectClientDocument = async ( files.value, parsed, original, + platform, ); if (ownershipBlocker !== undefined) return { status: "invalid", remediation: ownershipBlocker }; @@ -926,9 +966,15 @@ const inspectClientDocument = async ( command, parsed, files.value, + platform, resolveProviderRequestBudget, ); - const override = inlineOverrideBlocker(client, files.value, desired); + const override = inlineOverrideBlocker( + client, + files.value, + desired, + platform, + ); if (override !== undefined) return { status: "invalid", remediation: override }; const policy = await readClientPolicyBlock(client, parsed, desired); @@ -946,7 +992,7 @@ const inspectClientDocument = async ( (await privateProfileCurrent(client, original)) ) return { status: "already_current" }; - const fallback = await readZcodeFallbackServers(client, parsed); + const fallback = await readZcodeFallbackServers(client, platform, parsed); if (!fallback.ok) { if (fallback.error.kind !== "registration_ownership") throwCleanupFailure(fallback.error); @@ -993,14 +1039,9 @@ const inlineOverrideBlocker = ( client: SetupClient, sources: readonly ClientConfigurationSource[], desired: unknown, + platform: NodeJS.Platform, ): string | undefined => { - // Setup writes and its client reads this file on the running host. - const origin = inlineRegistrationOverride( - client, - sources, - desired, - HOST_PLATFORM.platform, - ); + const origin = inlineRegistrationOverride(client, sources, desired, platform); return origin === undefined ? undefined : inlineOverrideRemediation(client, origin); @@ -1034,6 +1075,7 @@ const writeTargetOwnershipBlocker = ( sources: readonly ClientConfigurationSource[], parsed: ClientConfigurationDocument | undefined, original: string | undefined, + platform: NodeJS.Platform, currentCommandPath?: string, ): string | undefined => { if (parsed !== undefined) @@ -1042,12 +1084,14 @@ const writeTargetOwnershipBlocker = ( parsed, original ?? "", sources, + platform, currentCommandPath, ); - const inherited = effectiveClientConfiguration(sources, { - client, - document: undefined, - }); + const inherited = effectiveClientConfiguration( + sources, + { client, document: undefined }, + platform, + ); return inherited === undefined ? undefined : registrationOwnershipBlocker( @@ -1055,6 +1099,7 @@ const writeTargetOwnershipBlocker = ( inherited, "", sources, + platform, currentCommandPath, ); }; @@ -1064,17 +1109,19 @@ const registrationOwnershipBlocker = ( client: SetupClient, parsed: ClientConfigurationDocument, original: string, - sources: readonly ClientConfigurationSource[] = [], + sources: readonly ClientConfigurationSource[], + platform: NodeJS.Platform, currentCommandPath?: string, ): string | undefined => { // Cordis owns registrations by stable entry ID, including malformed launchers. if (parsed.dialect === "dsh") return undefined; const name = PRODUCT_IDENTITY.mcpServerKey; const entries: { value: unknown; paths: readonly string[] }[] = []; - const effective = effectiveClientConfiguration(sources, { - client, - document: parsed, - }); + const effective = effectiveClientConfiguration( + sources, + { client, document: parsed }, + platform, + ); if (effective !== undefined && Object.hasOwn(effective.servers, name)) entries.push({ value: effective.servers[name], @@ -1121,6 +1168,7 @@ const registrationOwnershipBlocker = ( /** Admit the fallback only when the first native ZCode server would suppress it. */ const readZcodeFallbackServers = async ( client: SetupClient, + platform: NodeJS.Platform, parsed?: ClientConfigurationDocument, ): Promise< Result< @@ -1155,6 +1203,8 @@ const readZcodeFallbackServers = async ( { ...client, configPath: path, format: "zcode_fallback" }, parsed, source.value, + [], + platform, ); if (ownershipBlocker !== undefined) return err({ @@ -1162,7 +1212,7 @@ const readZcodeFallbackServers = async ( path, detail: ownershipBlocker, }); - const environment = clientRegistrationEnvironment(parsed, client); + const environment = clientRegistrationEnvironment(parsed, client, platform); if (!environment.ok) return err({ kind: "malformed", @@ -1262,7 +1312,11 @@ const registrationCurrent = ( !Object.hasOwn(parsed.legacyServers, PRODUCT_IDENTITY.mcpServerKey) && !clientServerListedDisabled(parsed, PRODUCT_IDENTITY.mcpServerKey); -/** A private profile with the wrong modes needs publication even when its row is current. */ +/** + * A private profile with the wrong modes needs publication even when its row + * is current. Modes belong to the host holding the file, not to the platform + * the registration is written for. + */ const privateProfileCurrent = async ( client: SetupClient, original: string | undefined, @@ -1280,12 +1334,14 @@ const clientConfigurationDesired = async ( command: readonly string[], parsed: ClientConfigurationDocument | undefined, sources: readonly ClientConfigurationSource[], + platform: NodeJS.Platform, resolveProviderRequestBudget?: ProviderRequestBudgetResolver, ): Promise => { - const effective = effectiveClientConfiguration(sources, { - client, - document: parsed, - }); + const effective = effectiveClientConfiguration( + sources, + { client, document: parsed }, + platform, + ); const format = clientWriteDialect( parsed?.dialect ?? client.format ?? "json", sources, @@ -1296,6 +1352,7 @@ const clientConfigurationDesired = async ( const validated = clientRegistrationEnvironment( effective, client, + platform, providerEnvironment, ); if (!validated.ok) throw validated.error; @@ -1303,7 +1360,7 @@ const clientConfigurationDesired = async ( parsed === undefined ? new DshCordisDocument("") : dshDocument(parsed.document) - ).desired(launcher, providerEnvironment, HOST_PLATFORM.platform); + ).desired(launcher, providerEnvironment, platform); if (!desired.ok) throw desired.error; return desired.value; } @@ -1315,6 +1372,7 @@ const clientConfigurationDesired = async ( client.name === "codex" || client.name === "kimi" ? resolveProviderRequestBudget : undefined, + platform, ); if (!validated.ok) throw validated.error; // Preserve explicitly configured server settings, never the ambient process @@ -1323,6 +1381,7 @@ const clientConfigurationDesired = async ( const environment = clientRegistrationEnvironment( parsed, client, + platform, providerEnvironment, "fragment", ); @@ -1346,6 +1405,7 @@ const clientConfigurationDesired = async ( sources, effective, providerEnvironment, + platform, ); }; @@ -1371,13 +1431,15 @@ const withInheritedGooseSettings = ( sources: readonly ClientConfigurationSource[], effective: ClientConfigurationDocument | undefined, providerEnvironment: SetupProviderEnvironment, + platform: NodeJS.Platform, ): Record => { if (format !== "goose") return withoutInherited; // The layers below the user file, which the user entry overlays field by field. - const lower = effectiveClientConfiguration(sources, { - client, - document: undefined, - }); + const lower = effectiveClientConfiguration( + sources, + { client, document: undefined }, + platform, + ); const declares = ( document: ClientConfigurationDocument | undefined, keys: readonly string[], @@ -1422,6 +1484,7 @@ const withInheritedGooseSettings = ( const inherited = clientRegistrationEnvironment( effective, client, + platform, providerEnvironment, "fragment", ); diff --git a/src/application/SetupHost.ts b/src/application/SetupHost.ts index 7902150ec1..3351b30977 100644 --- a/src/application/SetupHost.ts +++ b/src/application/SetupHost.ts @@ -303,6 +303,7 @@ export const systemSetupHost = ( ...clientConfigurationOperations( resources, doctorHost.resolveProviderRequestBudget, + platform, ), skillNeedsInstall: (clientIds) => canonicalSkillNeedsInstall( @@ -363,6 +364,7 @@ const setupInstallerDiagnostic = ( const clientConfigurationOperations = ( resources: ArtifactResourceScope, resolveProviderRequestBudget: DoctorHost["resolveProviderRequestBudget"], + platform: NodeJS.Platform, ) => ({ configureClient: ( client: SetupClient, @@ -377,6 +379,7 @@ const clientConfigurationOperations = ( providerEnvironment, command, resolveProviderRequestBudget, + platform, ), ), clientNeedsConfigure: ( @@ -392,6 +395,7 @@ const clientConfigurationOperations = ( providerEnvironment, command, resolveProviderRequestBudget, + platform, )), ), inspectClientConfiguration: ( @@ -405,6 +409,7 @@ const clientConfigurationOperations = ( providerEnvironment, command, resolveProviderRequestBudget, + platform, ), ), }); diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts index e8ac607b9a..4c703dedfe 100644 --- a/src/application/SupportedClients.ts +++ b/src/application/SupportedClients.ts @@ -2006,7 +2006,7 @@ const layerLoadOrder = ( /** Describe every client location that setup, doctor, or uninstall may inspect. */ export const supportedClients = ( home: string, - platform: NodeJS.Platform = process.platform, + platform: NodeJS.Platform, env: ClientPathContext["env"] = process.env, systemPaths: ClientSystemPaths = {}, ): readonly SetupClient[] => { @@ -2136,8 +2136,8 @@ export const resolveSupportedClients = async ( export const clientSkillDirectories = ( home: string, clientIds: readonly string[] | undefined, - environment: Readonly = {}, - platform: NodeJS.Platform = process.platform, + environment: Readonly, + platform: NodeJS.Platform, ): { readonly directories: readonly { readonly client: string; diff --git a/src/application/Uninstall.ts b/src/application/Uninstall.ts index 49e27adb94..39d42ae7fe 100644 --- a/src/application/Uninstall.ts +++ b/src/application/Uninstall.ts @@ -550,10 +550,8 @@ export const systemUninstallHost = ( if (upper !== undefined) lowerLayers.push(upper); return { kind: "entry", - entry: - effectiveClientConfiguration(lowerLayers)?.servers[ - PRODUCT_IDENTITY.mcpServerKey - ], + entry: effectiveClientConfiguration(lowerLayers, undefined, platform) + ?.servers[PRODUCT_IDENTITY.mcpServerKey], }; }; /** @@ -574,9 +572,11 @@ export const systemUninstallHost = ( const inlineEntry = inline === undefined ? undefined - : effectiveClientConfiguration([ - { ...inline.parsed, inlineOrigin: inline.origin }, - ])?.servers[PRODUCT_IDENTITY.mcpServerKey]; + : effectiveClientConfiguration( + [{ ...inline.parsed, inlineOrigin: inline.origin }], + undefined, + platform, + )?.servers[PRODUCT_IDENTITY.mcpServerKey]; if ( inline !== undefined && enabledRegistration(inlineEntry) && @@ -820,14 +820,16 @@ export const systemUninstallHost = ( originalLayers.push(layer); remainingLayers.push(layer); } - const selected = - effectiveClientConfiguration(originalLayers)?.servers[ - PRODUCT_IDENTITY.mcpServerKey - ]; - const remaining = - effectiveClientConfiguration(remainingLayers)?.servers[ - PRODUCT_IDENTITY.mcpServerKey - ]; + const selected = effectiveClientConfiguration( + originalLayers, + undefined, + platform, + )?.servers[PRODUCT_IDENTITY.mcpServerKey]; + const remaining = effectiveClientConfiguration( + remainingLayers, + undefined, + platform, + )?.servers[PRODUCT_IDENTITY.mcpServerKey]; const altersEffectiveRegistration = clientRegistrationOwnership(selected, client) === "owned" ? remaining !== undefined @@ -878,6 +880,7 @@ export const systemUninstallHost = ( client, fileSystem, resources, + platform, () => clientWriteBlocker(client), preflightOwnedClients.has(client), ), @@ -1212,6 +1215,7 @@ const removeClient = async ( client: UninstallClient, fileSystem: UninstallFileSystem, resources: ArtifactResourceScope, + platform: NodeJS.Platform, writeBlocker?: () => string | undefined, preflightOwned = false, ): Promise => { @@ -1252,6 +1256,7 @@ const removeClient = async ( const document = withClientServers( parsed, remaining, + platform, removeLegacy ? name : undefined, ); const editedPaths = [ diff --git a/src/doctorRuntime.ts b/src/doctorRuntime.ts index c55b7946c2..5770ffd003 100644 --- a/src/doctorRuntime.ts +++ b/src/doctorRuntime.ts @@ -162,9 +162,9 @@ const readSelectedRegistration = async ( const details = clientRegistrationEnvironmentDetails( configuration.value.document, client, + platform, {}, "effective", - platform, ); return details.ok ? { diff --git a/src/mcpStartupPolicy.test.ts b/src/mcpStartupPolicy.test.ts index 877372f5fc..ceff84ca84 100644 --- a/src/mcpStartupPolicy.test.ts +++ b/src/mcpStartupPolicy.test.ts @@ -1,3 +1,4 @@ +import { HOST_PLATFORM } from "./platform/HostPlatform.js"; import { describe, expect, it } from "vitest"; import { parseClientConfiguration } from "./application/ClientConfigurationDocument.js"; @@ -55,6 +56,7 @@ describe("MCP tool deadline allowance for a Ghidra decompile limit", () => { "json", ), undefined, + HOST_PLATFORM.platform, ); if (!details.ok) throw details.error; expect(details.value.timeoutInputs).toMatchObject({ diff --git a/tests/boundary/filesystem/clientConfigurationDialects.test.ts b/tests/boundary/filesystem/clientConfigurationDialects.test.ts index e3729ede18..5580160324 100644 --- a/tests/boundary/filesystem/clientConfigurationDialects.test.ts +++ b/tests/boundary/filesystem/clientConfigurationDialects.test.ts @@ -33,7 +33,7 @@ beforeEach(clearClientLocationEnvironment); afterEach(() => vi.unstubAllEnvs()); const getClients = (home: string) => - supportedClients(home).filter(({ name }) => + supportedClients(home, HOST_PLATFORM.platform).filter(({ name }) => [ "opencode", "antigravity", @@ -57,7 +57,9 @@ describe("additional client configuration dialects", () => { if (CURRENT_ENTRYPOINT_PATH === undefined) throw new Error("test runner entrypoint is unavailable"); const home = await createTestTempDirectory("rea-relative-uninstall-"); - const cursor = supportedClients(home).find(({ name }) => name === "cursor"); + const cursor = supportedClients(home, HOST_PLATFORM.platform).find( + ({ name }) => name === "cursor", + ); if (cursor === undefined) throw new Error("missing Cursor client"); await mkdir(cursor.markerPath ?? home, { recursive: true }); const relativeScript = relative(process.cwd(), CURRENT_ENTRYPOINT_PATH); @@ -323,7 +325,9 @@ it.each([ expectedComment, }) => { const home = await createTestTempDirectory("rea-cursor-jsonc-remove-"); - const client = supportedClients(home).find(({ name }) => name === "cursor"); + const client = supportedClients(home, HOST_PLATFORM.platform).find( + ({ name }) => name === "cursor", + ); if (client === undefined) throw new Error("missing Cursor client"); await mkdir(client.markerPath ?? home, { recursive: true }); diff --git a/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts b/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts index 98c6998c47..1a981445ad 100644 --- a/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts +++ b/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts @@ -56,6 +56,7 @@ it("awaits the owner-parsed provider request budget without changing caller stri expect(provider).toBe("ida"); return ok(17); }, + HOST_PLATFORM.platform, ); expect(details).toMatchObject({ ok: true, @@ -202,6 +203,7 @@ it.skipIf(process.platform !== "win32").each([ const env = clientRegistrationEnvironmentDetails( parseClientConfiguration(text, "dsh"), undefined, + HOST_PLATFORM.platform, ); if (!env.ok) throw env.error; expect(env.value.values).toHaveProperty(override, value); @@ -295,6 +297,7 @@ it const details = clientRegistrationEnvironmentDetails( parseClientConfiguration(text, "dsh"), undefined, + HOST_PLATFORM.platform, ); if (!details.ok) throw details.error; expect(details.value.timeoutInputs?.analysisProvider).toBe("ghidra"); @@ -327,6 +330,7 @@ it.skipIf(process.platform !== "win32").each([ const result = clientRegistrationEnvironmentDetails( parsed, undefined, + HOST_PLATFORM.platform, overrides, ); if (!result.ok) throw result.error; @@ -489,9 +493,9 @@ it.each(["linux", "win32"] as const)( const details = clientRegistrationEnvironmentDetails( parseClientConfiguration(source, "dsh"), undefined, + platform, {}, "effective", - platform, ); if (!details.ok) throw details.error; expect(Object.entries(details.value.values)).toEqual([ @@ -520,15 +524,21 @@ it("retains an unresolved JEB endpoint expression until a selected literal repla {}, "effective", undefined, + HOST_PLATFORM.platform, ); if (!unresolved.ok) throw unresolved.error; expect(unresolved.value.unresolvedSettings).toEqual( new Set(["REA_JEB_MCP_URL"]), ); const endpoint = "http://127.0.0.1:8426/mcp/"; - const selected = clientRegistrationEnvironmentDetails(parsed, undefined, { - REA_JEB_MCP_URL: endpoint, - }); + const selected = clientRegistrationEnvironmentDetails( + parsed, + undefined, + HOST_PLATFORM.platform, + { + REA_JEB_MCP_URL: endpoint, + }, + ); if (!selected.ok) throw selected.error; expect(selected.value.values.REA_JEB_MCP_URL).toBe(endpoint); expect(selected.value.unresolvedSettings.size).toBe(0); @@ -561,9 +571,9 @@ it.each( const values = clientRegistrationEnvironment( parsed, undefined, + platform, {}, "effective", - platform, ); expect(values).toMatchObject({ ok: true, @@ -572,9 +582,9 @@ it.each( const details = clientRegistrationEnvironmentDetails( parsed, undefined, + platform, {}, "effective", - platform, ); if (!details.ok) throw details.error; expect(details.value.values).toEqual({ CALLER_SETTING: "retained" }); @@ -600,11 +610,11 @@ it.each( const overridden = clientRegistrationEnvironmentDetails( parsed, undefined, + platform, { [setting]: setting === "REA_ANALYSIS_PROVIDER" ? "ghidra" : "17000", }, "effective", - platform, ); if (!overridden.ok) throw overridden.error; expect(overridden.value.unresolvedSettings.size).toBe(0); @@ -625,7 +635,11 @@ it("retains an unknown companion while deriving unrelated literal timeout facts" `, "dsh", ); - const details = clientRegistrationEnvironmentDetails(parsed, undefined); + const details = clientRegistrationEnvironmentDetails( + parsed, + undefined, + HOST_PLATFORM.platform, + ); if (!details.ok) throw details.error; expect(details.value.unresolvedSettings).toEqual( new Set(["REA_GHIDRA_LANGUAGE_ID"]), @@ -660,6 +674,7 @@ it("does not resolve a provider budget from an expression omitted from literal v {}, "effective", undefined, + HOST_PLATFORM.platform, ), ).toMatchObject({ ok: true, @@ -672,6 +687,7 @@ it("does not resolve a provider budget from an expression omitted from literal v {}, "effective", resolveBudget, + HOST_PLATFORM.platform, ), ).toMatchObject({ ok: false, @@ -684,6 +700,7 @@ it("does not resolve a provider budget from an expression omitted from literal v { REA_IDA_MCP_CONFIG: "/selected/ida.json" }, "effective", resolveBudget, + HOST_PLATFORM.platform, ); expect(resolved).toMatchObject({ ok: true, @@ -701,6 +718,7 @@ it("rejects a non-string caller environment key before editing configuration", a const details = clientRegistrationEnvironmentDetails( parseClientConfiguration(source, "json"), undefined, + HOST_PLATFORM.platform, ); expect(details).toMatchObject({ ok: false, @@ -1015,10 +1033,14 @@ it("treats a variable-expanding client's recorded expression as unresolved", () }), "json", ); - const qwen = clientRegistrationEnvironmentDetails(parsed, { - name: "qwen_code", - registrationProfile: supportedClientRegistrationProfile("qwen_code"), - }); + const qwen = clientRegistrationEnvironmentDetails( + parsed, + { + name: "qwen_code", + registrationProfile: supportedClientRegistrationProfile("qwen_code"), + }, + HOST_PLATFORM.platform, + ); if (!qwen.ok) throw qwen.error; // Qwen expands the expression from its own environment, so REA records the // name as unresolved rather than treating the literal text as the value. @@ -1026,10 +1048,14 @@ it("treats a variable-expanding client's recorded expression as unresolved", () expect(qwen.value.values.PATH).toBe("${PATH}:/opt/jdk/bin"); // A client that does not expand expressions keeps the same text as a value. - const cursor = clientRegistrationEnvironmentDetails(parsed, { - name: "cursor", - registrationProfile: supportedClientRegistrationProfile("cursor"), - }); + const cursor = clientRegistrationEnvironmentDetails( + parsed, + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + }, + HOST_PLATFORM.platform, + ); if (!cursor.ok) throw cursor.error; expect(cursor.value.unresolvedSettings).toEqual(new Set()); expect(cursor.value.values.PATH).toBe("${PATH}:/opt/jdk/bin"); @@ -1048,10 +1074,14 @@ it("keeps a typed Qwen setting unresolved without discarding its recorded expres }), "json", ); - const details = clientRegistrationEnvironmentDetails(parsed, { - name: "qwen_code", - registrationProfile: supportedClientRegistrationProfile("qwen_code"), - }); + const details = clientRegistrationEnvironmentDetails( + parsed, + { + name: "qwen_code", + registrationProfile: supportedClientRegistrationProfile("qwen_code"), + }, + HOST_PLATFORM.platform, + ); expect(details.ok).toBe(true); if (!details.ok) return; expect(details.value.values.REA_GHIDRA_STARTUP_TIMEOUT_MS).toBe("${JOBS}"); @@ -1079,9 +1109,9 @@ it("keeps a differently cased Qwen expression unresolved on Windows", () => { name: "qwen_code", registrationProfile: supportedClientRegistrationProfile("qwen_code"), }, + "win32", {}, "fragment", - "win32", ); expect(details.ok).toBe(true); if (!details.ok) return; @@ -1114,10 +1144,14 @@ it.each([ }), "json", ); - const details = clientRegistrationEnvironmentDetails(parsed, { - name, - registrationProfile: supportedClientRegistrationProfile(name), - }); + const details = clientRegistrationEnvironmentDetails( + parsed, + { + name, + registrationProfile: supportedClientRegistrationProfile(name), + }, + HOST_PLATFORM.platform, + ); if (name === "cline" && typeof value !== "string") { expect(details).toMatchObject({ ok: false, diff --git a/tests/boundary/filesystem/clientConfigurationLayeredEnvironment.test.ts b/tests/boundary/filesystem/clientConfigurationLayeredEnvironment.test.ts index e9fb1988f1..0ae9f38e6f 100644 --- a/tests/boundary/filesystem/clientConfigurationLayeredEnvironment.test.ts +++ b/tests/boundary/filesystem/clientConfigurationLayeredEnvironment.test.ts @@ -78,9 +78,17 @@ it.each(["missing", "empty", "legacy", "native"] as const)( expect( await configureClientConfiguration(client, {}, command), ).toMatchObject({ status: "configured" }); - const files = await readClientConfigurationFiles(client); + const files = await readClientConfigurationFiles( + client, + undefined, + HOST_PLATFORM.platform, + ); if (!files.ok) throw new Error(files.error.detail); - const effective = effectiveClientConfiguration(files.value); + const effective = effectiveClientConfiguration( + files.value, + undefined, + HOST_PLATFORM.platform, + ); expect(effective?.dialect).toBe("opencode_v2"); expect( effective === undefined @@ -165,11 +173,20 @@ it.skipIf(process.platform !== "win32").each([ command, ), ).toMatchObject({ status: "configured" }); - const sources = await readClientConfigurationFiles(fixture.client); + const sources = await readClientConfigurationFiles( + fixture.client, + undefined, + HOST_PLATFORM.platform, + ); if (!sources.ok) throw new Error(sources.error.detail); const environment = clientRegistrationEnvironmentDetails( - effectiveClientConfiguration(sources.value), + effectiveClientConfiguration( + sources.value, + undefined, + HOST_PLATFORM.platform, + ), undefined, + HOST_PLATFORM.platform, ); if (!environment.ok) throw environment.error; expect(environment.value.timeoutInputs?.analysisProvider).toBe("ghidra"); @@ -289,10 +306,14 @@ it("merges caller server names as own keys without inventing absent registration "opencode", ); expect(effectiveClientServer(lower, "constructor")).toBeUndefined(); - const effective = effectiveClientConfiguration([ - { ...lower, sourcePath: "lower.json" }, - { ...upper, sourcePath: "upper.json" }, - ]); + const effective = effectiveClientConfiguration( + [ + { ...lower, sourcePath: "lower.json" }, + { ...upper, sourcePath: "upper.json" }, + ], + undefined, + HOST_PLATFORM.platform, + ); expect(effective).toBeDefined(); if (effective === undefined) throw new Error("missing layered configuration"); expect(Object.keys(effective.servers)).toEqual(["__proto__"]); diff --git a/tests/boundary/filesystem/clientConfigurationOverrideOrigin.test.ts b/tests/boundary/filesystem/clientConfigurationOverrideOrigin.test.ts index 075056f028..87c2e576bd 100644 --- a/tests/boundary/filesystem/clientConfigurationOverrideOrigin.test.ts +++ b/tests/boundary/filesystem/clientConfigurationOverrideOrigin.test.ts @@ -1,3 +1,4 @@ +import { HOST_PLATFORM } from "../../../src/platform/HostPlatform.js"; import { join } from "node:path"; import { writeFile } from "node:fs/promises"; import { expect, it } from "vitest"; @@ -47,12 +48,16 @@ it("keeps a malformed stored environment attributed to its file", async () => { }, }), ); - const result = await readClientConfigurationFiles({ - name: "cursor", - registrationProfile: supportedClientRegistrationProfile("cursor"), - format: "json", - configPath, - }); + const result = await readClientConfigurationFiles( + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + format: "json", + configPath, + }, + undefined, + HOST_PLATFORM.platform, + ); expect(result).toMatchObject({ ok: false, error: { detail: expect.stringContaining(`Configuration ${configPath}`) }, diff --git a/tests/boundary/filesystem/clientLifecycle.test.ts b/tests/boundary/filesystem/clientLifecycle.test.ts index 2a6fbb84c8..56fc4a0f0b 100644 --- a/tests/boundary/filesystem/clientLifecycle.test.ts +++ b/tests/boundary/filesystem/clientLifecycle.test.ts @@ -92,7 +92,7 @@ const DETECTED_CLIENT_ORDER = [ describe("client configuration filesystem lifecycle", () => { it("detects every supported client and skips absent clients", async () => { const home = await createTestTempDirectory("rea-detect-"); - for (const client of supportedClients(home)) + for (const client of supportedClients(home, HOST_PLATFORM.platform)) if (client.markerPath !== undefined) await mkdir(client.markerPath, { recursive: true }); // Gemini CLI follows its own session state, not the shared .gemini root. @@ -109,7 +109,7 @@ describe("client configuration filesystem lifecycle", () => { it("does not detect Gemini CLI from Antigravity's shared .gemini storage", async () => { const home = await createTestTempDirectory("rea-detect-gemini-"); - const antigravity = supportedClients(home).find( + const antigravity = supportedClients(home, HOST_PLATFORM.platform).find( ({ name }) => name === "antigravity", ); if (antigravity?.markerPath === undefined) @@ -1563,7 +1563,7 @@ describe("Grok Bot registration", () => { const original = '{"mcpServers":{"other":{"command":"other"},"rea":{"command":"rea","args":["mcp"]}}}\n'; await writeFile(configPath, original); - const client = supportedClients(home).find( + const client = supportedClients(home, HOST_PLATFORM.platform).find( ({ name }) => name === "grok_bot", ); if (client === undefined) throw new Error("missing grok_bot"); diff --git a/tests/boundary/filesystem/dshClientConfiguration.test.ts b/tests/boundary/filesystem/dshClientConfiguration.test.ts index 04c5f9ddc0..c402443887 100644 --- a/tests/boundary/filesystem/dshClientConfiguration.test.ts +++ b/tests/boundary/filesystem/dshClientConfiguration.test.ts @@ -1,3 +1,4 @@ +import { HOST_PLATFORM } from "../../../src/platform/HostPlatform.js"; import { currentClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; @@ -98,7 +99,12 @@ describe("DeepSeek Harness configuration paths", () => { markerPath: join(home, dshRoot), }); expect( - clientSkillDirectories(home, ["dsh"], environment).directories, + clientSkillDirectories( + home, + ["dsh"], + environment, + HOST_PLATFORM.platform, + ).directories, ).toEqual([{ client, directory: join(home, agentsRoot, "skills") }]); }, ); @@ -118,7 +124,12 @@ describe("DeepSeek Harness configuration paths", () => { markerPath: root, }); expect( - clientSkillDirectories(home, ["dsh"], environment).directories, + clientSkillDirectories( + home, + ["dsh"], + environment, + HOST_PLATFORM.platform, + ).directories, ).toEqual([{ client: "dsh", directory: join(root, "skills") }]); }, ); @@ -136,9 +147,14 @@ describe("DeepSeek Harness configuration paths", () => { resolve("/custom/harness", "cordis.patch.yml"), ); expect( - clientSkillDirectories(join("/home/a"), ["dsh"], { - DSH_AGENTS_HOME: "/custom/agents", - }).directories, + clientSkillDirectories( + join("/home/a"), + ["dsh"], + { + DSH_AGENTS_HOME: "/custom/agents", + }, + HOST_PLATFORM.platform, + ).directories, ).toEqual([ { client: "dsh", directory: resolve("/custom/agents", "skills") }, ]); @@ -306,6 +322,58 @@ describe("DeepSeek Harness Cordis patch registration", () => { }); }); +describe("DeepSeek Harness caller-selected platform", () => { + it.each([ + ["win32", { GHIDRA_INSTALL_DIR: "/new", CALLER: "keep" }], + [ + "linux", + { + ghidra_install_dir: "/old", + CALLER: "keep", + GHIDRA_INSTALL_DIR: "/new", + }, + ], + ] as const)( + "writes environment names with %s identity", + async (platform, env) => { + const { client } = await harnessClient(); + await writeFile( + client.configPath, + `- insert: + - id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: rea + transport: stdio + command: stale-launcher + args: [mcp] + env: + ghidra_install_dir: /old + CALLER: keep +`, + ); + // Windows names match case-insensitively, so the caller's row is + // renamed in place; elsewhere the names are distinct settings. + expect( + ( + await configureClientConfiguration( + client, + { GHIDRA_INSTALL_DIR: "/new" }, + command, + undefined, + platform, + ) + ).status, + ).toBe("configured"); + const patches = parseYaml(await readFile(client.configPath, "utf8")) as { + insert: { config?: { env?: Record } }[]; + }[]; + const written = patches[0]?.insert[0]?.config?.env ?? {}; + expect(Object.entries(written)).toEqual(Object.entries(env)); + }, + ); +}); + describe("DeepSeek Harness registration identity", () => { it("applies a direct config patch before judging the registration", async () => { const { home, client } = await harnessClient(); diff --git a/tests/boundary/filesystem/hermesClientConfiguration.test.ts b/tests/boundary/filesystem/hermesClientConfiguration.test.ts index 7db8024f64..ba5693dd9e 100644 --- a/tests/boundary/filesystem/hermesClientConfiguration.test.ts +++ b/tests/boundary/filesystem/hermesClientConfiguration.test.ts @@ -313,7 +313,12 @@ describe("Hermes configuration paths", () => { expect(client.markerPath).toBe(selected); expect(client.configPath).toBe(`${selected}/config.yaml`); expect( - clientSkillDirectories(home, ["hermes"], environment).directories, + clientSkillDirectories( + home, + ["hermes"], + environment, + HOST_PLATFORM.platform, + ).directories, ).toEqual([{ client: "hermes", directory: `${selected}/skills` }]); expect( await configureClientConfiguration(client, {}, command), @@ -447,7 +452,12 @@ describe("Hermes unresolved home identity", () => { if (client === undefined) throw new Error("missing Hermes client"); expect(client.markerPath).toBe(selected); expect( - clientSkillDirectories(home, ["hermes"], environment).directories, + clientSkillDirectories( + home, + ["hermes"], + environment, + HOST_PLATFORM.platform, + ).directories, ).toEqual([{ client: "hermes", directory: join(selected, "skills") }]); expect( await configureClientConfiguration(client, {}, command), @@ -484,7 +494,12 @@ describe("Hermes unresolved home identity", () => { }); expect(client).not.toHaveProperty("markerPath"); expect( - clientSkillDirectories(home, ["hermes"], environment), + clientSkillDirectories( + home, + ["hermes"], + environment, + HOST_PLATFORM.platform, + ), ).toMatchObject({ directories: [], unresolved: [{ client: "hermes", path: selected }], diff --git a/tests/boundary/filesystem/kiloClientLocation.test.ts b/tests/boundary/filesystem/kiloClientLocation.test.ts index 016cb4acd5..10422a3214 100644 --- a/tests/boundary/filesystem/kiloClientLocation.test.ts +++ b/tests/boundary/filesystem/kiloClientLocation.test.ts @@ -196,7 +196,11 @@ it("refuses a relative Kilo configuration path instead of guessing its folder", // Readers such as doctor's provider settings refuse it instead of reading // a file relative to REA's own folder. expect( - await readEffectiveClientRegistrationConfiguration(resolved), + await readEffectiveClientRegistrationConfiguration( + resolved, + undefined, + HOST_PLATFORM.platform, + ), ).toMatchObject({ ok: false, error: { kind: "unreadable" } }); } }); diff --git a/tests/boundary/filesystem/kimiClientConfiguration.test.ts b/tests/boundary/filesystem/kimiClientConfiguration.test.ts index a849926940..e5f8fbbf15 100644 --- a/tests/boundary/filesystem/kimiClientConfiguration.test.ts +++ b/tests/boundary/filesystem/kimiClientConfiguration.test.ts @@ -72,9 +72,10 @@ describe("Kimi Code configuration paths and skills", () => { }); it("uses the existing shared skill destination without a separate skill root", () => { - expect(clientSkillDirectories("/home/a", ["kimi"]).directories).toEqual([ - { client: "shared", directory: "/home/a/.agents/skills" }, - ]); + expect( + clientSkillDirectories("/home/a", ["kimi"], {}, HOST_PLATFORM.platform) + .directories, + ).toEqual([{ client: "shared", directory: "/home/a/.agents/skills" }]); }); }); diff --git a/tests/boundary/filesystem/mcpAddClientLifecycle.test.ts b/tests/boundary/filesystem/mcpAddClientLifecycle.test.ts index cce3b6d474..6bb9588ed2 100644 --- a/tests/boundary/filesystem/mcpAddClientLifecycle.test.ts +++ b/tests/boundary/filesystem/mcpAddClientLifecycle.test.ts @@ -979,3 +979,29 @@ const gooseFixture = async (prefix: string) => { if (row === undefined) throw new Error("Goose has no table row"); return { home, team, row, environment, systemPaths }; }; + +it.skipIf(HOST_PLATFORM.platform === "win32")( + "secures a private profile on this host when setup targets another platform", + async () => { + const home = await createTestTempDirectory("rea-private-profile-host-"); + const row = supportedClients(home, HOST_PLATFORM.platform, { + HOME: home, + }).find(({ name }) => name === "fx"); + if (row?.privateProfile === undefined) + throw new Error("fx has no private profile row"); + await expect( + configureClientConfiguration(row, {}, command), + ).resolves.toMatchObject({ status: "configured" }); + const directory = dirname(row.configPath); + await chmod(row.configPath, 0o644); + await chmod(directory, 0o755); + + // The selected platform shapes the registration; the file's modes stay + // this host's, so a readable private profile is still repaired. + await expect( + configureClientConfiguration(row, {}, command, undefined, "win32"), + ).resolves.toMatchObject({ status: "configured" }); + expect((await stat(row.configPath)).mode & 0o777).toBe(0o600); + expect((await stat(directory)).mode & 0o777).toBe(0o700); + }, +); diff --git a/tests/boundary/filesystem/opencodeLayeredUninstall.test.ts b/tests/boundary/filesystem/opencodeLayeredUninstall.test.ts index 4edb1404b6..7355d7f34f 100644 --- a/tests/boundary/filesystem/opencodeLayeredUninstall.test.ts +++ b/tests/boundary/filesystem/opencodeLayeredUninstall.test.ts @@ -1,3 +1,4 @@ +import { HOST_PLATFORM } from "../../../src/platform/HostPlatform.js"; import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it } from "vitest"; @@ -54,6 +55,8 @@ it.each(["native", "legacy"])( sourcePath, })), ), + undefined, + HOST_PLATFORM.platform, )?.servers.rea; const before = await effective(); expect(before).toMatchObject({ diff --git a/tests/boundary/filesystem/qwenClientConfiguration.test.ts b/tests/boundary/filesystem/qwenClientConfiguration.test.ts index efbe6b14e6..26ce30af9d 100644 --- a/tests/boundary/filesystem/qwenClientConfiguration.test.ts +++ b/tests/boundary/filesystem/qwenClientConfiguration.test.ts @@ -58,7 +58,9 @@ describe("Qwen Code configuration paths", () => { QWEN_HOME: override, }); expect( - supportedClients("/home/a").find(({ name }) => name === "qwen_code"), + supportedClients("/home/a", HOST_PLATFORM.platform).find( + ({ name }) => name === "qwen_code", + ), ).toEqual(client); expect(client).toMatchObject({ configPath: join(directory, "settings.json"), diff --git a/tests/boundary/filesystem/skillSelectionProvenance.test.ts b/tests/boundary/filesystem/skillSelectionProvenance.test.ts index 6f7881a2ab..f022838516 100644 --- a/tests/boundary/filesystem/skillSelectionProvenance.test.ts +++ b/tests/boundary/filesystem/skillSelectionProvenance.test.ts @@ -1,3 +1,4 @@ +import { HOST_PLATFORM } from "../../../src/platform/HostPlatform.js"; import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it } from "vitest"; @@ -27,8 +28,12 @@ it("uses the same Hermes root for sticky selection and named profile resolution" profile, ); expect( - clientSkillDirectories(home, ["hermes"], { HERMES_HOME: nested }) - .directories, + clientSkillDirectories( + home, + ["hermes"], + { HERMES_HOME: nested }, + HOST_PLATFORM.platform, + ).directories, ).toEqual([{ client: "hermes", directory: join(profile, "skills") }]); }); @@ -38,7 +43,12 @@ it("retains an explicitly selected missing Hermes profile as unresolved without await mkdir(root); await writeFile(join(root, "active_profile"), "missing-profile\n"); const environment = { HERMES_HOME: root }; - const selection = clientSkillDirectories(home, ["hermes"], environment); + const selection = clientSkillDirectories( + home, + ["hermes"], + environment, + HOST_PLATFORM.platform, + ); expect(selection).toMatchObject({ directories: [], unresolved: [ diff --git a/tests/boundary/filesystem/zcodeClientConfiguration.test.ts b/tests/boundary/filesystem/zcodeClientConfiguration.test.ts index ba11b1cdf9..d005ccf469 100644 --- a/tests/boundary/filesystem/zcodeClientConfiguration.test.ts +++ b/tests/boundary/filesystem/zcodeClientConfiguration.test.ts @@ -1,3 +1,4 @@ +import { HOST_PLATFORM } from "../../../src/platform/HostPlatform.js"; import { currentClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; import { execFile } from "node:child_process"; import { mkdir, readFile, writeFile } from "node:fs/promises"; @@ -349,7 +350,9 @@ describe("ZCode fallback uninstall", () => { const native = JSON.stringify({ mcp: { servers: { rea: owned } } }); await writeFile(client.configPath, native); const fallback = await writeFallback(home, servers); - const cursor = supportedClients(home).find(({ name }) => name === "cursor"); + const cursor = supportedClients(home, HOST_PLATFORM.platform).find( + ({ name }) => name === "cursor", + ); if (cursor === undefined) throw new Error("missing Cursor client"); await mkdir(dirname(cursor.configPath), { recursive: true }); const otherClient = JSON.stringify({ mcpServers: { rea: owned } }); diff --git a/tests/process-global/updateMaintenance.test.ts b/tests/process-global/updateMaintenance.test.ts index 22f81d6bb7..f8e02d71b3 100644 --- a/tests/process-global/updateMaintenance.test.ts +++ b/tests/process-global/updateMaintenance.test.ts @@ -66,7 +66,9 @@ const plan = ( ); const writeClient = async (id: string, content: string): Promise => { - const client = supportedClients(home).find(({ name }) => name === id); + const client = supportedClients(home, HOST_PLATFORM.platform).find( + ({ name }) => name === id, + ); if (client === undefined) throw new Error(`Unknown test client ${id}`); await mkdir(dirname(client.configPath), { recursive: true }); await writeFile(client.configPath, content);