mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 19:23:24 +00:00
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
This commit is contained in:
1 parent
bb445d8691
commit
3e4e178ccc
37 files changed
+548
-212
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
platform: NodeJS.Platform,
|
||||
removeLegacy?: string,
|
||||
): Record<string, unknown> => {
|
||||
// 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;
|
||||
|
||||
@@ -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<Record<string, string>>,
|
||||
): Promise<
|
||||
Result<readonly ClientConfigurationSource[], ClientConfigurationFileError>
|
||||
@@ -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<SetupClient, "configPath" | "configPaths">;
|
||||
readonly document: ClientConfigurationDocument | undefined;
|
||||
},
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
target:
|
||||
| {
|
||||
readonly client: Pick<SetupClient, "configPath" | "configPaths">;
|
||||
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<
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -99,16 +99,16 @@ const expressionSettings = (
|
||||
export const clientRegistrationEnvironment = (
|
||||
parsed: ClientConfigurationDocument | undefined,
|
||||
client: Pick<SetupClient, "name" | "registrationProfile"> | undefined,
|
||||
platform: NodeJS.Platform,
|
||||
overrides: Readonly<Record<string, string>> = {},
|
||||
validation: "fragment" | "effective" = "effective",
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Result<Readonly<Record<string, string>>, 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<SetupClient, "name" | "registrationProfile"> | undefined,
|
||||
platform: NodeJS.Platform,
|
||||
overrides: Readonly<Record<string, string>> = {},
|
||||
validation: "fragment" | "effective" = "effective",
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Result<ClientRegistrationEnvironmentDetails, ConfigurationError> => {
|
||||
if (parsed?.dialect === "dsh") {
|
||||
const environment = dshDocument(parsed.document).environment(
|
||||
@@ -207,7 +207,7 @@ export const recordedProviderSettings = (
|
||||
parsed: ClientConfigurationDocument,
|
||||
client: Pick<SetupClient, "name" | "registrationProfile"> | undefined,
|
||||
selected: Readonly<Record<string, string>>,
|
||||
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<Record<string, string>>,
|
||||
validation: "fragment" | "effective",
|
||||
resolveProviderRequestBudget: ProviderRequestBudgetResolver | undefined,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
platform: NodeJS.Platform,
|
||||
): Promise<
|
||||
Result<ResolvedClientRegistrationEnvironmentDetails, ConfigurationError>
|
||||
> => {
|
||||
const details = clientRegistrationEnvironmentDetails(
|
||||
parsed,
|
||||
client,
|
||||
platform,
|
||||
overrides,
|
||||
validation,
|
||||
platform,
|
||||
);
|
||||
if (!details.ok) return details;
|
||||
const unresolved = [...details.value.unresolvedSettings].filter(
|
||||
|
||||
@@ -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, unknown>): 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<string, unknown>,
|
||||
platform: NodeJS.Platform,
|
||||
invalidated: ReadonlySet<Node>,
|
||||
): YAMLMap<unknown, unknown> {
|
||||
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<string, unknown>;
|
||||
readonly platform: NodeJS.Platform;
|
||||
}
|
||||
|
||||
class DshCordisEdit {
|
||||
constructor(
|
||||
readonly source: DshCordisDocument,
|
||||
readonly desired?: Record<string, unknown>,
|
||||
readonly registration?: DshCordisRegistration,
|
||||
) {}
|
||||
}
|
||||
export const dshDocument = (
|
||||
@@ -443,16 +453,16 @@ export const parseDshCordisDocument = (
|
||||
};
|
||||
export const editDshDocument = (
|
||||
parsed: ClientConfigurationDocument,
|
||||
desired?: Record<string, unknown>,
|
||||
registration?: DshCordisRegistration,
|
||||
): Record<string, unknown> => ({
|
||||
cordis: new DshCordisEdit(dshDocument(parsed.document), desired),
|
||||
cordis: new DshCordisEdit(dshDocument(parsed.document), registration),
|
||||
});
|
||||
export const serializeDshDocument = (
|
||||
document: Record<string, unknown>,
|
||||
): 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[],
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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,
|
||||
);
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ClientConfigurationResult> => {
|
||||
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<Record<string, unknown>>;
|
||||
readonly platform: NodeJS.Platform;
|
||||
}
|
||||
|
||||
const persistClientConfiguration = async ({
|
||||
@@ -302,6 +319,7 @@ const persistClientConfiguration = async ({
|
||||
parsed,
|
||||
desired,
|
||||
fallbackServers,
|
||||
platform,
|
||||
}: ClientConfigurationUpdate): Promise<ClientConfigurationResult> => {
|
||||
// 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<Result<ClientRefresh, ClientConfigurationFileError>> => {
|
||||
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<boolean> => {
|
||||
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<ClientConfigurationInspection> => {
|
||||
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<ClientConfigurationInspection> => {
|
||||
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<unknown> => {
|
||||
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<string, unknown> => {
|
||||
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",
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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<NodeJS.ProcessEnv> = {},
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
platform: NodeJS.Platform,
|
||||
): {
|
||||
readonly directories: readonly {
|
||||
readonly client: string;
|
||||
|
||||
@@ -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<UninstallItem> => {
|
||||
@@ -1252,6 +1256,7 @@ const removeClient = async (
|
||||
const document = withClientServers(
|
||||
parsed,
|
||||
remaining,
|
||||
platform,
|
||||
removeLegacy ? name : undefined,
|
||||
);
|
||||
const editedPaths = [
|
||||
|
||||
@@ -162,9 +162,9 @@ const readSelectedRegistration = async (
|
||||
const details = clientRegistrationEnvironmentDetails(
|
||||
configuration.value.document,
|
||||
client,
|
||||
platform,
|
||||
{},
|
||||
"effective",
|
||||
platform,
|
||||
);
|
||||
return details.ok
|
||||
? {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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__"]);
|
||||
|
||||
@@ -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}`) },
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<string, string> } }[];
|
||||
}[];
|
||||
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();
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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" } });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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" }]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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({
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -66,7 +66,9 @@ const plan = (
|
||||
);
|
||||
|
||||
const writeClient = async (id: string, content: string): Promise<string> => {
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user