diff --git a/docs/getting-started/cli.md b/docs/getting-started/cli.md index 8e8c809798..fc9e607bc2 100644 --- a/docs/getting-started/cli.md +++ b/docs/getting-started/cli.md @@ -1289,6 +1289,50 @@ backing up client settings or starting registration. Its `--agent` and `--command`/`-c` options accept spaced and equals forms. Values must be nonempty; a following flag does not satisfy a spaced option's missing value. +Registration command text is a launcher format, not a shell program. ASCII spaces +separate words; single and double quotes delimit adjacent fragments of a word. +Backslashes, tabs, variables and shell punctuation are literal data. Quoted empty +arguments are preserved, including an empty argument after the executable. An +empty executable, unfinished quote or NUL character fails before registration. For example: + +```sh +rea mcp add --agent cursor --command '"/path with spaces/server" "" --mcp' --json +``` + +The registered executable is `/path with spaces/server`, with arguments `""` and +`--mcp`. Windows backslashes use the same literal rules after the invoking shell +has passed the option value. With no `--command`, REA uses its exact current +runtime and entrypoint arguments without converting them to text and parsing them +again. No package acquisition or server execution occurs during registration. +`--no-global` refuses any selection requiring global storage before writing files. + +`rea mcp add --schema --json` returns the named input and output JSON Schemas. +Successful and partial registration reports contain the exact `command`, `args` +and a `clients` outcome list. `ok` is true only when every selected client is +configured or unchanged. Failed clients retain actionable errors; cancellation +marks remaining clients `not_started`. A failed client does not erase a successful +sibling's receipt. Existing settings and environment overrides survive refresh; +backups identify the original bytes, and identical repeats create no new backup. +Client IDs are case-insensitive for the helper-compatible catalog; documented +aliases include `droid`/`factory`, `gemini`, `grok`, `kilo`/`kilocode`, `kimi`, +`kiro`, `mastra`, `pi-agent`, `hermes-agent`, `cline-vscode`, and +`codeium`/`cascade`. `github-copilot` selects VS Code; use +`github-copilot-cli` for Copilot CLI. Amp uses `--agent amp`. + +Kilo project registration follows its native configuration layers and requires a +verified Git worktree. Selected `KILO_CONFIG`, `KILO_CONFIG_DIR` and +`KILO_CONFIG_CONTENT` overrides remain authoritative. A conflicting higher-priority +source must be updated before registration can succeed; readonly layers are never +copied into the selected file. An unknown project root fails before writing and +does not fall back to global settings. + +`rea mcp add --command` accepts stdio launcher text. An HTTP(S) URL in the +executable position is refused before any settings change; configure remote MCP +transport in the client's own settings. Earlier delegated registration could +interpret a URL as remote transport for some clients while storing it as an +executable for Amp. That inconsistent URL interpretation and package acquisition +are removed. Launcher arguments that happen to contain URLs remain arguments. + `rea mcp doctor --json` checks production MCP startup and reports child logs in `diagnostics`. It drains stderr while retaining at most its first 64 KiB of raw bytes as text, omitting a final UTF-8 code point if it crosses that byte limit. diff --git a/docs/getting-started/client-configuration.md b/docs/getting-started/client-configuration.md index f18997abdd..c8b8d937dd 100644 --- a/docs/getting-started/client-configuration.md +++ b/docs/getting-started/client-configuration.md @@ -45,6 +45,11 @@ policy can leave ownership established. Setup uses that recorded observation for its default client selection, then inspects selected configuration again before planning writes; a missing or unreadable launcher remains unknown. +A retained command does not prove the client selects it. Doctor also checks +that client's native transport selectors; a higher-priority remote selector can +leave the command owned while alignment remains unknown. Metadata that is not a +native connection selector is preserved rather than interpreted as a transport. + `rea mcp doctor --json` tests the production MCP adapter using the runtime executable and environment of the terminal running doctor. It does not launch the client's configured command or reproduce the client's environment. If doctor @@ -744,6 +749,13 @@ and top-level key when it upserts `rea`. WorkBuddy also reads a project-level user-level file only. Loading this registration and real REA tool calls through WorkBuddy are verified. +For CodeBuddy and WorkBuddy, REA's write checks cover their documented stdio +entries. If an existing `rea` row contains fields whose native interpretation is +unclassified, setup preserves it and reports unknown write applicability before +changing the file. Doctor retains any established launcher ownership while +reporting alignment as unknown. This does not prevent safe removal of a row +whose REA ownership is established. + ## ZCode For [ZCode](https://zcode.z.ai/en/docs/mcp-services), setup registers `rea` in the nested `mcp.servers` table of the diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index cbb29e659c..54fdf3743d 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -1247,17 +1247,35 @@ installation remains unavailable on Windows. Setup never installs Ghidra, Java, or Python. It preserves valid detected Ghidra/JDK settings in agent registrations. See [Windows Ghidra P0](../providers/windows-ghidra-p0.md) for provider diagnostics. -The separate `rea mcp add` builtin delegates most clients to Incur's package-runner -child launcher. Run that acquisition from a trusted directory; prefer managed -`rea setup` when supported. That child launcher can fail on Windows with `MCP_ADD_FAILED: spawn npx ENOENT` -before registering a client. REA's setup path is unaffected. For a client supported -by setup, review `rea setup --client opencode --skill=false --dry-run --json` -(substitute its client ID), then use the same scoped command without `--dry-run` -and approve the displayed plan. Non-interactive application additionally needs -`--yes`. This configures setup's user-level client location; it does not replace -`mcp add --no-global`, arbitrary `--command` registrations, or unsupported clients. -The builtin launcher defect remains pending a dependency fix; a successful setup -run does not verify that `mcp add` works on Windows. +`rea mcp add` registers the selected server launcher directly in client settings. +It does not acquire a package or start the server. Omit `--command` to register +the runtime and REA entrypoint currently executing the CLI. Use `--agent` to +select clients; repeat it for a mixed selection. With no selection, REA uses +detected user-level clients and Amp; if none of the other supported clients are +detected, it selects their full registration catalog and Amp. + +REA preserves existing environment overrides, client policy, peer servers and +unrelated settings. A changed existing file gets a private backup; an identical +repeat keeps the file and creates no new backup. The result reports exact +`command` and `args`, each selected client's effective scope, and configured, +unchanged, failed or not-started outcomes. A partial failure returns a nonzero +status while retaining successful client outcomes and recovery paths. + +`--no-global` writes only project-level settings. A selection containing Amp or +another client without project storage fails before any writes; select those +clients in a separate global registration. Managed `rea setup` remains available +for its supported clients and additional setup tasks. Registration preserves +Windows launcher paths and arguments as data. Windows ACL privacy cannot be +verified through POSIX mode bits; Fx results distinguish requested Windows modes +from verified POSIX modes. Controlled source tests do not establish real Windows +client behavior. + +`rea mcp add --command` accepts stdio launcher text. An HTTP(S) URL in the +executable position is refused before any settings change; configure remote MCP +transport in the client's own settings. Earlier delegated registration could +interpret a URL as remote transport for some clients while storing it as an +executable for Amp. That inconsistent URL interpretation and package acquisition +are removed. Launcher arguments that happen to contain URLs remain arguments. When `GHIDRA_INSTALL_DIR` is unset, doctor and the Ghidra provider also search `PATH` for `ghidra` or `ghidraRun`, resolve the selected launcher through its diff --git a/scripts/rea.mjs b/scripts/rea.mjs index 3c57696a85..f5a25da273 100755 --- a/scripts/rea.mjs +++ b/scripts/rea.mjs @@ -159,15 +159,14 @@ if (!(await compiledRuntimeExists(runtimeFiles))) { !isShellCompletion && builtinArguments.isProjectScopedAmpRegistration(cliArgs) ) { - // Incur's Amp writer ignores --no-global and always writes the global - // settings file, so fail closed before it runs rather than reporting - // a project registration that never happened. + // Amp has global settings only. Share the parsed scope fact with the + // registration workflow and refuse before loading the command runtime. // // A caller that asked for a structured document gets the refusal in // that document, through the same pre-execution error renderer the // output-argument branch uses; anything else keeps the plain message. const refusalMessage = - "Amp does not support project-scoped registration: 'rea mcp add --no-global' with Amp would write the global Amp settings file instead of a project file. Run without --no-global to register Amp globally."; + "Amp does not support project-scoped registration. Run without --no-global to register Amp globally, or select a client with project settings. No client settings were changed."; const refusalFormat = outputOptions.format; if ( refusalFormat === "json" || diff --git a/src/application/ClientConfigurationDocument.jsonRetention.test.ts b/src/application/ClientConfigurationDocument.jsonRetention.test.ts new file mode 100644 index 0000000000..e1f2044152 --- /dev/null +++ b/src/application/ClientConfigurationDocument.jsonRetention.test.ts @@ -0,0 +1,142 @@ +import { expect, it } from "vitest"; + +import { + clientServerPath, + parseClientConfiguration, + serializeClientConfiguration, + withClientServers, + type ClientConfigurationFormat, +} from "./ClientConfigurationDocument.js"; + +const replaceLauncher = (source: string, format: ClientConfigurationFormat) => { + const parsed = parseClientConfiguration(source, format); + 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 result = serializeClientConfiguration(document, format, source, [ + clientServerPath(parsed, "rea"), + ]); + if (!result.ok) throw new Error("launcher replacement refused"); + return result.value; +}; + +it.each(["json", "fx", "copilot_cli", "copilot_cli_project"] as const)( + "%s retains opaque number and string tokens inside and outside the edited row", + (format) => { + const source = `{"metadata":{"opaque":9007199254740993},"mcpServers":{"peer":{"opaque":9007199254740993},"rea":{"command":"old-rea","metadata":{"opaque":9007199254740993,"nested":[{"number":1e+03,"negative":-0,"text":"\\u0061"}]}}}}`; + const output = replaceLauncher(source, format); + expect(output.match(/9007199254740993/gu)).toHaveLength(3); + expect(output).not.toContain("9007199254740992"); + expect(output).toContain("1e+03"); + expect(output).toContain('"negative":-0'); + expect(output).toContain('"\\u0061"'); + expect(parseClientConfiguration(output, format).servers.rea).toMatchObject({ + command: "new-rea", + }); + }, +); + +it.each(["fx", "copilot_cli", "copilot_cli_project"] as const)( + "%s strips only non-native JSON syntax while retaining opaque source tokens", + (format) => { + const source = `\uFEFF{/*root*/"metadata":{"opaque":9007199254740993,},"mcpServers":{"rea":{"command":"old-rea",// row\n"opaque":9007199254740993,"text":"/*literal*/",},},}`; + const output = replaceLauncher(source, format); + expect(() => JSON.parse(output)).not.toThrow(); + expect(output.match(/9007199254740993/gu)).toHaveLength(2); + expect(output).toContain('"/*literal*/"'); + expect(output).not.toContain("/*root*/"); + expect(output).not.toContain("// row"); + }, +); + +it("retains JSONC comments, trailing commas, BOM and untouched token spellings", () => { + const source = `\uFEFF{/*root*/"mcpServers":{"rea":{"command":"old-rea",/*owned metadata*/"opaque":9007199254740993,},},}`; + const output = replaceLauncher(source, "json"); + expect(output.startsWith("\uFEFF")).toBe(true); + expect(output).toContain("/*root*/"); + expect(output).toContain("/*owned metadata*/"); + expect(output).toContain("9007199254740993"); + expect(output).toMatch(/,\s*\}/u); +}); + +it("retains nested array tokens while changing siblings, shrinking and extending arrays", () => { + 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], + }, + }), + "json", + source, + [clientServerPath(parsed, "rea")], + ); + if (!result.ok) throw new Error("nested edit refused"); + expect(result.value.match(/9007199254740993/gu)).toHaveLength(2); + expect(JSON.parse(result.value).mcpServers.rea).toMatchObject({ + items: [{ label: "new" }], + grow: [9007199254740992, 4], + }); +}); + +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" } }), + "fx", + source, + ); + if (!result.ok) throw new Error("whole document edit refused"); + expect(result.value).toContain("9007199254740993"); + expect(JSON.parse(result.value).mcpServers.rea.command).toBe("new"); +}); + +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 }, + }, + }), + "json", + source, + [clientServerPath(parsed, "rea")], + ); + if (!result.ok) throw new Error("owned row edit refused"); + expect(result.value).not.toContain('"remove"'); + expect(result.value).toContain("9007199254740993"); + expect(JSON.parse(result.value).mcpServers.rea).toMatchObject({ + command: "new", + added: { enabled: true }, + }); +}); + +it.each([ + ["replace all members", { replacement: true }], + ["remove all members", {}], + ["replace array contents", { items: [5] }], + ["empty arrays", { items: [] }], +])("keeps collection separators valid when editing %s", (_name, rea) => { + const source = `{"mcpServers":{"rea":{"items":[1,2,],"old":3,}}}`; + const parsed = parseClientConfiguration(source, "fx"); + const result = serializeClientConfiguration( + withClientServers(parsed, { rea }), + "fx", + source, + [clientServerPath(parsed, "rea")], + ); + if (!result.ok) throw new Error("collection edit refused"); + expect(JSON.parse(result.value).mcpServers.rea).toEqual(rea); +}); diff --git a/src/application/ClientConfigurationDocument.ts b/src/application/ClientConfigurationDocument.ts index da5e251729..487c07d905 100644 --- a/src/application/ClientConfigurationDocument.ts +++ b/src/application/ClientConfigurationDocument.ts @@ -1,4 +1,9 @@ import { parse as parseToml, stringify as stringifyToml } from "smol-toml"; +import { + parseGooseConfigurationYaml, + stringifyGooseConfigurationYaml, + editGooseConfigurationYaml, +} from "./GooseConfigurationYaml.js"; import { isDeepStrictEqual } from "node:util"; import { @@ -31,8 +36,22 @@ import { PRODUCT_IDENTITY } from "../identity.js"; import type { SetupClient } from "./SupportedClients.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; -export type ClientConfigurationFormat = NonNullable; -type ClientServersKey = "mcp_servers" | "mcpServers" | "mcp" | "servers"; +export type ClientConfigurationFormat = + | NonNullable + | "amp" + | "fx" + | "zed" + | "kilo" + | "goose" + | "copilot_cli_project"; +type ClientServersKey = + | "mcp_servers" + | "mcpServers" + | "mcp" + | "servers" + | "amp.mcpServers" + | "context_servers" + | "extensions"; /** * Registration entry dialect. OpenCode V2 reads V1 `mcp.` entries, but a @@ -45,13 +64,16 @@ export type ClientRegistrationDialect = /** Validated client document and its server table, preserving unrelated settings. */ export interface ClientConfigurationDocument { readonly document: Record; + /** A repairable source syntax is admitted for preservation but must be rewritten + * before this client's strict reader can load the published registration. */ + readonly requiresRewrite?: true; /** Server table that registrations are written to. */ readonly servers: Record; /** Key path of `servers` in `document`. */ readonly serversPath: readonly string[]; readonly dialect: ClientRegistrationDialect; /** Member holding explicit environment overrides in a server registration. */ - readonly environmentKey: "env" | "environment"; + readonly environmentKey: "env" | "environment" | "envs"; /** * OpenCode V1 server members beside a native V2 `mcp.servers` table. OpenCode * still loads them, and a native entry with the same name takes precedence. @@ -100,7 +122,14 @@ export const clientConfigurationServersKey = ( return "mcp_servers"; case "opencode": case "zcode": + case "kilo": return "mcp"; + case "amp": + return "amp.mcpServers"; + case "zed": + return "context_servers"; + case "goose": + return "extensions"; case "vscode": return "servers"; default: @@ -162,6 +191,11 @@ const parseDocument = ( // holds no settings to preserve. if (isEmptyClientConfigurationText(text)) return {}; // Hermes stores MCP servers in `mcp_servers` inside a YAML document. + if (format === "goose") + return requireObject( + parseGooseConfigurationYaml(text), + "The configuration root", + ); if (format === "hermes") return requireObject( parseHermesConfigurationYaml(text), @@ -229,6 +263,27 @@ export const parseZcodeFallbackConfiguration = ( const OPENCODE_V2_MCP_SETTINGS = new Set(["servers", "timeout"]); /** Parse a client document and reject malformed roots or MCP server tables. */ +const strictJsonNeedsRewrite = (text: string): boolean => { + try { + JSON.parse(text); + return false; + } catch { + return true; + } +}; + +/** Fx's native writer preserves a sole profile table and rejects hidden peers. */ +const fxConfigurationServersKey = ( + document: Record, +): "mcp" | "mcpServers" => { + const legacy = Object.hasOwn(document, "mcp"); + if (legacy && Object.hasOwn(document, "mcpServers")) + throw new ClientConfigurationShapeError( + 'Fx profile contains both "mcp" and "mcpServers". Consolidate these into one server table before registering so existing servers remain visible.', + ); + return legacy ? "mcp" : "mcpServers"; +}; + export const parseClientConfiguration = ( text: string, format: ClientConfigurationFormat | undefined, @@ -239,8 +294,57 @@ export const parseClientConfiguration = ( if (format === "zcode_fallback") return parseZcodeFallbackConfiguration(text); if (format === "dsh") return parseDshCordisDocument(text); const document = parseDocument(text, format); - const environmentKey = format === "opencode" ? "environment" : "env"; - const serversKey = clientConfigurationServersKey(format); + const requiresRewrite: true | undefined = + (format === "copilot_cli_project" || + format === "copilot_cli" || + format === "fx") && + strictJsonNeedsRewrite(text) + ? true + : undefined; + if ( + format === "copilot_cli_project" && + !Object.hasOwn(document, "mcpServers") + ) { + const serverLike = (value: unknown): boolean => { + const entry = clientConfigurationRecordSchema.safeParse(value); + return ( + entry.success && + ["command", "url", "type"].some( + (key) => typeof entry.data[key] === "string", + ) + ); + }; + const wrapper = clientConfigurationRecordSchema.safeParse(document.servers); + if ( + wrapper.success && + !serverLike(wrapper.data) && + Object.values(wrapper.data).some(serverLike) + ) + throw new ClientConfigurationShapeError( + 'Copilot CLI reads mcpServers or a bare server map, not the VS Code "servers" wrapper. Select --agent vscode for that document.', + ); + const entries = Object.values(document); + if (entries.length > 0 && entries.every(serverLike)) + return { + document, + ...(requiresRewrite === undefined ? {} : { requiresRewrite }), + servers: document, + serversPath: [], + dialect: format, + environmentKey: "env", + legacyServers: {}, + }; + } + const environmentKey = + format === "opencode" || format === "fx" || format === "kilo" + ? "environment" + : format === "goose" + ? "envs" + : "env"; + const serversKey = + format === "fx" + ? fxConfigurationServersKey(document) + : clientConfigurationServersKey(format); const value = document[serversKey]; const servers = value === undefined ? {} : requireObject(value, `"${serversKey}"`); @@ -273,6 +377,7 @@ export const parseClientConfiguration = ( ) return { document, + ...(requiresRewrite === undefined ? {} : { requiresRewrite }), servers, serversPath: [serversKey], dialect: format, @@ -320,6 +425,18 @@ export const effectiveClientServer = ( : registration; }; +/** Preserve source policy when editing its native table. A legacy OpenCode + * entry moving to the V2 table first uses that owner's activation migration. */ +export const clientServerForEdit = ( + parsed: ClientConfigurationDocument, + name: string, +): unknown => + Object.hasOwn(parsed.servers, name) + ? parsed.servers[name] + : parsed.dialect === "opencode_v2" + ? effectiveClientServer(parsed, name) + : undefined; + /** * Return `parsed.document` with `servers` at its server table. A legacy OpenCode * V1 member named in `removeLegacy` is removed beside a native V2 table. @@ -339,6 +456,7 @@ export const withClientServers = ( ), ) : editDshDocument(parsed); + if (parsed.serversPath.length === 0) return { ...servers }; const [key = "", nested] = parsed.serversPath; if (nested === undefined) return { ...parsed.document, [key]: servers }; // A fresh document may not hold the outer table yet (for example a new @@ -1536,11 +1654,11 @@ const applyFormattedJsonEdit = (text: string, edit: Edit): string => { }; /** Remove selected member spans and only the separators needed by kept members. */ -const deleteJsonMembers = ( +const jsonChildRemovalEdits = ( text: string, object: Node, properties: readonly Node[], -): string => { +): Edit[] => { const siblings = object.children ?? []; const removed = new Set(properties); const anyKept = siblings.some((member) => !removed.has(member)); @@ -1561,6 +1679,113 @@ const deleteJsonMembers = ( if (separator.kind === JSON_COMMA && !keepSeparator) edits.push({ offset: separator.offset, length: 1, content: "" }); } + return edits; +}; + +const deleteJsonMembers = ( + text: string, + object: Node, + properties: readonly Node[], +): string => applyEdits(text, jsonChildRemovalEdits(text, object, properties)); + +/** + * Edit values through their source nodes. Parsed numbers are useful for deciding + * whether a field changed, but serializing unchanged values would discard their + * original precision and spelling. Only effective object members are updated; + * removing a nested field removes every repeat, as replacing its owning object + * did, so an earlier field cannot become active. + */ +const jsonValueEdits = (text: string, node: Node, value: unknown): Edit[] => { + if (node.type === "object") { + const record = clientConfigurationRecordSchema.safeParse(value); + if (record.success) { + const members = node.children ?? []; + const removed = members.filter((member) => { + const key: unknown = member.children?.[0]?.value; + return ( + typeof key === "string" && + (!Object.hasOwn(record.data, key) || record.data[key] === undefined) + ); + }); + const edits = jsonChildRemovalEdits(text, node, removed); + const added: string[] = []; + for (const [key, next] of Object.entries(record.data)) { + if (next === undefined) continue; + const child = effectiveJsonProperty(node, key)?.children?.[1]; + if (child === undefined) + added.push(`${JSON.stringify(key)}:${JSON.stringify(next)}`); + else edits.push(...jsonValueEdits(text, child, next)); + } + if (added.length > 0) + edits.push({ + offset: node.offset + 1, + length: 0, + content: + added.join(",") + + (members.some((member) => !removed.includes(member)) ? "," : ""), + }); + return edits; + } + } + if (node.type === "array" && Array.isArray(value)) { + const children = node.children ?? []; + const edits = jsonChildRemovalEdits( + text, + node, + children.slice(value.length), + ); + for (const [index, child] of children.slice(0, value.length).entries()) + edits.push(...jsonValueEdits(text, child, value[index] ?? null)); + if (value.length > children.length) { + const previous = children.at(-1); + edits.push({ + offset: + previous === undefined + ? node.offset + 1 + : previous.offset + previous.length, + length: 0, + content: + (previous === undefined ? "" : ",") + + value + .slice(children.length) + .map((entry) => JSON.stringify(entry ?? null)) + .join(","), + }); + } + return edits; + } + return Object.is(node.value, value) + ? [] + : [ + { + offset: node.offset, + length: node.length, + content: JSON.stringify(value), + }, + ]; +}; + +// Token codes are checked against jsonc-parser's ambient enum. +const JSON_CLOSE_BRACE = 2 satisfies SyntaxKind; +const JSON_CLOSE_BRACKET = 4 satisfies SyntaxKind; +const JSON_LINE_COMMENT = 12 satisfies SyntaxKind; +const JSON_BLOCK_COMMENT = 13 satisfies SyntaxKind; + +/** Remove JSONC syntax without decoding and re-encoding source value tokens. */ +const strictJsonSyntax = (source: string): string => { + const text = source.startsWith("\uFEFF") ? source.slice(1) : source; + const scanner = createScanner(text, false); + const edits: Edit[] = []; + for (let token = scanner.scan(); token !== JSON_EOF; token = scanner.scan()) { + const offset = scanner.getTokenOffset(); + if (token === JSON_LINE_COMMENT || token === JSON_BLOCK_COMMENT) + edits.push({ offset, length: scanner.getTokenLength(), content: " " }); + else if (token === JSON_COMMA) { + const next = nextJsoncToken(text, offset + 1); + if (next.kind === JSON_CLOSE_BRACE || next.kind === JSON_CLOSE_BRACKET) + edits.push({ offset, length: 1, content: "" }); + } + } return applyEdits(text, edits); }; @@ -1615,13 +1840,7 @@ const editJsonMember = ( continue; } if (value !== undefined) - return ok( - applyFormattedJsonEdit(text, { - offset: member.offset, - length: member.length, - content: JSON.stringify(value), - }), - ); + return ok(applyEdits(text, jsonValueEdits(text, member, value))); if ( object.children?.some( (sibling) => sibling !== property && isJsonMember(sibling, key), @@ -1672,17 +1891,29 @@ export const serializeClientConfiguration = ( return ok(serializeGrokConfiguration(document, originalText)); if (format === "hermes") return ok(serializeHermesConfiguration(document, originalText)); + if (format === "goose") { + if (originalText === undefined || editedPaths === undefined) + return ok(stringifyGooseConfigurationYaml(document)); + let text = originalText; + for (const path of editedPaths) + text = editGooseConfigurationYaml(text, path, valueAt(document, path)); + return ok(text); + } if (format === "dsh") return ok(serializeDshDocument(document)); // An empty original holds no comments or settings to preserve. Writing a // fresh document also keeps a lone BOM from shifting past the closing brace. if ( originalText === undefined || - isEmptyClientConfigurationText(originalText) || - editedPaths === undefined + isEmptyClientConfigurationText(originalText) ) return ok(`${JSON.stringify(document, null, 2)}\n`); let text = originalText; - for (const path of editedPaths) { + if (editedPaths === undefined) { + const root = parseTree(text, [], { allowTrailingComma: true }); + if (root === undefined) throw new TypeError("JSON document is missing"); + text = applyEdits(text, jsonValueEdits(text, root, document)); + } + for (const path of editedPaths ?? []) { const edited = editJsonMember( text, path, @@ -1696,7 +1927,13 @@ export const serializeClientConfiguration = ( if (!edited.ok) return edited; text = edited.value; } - return ok(text); + return ok( + format === "copilot_cli_project" || + format === "copilot_cli" || + format === "fx" + ? strictJsonSyntax(text) + : text, + ); }; /** Key path of a named registration in the server table. */ @@ -1743,83 +1980,70 @@ const serializeHermesConfiguration = ( }; /** Build the stdio entry shape expected by one client's configuration dialect. */ +/** Native launcher fields shared by setup and explicit MCP registration. */ +export const clientRegistrationLauncherEntry = ( + format: ClientRegistrationDialect, + command: readonly string[], +): Record => { + const [executable = "rea", ...args] = command; + switch (format) { + case "opencode": + case "opencode_v2": + case "fx": + case "kilo": + return { type: "local", command: [...command] }; + case "goose": + return { type: "stdio", cmd: executable, args }; + case "vscode": + case "omp": + case "pi": + case "copilot_cli": + case "copilot_cli_project": + return { type: "stdio", command: executable, args }; + case "commandcode": + return { transport: "stdio", command: executable, args }; + default: + return { command: executable, args }; + } +}; + +/** Setup defaults and explicit environment extend the same native launcher facts. */ export const clientRegistrationEntry = ( format: ClientRegistrationDialect, command: readonly string[], environment: Readonly>, ): Record => { - const [executable = "rea", ...args] = command; - switch (format) { - case "opencode": - return { - type: "local", - command: [...command], - enabled: true, - ...(Object.keys(environment).length === 0 - ? {} - : { environment: { ...environment } }), - }; - case "kimi": - case "lm_studio": - // Both infer stdio from command; Kimi uses `transport`, not `type`. - return { - command: executable, - args, - ...(Object.keys(environment).length === 0 ? {} : { env: environment }), - }; - case "opencode_v2": - // Override lower-priority disabled flags when refreshing a V2 registration. - return { - type: "local", - command: [...command], - disabled: false, - ...(Object.keys(environment).length === 0 - ? {} - : { environment: { ...environment } }), - }; - case "vscode": - case "omp": - case "pi": - return { - type: "stdio", - command: executable, - args, - ...(Object.keys(environment).length === 0 ? {} : { env: environment }), - }; - case "copilot_cli": - return { - type: "stdio", - command: executable, - args, - tools: ["*"], - ...(Object.keys(environment).length === 0 ? {} : { env: environment }), - }; - case "commandcode": - return { - transport: "stdio", - enabled: true, - command: executable, - args, - ...(Object.keys(environment).length === 0 ? {} : { env: environment }), - }; - case "dsh": - return dshClientRegistrationEntry(command, environment); - case "hermes": - // Hermes reads stdio servers from `mcp_servers.` and connects them - // unless `enabled` is false, so the entry states it explicitly. - return { - command: executable, - args, - ...(Object.keys(environment).length === 0 ? {} : { env: environment }), - enabled: true, - }; - default: - return { - command: executable, - args, - ...(Object.keys(environment).length === 0 ? {} : { env: environment }), - }; - } + if (format === "dsh") return dshClientRegistrationEntry(command, environment); + const defaults = + format === "opencode_v2" + ? { disabled: false } + : format === "copilot_cli" + ? { tools: ["*"] } + : format === "goose" + ? { + name: PRODUCT_IDENTITY.mcpServerKey, + description: "", + envs: {}, + enabled: true, + timeout: 300, + } + : ["opencode", "commandcode", "hermes", "fx", "kilo"].includes(format) + ? { enabled: true } + : {}; + const environmentKey = ["opencode", "opencode_v2", "fx", "kilo"].includes( + format, + ) + ? "environment" + : format === "goose" + ? "envs" + : "env"; + return { + ...clientRegistrationLauncherEntry(format, command), + ...defaults, + ...(Object.keys(environment).length === 0 + ? {} + : { [environmentKey]: { ...environment } }), + }; }; /** Parse one Gemini policy at its source boundary before consolidating scopes. */ diff --git a/src/application/ClientConfigurationFiles.ts b/src/application/ClientConfigurationFiles.ts index ef92531bb5..a299236bea 100644 --- a/src/application/ClientConfigurationFiles.ts +++ b/src/application/ClientConfigurationFiles.ts @@ -5,6 +5,7 @@ import { effectiveClientServer, parseClientConfiguration, type ClientConfigurationDocument, + type ClientConfigurationFormat, type ClientRegistrationDialect, } from "./ClientConfigurationDocument.js"; import type { SetupClient } from "./SupportedClients.js"; @@ -88,7 +89,7 @@ export const readConfigurationText = async ( /** Read and parse one configuration at its source boundary. */ export const readConfigurationFile = async ( path: string, - format: SetupClient["format"], + format: ClientConfigurationFormat | undefined, resources?: ArtifactResourceScope, ): Promise< Result @@ -235,6 +236,14 @@ export const effectiveClientConfiguration = ( return document === undefined ? [] : [document]; }, ); + return mergeClientConfigurationDocuments(documents, platform); +}; + +/** Merge admitted layers without re-reading or changing their source documents. */ +export const mergeClientConfigurationDocuments = ( + documents: readonly ClientConfigurationDocument[], + platform: NodeJS.Platform = process.platform, +): ClientConfigurationDocument | undefined => { const last = documents.at(-1); if (last === undefined) return undefined; if (documents.length === 1) return last; diff --git a/src/application/ClientConfigurationPublication.ts b/src/application/ClientConfigurationPublication.ts index 4690bf0c76..6b91af1e99 100644 --- a/src/application/ClientConfigurationPublication.ts +++ b/src/application/ClientConfigurationPublication.ts @@ -64,6 +64,8 @@ export class ClientConfigurationWriteError extends Error { export interface ClientConfigurationSelection { readonly requestedPath: string; readonly state: Stats | undefined; + /** Required by fx's trusted private profile. */ + readonly requiredMode?: 0o600; } /** The exact staged file retained through publication, verification and recovery. */ @@ -119,6 +121,20 @@ export class ClientConfigurationPublication { staging.setFile(owner); await owner.handle.writeFile(contents, { encoding: "utf8" }); await preserveConfigurationAttributes(owner, input, destination); + if (selection?.requiredMode !== undefined) { + await owner.handle.chmod(selection.requiredMode); + if ( + process.platform !== "win32" && + ((await owner.handle.stat()).mode & 0o777) !== selection.requiredMode + ) + throw new ClientConfigurationWriteError( + destination, + "preserve_mode", + new Error( + "The required private profile mode could not be verified", + ), + ); + } await owner.handle.sync(); if ((await owner.handle.stat()).ino === 0) throw new RegularFileChangedError(stagedPath); diff --git a/src/application/ClientRegistration.test.ts b/src/application/ClientRegistration.test.ts index c507359567..e5522ec019 100644 --- a/src/application/ClientRegistration.test.ts +++ b/src/application/ClientRegistration.test.ts @@ -1,10 +1,39 @@ import { expect, it } from "vitest"; -import { parseClientRegistration } from "./ClientRegistration.js"; -import type { SetupClient } from "./SupportedClients.js"; +import { + parseClientRegistrationPolicy, + parseClientRegistrationLauncher, + replaceClientRegistrationLauncher, +} from "./ClientRegistration.js"; +import { ConfigurationError } from "../domain/configurationErrors.js"; +import { + parseClientConfiguration, + clientConfigurationRecordSchema, +} from "./ClientConfigurationDocument.js"; +import { + supportedClientRegistrationProfile, + type SetupClient, +} from "./SupportedClients.js"; +import { mcpClientRegistrationProfile } from "./McpRegistrationCatalog.js"; +import { admitClientStdioConnection } from "./ClientRegistrationConnection.js"; +import type { ClientRegistrationTarget } from "./ClientRegistrationProfile.js"; + +const zedTarget: ClientRegistrationTarget = { + dialect: "zed", + profile: mcpClientRegistrationProfile("zed"), +}; +const gooseTarget: ClientRegistrationTarget = { + dialect: "goose", + profile: mcpClientRegistrationProfile("goose"), +}; +const cursorTarget: ClientRegistrationTarget = { + dialect: "json", + profile: mcpClientRegistrationProfile("cursor"), +}; const client: SetupClient = { name: "opencode", + registrationProfile: supportedClientRegistrationProfile("opencode"), format: "opencode", configPath: "/fixture/opencode.json", }; @@ -13,7 +42,7 @@ it("preserves every OpenCode environment key while normalizing its launcher", () const input = JSON.parse( '{"type":"local","command":["rea","mcp"],"environment":{"__proto__":"operator","constructor":"selected","normal":"value"}}', ); - const result = parseClientRegistration(input, client); + const result = parseClientRegistrationPolicy(input, client); if (!result.ok) throw result.error; expect(result.value.command).toBe("rea"); expect(result.value.args).toEqual(["mcp"]); @@ -28,10 +57,208 @@ it.each([42, null, {}])( "rejects malformed OpenCode environment values at __proto__: %j", (value) => { const environment = Object.fromEntries([["__proto__", value]]); - const result = parseClientRegistration( + const result = parseClientRegistrationPolicy( { type: "local", command: ["rea", "mcp"], environment }, client, ); expect(result.ok).toBe(false); }, ); + +it("preserves Zed source metadata while replacing its native launcher", () => { + const previous = { + source: "operator-metadata", + command: "old", + args: ["old"], + env: { KEEP: "1" }, + custom: { retained: true }, + }; + const updated = replaceClientRegistrationLauncher( + zedTarget, + ["fixture", ""], + previous, + ); + if (!updated.ok) throw updated.error; + expect(updated.value).toEqual({ + ...previous, + command: "fixture", + args: [""], + }); + const fresh = replaceClientRegistrationLauncher( + zedTarget, + ["fixture"], + undefined, + ); + if (!fresh.ok) throw fresh.error; + expect(fresh.value).not.toHaveProperty("source"); +}); + +it("normalizes Goose's single env alias without emitting duplicate native fields", () => { + const env = JSON.parse( + '{"KEEP":"1","__proto__":"selected","constructor":"owned"}', + ); + const previous = { + cmd: "old", + args: [], + env, + enabled: false, + custom: { retained: true }, + }; + const before = structuredClone(previous); + const result = replaceClientRegistrationLauncher( + gooseTarget, + ["fixture", ""], + previous, + ); + if (!result.ok) throw result.error; + expect(result.value.envs).toEqual(env); + expect(result.value).not.toHaveProperty("env"); + const canonical = clientConfigurationRecordSchema.parse(result.value.envs); + expect(Object.hasOwn(canonical, "__proto__")).toBe(true); + expect(result.value).toMatchObject({ + enabled: false, + custom: { retained: true }, + }); + expect(previous).toEqual(before); +}); + +it.each([{ envs: { KEEP: "1" } }, {}])( + "keeps canonical Goose environment data stable: %j", + (previous) => { + const result = replaceClientRegistrationLauncher( + gooseTarget, + ["fixture"], + previous, + ); + if (!result.ok) throw result.error; + expect(result.value.envs).toEqual("envs" in previous ? previous.envs : {}); + expect(result.value).not.toHaveProperty("env"); + }, +); + +it("keeps a native Goose leading-zero string when normalizing the env alias", () => { + const parsed = parseClientConfiguration( + "extensions:\n rea:\n cmd: old\n env:\n VALUE: 0123\n", + "goose", + ); + const result = replaceClientRegistrationLauncher( + gooseTarget, + ["fixture"], + parsed.servers.rea, + ); + if (!result.ok) throw result.error; + expect(result.value.envs).toEqual({ VALUE: "0123" }); + expect(result.value).not.toHaveProperty("env"); +}); + +it("normalizes an explicitly empty Goose env alias to one canonical field", () => { + const result = replaceClientRegistrationLauncher(gooseTarget, ["fixture"], { + env: {}, + }); + if (!result.ok) throw result.error; + expect(result.value.envs).toEqual({}); + expect(result.value).not.toHaveProperty("env"); +}); + +it.each([ + { env: { KEEP: "1" }, envs: { KEEP: "1" } }, + { env: { KEEP: "old" }, envs: { KEEP: "new" } }, +])( + "refuses both Goose environment aliases even when their contents agree: %j", + (previous) => { + const result = replaceClientRegistrationLauncher( + gooseTarget, + ["fixture"], + previous, + ); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Ambiguous Goose aliases were admitted"); + expect(result.error).toBeInstanceOf(ConfigurationError); + expect(result.error.message).toContain("envs or its env alias, not both"); + expect(result.error.settings).toEqual([ + { + setting: "envs", + constraint: "env and envs declare the same native field", + }, + ]); + }, +); + +it.each([null, 42, ["value"], { VALUE: 123 }])( + "refuses a malformed Goose env alias: %j", + (env) => { + const result = replaceClientRegistrationLauncher(gooseTarget, ["fixture"], { + env, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Malformed Goose alias was admitted"); + expect(result.error).toBeInstanceOf(ConfigurationError); + expect(result.error.settings).toEqual([ + { + setting: "env", + constraint: "Expected a mapping of string environment values", + }, + ]); + }, +); + +it("keeps other clients' env field in its own dialect", () => { + const previous = { env: { KEEP: "1" }, command: "old" }; + const result = replaceClientRegistrationLauncher( + cursorTarget, + ["fixture"], + previous, + ); + if (!result.ok) throw result.error; + expect(result.value.env).toEqual({ KEEP: "1" }); + expect(result.value).not.toHaveProperty("envs"); +}); + +it.each([ + { + target: { + dialect: "opencode", + profile: mcpClientRegistrationProfile("opencode"), + }, + value: { + type: "remote", + url: "https://fixture.invalid", + command: ["/fixture/rea", "--mcp"], + }, + }, + { + target: { dialect: "json", profile: mcpClientRegistrationProfile("cline") }, + value: { + transport: { + type: "streamableHttp", + url: "https://fixture.invalid", + command: "/fixture/rea", + args: ["--mcp"], + }, + }, + }, +] satisfies readonly { target: ClientRegistrationTarget; value: unknown }[])( + "retains launcher syntax independently of a non-stdio native transport: $target.profile.nativeClient", + ({ target, value }) => { + expect(parseClientRegistrationLauncher(value, target)).toEqual({ + ok: true, + value: { command: "/fixture/rea", args: ["--mcp"] }, + }); + expect(admitClientStdioConnection(value, target).ok).toBe(false); + }, +); + +it("retains malformed stored argv syntax for ownership while refusing launch readiness", () => { + const value = { + command: "/fixture/rea", + args: ["--mcp", "recorded\0argument"], + }; + expect(parseClientRegistrationLauncher(value, cursorTarget)).toEqual({ + ok: true, + value, + }); + expect(admitClientStdioConnection(value, cursorTarget)).toMatchObject({ + ok: false, + error: { kind: "invalid", cause: { _tag: "ConfigurationError" } }, + }); +}); diff --git a/src/application/ClientRegistration.ts b/src/application/ClientRegistration.ts index dd1a74dc1f..d9c3120609 100644 --- a/src/application/ClientRegistration.ts +++ b/src/application/ClientRegistration.ts @@ -13,9 +13,11 @@ import { import { clientConfigurationRecordSchema, clientRegistrationEntry, + clientRegistrationLauncherEntry, type ClientRegistrationDialect, } from "./ClientConfigurationDocument.js"; import type { SetupClient } from "./SupportedClients.js"; +import type { ClientRegistrationTarget } from "./ClientRegistrationProfile.js"; import { classifyClientRegistrationCommand, equivalentClientRegistrationCommands, @@ -34,14 +36,16 @@ const launcherSchema = z args: z.array(z.string()).default([]), }) .passthrough(); -const opencodeLauncherSchema = z +const arrayLauncherSchema = z .object({ - type: z.literal("local"), command: z.array(z.string()).min(1), }) .passthrough(); +const opencodeLauncherSchema = arrayLauncherSchema.extend({ + type: z.literal("local"), +}); const normalizeOpencodeLauncher = ( - entry: z.output, + entry: z.output, ): z.output => { const [command = "", ...args] = entry.command; return { ...entry, command, args }; @@ -52,8 +56,10 @@ const registrationSchema = launcherSchema.extend({ tool_timeout_sec: tomlTimeoutSchema.optional(), startupTimeoutMs: z.number().positive().optional(), toolTimeoutMs: z.number().positive().optional(), - type: z.string().optional(), - transport: z.string().optional(), + // Native transport tags belong to the selected profile's admission owner. + // Other clients may retain arbitrary metadata under these same names. + type: z.unknown().optional(), + transport: z.unknown().optional(), serverName: z.string().optional(), disabled: z.boolean().optional(), enabled: z.boolean().optional(), @@ -100,14 +106,38 @@ const registrationError = (error: z.ZodError): ConfigurationError => /** Launcher identity remains usable when unrelated readiness settings are invalid. */ export const parseClientRegistrationLauncher = ( value: unknown, - client: SetupClient, + target: ClientRegistrationTarget, ): Result<{ command: string; args: string[] }, ConfigurationError> => { + const { dialect, profile } = target; + if (profile.connection.kind === "unavailable") + return err(new ConfigurationError(profile.connection.reason)); + if (profile.connection.kind === "cline") { + const row = clientConfigurationRecordSchema.safeParse(value); + if (row.success && Object.hasOwn(row.data, "transport")) { + const transport = clientConfigurationRecordSchema.safeParse( + row.data.transport, + ); + if (transport.success) value = transport.data; + } + } const parsed = - client.format === "opencode" - ? opencodeLauncherSchema + dialect === "opencode" || + dialect === "opencode_v2" || + dialect === "fx" || + dialect === "kilo" + ? arrayLauncherSchema .transform(normalizeOpencodeLauncher) .safeParse(value) - : launcherSchema.safeParse(value); + : dialect === "goose" + ? z + .object({ + cmd: z.string().min(1), + args: z.array(z.string()).default([]), + }) + .passthrough() + .transform((entry) => ({ command: entry.cmd, args: entry.args })) + .safeParse(value) + : launcherSchema.safeParse(value); return parsed.success ? ok({ command: parsed.data.command, args: parsed.data.args }) : err(registrationError(parsed.error)); @@ -118,7 +148,10 @@ export const clientRegistrationOwnership = ( value: unknown, client: SetupClient, ): ClientRegistrationCommandIdentity => { - const parsed = parseClientRegistrationLauncher(value, client); + const parsed = parseClientRegistrationLauncher(value, { + dialect: client.format ?? "json", + profile: client.registrationProfile, + }); return parsed.ok ? classifyClientRegistrationCommand( [parsed.value.command, ...parsed.value.args], @@ -150,8 +183,8 @@ const validateClientPolicy = ( : err(registrationError(policy.error)); }; -/** Normalize launcher facts once; policy fields keep their dialect-specific shape. */ -export const parseClientRegistration = ( +/** Parse launcher syntax and client policy; native transport selection is admitted separately. */ +export const parseClientRegistrationPolicy = ( value: unknown, client: SetupClient, ): Result => { @@ -179,51 +212,182 @@ export const parseClientRegistration = ( const parsed = registrationSchema.safeParse(value); if (!parsed.success) return err(registrationError(parsed.error)); const registration = parsed.data; - const transportInvalid = - ((client.format === "kimi" || client.format === "commandcode") && - registration.transport !== undefined && - registration.transport !== "stdio") || - (client.format === "commandcode" && registration.transport === undefined); - const typeInvalid = - ((client.format === "vscode" || client.format === "copilot_cli") && - registration.type !== "stdio") || - ((client.format === "kimi" || - client.format === "omp" || - client.format === "pi") && - registration.type !== undefined && - registration.type !== "stdio"); - if (transportInvalid || typeInvalid) - return err( - new ConfigurationError("Expected an stdio registration", { - settings: [ - { - setting: transportInvalid ? "transport" : "type", - constraint: "Expected stdio", - }, - ], - }), - ); return ok(registration); }; // These fields select REA's connection and activation. Everything else belongs // to the caller/client, including future policy keys. Client-specific timeout // defaults and preservation rules are applied below. -const managedConnectionKeys = new Set([ - "command", - "args", +const managedPolicyKeys = new Set([ "env", "environment", - "type", - "transport", "enabled", "disabled", "enable", "serverName", - "url", - "httpUrl", ]); +/** Own only fields selected by this native client's admitted profile. */ +export const clientRegistrationSourceFacts = ( + target: ClientRegistrationTarget, + command: readonly string[], + previous: unknown, +): Result< + { + readonly source: Record | undefined; + readonly connectionFields: ReadonlySet; + }, + ConfigurationError +> => { + const { profile } = target; + const connection = profile.connection; + if (connection.kind === "unavailable") + return err(new ConfigurationError(connection.reason)); + const parsed = clientConfigurationRecordSchema.safeParse(previous); + const source = parsed.success ? parsed.data : undefined; + const fields = new Set( + Object.keys(clientRegistrationLauncherEntry(target.dialect, command)), + ); + if (connection.kind === "stdio-only") { + const admitted = new Set([...fields, ...connection.documentedFields]); + const unknown = Object.keys(source ?? {}).filter( + (key) => !admitted.has(key), + ); + if ( + (previous !== undefined && source === undefined) || + unknown.length > 0 || + (source !== undefined && + Object.hasOwn(source, "type") && + source.type !== "stdio") + ) + return err( + new ConfigurationError( + `Write applicability for ${profile.nativeClient} is unknown outside its documented stdio profile. Review the selected row in the client's native settings; no configuration was changed.`, + { + settings: [ + { + setting: unknown[0] ?? "type", + constraint: + "Native interpretation is not established by the documented stdio profile", + }, + ], + }, + ), + ); + } else { + for (const field of connection.kind === "cline" + ? ["transport", "type", "transportType", "url"] + : connection.kind === "commandcode" + ? ["type", "url"] + : connection.fields) + fields.add(field); + } + return ok({ source, connectionFields: fields }); +}; + +/** Explicit launcher replacement preserves policy and native environment, while + * superseding connection selectors that could keep the old remote transport. + * Goose's single env alias becomes envs; declaring both remains ambiguous. */ +export const replaceClientRegistrationLauncher = ( + target: ClientRegistrationTarget, + command: readonly string[], + previous: unknown, + inherited?: unknown, +): Result, ConfigurationError> => { + const { dialect, profile } = target; + const facts = clientRegistrationSourceFacts(target, command, previous); + if (!facts.ok) return facts; + const { source, connectionFields: launcherConnectionKeys } = facts.value; + const retained: Record = + source === undefined + ? {} + : Object.fromEntries( + Object.entries(source).filter( + ([key]) => !launcherConnectionKeys.has(key), + ), + ); + if (dialect === "goose" && Object.hasOwn(retained, "env")) { + if (Object.hasOwn(retained, "envs")) + return err( + new ConfigurationError( + "Goose environment is ambiguous: keep only envs or its env alias, not both.", + { + settings: [ + { + setting: "envs", + constraint: "env and envs declare the same native field", + }, + ], + }, + ), + ); + const alias = jsonRecordSchema(z.string()).safeParse(retained.env); + if (!alias.success) + return err( + new ConfigurationError("Invalid Goose env alias", { + cause: alias.error, + settings: [ + { + setting: "env", + constraint: "Expected a mapping of string environment values", + }, + ], + }), + ); + retained.envs = alias.data; + delete retained.env; + } + const defaults = clientRegistrationEntry(dialect, command, {}); + // Layered native clients inherit policy without copying it into the edited + // fragment. Defaults must not override an explicitly configured lower layer. + const inheritedPolicy = clientConfigurationRecordSchema.safeParse(inherited); + if (inheritedPolicy.success) + for (const key of Object.keys(defaults)) + if ( + !launcherConnectionKeys.has(key) && + (Object.hasOwn(inheritedPolicy.data, key) || + (dialect === "opencode" && + key === "enabled" && + Object.hasOwn(inheritedPolicy.data, "disabled"))) + ) + delete defaults[key]; + // V1 already accepts a disabled policy. Adding enabled:true would override + // that existing activation dialect when OpenCode migrates the row. + if ( + dialect === "opencode" && + source !== undefined && + Object.hasOwn(source, "disabled") + ) + delete defaults.enabled; + const launcher = clientRegistrationLauncherEntry(dialect, command); + if (profile.connection.kind === "cline" && source !== undefined) { + const nested = clientConfigurationRecordSchema.safeParse(source.transport); + if (nested.success) { + for (const key of Object.keys(launcher)) delete defaults[key]; + const retainedTransport = Object.fromEntries( + Object.entries(nested.data).filter( + ([key]) => + key !== "type" && key !== "url" && !Object.hasOwn(launcher, key), + ), + ); + return ok({ + ...defaults, + ...retained, + transport: { + ...retainedTransport, + type: "stdio", + ...launcher, + }, + }); + } + } + return ok({ + ...defaults, + ...retained, + ...launcher, + }); +}; + /** Whether a rea setup repair for this target preserves the row's * `enable: false` instead of dropping it. Shared registration doubles as * ZCode's fallback, so the activation fact belongs to every reader; a repair @@ -245,11 +409,13 @@ export const refreshClientRegistration = ( previous: unknown, timeoutInputs: McpToolTimeoutInputs, ): Result, ConfigurationError> => { - const source = clientConfigurationRecordSchema.safeParse(previous); - const existing = source.success ? source.data : {}; + const target = { dialect, profile: client.registrationProfile }; + const facts = clientRegistrationSourceFacts(target, command, previous); + if (!facts.ok) return facts; + const existing = facts.value.source ?? {}; const policy = validateClientPolicy(existing, client); if (!policy.ok) return policy; - const prior = parseClientRegistrationLauncher(existing, client); + const prior = parseClientRegistrationLauncher(existing, target); const priorCommand = prior.ok ? [prior.value.command, ...prior.value.args] : undefined; @@ -262,7 +428,8 @@ export const refreshClientRegistration = ( ...clientRegistrationEntry(dialect, launcher, environment), ...Object.fromEntries( Object.entries(existing).filter( - ([key]) => !managedConnectionKeys.has(key), + ([key]) => + !facts.value.connectionFields.has(key) && !managedPolicyKeys.has(key), ), ), }; diff --git a/src/cli/ampRegistrationBackup.ts b/src/application/ClientRegistrationBackup.ts similarity index 83% rename from src/cli/ampRegistrationBackup.ts rename to src/application/ClientRegistrationBackup.ts index 712dfac488..af1a7d5ad6 100644 --- a/src/cli/ampRegistrationBackup.ts +++ b/src/application/ClientRegistrationBackup.ts @@ -4,7 +4,7 @@ import { readFile, writeFile } from "node:fs/promises"; import { preserveCurrentRecoveryBackup, type RecoveryBackupPreservationFailure, -} from "../application/SetupRecoveryBackup.js"; +} from "./SetupRecoveryBackup.js"; import { err, ok, type Result } from "../domain/result.js"; const errorDetail = (cause: unknown): string => @@ -27,14 +27,14 @@ const restrictExistingBackup = ( return err({ kind: "unreadable", path: backupPath, - detail: `The existing Amp settings backup could not be checked: ${errorDetail(cause)}`, + detail: `The existing client configuration backup could not be checked: ${errorDetail(cause)}`, }); } if (!existing.isFile()) return err({ kind: "occupied", path: backupPath, - detail: "The existing Amp settings backup is not a regular file.", + detail: "The existing client configuration backup is not a regular file.", }); try { chmodSync(backupPath, 0o600); @@ -42,7 +42,7 @@ const restrictExistingBackup = ( return err({ kind: "unreadable", path: backupPath, - detail: `The existing Amp settings backup permissions could not be secured: ${errorDetail(cause)}`, + detail: `The existing client configuration backup permissions could not be secured: ${errorDetail(cause)}`, }); } return ok(undefined); @@ -58,7 +58,7 @@ const copyPrivateBackup = async ( }); }; -export const writeAmpRegistrationBackup = async ( +export const writeClientRegistrationBackup = async ( settingsPath: string, settingsText: string, ): Promise> => { diff --git a/src/application/ClientRegistrationConnection.ts b/src/application/ClientRegistrationConnection.ts new file mode 100644 index 0000000000..37587a56db --- /dev/null +++ b/src/application/ClientRegistrationConnection.ts @@ -0,0 +1,160 @@ +import { ConfigurationError } from "../domain/configurationErrors.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { processArgumentSchema } from "../domain/process/processArgument.js"; +import { clientConfigurationRecordSchema } from "./ClientConfigurationDocument.js"; +import { + clientRegistrationSourceFacts, + parseClientRegistrationLauncher, +} from "./ClientRegistration.js"; +import type { + ClientRegistrationTarget, + NativeStdioIncompatibility, +} from "./ClientRegistrationProfile.js"; + +export interface NativeStdioAdmissionIssue { + readonly kind: "invalid" | "unknown"; + readonly cause: ConfigurationError; +} + +/** Evaluate only the native conditions recorded by the selected client owner. */ +const incompatible = ( + condition: NativeStdioIncompatibility, + source: Record, +): boolean => { + if (condition.kind === "all") + return condition.conditions.every((item) => incompatible(item, source)); + const value = Object.hasOwn(source, condition.field) + ? source[condition.field] + : undefined; + switch (condition.kind) { + case "truthy": + return Boolean(value); + case "non-nullish": + return value !== undefined && value !== null; + case "present": + return Object.hasOwn(source, condition.field); + case "missing": + return value === undefined; + case "nullish": + return value === undefined || value === null; + case "string": + return typeof value === "string"; + case "invalid-string": + return value !== undefined && typeof value !== "string"; + case "equals": + return value === condition.value; + case "required-enum": + return typeof value !== "string" || !condition.values.includes(value); + case "optional-enum": + return ( + value !== undefined && + (typeof value !== "string" || !condition.values.includes(value)) + ); + } +}; + +/** Admit selected stdio separately from ownership of a retained launcher field. */ +export const admitClientStdioConnection = ( + value: unknown, + target: ClientRegistrationTarget, +): Result<{ command: string; args: string[] }, NativeStdioAdmissionIssue> => { + const parsed = clientConfigurationRecordSchema.safeParse(value); + if (!parsed.success) + return err({ + kind: "invalid", + cause: new ConfigurationError( + "The native client connection is not an admitted server mapping.", + ), + }); + const source = parsed.data; + const connection = target.profile.connection; + if (connection.kind === "unavailable") + return err({ + kind: "unknown", + cause: new ConfigurationError(connection.reason), + }); + if (connection.kind === "commandcode") { + // Published file-loader semantics; its add-json management parser differs. + const selector = source.transport ?? source.type ?? ""; + if (typeof selector !== "string") + return err({ + kind: "invalid", + cause: new ConfigurationError( + "The selected native CommandCode transport tag must be text.", + ), + }); + const transport = selector.toLowerCase(); + if ( + transport === "http" || + transport === "sse" || + (transport !== "stdio" && source.url && !source.command) + ) + return err({ + kind: "unknown", + cause: new ConfigurationError( + "The native CommandCode configuration selects a remote connection rather than the retained stdio launcher.", + ), + }); + } + if (connection.kind === "flat") { + const conflict = connection.stdio.find((item) => + incompatible(item, source), + ); + if (conflict !== undefined) + return err({ + kind: + conflict.kind === "required-enum" || conflict.kind === "optional-enum" + ? "invalid" + : "unknown", + cause: new ConfigurationError( + `The native ${target.profile.nativeClient} connection does not establish the retained stdio launcher as selected. Repair its native transport selectors before treating the registration as aligned.`, + { + settings: [ + { + setting: + conflict.kind === "all" ? "native transport" : conflict.field, + constraint: + "Native connection selection does not establish stdio", + }, + ], + }, + ), + }); + } + if (connection.kind === "cline") { + const nested = clientConfigurationRecordSchema.safeParse(source.transport); + const type = nested.success + ? nested.data.type + : (source.type ?? source.transportType); + if (type !== undefined && type !== "stdio") + return err({ + kind: "unknown", + cause: new ConfigurationError( + "The native Cline transport does not select stdio.", + ), + }); + } + const launcher = parseClientRegistrationLauncher(value, target); + if (!launcher.ok) return err({ kind: "invalid", cause: launcher.error }); + if ( + [launcher.value.command, ...launcher.value.args].some( + (word) => !processArgumentSchema.safeParse(word).success, + ) + ) + return err({ + kind: "invalid", + cause: new ConfigurationError( + "The stored stdio executable or arguments contain NUL and cannot be launched. The existing registration was retained; correct its process arguments in the client settings.", + ), + }); + if (connection.kind === "stdio-only") { + const applicability = clientRegistrationSourceFacts( + target, + [launcher.value.command, ...launcher.value.args], + value, + ); + if (!applicability.ok) + return err({ kind: "unknown", cause: applicability.error }); + } + return ok(launcher.value); +}; diff --git a/src/application/ClientRegistrationProfile.ts b/src/application/ClientRegistrationProfile.ts new file mode 100644 index 0000000000..31904a31ca --- /dev/null +++ b/src/application/ClientRegistrationProfile.ts @@ -0,0 +1,53 @@ +import type { ClientRegistrationDialect } from "./ClientConfigurationDocument.js"; + +/** Proven native conditions incompatible with selecting the retained stdio launcher. */ +export type NativeStdioIncompatibility = + | { + readonly kind: + | "truthy" + | "non-nullish" + | "present" + | "missing" + | "nullish" + | "string" + | "invalid-string"; + readonly field: string; + } + | { + readonly kind: "required-enum" | "optional-enum"; + readonly field: string; + readonly values: readonly string[]; + } + | { readonly kind: "equals"; readonly field: string; readonly value: string } + | { + readonly kind: "all"; + readonly conditions: readonly NativeStdioIncompatibility[]; + }; + +/** Native connection semantics, separate from the format storing the document. */ +export type ClientRegistrationConnectionProfile = + | { + readonly kind: "flat"; + /** Proven native selectors/aliases in addition to canonical stdio fields. */ + readonly fields: readonly string[]; + readonly stdio: readonly NativeStdioIncompatibility[]; + } + | { readonly kind: "cline" } + | { readonly kind: "commandcode" } + | { + readonly kind: "stdio-only"; + /** Documented local members; other row members leave write applicability unknown. */ + readonly documentedFields: readonly string[]; + } + | { readonly kind: "unavailable"; readonly reason: string }; + +/** Resolved by the client definition owner, never inferred from display text. */ +export interface ClientRegistrationProfile { + readonly nativeClient: string; + readonly connection: ClientRegistrationConnectionProfile; +} + +export interface ClientRegistrationTarget { + readonly dialect: ClientRegistrationDialect; + readonly profile: ClientRegistrationProfile; +} diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts index f0b70b03cf..935657e74d 100644 --- a/src/application/ClientRegistrationStatus.ts +++ b/src/application/ClientRegistrationStatus.ts @@ -47,12 +47,13 @@ import type { import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { - parseClientRegistration, + parseClientRegistrationPolicy, parseClientRegistrationLauncher, clientRegistrationOwnershipRemediation, setupPreservesDisabledActivation, type ClientRegistration, } from "./ClientRegistration.js"; +import { admitClientStdioConnection } from "./ClientRegistrationConnection.js"; interface ClientRegistrationStatusBase { readonly client: string; @@ -282,10 +283,16 @@ export const readClientRegistrationInspections = async ( format: "zcode_fallback", } : client; - const parsedRegistration = parseClientRegistration(raw, effectiveClient); + const parsedRegistration = parseClientRegistrationPolicy( + raw, + effectiveClient, + ); if (!parsedRegistration.ok) { // Invalid readiness settings do not erase a usable launcher identity. - const launcher = parseClientRegistrationLauncher(raw, effectiveClient); + const launcher = parseClientRegistrationLauncher(raw, { + dialect: parsed.dialect, + profile: client.registrationProfile, + }); const commandIdentity = launcher.ok ? inspectClientRegistrationCommand( [launcher.value.command, ...launcher.value.args], @@ -311,6 +318,24 @@ export const readClientRegistrationInspections = async ( command, currentCommandPath, ); + const nativeApplicability = admitClientStdioConnection(raw, { + dialect: parsed.dialect, + profile: client.registrationProfile, + }); + if ( + !nativeApplicability.ok && + nativeApplicability.error.kind === "invalid" + ) { + record({ + client: client.name, + config_path: configPath, + command: [], + command_identity: commandFacts.identity, + state: "invalid", + remediation: nativeApplicability.error.cause.message, + }); + continue; + } const registrationEnvironment = compactInputSchemaByDefault(client) || client.name === "codex" ? await clientRegistrationEnvironmentDetailsWithBudget( @@ -344,17 +369,19 @@ export const readClientRegistrationInspections = async ( const packageVersionDrift = packageVersion !== undefined && packageVersion !== PRODUCT_IDENTITY.packageVersion; - const alignment = registrationAligned({ - registration, - client: effectiveClient, - commandFacts, - inputSchemaProfileSelected: - registrationEnvironment?.ok === true && - snapshotEnvironment( - registrationEnvironment.value.values, - options.platform, - ).REA_MCP_INPUT_SCHEMA_PROFILE !== undefined, - }); + const alignment = !nativeApplicability.ok + ? "unknown" + : registrationAligned({ + registration, + client: effectiveClient, + commandFacts, + inputSchemaProfileSelected: + registrationEnvironment?.ok === true && + snapshotEnvironment( + registrationEnvironment.value.values, + options.platform, + ).REA_MCP_INPUT_SCHEMA_PROFILE !== undefined, + }); record( configuredStatus( effectiveClient, @@ -375,6 +402,9 @@ export const readClientRegistrationInspections = async ( configPath, ]) : undefined) ?? + (!nativeApplicability.ok + ? nativeApplicability.error.cause.message + : undefined) ?? policyBlock ?? (packageVersionDrift ? packageVersionRemediation(packageReference ?? "", client) @@ -509,12 +539,8 @@ const registrationAligned = ({ return "stale"; if (compactInputSchemaByDefault(client) && !inputSchemaProfileSelected) return "stale"; - // A Harness registration only connects as the rea stdio server; another - // server name or transport leaves the session without REA tools. - if ( - client.format === "dsh" && - (registration.serverName !== "rea" || registration.transport !== "stdio") - ) + // Native transport is admitted separately; the Harness route must name REA. + if (client.format === "dsh" && registration.serverName !== "rea") return "stale"; // Legacy package runners remain owned, but setup migrates them to the selected installation. if (commandFacts.package !== undefined) return "stale"; diff --git a/src/application/GooseConfigurationYaml.test.ts b/src/application/GooseConfigurationYaml.test.ts new file mode 100644 index 0000000000..96a9356acc --- /dev/null +++ b/src/application/GooseConfigurationYaml.test.ts @@ -0,0 +1,343 @@ +import { describe, expect, it } from "vitest"; +import { parseDocument } from "yaml"; + +import { ConfigurationError } from "../domain/configurationErrors.js"; +import { + editGooseConfigurationYaml, + GooseConfigurationYamlError, + parseGooseConfigurationYaml, + stringifyGooseConfigurationYaml, +} from "./GooseConfigurationYaml.js"; + +const extension = { + name: "rea", + cmd: "/fixture/server path", + args: ["", "--mcp", "a'b"], + envs: { MODE: "off" }, + type: "stdio", + enabled: true, + timeout: 300, +}; + +describe("Goose YAML 1.2 source facts", () => { + it("keeps native strings and exact large integer facts distinct", () => { + const source = + "on: off\ndate: 2026-11-11\nleading: 0123\nlarge: 9007199254740993\nbool: true\nnil: null\nquoted: 'true'\n"; + expect(parseGooseConfigurationYaml(source)).toEqual({ + on: "off", + date: "2026-11-11", + leading: "0123", + large: 9007199254740993n, + bool: true, + nil: null, + quoted: "true", + }); + const updated = editGooseConfigurationYaml( + source, + ["extensions", "rea"], + extension, + ); + expect(updated).toContain("large: 9007199254740993"); + expect(updated).toContain("leading: 0123"); + expect(parseGooseConfigurationYaml(updated)).toMatchObject({ + leading: "0123", + large: 9007199254740993n, + }); + }); + + it("uses core scalar and literal merge-key facts even with a YAML 1.1 directive", () => { + const source = + "%YAML 1.1\n---\ndefaults: &defaults {MODE: off}\nselected: {<<: *defaults}\n"; + const original = { + defaults: { MODE: "off" }, + selected: { "<<": { MODE: "off" } }, + }; + expect(parseGooseConfigurationYaml(source)).toEqual(original); + expect( + parseGooseConfigurationYaml( + editGooseConfigurationYaml(source, ["extensions", "rea"], extension), + ), + ).toEqual({ ...original, extensions: { rea: extension } }); + }); + + it.each(["", "# settings note\n", "---\n# settings note\n"])( + "admits an unwritten mapping root and keeps its comments: %j", + (source) => { + expect(parseGooseConfigurationYaml(source)).toEqual({}); + const updated = editGooseConfigurationYaml( + source, + ["extensions", "rea"], + extension, + ); + expect(parseGooseConfigurationYaml(updated)).toEqual({ + extensions: { rea: extension }, + }); + if (source.includes("settings note")) + expect(updated).toContain("# settings note"); + }, + ); + + it.each([ + "null\n", + "false\n", + "[]\n", + "extensions: null\n", + "extensions: []\n", + ])( + "refuses a source shape that cannot hold the selected extension: %j", + (source) => { + expect(() => + editGooseConfigurationYaml(source, ["extensions", "rea"], extension), + ).toThrow(ConfigurationError); + }, + ); + + it.each([ + [ + "extensions:\n rea: {cmd: first}\n rea: {cmd: second}\n", + ["extensions", "rea"], + ], + ["extensions: {}\nextensions: {}\n", ["extensions"]], + [ + "extensions:\n rea:\n cmd: first\n cmd: second\n", + ["extensions", "rea", "cmd"], + ], + ] as const)( + "reports duplicate source members with their owned key path", + (source, path) => { + let failure: unknown; + try { + parseGooseConfigurationYaml(source); + } catch (cause: unknown) { + failure = cause; + } + expect(failure).toBeInstanceOf(GooseConfigurationYamlError); + expect(failure).toMatchObject({ kind: "shadowed_member", path }); + expect(() => + editGooseConfigurationYaml(source, ["extensions", "rea"], extension), + ).toThrow(GooseConfigurationYamlError); + }, + ); + + it.each([ + "extensions: [\n", + "custom: !unknown tag\n", + "extensions: *absent\n", + ])( + "retains malformed or unresolved YAML as a typed refusal: %j", + (source) => { + expect(() => parseGooseConfigurationYaml(source)).toThrow( + ConfigurationError, + ); + expect(() => + editGooseConfigurationYaml(source, ["extensions", "rea"], extension), + ).toThrow(ConfigurationError); + }, + ); + it.each([ + "value: &loop {self: *loop}\n", + "extensions: &loop {rea: {args: [*loop]}}\n", + ])( + "refuses cyclic source aliases before producing a recursive configuration value", + (source) => { + expect(() => parseGooseConfigurationYaml(source)).toThrow( + ConfigurationError, + ); + expect(() => + editGooseConfigurationYaml(source, ["extensions", "rea"], extension), + ).toThrow(ConfigurationError); + }, + ); +}); + +describe("Goose extension edits", () => { + it("retains comments, unrelated settings and exact cmd/args/envs", () => { + const source = + "# config note\nprovider: fixture # provider note\nprompt: |\n keep this long prompt and formatting\nextensions:\n other: {cmd: peer, args: [off]} # peer note\n rea: # registration note\n cmd: old # executable note\n args: # arguments note\n - old # argument note\n envs: # environment note\n MODE: old # mode note\n"; + const updated = editGooseConfigurationYaml( + source, + ["extensions", "rea"], + extension, + ); + expect(parseGooseConfigurationYaml(updated)).toEqual({ + provider: "fixture", + prompt: "keep this long prompt and formatting\n", + extensions: { other: { cmd: "peer", args: ["off"] }, rea: extension }, + }); + for (const note of [ + "config note", + "provider note", + "peer note", + "registration note", + "executable note", + "arguments note", + "argument note", + "environment note", + "mode note", + ]) + expect(updated.split(note)).toHaveLength(2); + expect(updated).toContain( + "prompt: |\n keep this long prompt and formatting\n", + ); + expect(parseDocument(updated, { schema: "core" }).errors).toEqual([]); + }); + + it("moves a unique argument's comment with its value without duplicating removed annotations", () => { + const source = + "extensions:\n rea:\n cmd: old\n args:\n - --mcp # protocol\n - dropped # removed\n"; + const updated = editGooseConfigurationYaml(source, ["extensions", "rea"], { + cmd: "new", + args: ["", "--mcp"], + }); + expect(updated).toMatch(/- --mcp # protocol/); + expect(updated.split("# removed")).toHaveLength(2); + expect(parseGooseConfigurationYaml(updated)).toEqual({ + extensions: { rea: { cmd: "new", args: ["", "--mcp"] } }, + }); + }); + + it.each([undefined, extension])( + "preserves surviving consumers of a replaced anchored entry: %j", + (value) => { + const source = + "defaults: &defaults {MODE: off}\nextensions:\n rea: &server\n cmd: old\n args: [--old]\n envs: *defaults\n other: *server # independent peer\nobserver: *defaults\n"; + const updated = editGooseConfigurationYaml( + source, + ["extensions", "rea"], + value, + ); + expect(parseGooseConfigurationYaml(updated)).toEqual({ + defaults: { MODE: "off" }, + extensions: { + ...(value === undefined ? {} : { rea: value }), + other: { cmd: "old", args: ["--old"], envs: { MODE: "off" } }, + }, + observer: { MODE: "off" }, + }); + expect(updated).toContain("# independent peer"); + }, + ); + + it("edits an aliased extensions table without mutating its original owner", () => { + const source = + "defaults: &servers\n peer: {cmd: old}\nextensions: *servers # extension table\n"; + const updated = editGooseConfigurationYaml( + source, + ["extensions", "rea"], + extension, + ); + expect(parseGooseConfigurationYaml(updated)).toEqual({ + defaults: { peer: { cmd: "old" } }, + extensions: { peer: { cmd: "old" }, rea: extension }, + }); + expect(updated).toContain("# extension table"); + }); + + it("deleting a missing extension leaves the original document intact", () => { + const source = "# exact original\nprovider: fixture\n"; + expect( + editGooseConfigurationYaml(source, ["extensions", "rea"], undefined), + ).toBe(source); + }); + + it("serializes fresh values with a YAML 1.2 scalar round trip", () => { + const document = { + provider: "off", + extensions: { rea: extension }, + date: "2026-11-11", + large: 9007199254740993n, + }; + const text = stringifyGooseConfigurationYaml(document); + expect(parseGooseConfigurationYaml(text)).toEqual(document); + expect( + parseDocument(text, { schema: "core", intAsBigInt: true }).errors, + ).toEqual([]); + }); +}); + +describe("pinned Goose serde_yaml scalar resolution", () => { + it.each([ + ["0123", "0123"], + ["-0123", "-0123"], + ["+0123", "+0123"], + ["00", "00"], + ["0", 0], + ["+0", 0], + ["-0", 0], + ["-12", -12], + ["0b11", 3], + ["-0b11", -3], + ["+0b11", 3], + ["0o17", 15], + ["-0o17", -15], + ["+0o17", 15], + ["0xAf", 175], + ["-0xAf", -175], + ["+0xAf", 175], + ["0XAF", "0XAF"], + ["1_000", "1_000"], + ["0b1_0", "0b1_0"], + ["0123.0", 123], + ["0123e2", 12300], + [".5", 0.5], + ["1.", 1], + ["-1e-3", -0.001], + ["1e309", "1e309"], + ["-1e309", "-1e309"], + [".inf", Infinity], + ["-.INF", -Infinity], + ["+.Inf", Infinity], + [".nan", NaN], + ["+.nan", "+.nan"], + ["on", "on"], + ["off", "off"], + ["true", true], + ["FALSE", false], + ["2026-11-11", "2026-11-11"], + ["9223372036854775807", 9223372036854775807n], + ["-9223372036854775808", -9223372036854775808n], + ["18446744073709551615", 18446744073709551615n], + [ + "340282366920938463463374607431768211456", + Number("340282366920938463463374607431768211456"), + ], + ["9".repeat(400), "9".repeat(400)], + ["!!int +0b11", 3], + ["!!float 0123", 123], + ["!!float 1", 1], + ["!!int |-\n 12", 12], + ["!!float |-\n 1.5", 1.5], + ["!!bool TRUE", true], + ["!!null NULL", null], + ["'0123'", "0123"], + ["!!str 0b11", "0b11"], + ])( + "admits native scalar %s without changing its fact", + (source, expected) => { + expect(parseGooseConfigurationYaml(`value: ${source}\n`)).toEqual({ + value: expected, + }); + }, + ); + + it.each([ + "!!int 0123", + "!!int -0123", + "!!bool on", + '!!null ""', + "!!int |\n 12\n", + "!!float |\n 1.5\n", + "!!int 1_000", + "!!int 0XAF", + "!!float 1e309", + "!!float 0xAf", + "!!int 18446744073709551616", + "18446744073709551616", + "-9223372036854775809", + ])( + "rejects scalar %s that the native Value visitor cannot admit", + (source) => { + expect(() => parseGooseConfigurationYaml(`value: ${source}\n`)).toThrow(); + }, + ); +}); diff --git a/src/application/GooseConfigurationYaml.ts b/src/application/GooseConfigurationYaml.ts new file mode 100644 index 0000000000..005d5df9e6 --- /dev/null +++ b/src/application/GooseConfigurationYaml.ts @@ -0,0 +1,291 @@ +import { + Document, + isAlias, + isMap, + isScalar, + isSeq, + parseDocument, + type Tags, + type ScalarTag, +} from "yaml"; + +import { ConfigurationError } from "../domain/configurationErrors.js"; +import { + copyYamlAliasSource, + preserveYamlAliasValues, +} from "./YamlConfigurationAliases.js"; +import { + retainYamlConfigurationSource, + retainYamlScalarSource, +} from "./YamlConfigurationSource.js"; + +// Goose 1.59.0 loads Mapping/Value through serde_yaml 0.9.34. Its +// scalar resolver differs from the YAML core schema (notably 0123 and 0b11). +// https://github.com/dtolnay/serde-yaml/blob/2009506d33767dfc88e979d6bc0d53d09f941c94/src/de.rs#L858-L1150 +const nativeIntegerSyntax = + /^[+-]?(?:0|[1-9][0-9]*|0b[01]+|0o[0-7]+|0x[0-9a-fA-F]+)$/; +const leadingZeroDigits = /^[+-]?0[0-9]+$/; +const finiteFloatSyntax = + /^[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$/; +const nativeFloatSyntax = + /^(?![+-]?0[0-9]+$)(?:[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?|[+-]?\.(?:inf|Inf|INF)|\.(?:nan|NaN|NAN))$/; +const minimumI64 = -(1n << 63n); +const maximumU64 = (1n << 64n) - 1n; +const minimumI128 = -(1n << 127n); +const maximumU128 = (1n << 128n) - 1n; + +/** Undefined means visit_int falls through; Value does not accept i128/u128. */ +const nativeGooseInteger = (source: string): bigint | undefined => { + if (!nativeIntegerSyntax.test(source)) return undefined; + const negative = source.startsWith("-"); + const unsigned = source.replace(/^[+-]/, ""); + const prefix = /^0[box]/.exec(unsigned)?.[0] ?? ""; + const radix = + prefix === "0b" ? 2 : prefix === "0o" ? 8 : prefix === "0x" ? 16 : 10; + const digits = unsigned.slice(prefix.length).replace(/^0+/, "") || "0"; + const maximumMagnitude = negative ? -minimumI128 : maximumU128; + // Rust's bounded integer readers fall through before a wider allocation. + if (digits.length > maximumMagnitude.toString(radix).length) return undefined; + const magnitude = BigInt(`${prefix}${digits}`); + const value = negative ? -magnitude : magnitude; + if (value < minimumI128 || value > maximumU128) return undefined; + if (value < minimumI64 || value > maximumU64) + throw new TypeError( + "Goose serde_yaml Value cannot represent an i128/u128 integer", + ); + return value; +}; + +const nativeGooseFloat = (source: string): number | undefined => { + if (/^[+]?\.(?:inf|Inf|INF)$/.test(source)) return Infinity; + if (/^-\.(?:inf|Inf|INF)$/.test(source)) return -Infinity; + if (/^\.(?:nan|NaN|NAN)$/.test(source)) return NaN; + if (!finiteFloatSyntax.test(source)) return undefined; + const value = Number(source); + return Number.isFinite(value) ? value : undefined; +}; + +const implicitGooseNumber = (source: string): bigint | number | string => { + const integer = nativeGooseInteger(source); + if (integer !== undefined) return integer; + if (leadingZeroDigits.test(source)) return source; + return nativeGooseFloat(source) ?? source; +}; + +const gooseScalarTags = (tags: Tags): Tags => { + const scalar = (name: string): ScalarTag => { + const tag = tags.find( + (candidate): candidate is ScalarTag => + typeof candidate !== "string" && + candidate.collection === undefined && + candidate.tag === `tag:yaml.org,2002:${name}` && + candidate.format === undefined, + ); + if (tag === undefined) + throw new Error(`Missing YAML core ${name} scalar tag`); + return tag; + }; + const integer = scalar("int"); + const float = scalar("float"); + const explicit = ( + tag: ScalarTag, + resolve: (source: string) => unknown, + ): ScalarTag => { + const { test: _test, ...rest } = tag; + return { ...rest, default: false, resolve }; + }; + return [ + ...tags.filter( + (tag) => + typeof tag === "string" || + (tag.tag !== integer.tag && tag.tag !== float.tag), + ), + retainYamlScalarSource({ + ...integer, + test: nativeIntegerSyntax, + resolve: implicitGooseNumber, + }), + retainYamlScalarSource({ + ...float, + test: nativeFloatSyntax, + resolve: implicitGooseNumber, + }), + explicit(integer, (source) => { + const value = nativeGooseInteger(source); + if (value === undefined) + throw new TypeError("Invalid Goose integer scalar"); + return value; + }), + explicit(float, (source) => { + const value = nativeGooseFloat(source); + if (value === undefined) + throw new TypeError("Invalid Goose float scalar"); + return value; + }), + explicit(scalar("bool"), (source) => { + if (/^(?:true|True|TRUE)$/.test(source)) return true; + if (/^(?:false|False|FALSE)$/.test(source)) return false; + throw new TypeError("Invalid Goose boolean scalar"); + }), + explicit(scalar("null"), (source) => { + if (/^(?:null|Null|NULL|~)$/.test(source)) return null; + throw new TypeError("Invalid Goose null scalar"); + }), + ]; +}; + +const gooseYamlOptions = { + version: "1.2", + schema: "core", + intAsBigInt: true, + logLevel: "error", + customTags: gooseScalarTags, +} as const; + +/** A duplicate YAML member cannot establish one selected configuration value. */ +export class GooseConfigurationYamlError extends ConfigurationError { + readonly kind = "shadowed_member"; + constructor( + readonly path: readonly string[], + cause: unknown, + ) { + super( + `Goose YAML contains a duplicate mapping member at ${path.join(".") || "the document root"}`, + { cause }, + ); + } +} + +const emptyRoot = (root: Document["contents"]): boolean => + root === null || + (isScalar(root) && + root.value === null && + root.source === "" && + root.tag === undefined && + root.anchor === undefined); + +/** Recover a diagnostic key path from the parser-owned duplicate position. */ +const duplicatePath = ( + node: unknown, + offset: number, + path: readonly string[] = [], +): readonly string[] | undefined => { + if (isMap(node)) { + for (const pair of node.items) { + const key = isScalar(pair.key) ? pair.key : undefined; + const next = key === undefined ? path : [...path, String(key.value)]; + if (key?.range?.[0] === offset) return next; + const nested = duplicatePath(pair.value, offset, next); + if (nested !== undefined) return nested; + } + } else if (isSeq(node)) { + for (const [index, item] of node.items.entries()) { + const nested = duplicatePath(item, offset, [...path, String(index)]); + if (nested !== undefined) return nested; + } + } + return undefined; +}; + +const parseGooseDocument = (text: string): Document => { + const document = parseDocument(text, gooseYamlOptions); + const failure = document.errors[0] ?? document.warnings[0]; + if (failure?.code === "DUPLICATE_KEY") + throw new GooseConfigurationYamlError( + duplicatePath(document.contents, failure.pos[0]) ?? [], + failure, + ); + if (failure !== undefined) + throw new ConfigurationError("Invalid or unsupported Goose YAML", { + cause: failure, + }); + return document; +}; + +const gooseDocumentValue = (document: Document): unknown => { + if (emptyRoot(document.contents)) return {}; + try { + return document.toJS({ + reviver: (_key, value) => + typeof value === "bigint" && + value >= BigInt(Number.MIN_SAFE_INTEGER) && + value <= BigInt(Number.MAX_SAFE_INTEGER) + ? Number(value) + : value, + }); + } catch (cause: unknown) { + throw new ConfigurationError("Goose YAML values could not be resolved", { + cause, + }); + } +}; + +/** Parse Goose's native scalar facts without replacing exact 64-bit integers. */ +export const parseGooseConfigurationYaml = (text: string): unknown => + gooseDocumentValue(parseGooseDocument(text)); + +export const stringifyGooseConfigurationYaml = ( + document: Record, +): string => + new Document(document, gooseYamlOptions).toString({ lineWidth: 0 }); + +/** Edit one extension member while retaining unrelated YAML nodes and comments. */ +export const editGooseConfigurationYaml = ( + text: string, + path: readonly string[], + value: unknown, +): string => { + const document = parseGooseDocument(text); + // Resolve against the original graph before editing, including the YAML + // library's native alias-expansion admission. + gooseDocumentValue(document); + const root = document.contents; + if (emptyRoot(root)) { + const map = document.createNode({}); + if (isScalar(root)) + map.commentBefore = [root.commentBefore, root.comment] + .filter((comment) => comment !== undefined) + .join("\n"); + document.contents = map; + } + if (!isMap(document.contents)) + throw new ConfigurationError("Goose configuration root must be a mapping"); + for (let length = 1; length < path.length; length++) { + const parent = path.slice(0, length); + let previous = document.getIn(parent, true); + if (isAlias(previous)) { + previous = copyYamlAliasSource(previous, document); + document.setIn(parent, previous); + } + if (previous === undefined && !document.hasIn(parent)) { + if (value === undefined) return text; + document.setIn(parent, document.createNode({})); + } else if (!isMap(previous)) + throw new ConfigurationError( + `Goose configuration ${parent.join(".")} must be a mapping`, + ); + } + const previous = document.getIn(path, true); + const parent = document.getIn(path.slice(0, -1), true); + const removedKey = + value === undefined && isMap(parent) + ? parent.items.find( + (pair) => isScalar(pair.key) && pair.key.value === path.at(-1), + )?.key + : undefined; + const retained = preserveYamlAliasValues(document, [previous, removedKey]); + if (value === undefined) { + if (!document.deleteIn(path)) return text; + } else + document.setIn( + path, + isMap(previous) || isSeq(previous) + ? retainYamlConfigurationSource( + previous, + document.createNode(value), + retained, + ) + : value, + ); + return document.toString({ lineWidth: 0 }); +}; diff --git a/src/application/HermesConfigurationYaml.ts b/src/application/HermesConfigurationYaml.ts index 68b8a56589..d88437d586 100644 --- a/src/application/HermesConfigurationYaml.ts +++ b/src/application/HermesConfigurationYaml.ts @@ -1,19 +1,24 @@ -import { retainYamlSequenceAnnotations } from "./YamlSequenceAnnotations.js"; import { isAlias, isMap, - isNode, isScalar, isSeq, parseDocument, Document, Schema, type ParsedNode, - type Node, type ScalarTag, type Tags, } from "yaml"; -import { preserveHermesAliasValues } from "./HermesConfigurationAliases.js"; +import { + preserveYamlAliasValues, + copyYamlAliasSource, +} from "./YamlConfigurationAliases.js"; +import { + retainYamlConfigurationSource, + retainYamlScalarSource, + yamlNumericValuesEqual, +} from "./YamlConfigurationSource.js"; const SOURCE_SCALAR_TAGS = new Set([ "tag:yaml.org,2002:int", @@ -162,65 +167,6 @@ const numericValue = (value: unknown): bigint | number | undefined => { return undefined; }; -const numericValuesEqual = ( - left: bigint | number, - right: bigint | number, -): boolean => { - if (typeof left === "bigint" && typeof right === "bigint") - return left === right; - if (typeof left === "number" && typeof right === "number") - return left === right || (Number.isNaN(left) && Number.isNaN(right)); - const integer = typeof left === "bigint" ? left : right; - const number = typeof left === "number" ? left : right; - return ( - Number.isFinite(number) && - Number.isInteger(number) && - BigInt(number) === integer - ); -}; - -const sameResolvedScalarValue = (left: unknown, right: unknown): boolean => - Object.is(left, right) || - (left instanceof Date && - right instanceof Date && - left.getTime() === right.getTime()); - -/** - * Write a parsed scalar with its source spelling while that spelling still - * reads as the node's value. Hermes reads config.yaml as YAML 1.1 with exact - * integers, so re-printing a JavaScript number changes what it reads: a - * Discord channel ID loses its low digits, `0600` (octal) becomes `600`, and - * `0.0000001` becomes `1e-7`, which YAML 1.1 does not define as a float. - * Timestamp source also retains precision and offsets absent from JS Dates. - */ -const keepScalarSource = (tag: Tags[number]): Tags[number] => { - if (typeof tag === "string" || tag.collection !== undefined) return tag; - const { stringify } = tag; - if (!SOURCE_SCALAR_TAGS.has(tag.tag) || stringify === undefined) return tag; - return { - ...tag, - stringify: (item, context, ...rest) => { - const { source } = item; - if (source === undefined) return stringify(item, context, ...rest); - const reader = context.doc.schema.tags.find( - (candidate): candidate is ScalarTag => - candidate.default === true && candidate.test?.test(source) === true, - ); - const read = reader?.resolve( - source, - () => undefined, - context.doc.options, - ); - return sameResolvedScalarValue( - isScalar(read) ? read.value : read, - item.value, - ) - ? source - : stringify(item, context, ...rest); - }, - }; -}; - const createHermesYamlOptions = () => { const timestamps = new Map(); const timestampFacts = (source: string): HermesTimestampFacts | undefined => { @@ -268,7 +214,7 @@ const createHermesYamlOptions = () => { const right = hermesKey(rightNode); if (left.kind !== right.kind) return false; if (left.kind === "numeric" && right.kind === "numeric") - return numericValuesEqual(left.value, right.value); + return yamlNumericValuesEqual(left.value, right.value); return left.value === right.value; }; return { @@ -289,7 +235,11 @@ const createHermesYamlOptions = () => { : tag, ) : [...tags, hermesTimestampTag]; - return withHermesTimestamp.map(keepScalarSource); + return withHermesTimestamp.map((tag) => + typeof tag !== "string" && SOURCE_SCALAR_TAGS.has(tag.tag) + ? retainYamlScalarSource(tag) + : tag, + ); }, uniqueKeys: hermesKeysEqual, }; @@ -331,119 +281,6 @@ export const parseHermesConfigurationYaml = (text: string): unknown => { }); }; -/** Keep source annotations and unchanged scalars when updating a value. */ -const retainHermesSource = ( - previous: unknown, - next: Node, - retainedSources: ReadonlySet, -): Node => { - if ( - isScalar(previous) && - isScalar(next) && - (previous.value === next.value || - (typeof previous.value === "bigint" && - typeof next.value === "number" && - numericValuesEqual(previous.value, next.value))) - ) { - if (!retainedSources.has(previous)) return previous; - const copied = previous.clone(); - if (!isScalar(copied)) throw new TypeError("Invalid Hermes scalar copy"); - delete copied.anchor; - delete copied.comment; - delete copied.commentBefore; - delete copied.spaceBefore; - return copied; - } - // An anchor moved to a surviving consumer owns its original comments. - // Otherwise retain annotations on the replacement without retaining the - // old anchor, tag, or scalar spelling for the changed value. - if (isNode(previous) && !retainedSources.has(previous)) { - if (previous.comment !== undefined) next.comment = previous.comment; - if (previous.commentBefore !== undefined) - next.commentBefore = previous.commentBefore; - if (previous.spaceBefore !== undefined) - next.spaceBefore = previous.spaceBefore; - } - if (isMap(previous) && isMap(next)) { - const entries = new Map( - previous.items.flatMap((pair) => - isScalar(pair.key) && typeof pair.key.value === "string" - ? [[pair.key.value, pair] as const] - : [], - ), - ); - for (const pair of next.items) { - const key = isScalar(pair.key) ? pair.key.value : undefined; - const prior = typeof key === "string" ? entries.get(key) : undefined; - if (prior === undefined) continue; - pair.key = isNode(pair.key) - ? retainHermesSource(prior.key, pair.key, retainedSources) - : prior.key; - if (isNode(pair.value)) - pair.value = retainHermesSource( - prior.value, - pair.value, - retainedSources, - ); - } - } else if (isSeq(previous) && isSeq(next)) { - const stringItems = (items: readonly unknown[]) => - items.every((item) => isScalar(item) && typeof item.value === "string"); - if (stringItems(previous.items) && stringItems(next.items)) - retainYamlSequenceAnnotations( - previous, - next, - (item) => (isScalar(item) ? item.value : undefined), - (source, target) => retainHermesSource(source, target, retainedSources), - (source) => !retainedSources.has(source), - ); - else - next.items = next.items.map((item, index) => - isNode(item) - ? retainHermesSource(previous.items[index], item, retainedSources) - : item, - ); - } - return next; -}; - -/** Copy original bindings so moved aliases cannot bind to later anchors. */ -const copyHermesAliasSource = ( - source: unknown, - document: Document, - visiting = new Set(), -): unknown => { - const node = isAlias(source) ? source.resolve(document) : source; - if (isAlias(source) && node === undefined) - throw new TypeError("Cannot edit an unresolved Hermes YAML alias"); - if (!isNode(node)) return node; - if (visiting.has(node)) - throw new TypeError("Cannot edit cyclic Hermes YAML aliases"); - visiting.add(node); - const copied = node.clone(); - if (isScalar(copied) || isMap(copied) || isSeq(copied)) delete copied.anchor; - if (isSeq(node) && isSeq(copied)) - copied.items = node.items.map((item) => - copyHermesAliasSource(item, document, visiting), - ); - else if (isMap(node) && isMap(copied)) - copied.items.forEach((pair, index) => { - const original = node.items[index]; - if (original === undefined) return; - pair.key = copyHermesAliasSource(original.key, document, visiting); - pair.value = copyHermesAliasSource(original.value, document, visiting); - }); - if (isAlias(source)) { - delete copied.commentBefore; - delete copied.comment; - if (source.commentBefore !== undefined) - copied.commentBefore = source.commentBefore; - if (source.comment !== undefined) copied.comment = source.comment; - } - visiting.delete(node); - return copied; -}; - /** Set or delete one value in a Hermes `config.yaml`, keeping the rest as written. */ export const editHermesConfigurationYaml = ( text: string, @@ -465,7 +302,7 @@ export const editHermesConfigurationYaml = ( const parent = path.slice(0, length); const previous = document.getIn(parent, true); if (isAlias(previous)) { - const copied = copyHermesAliasSource(previous, document); + const copied = copyYamlAliasSource(previous, document); if (isMap(copied)) { // Edit a private mapping, keeping the alias source and its consumers. document.setIn(parent, copied); @@ -484,7 +321,7 @@ export const editHermesConfigurationYaml = ( (pair) => isScalar(pair.key) && pair.key.value === path.at(-1), )?.key : undefined; - const retainedSources = preserveHermesAliasValues(document, [ + const retainedSources = preserveYamlAliasValues(document, [ previous, removedKey, ]); @@ -496,7 +333,7 @@ export const editHermesConfigurationYaml = ( document.setIn( path, isMap(previous) || isSeq(previous) - ? retainHermesSource( + ? retainYamlConfigurationSource( previous, document.createNode(value), retainedSources, diff --git a/src/application/McpClientRegistration.formats.test.ts b/src/application/McpClientRegistration.formats.test.ts new file mode 100644 index 0000000000..e70ca0fa5e --- /dev/null +++ b/src/application/McpClientRegistration.formats.test.ts @@ -0,0 +1,502 @@ +import { readFile, stat, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { registerMcpClient } from "./McpClientRegistration.js"; +import { parseGooseConfigurationYaml } from "./GooseConfigurationYaml.js"; +import { clientConfigurationRecordSchema } from "./ClientConfigurationDocument.js"; +import { + mcpClientRegistrationProfile, + type McpRegistrationClient, +} from "./McpRegistrationCatalog.js"; + +let resources: ArtifactResourceScope; +beforeEach(() => { + resources = new ArtifactResourceScope(); +}); +afterEach(async () => { + await resources.close(); +}); +const launcher = { + command: "/fixture/server path", + args: ["", "a'b", "--mcp"], +}; +const clientFor = ( + path: string, + format: McpRegistrationClient["format"], + id: McpRegistrationClient["id"], +): McpRegistrationClient => ({ + id, + displayName: id, + registrationProfile: mcpClientRegistrationProfile(id), + configPath: path, + format, + scope: "global", +}); +const settingsFixture = async (original: string) => { + const directory = await createTestTempDirectory("rea-native-registration-"); + const path = join(directory, "settings.json"); + await writeFile(path, original); + return { directory, path }; +}; + +it.each([ + { + format: "json", + id: "cursor", + text: '{"mcpServers":{"rea":{"url":"https://fixture.invalid","type":"http","env":{"KEEP":"1"},"enabled":false}}}', + expected: { + type: undefined, + command: launcher.command, + args: launcher.args, + env: { KEEP: "1" }, + enabled: false, + }, + }, + { + format: "fx", + id: "fx", + text: '{"mcp":{"rea":{"url":"https://fixture.invalid","type":"http","environment":{"KEEP":"1"},"enabled":false}}}', + expected: { + type: "local", + command: [launcher.command, ...launcher.args], + environment: { KEEP: "1" }, + enabled: false, + }, + }, + { + format: "kilo", + id: "kilo-code", + text: '{"mcp":{"rea":{"url":"https://fixture.invalid","type":"remote","environment":{"KEEP":"1"},"enabled":false}}}', + expected: { + type: "local", + command: [launcher.command, ...launcher.args], + environment: { KEEP: "1" }, + enabled: false, + }, + }, + { + format: "zed", + id: "zed", + text: '{"context_servers":{"rea":{"url":"https://fixture.invalid","type":"http","env":{"KEEP":"1"},"enabled":false}}}', + expected: { + source: undefined, + type: "http", + command: launcher.command, + args: launcher.args, + env: { KEEP: "1" }, + enabled: false, + }, + }, +] as const)( + "publishes native $format launcher facts without retaining obsolete remote selection", + async ({ format, id, text, expected }) => { + const { path } = await settingsFixture(text); + const result = await registerMcpClient( + clientFor(path, format, id), + launcher, + undefined, + resources, + ); + expect(result.ok ? result.value : result.error).toMatchObject({ + status: "configured", + }); + const document = JSON.parse(await readFile(path, "utf8")); + const key = + format === "json" + ? "mcpServers" + : format === "zed" + ? "context_servers" + : "mcp"; + const entry = document[key].rea; + expect(entry).not.toHaveProperty("url"); + for (const [key, value] of Object.entries(expected)) { + if (value === undefined) expect(entry).not.toHaveProperty(key); + else expect(entry[key]).toEqual(value); + } + }, +); + +it("secures fx permissions on owned directory and staged file, including a semantic no-op", async () => { + const { path, directory } = await settingsFixture( + '{"mcp":{"rea":{"type":"local","command":["/fixture/server path","","a\'b","--mcp"],"enabled":true}}}', + ); + const { chmod } = await import("node:fs/promises"); + await chmod(directory, 0o755); + await chmod(path, 0o644); + const client = { + ...clientFor(path, "fx", "fx"), + privateProfile: true, + } as const; + const result = await registerMcpClient( + client, + launcher, + undefined, + resources, + ); + expect(result).toMatchObject({ + ok: true, + value: { + status: "configured", + permissions: { + kind: "verified_posix_modes", + directoryMode: 0o700, + fileMode: 0o600, + }, + }, + }); + expect((await stat(directory)).mode & 0o777).toBe(0o700); + expect((await stat(path)).mode & 0o777).toBe(0o600); + const before = await stat(path); + expect( + await registerMcpClient(client, launcher, undefined, resources), + ).toMatchObject({ + ok: true, + value: { status: "unchanged" }, + }); + expect((await stat(path)).ino).toBe(before.ino); +}); + +it.each([ + { + name: "V1 explicit disable", + text: '{"mcp":{"rea":{"type":"local","command":["old"],"enabled":false,"environment":{"KEEP":"1"}}}}', + native: false, + }, + { + name: "V1 disabled flag without enabled override", + text: '{"mcp":{"rea":{"type":"local","command":["old"],"disabled":true,"environment":{"KEEP":"1"}}}}', + native: false, + disabledOnly: true, + }, + { + name: "native V2 explicit disable", + text: '{"mcp":{"servers":{"rea":{"type":"local","command":["old"],"disabled":true,"environment":{"KEEP":"1"}}}}}', + native: true, + }, + { + name: "V1 disable migrated into native V2", + text: '{"mcp":{"rea":{"type":"local","command":["old"],"enabled":false,"environment":{"KEEP":"1"}},"servers":{"peer":{"type":"local","command":["peer"]}}}}', + native: true, + }, +])( + "retains OpenCode activation policy from $name", + async ({ text, native, ...control }) => { + const { path } = await settingsFixture(text); + const result = await registerMcpClient( + clientFor(path, "opencode", "opencode"), + launcher, + undefined, + resources, + ); + expect(result.ok ? result.value : result.error).toMatchObject({ + status: "configured", + }); + const document = JSON.parse(await readFile(path, "utf8")); + const entry = native ? document.mcp.servers.rea : document.mcp.rea; + expect(entry.command).toEqual([launcher.command, ...launcher.args]); + expect(entry.environment).toEqual({ KEEP: "1" }); + if (native) { + expect(entry.disabled).toBe(true); + expect(document.mcp).not.toHaveProperty("rea"); + } else if ("disabledOnly" in control) { + expect(entry.disabled).toBe(true); + expect(entry.enabled).not.toBe(true); + } else expect(entry.enabled).toBe(false); + }, +); + +it.each([ + { name: "empty entry", entry: "{}", enabled: true, envs: {} }, + { + name: "explicit policy", + entry: '{ enabled: false, timeout: 15, envs: { KEEP: "1" } }', + enabled: false, + envs: { KEEP: "1" }, + }, + { + name: "native leading-zero string through the environment alias", + entry: "{ enabled: false, env: { KEEP: 0123 } }", + enabled: false, + envs: { KEEP: "0123" }, + }, +])( + "retains native Goose defaults and explicit policy for $name", + async ({ entry, enabled, envs }) => { + const { path } = await settingsFixture( + `extensions:\n rea: ${entry}\nfeature: on\n`, + ); + expect( + await registerMcpClient( + clientFor(path, "goose", "goose"), + launcher, + undefined, + resources, + ), + ).toMatchObject({ ok: true }); + const document = clientConfigurationRecordSchema.parse( + parseGooseConfigurationYaml(await readFile(path, "utf8")), + ); + expect(document.feature).toBe("on"); + expect(document.extensions).toMatchObject({ + rea: { + name: "rea", + type: "stdio", + cmd: launcher.command, + args: launcher.args, + enabled, + envs, + }, + }); + expect(document.extensions).not.toHaveProperty("rea.env"); + }, +); + +it.runIf(process.platform === "win32")( + "reports native Windows mode requests without claiming ACL verification", + async () => { + const { path } = await settingsFixture("{}"); + const client = { + ...clientFor(path, "fx", "fx"), + privateProfile: true, + } as const; + const result = await registerMcpClient( + client, + launcher, + undefined, + resources, + ); + expect(result).toMatchObject({ + ok: true, + value: { + permissions: { + kind: "requested_windows_modes", + requestedDirectoryMode: 0o700, + requestedFileMode: 0o600, + aclVerification: "unavailable", + }, + }, + }); + const published = await readFile(path, "utf8"); + expect(JSON.parse(published).mcp.rea.command).toEqual([ + launcher.command, + ...launcher.args, + ]); + const before = await stat(path); + expect( + await registerMcpClient(client, launcher, undefined, resources), + ).toMatchObject({ ok: true, value: { status: "unchanged" } }); + expect(await readFile(path, "utf8")).toBe(published); + expect((await stat(path)).ino).toBe(before.ino); + }, +); + +it.each([ + { format: "copilot_cli_project", scope: "project" }, + { format: "copilot_cli", scope: "global" }, +] as const)( + "rewrites admitted $scope Copilot JSONC to strict JSON even when its launcher already matches", + async ({ format, scope }) => { + const entry = { + type: "stdio", + command: launcher.command, + args: launcher.args, + env: { KEEP: "1" }, + tools: ["fixture"], + }; + const original = `{\n// retained settings need native strict JSON\n"mcpServers":{"rea":${JSON.stringify(entry)},},\n}\n`; + const { path } = await settingsFixture(original); + const client = { + ...clientFor(path, format, "github-copilot-cli"), + scope, + } as const; + const first = await registerMcpClient( + client, + launcher, + undefined, + resources, + ); + expect(first).toMatchObject({ ok: true, value: { status: "configured" } }); + expect(JSON.parse(await readFile(path, "utf8"))).toEqual({ + mcpServers: { rea: entry }, + }); + expect(await readFile(`${path}.rea.backup`, "utf8")).toBe(original); + const beforeRepeat = await stat(path); + expect( + await registerMcpClient(client, launcher, undefined, resources), + ).toMatchObject({ ok: true, value: { status: "unchanged" } }); + expect((await stat(path)).ino).toBe(beforeRepeat.ino); + }, +); + +it("retains the private directory close owner during a semantic no-op", async () => { + const { path } = await settingsFixture("{}"); + const client = { + ...clientFor(path, "fx", "fx"), + privateProfile: true, + } as const; + expect( + await registerMcpClient(client, launcher, undefined, resources), + ).toMatchObject({ ok: true }); + const originalClose = OwnedFileHandle.prototype.close; + const close = vi + .spyOn(OwnedFileHandle.prototype, "close") + .mockImplementation(async function (this: OwnedFileHandle) { + if ((await this.handle.stat()).isDirectory()) + throw new Error("fixture private directory close denied"); + return originalClose.call(this); + }); + try { + const result = await registerMcpClient( + client, + launcher, + undefined, + resources, + ); + expect(result).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true }, + }); + expect(resources.hasPending).toBe(true); + await expect(resources.close()).rejects.toBeDefined(); + expect(resources.hasPending).toBe(true); + } finally { + close.mockRestore(); + } + await resources.close(); + expect(resources.hasPending).toBe(false); +}); + +// Fx v0.0.16's profile reader chooses mcp over mcpServers. Its native writer +// keeps the sole existing key, creates mcpServers, and refuses both keys. +it.each(["mcp", "mcpServers"])( + "preserves Fx peers and selected metadata in a sole %s profile table", + async (key) => { + const previous = { + type: "local", + command: ["old", "old-argument"], + environment: { KEEP: "1" }, + enabled: false, + custom: { retained: true }, + }; + const original = { + [key]: { rea: previous, peer: { command: "peer" } }, + theme: "dark", + }; + const { path } = await settingsFixture(JSON.stringify(original)); + const result = await registerMcpClient( + clientFor(path, "fx", "fx"), + launcher, + undefined, + resources, + ); + expect(result).toMatchObject({ ok: true, value: { status: "configured" } }); + const document = JSON.parse(await readFile(path, "utf8")); + expect(document).toEqual({ + ...original, + [key]: { + peer: { command: "peer" }, + rea: { ...previous, command: [launcher.command, ...launcher.args] }, + }, + }); + // Match the independent native reader's selected table, including its peers. + const nativeServers = document.mcp ?? document.mcpServers; + expect(nativeServers.peer).toEqual({ command: "peer" }); + expect(nativeServers.rea.environment).toEqual({ KEEP: "1" }); + expect(nativeServers.rea.enabled).toBe(false); + expect(nativeServers.rea.command).toEqual([ + launcher.command, + ...launcher.args, + ]); + const before = await stat(path); + expect( + await registerMcpClient( + clientFor(path, "fx", "fx"), + launcher, + undefined, + resources, + ), + ).toMatchObject({ ok: true, value: { status: "unchanged" } }); + expect((await stat(path)).ino).toBe(before.ino); + }, +); + +it("creates Fx registrations in the native writer's mcpServers profile table", async () => { + const { path } = await settingsFixture("{}"); + expect( + await registerMcpClient( + clientFor(path, "fx", "fx"), + launcher, + undefined, + resources, + ), + ).toMatchObject({ ok: true }); + expect(JSON.parse(await readFile(path, "utf8"))).toEqual({ + mcpServers: { + rea: { + type: "local", + command: [launcher.command, ...launcher.args], + enabled: true, + }, + }, + }); +}); + +it.each([ + { mcp: {}, mcpServers: {} }, + { + mcp: { legacy: { command: "legacy" } }, + mcpServers: { peer: { command: "peer" } }, + }, +])( + "refuses ambiguous Fx profile tables before backup or publication", + async (document) => { + const original = JSON.stringify(document); + const { path } = await settingsFixture(original); + const result = await registerMcpClient( + clientFor(path, "fx", "fx"), + launcher, + undefined, + resources, + ); + expect(result.ok).toBe(false); + if (result.ok) + throw new Error("Expected ambiguous native profile admission to fail"); + expect(result.error.message).toContain("mcp"); + expect(result.error.message).toContain("mcpServers"); + expect(await readFile(path, "utf8")).toBe(original); + await expect(readFile(`${path}.rea.backup`, "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); + }, +); + +it.each(["mcp", "mcpServers"])( + "rewrites Fx %s JSONC to native JSON even when the launcher already matches", + async (key) => { + const entry = { + type: "local", + command: [launcher.command, ...launcher.args], + enabled: false, + environment: { KEEP: "1" }, + }; + const original = `{\n// retained source needs strict Fx JSON\n"${key}":{"rea":${JSON.stringify(entry)},"peer":{"command":"peer"},},\n"theme":"dark",\n}\n`; + const { path } = await settingsFixture(original); + const client = clientFor(path, "fx", "fx"); + expect( + await registerMcpClient(client, launcher, undefined, resources), + ).toMatchObject({ ok: true, value: { status: "configured" } }); + expect(JSON.parse(await readFile(path, "utf8"))).toEqual({ + [key]: { rea: entry, peer: { command: "peer" } }, + theme: "dark", + }); + expect(await readFile(`${path}.rea.backup`, "utf8")).toBe(original); + const beforeRepeat = await stat(path); + expect( + await registerMcpClient(client, launcher, undefined, resources), + ).toMatchObject({ ok: true, value: { status: "unchanged" } }); + expect((await stat(path)).ino).toBe(beforeRepeat.ino); + }, +); diff --git a/src/application/McpClientRegistration.layers.test.ts b/src/application/McpClientRegistration.layers.test.ts new file mode 100644 index 0000000000..ed7351c817 --- /dev/null +++ b/src/application/McpClientRegistration.layers.test.ts @@ -0,0 +1,321 @@ +import { lstat, mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { afterEach, beforeEach, expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { + effectiveClientServer, + parseClientConfiguration, +} from "./ClientConfigurationDocument.js"; +import { mergeClientConfigurationDocuments } from "./ClientConfigurationFiles.js"; +import { registerMcpClient } from "./McpClientRegistration.js"; +import { + mcpClientRegistrationProfile, + type McpRegistrationClient, +} from "./McpRegistrationCatalog.js"; + +let resources: ArtifactResourceScope; +beforeEach(() => { + resources = new ArtifactResourceScope(); +}); +afterEach(async () => { + await resources.close(); +}); +const launcher = { command: "/fixture/server path", args: ["", "--mcp"] }; +const write = async (path: string, value: unknown): Promise => { + await mkdir(dirname(path), { recursive: true }); + const text = `${JSON.stringify(value)}\n`; + await writeFile(path, text); + return text; +}; +const clientFor = ( + configPath: string, + configPaths: readonly string[], + format: "kilo" | "opencode" = "kilo", +): McpRegistrationClient => ({ + id: format === "kilo" ? "kilo-code" : "opencode", + registrationProfile: mcpClientRegistrationProfile( + format === "kilo" ? "kilo-code" : "opencode", + ), + displayName: format === "kilo" ? "Kilo Code" : "OpenCode", + configPath, + configPaths, + format, + scope: "project", +}); +const effective = ( + texts: readonly string[], + format: "kilo" | "opencode" = "kilo", +) => { + const parsed = mergeClientConfigurationDocuments( + texts.map((text) => parseClientConfiguration(text, format)), + ); + if (parsed === undefined) + throw new Error("Fixture has no configuration documents"); + return effectiveClientServer(parsed, "rea"); +}; + +it("retains lower Kilo disable/environment policy without copying its source into the write target", async () => { + const root = await createTestTempDirectory("rea-registration-layer-"); + const missing = join(root, "absent", "config.json"); + const lower = join(root, "lower", "kilo.json"); + const target = join(root, "project", "kilo.json"); + const lowerText = await write(lower, { + mcp: { + rea: { + type: "local", + command: ["old"], + enabled: false, + environment: { LOWER: "1" }, + custom: { source: "lower" }, + }, + }, + }); + const targetDocument = { + theme: "target", + mcp: { peer: { type: "local", command: ["peer"] } }, + }; + const originalTarget = await write(target, targetDocument); + const client = clientFor(target, [missing, lower, target]); + expect( + await registerMcpClient(client, launcher, undefined, resources), + ).toMatchObject({ ok: true }); + const targetText = await readFile(target, "utf8"); + expect(JSON.parse(targetText)).toEqual({ + ...targetDocument, + mcp: { + ...targetDocument.mcp, + rea: { type: "local", command: [launcher.command, ...launcher.args] }, + }, + }); + expect(effective([lowerText, targetText])).toMatchObject({ + command: [launcher.command, ...launcher.args], + enabled: false, + environment: { LOWER: "1" }, + custom: { source: "lower" }, + }); + expect(await readFile(lower, "utf8")).toBe(lowerText); + expect(await readFile(`${target}.rea.backup`, "utf8")).toBe(originalTarget); + await expect(readFile(`${lower}.rea.backup`, "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); +}); + +it("refuses a higher Kilo file launcher before creating the lower selected target", async () => { + const root = await createTestTempDirectory( + "rea-registration-layer-conflict-", + ); + const target = join(root, "project", "kilo.json"); + const higher = join(root, "outside-project", "kilo.json"); + const higherText = await write(higher, { + mcp: { + rea: { type: "local", command: ["higher-server", "higher-argument"] }, + }, + }); + const result = await registerMcpClient( + clientFor(target, [join(root, "absent", "config.json"), target, higher]), + launcher, + undefined, + resources, + ); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "ConfigurationError" }, + }); + await expect(lstat(target)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(lstat(dirname(target))).rejects.toMatchObject({ + code: "ENOENT", + }); + expect(await readFile(higher, "utf8")).toBe(higherText); + await expect(readFile(`${higher}.rea.backup`, "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); +}); + +it("refuses a conflicting KILO_CONFIG_CONTENT launcher without mutating the inline source or creating a target", async () => { + const root = await createTestTempDirectory( + "rea-registration-inline-conflict-", + ); + const target = join(root, "project", "kilo.json"); + const inline = { + origin: "KILO_CONFIG_CONTENT", + parsed: parseClientConfiguration( + '{"mcp":{"rea":{"type":"local","command":["inline-server"]}}}', + "kilo", + ), + } as const; + const beforeInline = structuredClone(inline); + const result = await registerMcpClient( + { ...clientFor(target, [target]), inlineConfiguration: inline }, + launcher, + undefined, + resources, + ); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "ConfigurationError", + diagnostics: { inline_origin: "KILO_CONFIG_CONTENT" }, + }, + }); + await expect(lstat(target)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(lstat(dirname(target))).rejects.toMatchObject({ + code: "ENOENT", + }); + expect(inline).toEqual(beforeInline); +}); + +it("retains harmless higher file and inline metadata as read-only inherited facts", async () => { + const root = await createTestTempDirectory( + "rea-registration-higher-metadata-", + ); + const target = join(root, "project", "kilo.json"); + const higher = join(root, "outside-project", "kilo.json"); + const own = { + type: "local", + command: ["old"], + enabled: true, + environment: { OWN: "1" }, + custom: { own: true }, + }; + const targetDocument = { + theme: "target", + mcp: { rea: own, peer: { command: ["peer"] } }, + }; + await write(target, targetDocument); + const higherText = await write(higher, { + mcp: { + rea: { + enabled: false, + environment: { HIGHER: "1" }, + custom: { higher: true }, + }, + }, + }); + const inlineText = + '{"mcp":{"rea":{"environment":{"INLINE":"1"},"custom":{"inline":true}}}}'; + const inline = { + origin: "KILO_CONFIG_CONTENT", + parsed: parseClientConfiguration(inlineText, "kilo"), + } as const; + const beforeInline = structuredClone(inline); + const client = { + ...clientFor(target, [target, higher]), + inlineConfiguration: inline, + }; + expect( + await registerMcpClient(client, launcher, undefined, resources), + ).toMatchObject({ ok: true }); + const targetText = await readFile(target, "utf8"); + expect(JSON.parse(targetText)).toEqual({ + ...targetDocument, + mcp: { + ...targetDocument.mcp, + rea: { ...own, command: [launcher.command, ...launcher.args] }, + }, + }); + expect(effective([targetText, higherText, inlineText])).toMatchObject({ + command: [launcher.command, ...launcher.args], + enabled: false, + environment: { OWN: "1", HIGHER: "1", INLINE: "1" }, + custom: { own: true, higher: true, inline: true }, + }); + expect(await readFile(higher, "utf8")).toBe(higherText); + expect(inline).toEqual(beforeInline); +}); + +it("uses the effective OpenCode V2 dialect in its target fragment while preserving lower policy", async () => { + const root = await createTestTempDirectory( + "rea-registration-native-dialect-", + ); + const lower = join(root, "lower", "opencode.json"); + const target = join(root, "project", "opencode.json"); + const lowerText = await write(lower, { + mcp: { + servers: { + rea: { + type: "local", + command: ["old"], + disabled: true, + environment: { LOWER: "1" }, + }, + }, + }, + }); + const peer = { type: "local", command: ["peer"] }; + await write(target, { mcp: { peer } }); + expect( + await registerMcpClient( + clientFor(target, [lower, target], "opencode"), + launcher, + undefined, + resources, + ), + ).toMatchObject({ ok: true }); + const targetText = await readFile(target, "utf8"); + expect(JSON.parse(targetText)).toEqual({ + mcp: { + peer, + servers: { + rea: { type: "local", command: [launcher.command, ...launcher.args] }, + }, + }, + }); + expect(effective([lowerText, targetText], "opencode")).toMatchObject({ + command: [launcher.command, ...launcher.args], + disabled: true, + environment: { LOWER: "1" }, + }); + expect(await readFile(lower, "utf8")).toBe(lowerText); +}); + +it("publishes through the last repeated target layer without copying intervening read-only policy", async () => { + const root = await createTestTempDirectory( + "rea-registration-repeated-layer-", + ); + const target = join(root, "selected", "kilo.json"); + const middle = join(root, "middle", "kilo.json"); + const targetDocument = { + theme: "target", + mcp: { peer: { command: ["peer"] } }, + }; + const originalTarget = await write(target, targetDocument); + const middleText = await write(middle, { + mcp: { + rea: { + type: "local", + command: ["middle-old"], + enabled: false, + environment: { MIDDLE: "1" }, + custom: { readOnly: true }, + }, + }, + }); + const result = await registerMcpClient( + clientFor(target, [target, middle, target]), + launcher, + undefined, + resources, + ); + expect(result).toMatchObject({ ok: true, value: { status: "configured" } }); + const targetText = await readFile(target, "utf8"); + expect(JSON.parse(targetText)).toEqual({ + ...targetDocument, + mcp: { + ...targetDocument.mcp, + rea: { type: "local", command: [launcher.command, ...launcher.args] }, + }, + }); + expect(effective([targetText, middleText, targetText])).toMatchObject({ + command: [launcher.command, ...launcher.args], + enabled: false, + environment: { MIDDLE: "1" }, + custom: { readOnly: true }, + }); + expect(await readFile(middle, "utf8")).toBe(middleText); + expect(await readFile(`${target}.rea.backup`, "utf8")).toBe(originalTarget); + await expect(readFile(`${middle}.rea.backup`, "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); +}); diff --git a/src/application/McpClientRegistration.nativeFields.test.ts b/src/application/McpClientRegistration.nativeFields.test.ts new file mode 100644 index 0000000000..228e4dca84 --- /dev/null +++ b/src/application/McpClientRegistration.nativeFields.test.ts @@ -0,0 +1,221 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { beforeEach, afterEach, expect, it } from "vitest"; +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { clientConfigurationRecordSchema } from "./ClientConfigurationDocument.js"; +import { parseGooseConfigurationYaml } from "./GooseConfigurationYaml.js"; +import { registerMcpClient } from "./McpClientRegistration.js"; +import { + mcpClientRegistrationProfile, + type McpRegistrationClient, +} from "./McpRegistrationCatalog.js"; +let resources: ArtifactResourceScope; +beforeEach(() => { + resources = new ArtifactResourceScope(); +}); +afterEach(async () => { + await resources.close(); +}); +const launcher = { command: "/fixture/server path", args: ["", "--mcp"] }; +const clientFor = ( + path: string, + format: McpRegistrationClient["format"], + id: McpRegistrationClient["id"], +): McpRegistrationClient => ({ + id, + displayName: id, + configPath: path, + scope: "global", + format, + registrationProfile: mcpClientRegistrationProfile(id), +}); +const settingsFixture = async (original: string) => { + const directory = await createTestTempDirectory("rea-native-fields-"); + const path = join(directory, "settings.json"); + await writeFile(path, original); + return { path }; +}; +it.each([ + { + format: "json", + id: "cursor", + text: '{"mcpServers":{"rea":{"command":"old","cmd":"operator-metadata","uri":"local-evidence"}}}', + key: "mcpServers", + metadata: { cmd: "operator-metadata", uri: "local-evidence" }, + }, + { + format: "zed", + id: "zed", + text: '{"context_servers":{"rea":{"command":"old","cmd":"operator-metadata","uri":"local-evidence"}}}', + key: "context_servers", + metadata: { cmd: "operator-metadata", uri: "local-evidence" }, + }, + { + format: "goose", + id: "goose", + text: "extensions:\n rea: {cmd: old, command: operator-metadata}\n", + key: "extensions", + metadata: { command: "operator-metadata" }, + }, +] as const)( + "preserves non-selector $format metadata through native document publication", + async ({ format, id, text, key, metadata }) => { + const { path } = await settingsFixture(text); + const result = await registerMcpClient( + clientFor(path, format, id), + launcher, + undefined, + resources, + ); + expect(result).toMatchObject({ ok: true, value: { status: "configured" } }); + const content = await readFile(path, "utf8"); + const document = clientConfigurationRecordSchema.parse( + format === "goose" + ? parseGooseConfigurationYaml(content) + : JSON.parse(content), + ); + const servers = clientConfigurationRecordSchema.parse(document[key]); + expect(servers.rea).toMatchObject(metadata); + expect(await readFile(`${path}.rea.backup`, "utf8")).toBe(text); + }, +); + +it.each([ + { + id: "gemini-cli", + previous: { + command: "old", + httpUrl: "https://fixture.invalid/http", + url: "https://fixture.invalid/sse", + cmd: "metadata", + uri: "metadata", + }, + removed: ["httpUrl", "url"], + retained: { cmd: "metadata", uri: "metadata" }, + }, + { + id: "antigravity", + previous: { + command: "old", + serverUrl: "https://fixture.invalid", + httpUrl: "operator-metadata", + }, + removed: ["serverUrl"], + retained: { httpUrl: "operator-metadata" }, + }, + { + id: "mcporter", + previous: { + command: "old", + executable: "old-alias", + baseUrl: "https://fixture.invalid/base", + base_url: "https://fixture.invalid/base2", + url: "https://fixture.invalid/url", + serverUrl: "https://fixture.invalid/server", + server_url: "https://fixture.invalid/server2", + uri: "metadata", + }, + removed: [ + "baseUrl", + "base_url", + "url", + "serverUrl", + "server_url", + "executable", + ], + retained: { uri: "metadata" }, + }, +] as const)( + "supersedes $id native remote aliases while preserving ignored metadata", + async ({ id, previous, removed, retained }) => { + const original = JSON.stringify({ mcpServers: { rea: previous } }) + "\n"; + const { path } = await settingsFixture(original); + expect( + await registerMcpClient( + clientFor(path, "json", id), + launcher, + undefined, + resources, + ), + ).toMatchObject({ ok: true }); + const document = JSON.parse(await readFile(path, "utf8")); + expect(document.mcpServers.rea).toMatchObject({ + command: launcher.command, + args: launcher.args, + ...retained, + }); + for (const field of removed) + expect(document.mcpServers.rea).not.toHaveProperty(field); + expect(await readFile(`${path}.rea.backup`, "utf8")).toBe(original); + }, +); + +it.each([ + { + id: "cline", + transport: { + type: "stdio", + command: "old", + args: ["old"], + env: { KEEP: "selected" }, + cwd: "/fixture/selected-directory", + custom: { retained: true }, + }, + preserved: { + env: { KEEP: "selected" }, + cwd: "/fixture/selected-directory", + custom: { retained: true }, + }, + }, + { + id: "cline-cli", + transport: { + type: "streamableHttp", + url: "https://fixture.invalid", + headers: { "X-Selected": "fixture" }, + custom: { retained: true }, + }, + preserved: { + headers: { "X-Selected": "fixture" }, + custom: { retained: true }, + }, + }, +] as const)( + "replaces the selected $id nested transport and retains native launch metadata", + async ({ id, transport, preserved }) => { + const original = + JSON.stringify({ + mcpServers: { + rea: { + transport, + metadata: { operator: true }, + cmd: "top-level-evidence", + uri: "top-level-evidence", + }, + }, + }) + "\n"; + const { path } = await settingsFixture(original); + expect( + await registerMcpClient( + clientFor(path, "json", id), + launcher, + undefined, + resources, + ), + ).toMatchObject({ ok: true }); + const document = JSON.parse(await readFile(path, "utf8")); + expect(document.mcpServers.rea).toEqual({ + transport: { + ...preserved, + type: "stdio", + command: launcher.command, + args: launcher.args, + }, + metadata: { operator: true }, + cmd: "top-level-evidence", + uri: "top-level-evidence", + }); + expect(await readFile(`${path}.rea.backup`, "utf8")).toBe(original); + }, +); diff --git a/src/application/McpClientRegistration.test.ts b/src/application/McpClientRegistration.test.ts new file mode 100644 index 0000000000..5074fcae42 --- /dev/null +++ b/src/application/McpClientRegistration.test.ts @@ -0,0 +1,850 @@ +import { + mkdir, + readFile, + readdir, + rename, + rm, + stat, + symlink, + writeFile, +} from "node:fs/promises"; +import { join, sep } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { ok } from "../domain/result.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +let resources: ArtifactResourceScope; +beforeEach(() => { + resources = new ArtifactResourceScope(); +}); +import { OwnedStagedOutput } from "../filesystem/OwnedStagedOutput.js"; +import { + registerMcpClient, + registerMcpClients, +} from "./McpClientRegistration.js"; +import { + mcpClientRegistrationProfile, + resolveMcpRegistrationClients, + type McpRegistrationClient, +} from "./McpRegistrationCatalog.js"; +import { withClientConfigurationTransaction } from "./ClientConfigurationTransaction.js"; + +const clientFor = ( + path: string, + format: McpRegistrationClient["format"] = "amp", + id: McpRegistrationClient["id"] = "amp", +): McpRegistrationClient => ({ + id, + registrationProfile: mcpClientRegistrationProfile(id), + displayName: id, + configPath: path, + format, + scope: "global", +}); +const register = ( + path: string, + launcher: { command: string; args: readonly string[] }, + signal?: AbortSignal, +) => registerMcpClient(clientFor(path), launcher, signal, resources); +import { ClientConfigurationPublication } from "./ClientConfigurationPublication.js"; +import * as regularReads from "./RegularFileRead.js"; + +const launcher = { + command: "/fixture/server path", + args: ["", "a'b", "--mcp"], +}; + +it("registers Cline CLI in the settings file selected by its environment", async () => { + const home = await createTestTempDirectory("rea-cline-location-"); + const cases = [ + { + environment: { + CLINE_MCP_SETTINGS_PATH: ` ${join(home, "selected.json")} `, + CLINE_DATA_DIR: join(home, "data-ignored"), + CLINE_DIR: join(home, "dir-ignored"), + }, + expected: join(home, "selected.json"), + }, + { + environment: {}, + expected: join( + home, + ".cline", + "data", + "settings", + "cline_mcp_settings.json", + ), + }, + { + environment: { + CLINE_MCP_SETTINGS_PATH: " ", + CLINE_DATA_DIR: ` ${join(home, "custom-data")} `, + CLINE_DIR: join(home, "dir-ignored"), + }, + expected: join( + home, + "custom-data", + "settings", + "cline_mcp_settings.json", + ), + }, + { + environment: { + CLINE_DATA_DIR: " ", + CLINE_DIR: ` ${join(home, "custom-cline")} `, + }, + expected: join( + home, + "custom-cline", + "data", + "settings", + "cline_mcp_settings.json", + ), + }, + { + environment: { + CLINE_MCP_SETTINGS_PATH: " ", + CLINE_DATA_DIR: " ", + CLINE_DIR: " ", + }, + expected: join( + home, + ".cline", + "data", + "settings", + "cline_mcp_settings.json", + ), + unchanged: true, + }, + ]; + for (const { environment, expected, unchanged } of cases) { + const resolved = await resolveMcpRegistrationClients( + { + agents: ["cline-cli"], + global: true, + home, + cwd: home, + platform: process.platform, + environment, + }, + resources, + ); + expect(resolved.ok).toBe(true); + if (!resolved.ok) throw resolved.error; + const client = resolved.value[0]; + expect(client?.configPath).toBe(expected); + if (client === undefined) + throw new Error("Missing selected Cline CLI client"); + expect( + await registerMcpClient(client, launcher, undefined, resources), + ).toEqual(ok({ status: unchanged ? "unchanged" : "configured" })); + expect(JSON.parse(await readFile(expected, "utf8"))).toMatchObject({ + mcpServers: { rea: launcher }, + }); + } +}); + +it("resolves Cline CLI home using its native environment precedence", async () => { + const cases = [ + { + environment: { HOME: " C:\\home ", USERPROFILE: "D:\\profile" }, + expected: "C:\\home\\.cline\\data\\settings\\cline_mcp_settings.json", + }, + { + environment: { HOME: " ~ ", USERPROFILE: " D:\\profile " }, + expected: "D:\\profile\\.cline\\data\\settings\\cline_mcp_settings.json", + }, + { + environment: { + HOME: " ", + USERPROFILE: " ", + HOMEDRIVE: " E: ", + HOMEPATH: " \\users\\drive ", + }, + expected: + "E:\\users\\drive\\.cline\\data\\settings\\cline_mcp_settings.json", + }, + { + environment: { HOME: "relative-home" }, + expected: + "C:\\working\\relative-home\\.cline\\data\\settings\\cline_mcp_settings.json", + }, + ]; + for (const { environment, expected } of cases) { + const resolved = await resolveMcpRegistrationClients( + { + agents: ["cline-cli"], + global: true, + home: "D:\\fallback", + cwd: "C:\\working", + platform: "win32", + environment, + }, + resources, + ); + expect(resolved.ok).toBe(true); + if (!resolved.ok) throw resolved.error; + expect(resolved.value[0]?.configPath).toBe(expected); + } +}); + +it("uses Cline's literal fallback for an empty POSIX HOME and refuses unknown system home", async () => { + const selection = { + agents: ["cline-cli"], + global: true, + home: "/account/home", + cwd: "/work", + platform: "linux" as const, + }; + const empty = await resolveMcpRegistrationClients( + { ...selection, environment: { HOME: "" } }, + resources, + ); + expect(empty.ok).toBe(true); + if (!empty.ok) throw empty.error; + expect(empty.value[0]?.configPath).toBe( + "/work/~/.cline/data/settings/cline_mcp_settings.json", + ); + const unknown = await resolveMcpRegistrationClients( + { ...selection, environment: { USERPROFILE: " " } }, + resources, + ); + expect(unknown.ok).toBe(false); + if (unknown.ok) throw new Error("Expected unknown Cline home to be rejected"); + expect(unknown.error.message).toContain( + "Cannot determine Cline CLI's system home", + ); + for (const environment of [ + { HOMEDRIVE: " ", HOMEPATH: "C:\\wrong" }, + { HOME: "~" }, + ]) { + const windowsUnknown = await resolveMcpRegistrationClients( + { + ...selection, + platform: "win32", + cwd: "C:\\work", + home: "C:\\wrong", + environment, + }, + resources, + ); + expect(windowsUnknown.ok).toBe(false); + } + const override = await resolveMcpRegistrationClients( + { + ...selection, + environment: { USERPROFILE: " ", CLINE_DIR: "/selected/cline" }, + }, + resources, + ); + expect(override.ok).toBe(true); + if (!override.ok) throw override.error; + expect(override.value[0]?.configPath).toBe( + "/selected/cline/data/settings/cline_mcp_settings.json", + ); +}); + +it("selects Goose and Zed native settings paths for their platforms and overrides", async () => { + const cases = [ + { + agent: "goose", + platform: "linux" as const, + environment: { GOOSE_PATH_ROOT: "/isolated", XDG_CONFIG_HOME: "/custom" }, + expected: "/isolated/config/config.yaml", + }, + { + agent: "goose", + platform: "darwin" as const, + environment: { GOOSE_PATH_ROOT: "relative", XDG_CONFIG_HOME: "/custom" }, + expected: "/custom/goose/config.yaml", + }, + { + agent: "goose", + platform: "linux" as const, + environment: { XDG_CONFIG_HOME: "relative" }, + expected: "/home/user/.config/goose/config.yaml", + }, + { + agent: "zed", + platform: "darwin" as const, + environment: { XDG_CONFIG_HOME: "/custom" }, + expected: "/home/user/.config/zed/settings.json", + }, + { + agent: "zed", + platform: "linux" as const, + environment: { XDG_CONFIG_HOME: "/custom" }, + expected: "/custom/zed/settings.json", + }, + ]; + for (const { agent, platform, environment, expected } of cases) { + const resolved = await resolveMcpRegistrationClients( + { + agents: [agent], + global: true, + home: "/home/user", + cwd: "/work", + platform, + environment, + }, + resources, + ); + expect(resolved.ok).toBe(true); + if (!resolved.ok) throw resolved.error; + expect(resolved.value[0]?.configPath).toBe(expected); + } +}); + +it("refuses global Junie registration when known configuration disables or obscures default MCP locations", async () => { + const home = await createTestTempDirectory("rea-junie-selection-"); + await mkdir(join(home, ".junie")); + const config = join(home, ".junie", "config.json"); + const selection = { + agents: ["junie"], + global: true, + home, + cwd: home, + platform: process.platform, + }; + await writeFile(config, JSON.stringify({ "mcp-default-locations": false })); + const disabled = await resolveMcpRegistrationClients( + { ...selection, environment: {} }, + resources, + ); + expect(disabled.ok).toBe(false); + const configDisabled = await resolveMcpRegistrationClients( + { ...selection, environment: { JUNIE_CONFIG_DEFAULT_LOCATIONS: "false" } }, + resources, + ); + expect(configDisabled.ok).toBe(true); + const envDisabled = await resolveMcpRegistrationClients( + { + ...selection, + environment: { + JUNIE_CONFIG_DEFAULT_LOCATIONS: "false", + JUNIE_MCP_DEFAULT_LOCATIONS: "false", + }, + }, + resources, + ); + expect(envDisabled.ok).toBe(false); + const extra = await resolveMcpRegistrationClients( + { + ...selection, + environment: { JUNIE_CONFIG_LOCATION: "/fixture/extra.json" }, + }, + resources, + ); + expect(extra.ok).toBe(false); + await writeFile(config, JSON.stringify({ "mcp-default-locations": true })); + const enabled = await resolveMcpRegistrationClients( + { ...selection, environment: {} }, + resources, + ); + expect(enabled.ok).toBe(true); + await mkdir(join(home, ".cursor")); + await rm(join(home, ".junie"), { recursive: true }); + const automatic = await resolveMcpRegistrationClients( + { + ...selection, + agents: [], + environment: { JUNIE_MCP_DEFAULT_LOCATIONS: "false" }, + }, + resources, + ); + expect(automatic.ok).toBe(true); + if (!automatic.ok) throw automatic.error; + expect(automatic.value.some((client) => client.id === "cursor")).toBe(true); +}); +const settingsFixture = async (original?: string) => { + const directory = await createTestTempDirectory("rea-amp-writer-"); + const path = join(directory, "settings.json"); + if (original !== undefined) await writeFile(path, original); + return { directory, path }; +}; + +afterEach(async () => { + vi.restoreAllMocks(); + await resources.close(); +}); + +describe("Selected argv and client metadata preservation", () => { + it("preserves every peer and entry metadata while publishing exact argv with a private backup", async () => { + const document = { + "amp.mcpServers": { + rea: { + command: "old", + args: ["old"], + env: { KEEP: "value" }, + enabled: false, + custom: [1, null], + }, + other: { url: "http://fixture.invalid", custom: true }, + ["__proto__"]: { metadata: "preserved own key" }, + }, + theme: "dark", + nested: { value: [false, "preserved"] }, + }; + const original = JSON.stringify(document); + const { path, directory } = await settingsFixture(original); + const result = await register(path, launcher); + expect(result).toEqual( + ok({ status: "configured", backupPath: `${path}.rea.backup` }), + ); + expect(JSON.parse(await readFile(path, "utf8"))).toEqual({ + ...document, + "amp.mcpServers": { + ...document["amp.mcpServers"], + rea: { ...document["amp.mcpServers"].rea, ...launcher }, + }, + }); + expect(await readFile(`${path}.rea.backup`, "utf8")).toBe(original); + expect((await stat(`${path}.rea.backup`)).mode & 0o777).toBe(0o600); + expect( + (await readdir(directory)).some((name) => + name.startsWith(".rea-config-"), + ), + ).toBe(false); + const beforeRepeat = await stat(path); + const contents = await readdir(directory); + expect(await register(path, launcher)).toEqual(ok({ status: "unchanged" })); + expect(await stat(path)).toMatchObject({ + ino: beforeRepeat.ino, + mtimeMs: beforeRepeat.mtimeMs, + }); + expect(await readdir(directory)).toEqual(contents); + }); + + it("creates absent parent directories and replaces scalar entries only when explicitly requested", async () => { + const fixture = await settingsFixture(); + const path = join(fixture.directory, "new", "settings.json"); + expect(await register(path, launcher)).toEqual( + ok({ status: "configured" }), + ); + expect(JSON.parse(await readFile(path, "utf8"))).toEqual({ + "amp.mcpServers": { rea: launcher }, + }); + const scalar = '{"amp.mcpServers":{"rea":false,"other":7}}'; + await writeFile(fixture.path, scalar); + expect((await register(fixture.path, launcher)).ok).toBe(true); + expect( + JSON.parse(await readFile(fixture.path, "utf8"))["amp.mcpServers"], + ).toEqual({ rea: launcher, other: 7 }); + expect(await readFile(`${fixture.path}.rea.backup`, "utf8")).toBe(scalar); + }); + + it.each([ + "{", + "null", + "[]", + '{"amp.mcpServers":null}', + '{"amp.mcpServers":[]}', + ])( + "refuses malformed settings %s before backups or writes", + async (original) => { + const { path, directory } = await settingsFixture(original); + const result = await register(path, launcher); + expect(result.ok).toBe(false); + expect(await readFile(path, "utf8")).toBe(original); + expect(await readdir(directory)).toEqual(["settings.json"]); + }, + ); + + it("rejects an empty executable before file writes", async () => { + const { path, directory } = await settingsFixture(); + const result = await register(path, { command: "", args: [] }); + expect(result.ok).toBe(false); + expect(await readdir(directory)).toEqual([]); + }); + + it("retains a first backup and selects a matching recovery snapshot for later edits", async () => { + const { path } = await settingsFixture('{"theme":"first"}'); + await register(path, launcher); + const secondOriginal = await readFile(path, "utf8"); + const result = await register(path, { command: "next", args: [] }); + expect(result.ok && result.value.backupPath).toMatch(/\.recovery$/); + if (!result.ok || result.value.backupPath === undefined) + throw new Error("Missing recovery backup"); + expect(await readFile(result.value.backupPath, "utf8")).toBe( + secondOriginal, + ); + expect(await readFile(`${path}.rea.backup`, "utf8")).toBe( + '{"theme":"first"}', + ); + }); + it("refuses malformed UTF-8 and an occupied backup without replacing source facts", async () => { + const { path, directory } = await settingsFixture(); + const invalid = Buffer.from([0x7b, 0xff, 0x7d]); + await writeFile(path, invalid); + expect((await register(path, launcher)).ok).toBe(false); + expect((await readFile(path)).equals(invalid)).toBe(true); + expect(await readdir(directory)).toEqual(["settings.json"]); + await writeFile(path, "{}"); + await symlink(path, `${path}.rea.backup`); + expect((await register(path, launcher)).ok).toBe(false); + expect(await readFile(path, "utf8")).toBe("{}"); + }); +}); + +describe("Registration cancellation and mixed outcomes", () => { + it("checks cancellation before side effects and after publication settles", async () => { + const { path, directory } = await settingsFixture(); + const before = new AbortController(); + before.abort("before registration"); + expect(await register(path, launcher, before.signal)).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + expect(await readdir(directory)).toEqual([]); + const after = new AbortController(); + const write = ClientConfigurationPublication.write; + vi.spyOn(ClientConfigurationPublication, "write").mockImplementation( + async (...args) => { + const publication = await write(...args); + after.abort("after publication"); + return publication; + }, + ); + const result = await register(path, launcher, after.signal); + expect(result.ok ? undefined : result.error).toMatchObject({ + _tag: "AnalysisCancelledError", + diagnostics: { configured: true }, + }); + expect(JSON.parse(await readFile(path, "utf8"))).toEqual({ + "amp.mcpServers": { rea: launcher }, + }); + expect(await readdir(directory)).toEqual(["settings.json"]); + }); +}); + +describe("Registration cancellation with independent failures", () => { + it("retains a physical readback failure and late cancellation before stopping siblings", async () => { + const first = await settingsFixture("{}"); + const second = await settingsFixture("{}"); + const controller = new AbortController(); + const reason = { operator: "stop after readback" }; + const physical = Object.assign(new Error("readback failed"), { + code: "EIO", + }); + vi.spyOn(regularReads, "readRegularFileText").mockImplementationOnce( + async () => { + controller.abort(reason); + throw physical; + }, + ); + const outcomes = await registerMcpClients( + [clientFor(first.path), clientFor(second.path)], + launcher, + controller.signal, + resources, + ); + const firstOutcome = outcomes[0]; + if (firstOutcome?.status !== "completed" || firstOutcome.result.ok) + throw new Error("Expected a readback failure"); + expect(firstOutcome.result.error.cause).toBe(physical); + expect(firstOutcome.result.error.secondaryFailures).toEqual([ + expect.objectContaining({ + _tag: "AnalysisCancelledError", + cause: reason, + }), + ]); + expect(outcomes[1]).toMatchObject({ status: "not_started" }); + expect(await readFile(first.path, "utf8")).toBe("{}"); + expect(await readFile(second.path, "utf8")).toBe("{}"); + }); + + it("retains cancellation and the cleanup lease when published staging cannot close", async () => { + const first = await settingsFixture(); + const second = await settingsFixture("{}"); + const controller = new AbortController(); + const reason = { operator: "stop during staging cleanup" }; + const cleanup = new Error("staging close denied"); + const close = vi + .spyOn(OwnedStagedOutput.prototype, "close") + .mockImplementation(async function (this: OwnedStagedOutput) { + if (this.directory.startsWith(first.directory + sep)) { + controller.abort(reason); + throw cleanup; + } + throw new Error("Unexpected staging owner"); + }); + const outcomes = await registerMcpClients( + [clientFor(first.path), clientFor(second.path)], + launcher, + controller.signal, + resources, + ); + const firstOutcome = outcomes[0]; + if (firstOutcome?.status !== "completed" || firstOutcome.result.ok) + throw new Error("Expected a cleanup failure"); + expect(firstOutcome.result.error).toMatchObject({ + cleanupIncomplete: true, + diagnostics: { configured: true }, + secondaryFailures: [{ _tag: "AnalysisCancelledError", cause: reason }], + }); + expect(firstOutcome.result.error.cleanupResources).toEqual([ + expect.stringContaining(first.directory), + ]); + expect(outcomes[1]).toMatchObject({ status: "not_started" }); + expect(JSON.parse(await readFile(first.path, "utf8"))).toEqual({ + "amp.mcpServers": { rea: launcher }, + }); + expect(await readFile(second.path, "utf8")).toBe("{}"); + close.mockRestore(); + await resources.close(); + expect(firstOutcome.result.error.cleanupIncomplete).toBe(false); + }); +}); + +describe("Registration cancellation and sibling outcomes", () => { + it("cancels queued registration after the existing transaction settles", async () => { + const { path } = await settingsFixture("{}"); + let unblock!: () => void; + let entered!: () => void; + const blocked = new Promise((resolve) => { + unblock = resolve; + }); + const admitted = new Promise((resolve) => { + entered = resolve; + }); + const first = withClientConfigurationTransaction(path, async () => { + entered(); + await blocked; + }); + await admitted; + const controller = new AbortController(); + const queued = register(path, launcher, controller.signal); + controller.abort("queued cancellation"); + unblock(); + await first; + expect(await queued).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + expect(await readFile(path, "utf8")).toBe("{}"); + }); + + it("keeps successful siblings after ordinary failures and marks work not started after cancellation", async () => { + const bad = await settingsFixture("null"); + const good = await settingsFixture("{}"); + const clients = [ + clientFor(bad.path), + clientFor(good.path, "json", "cursor"), + ]; + const outcomes = await registerMcpClients( + clients, + launcher, + undefined, + resources, + ); + expect(outcomes).toMatchObject([ + { status: "completed", result: { ok: false } }, + { + status: "completed", + result: { ok: true, value: { status: "configured" } }, + }, + ]); + const controller = new AbortController(); + controller.abort("before batch"); + const cancelled = await registerMcpClients( + clients, + launcher, + controller.signal, + resources, + ); + expect(cancelled).toMatchObject([ + { + status: "completed", + result: { ok: false, error: { _tag: "AnalysisCancelledError" } }, + }, + { status: "not_started" }, + ]); + expect(await readFile(bad.path, "utf8")).toBe("null"); + const late = new AbortController(); + const write = ClientConfigurationPublication.write; + vi.spyOn(ClientConfigurationPublication, "write").mockImplementation( + async (...args) => { + const publication = await write(...args); + late.abort("published first sibling"); + return publication; + }, + ); + const partial = await registerMcpClients( + [clients[1]!, clients[0]!], + { command: "next", args: [] }, + late.signal, + resources, + ); + expect(partial).toMatchObject([ + { + status: "completed", + result: { + ok: false, + error: { + _tag: "AnalysisCancelledError", + diagnostics: { configured: true }, + }, + }, + }, + { status: "not_started" }, + ]); + expect( + JSON.parse(await readFile(good.path, "utf8")).mcpServers.rea.command, + ).toBe("next"); + }); +}); + +describe("Client registration publication recovery", () => { + it("restores original bytes when owned readback fails", async () => { + const original = '{"theme":"keep"}'; + const { path, directory } = await settingsFixture(original); + vi.spyOn(regularReads, "readRegularFileText").mockRejectedValueOnce( + new Error("fixture readback denied"), + ); + const result = await register(path, launcher); + expect(result.ok).toBe(false); + expect(await readFile(path, "utf8")).toBe(original); + expect(await readFile(`${path}.rea.backup`, "utf8")).toBe(original); + expect( + (await readdir(directory)).some((name) => + name.startsWith(".rea-config-"), + ), + ).toBe(false); + }); + + it("preserves an external replacement instead of rolling it back", async () => { + const { path } = await settingsFixture('{"theme":"keep"}'); + const external = '{"theme":"external"}'; + const write = ClientConfigurationPublication.write; + vi.spyOn(ClientConfigurationPublication, "write").mockImplementation( + async (...args) => { + const publication = await write(...args); + await writeFile(path, external); + return publication; + }, + ); + expect((await register(path, launcher)).ok).toBe(false); + expect(await readFile(path, "utf8")).toBe(external); + }); + + it("retains failed staging cleanup after projection while a later layered sibling settles", async () => { + const first = await settingsFixture(); + const second = await settingsFixture("{}"); + const lower = join(second.directory, "lower.json"); + await writeFile( + lower, + '{"mcp":{"peer":{"type":"local","command":["peer"]}}}', + ); + const originalClose = OwnedStagedOutput.prototype.close; + const close = vi + .spyOn(OwnedStagedOutput.prototype, "close") + .mockImplementation(async function (this: OwnedStagedOutput) { + if (this.directory.startsWith(first.directory + sep)) + throw new Error("fixture first staging cleanup denied"); + return originalClose.call(this); + }); + // Observe forbidden nested admission without leaving a self-waiting test. + const originalRun = resources.run.bind(resources); + let activeAdmission = false; + let nestedAdmissions = 0; + const admission = vi + .spyOn(resources, "run") + .mockImplementation( + async (operation: () => Promise): Promise => { + if (activeAdmission) { + nestedAdmissions++; + throw new Error( + "A retained caller scope must not be admitted recursively", + ); + } + activeAdmission = true; + try { + return await originalRun(operation); + } finally { + activeAdmission = false; + } + }, + ); + try { + let projected: readonly string[]; + { + const clients = [ + clientFor(first.path), + { + ...clientFor(second.path, "kilo", "kilo-code"), + configPaths: [lower, second.path], + }, + ]; + const outcomes = await resources.run(() => + registerMcpClients(clients, launcher, undefined, resources), + ); + expect(outcomes).toMatchObject([ + { + status: "completed", + result: { ok: false, error: { cleanupIncomplete: true } }, + }, + { + status: "completed", + result: { ok: true, value: { status: "configured" } }, + }, + ]); + projected = outcomes.map((outcome) => + outcome.status === "not_started" + ? "not_started" + : outcome.result.ok + ? outcome.result.value.status + : outcome.result.error._tag, + ); + } + expect(projected).toEqual(["ConfigurationError", "configured"]); + expect(nestedAdmissions).toBe(0); + expect(resources.hasPending).toBe(true); + await expect(resources.close()).rejects.toBeDefined(); + expect(resources.hasPending).toBe(true); + close.mockRestore(); + await resources.close(); + expect(resources.hasPending).toBe(false); + expect(await readdir(first.directory)).toEqual(["settings.json"]); + expect( + JSON.parse(await readFile(second.path, "utf8")).mcp.rea.command, + ).toEqual([launcher.command, ...launcher.args]); + } finally { + close.mockRestore(); + admission.mockRestore(); + } + }); + + it("resolves an owned configuration symlink to its selected file", async () => { + const { path, directory } = await settingsFixture("{}"); + const alias = join(directory, "alias.json"); + await symlink(path, alias); + const result = await register(alias, launcher); + expect(result.ok).toBe(true); + expect( + JSON.parse(await readFile(path, "utf8"))["amp.mcpServers"].rea, + ).toEqual(launcher); + expect(await readFile(alias, "utf8")).toBe(await readFile(path, "utf8")); + }); +}); + +it("refuses a semantic no-op when the admitted file was replaced with identical bytes", async () => { + const original = JSON.stringify({ "amp.mcpServers": { rea: launcher } }); + const { path } = await settingsFixture(original); + const read = regularReads.readRegularFileTextSnapshot; + let reads = 0; + let replaced = false; + vi.spyOn(regularReads, "readRegularFileTextSnapshot").mockImplementation( + async (...args) => { + if (++reads === 2) { + await writeFile(`${path}.replacement`, original); + await rename(`${path}.replacement`, path); + replaced = true; + } + return read(...args); + }, + ); + expect(await register(path, launcher)).toMatchObject({ ok: false }); + expect(replaced).toBe(true); + expect(await readFile(path, "utf8")).toBe(original); +}); diff --git a/src/application/McpClientRegistration.ts b/src/application/McpClientRegistration.ts new file mode 100644 index 0000000000..fd2ec53724 --- /dev/null +++ b/src/application/McpClientRegistration.ts @@ -0,0 +1,425 @@ +import { constants } from "node:fs"; +import { lstat, mkdir, open } from "node:fs/promises"; +import { dirname } from "node:path"; + +import { + AnalysisCleanupLease, + AnalysisCleanupLeaseGroup, +} from "../artifacts/AnalysisCleanupLease.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { analysisErrorWithCancellation } from "../domain/analysisErrorCancellation.js"; +import { ConfigurationError } from "../domain/configurationErrors.js"; +import { err, ok, type Result } from "../domain/result.js"; +import type { ClientRegistrationCommand } from "../domain/clientRegistrationCommand.js"; +import { + sameRegularFileState, + RegularFileChangedError, +} from "../filesystem/RegularFile.js"; +import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; +import { writeClientRegistrationBackup } from "./ClientRegistrationBackup.js"; +import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js"; +import { + ClientConfigurationPublicationFailure, + isClientConfigurationCleanupFailure, + retryClientConfigurationCleanup, +} from "./ClientConfigurationCleanup.js"; +import { clientConfigurationValuesEqual } from "./ClientConfigurationDocument.js"; +import { ClientConfigurationPublication } from "./ClientConfigurationPublication.js"; +import { withClientConfigurationTransaction } from "./ClientConfigurationTransaction.js"; +import { + prepareRegistration, + verifyEffectiveRegistration, +} from "./McpRegistrationConfiguration.js"; +import type { McpRegistrationClient } from "./McpRegistrationCatalog.js"; +import { + readRegularFileText, + readRegularFileTextSnapshot, +} from "./RegularFileRead.js"; + +export interface McpClientRegistrationReceipt { + readonly status: "configured" | "unchanged"; + readonly backupPath?: string; + readonly permissions?: + | { + readonly kind: "verified_posix_modes"; + readonly directoryMode: 0o700; + readonly fileMode: 0o600; + } + | { + readonly kind: "requested_windows_modes"; + readonly requestedDirectoryMode: 0o700; + readonly requestedFileMode: 0o600; + readonly aclVerification: "unavailable"; + }; +} +export type McpClientRegistrationFailure = + | ConfigurationError + | AnalysisCancelledError; +interface OutcomeLabels { + readonly client: McpRegistrationClient["id"]; + readonly configPath: string; + readonly scope: McpRegistrationClient["scope"]; +} +export type McpClientRegistrationOutcome = OutcomeLabels & + ( + | { + readonly status: "completed"; + readonly result: Result< + McpClientRegistrationReceipt, + McpClientRegistrationFailure + >; + } + | { readonly status: "not_started" } + ); + +/** Preserve every selected client's outcome, including work never started after cancellation. */ +export const registerMcpClients = async ( + clients: readonly McpRegistrationClient[], + launcher: ClientRegistrationCommand, + signal: AbortSignal | undefined, + resources: ArtifactResourceScope, +): Promise => { + const outcomes: McpClientRegistrationOutcome[] = []; + let cancelled = false; + for (const client of clients) { + const labels = { + client: client.id, + configPath: client.configPath, + scope: client.scope, + }; + if (cancelled) { + outcomes.push({ ...labels, status: "not_started" }); + continue; + } + const result = await registerMcpClient(client, launcher, signal, resources); + outcomes.push({ ...labels, status: "completed", result }); + cancelled = + signal?.aborted === true || + (!result.ok && result.error instanceof AnalysisCancelledError); + } + return outcomes; +}; + +const isMissing = (cause: unknown): boolean => + cause instanceof Error && "code" in cause && cause.code === "ENOENT"; + +/** One selected document owner admits, merges and verifies exact launcher facts. */ +export const registerMcpClient = async ( + client: McpRegistrationClient, + launcher: ClientRegistrationCommand, + signal: AbortSignal | undefined, + resources: ArtifactResourceScope, +): Promise< + Result +> => { + const settingsPath = client.configPath; + let backupPath: string | undefined; + let configured = false; + const checkCancellation = (): void => { + if (signal?.aborted === true) + throw new AnalysisCancelledError("MCP registration", { + cause: signal.reason, + diagnostics: { + client: client.id, + settings_path: settingsPath, + configured, + ...(backupPath === undefined ? {} : { backup_path: backupPath }), + }, + }); + }; + try { + checkCancellation(); + // The caller supplies canonical argv, not a native client-shaped entry. + if (launcher.command.length === 0) + throw new ConfigurationError( + "The registration needs a nonempty executable.", + ); + const path = await resolveClientConfigTransactionPath(settingsPath); + if (path === undefined) + throw new ConfigurationError( + `Client settings path could not be verified: ${settingsPath}`, + ); + return await withClientConfigurationTransaction(path, async () => { + checkCancellation(); + const prepared = await prepareRegistration(path, client, launcher); + const { snapshot, original, contents } = prepared; + checkCancellation(); + const unchanged = await unchangedRegistration( + prepared, + checkCancellation, + resources, + ); + checkCancellation(); + if (unchanged !== undefined) return ok(unchanged); + if (original !== undefined) { + const backup = await writeClientRegistrationBackup(path, original); + if (!backup.ok) + throw new ConfigurationError(backup.error.detail, { + diagnostics: { + settings_path: settingsPath, + backup_path: backup.error.path, + }, + }); + backupPath = backup.value; + } + checkCancellation(); + await mkdir(dirname(path), { + recursive: true, + ...(client.privateProfile === true ? { mode: 0o700 } : {}), + }); + checkCancellation(); + if (client.privateProfile === true && process.platform !== "win32") + await securePrivateDirectory( + dirname(path), + checkCancellation, + resources, + ); + checkCancellation(); + const publication = await ClientConfigurationPublication.write( + path, + contents, + original, + { + requestedPath: settingsPath, + state: snapshot?.state, + ...(client.privateProfile === true + ? { requiredMode: 0o600 as const } + : {}), + }, + ); + // Cancellation cannot abandon published bytes, verification or retained cleanup. + await verifyRegistration(publication, prepared, backupPath, resources); + configured = true; + checkCancellation(); + return ok({ + status: "configured", + ...(backupPath === undefined ? {} : { backupPath }), + ...(client.privateProfile === true + ? { permissions: privateProfilePermissions() } + : {}), + }); + }); + } catch (cause: unknown) { + if (cause instanceof AnalysisCancelledError) return err(cause); + configured ||= + cause instanceof ClientConfigurationPublicationFailure && + cause.outcome.kind === "completed"; + const failure = await registrationFailure(cause, settingsPath, resources, { + backupPath, + configured, + }); + if (signal?.aborted === true) + analysisErrorWithCancellation( + failure, + new AnalysisCancelledError("MCP registration", { + cause: signal.reason, + }), + ); + return err(failure); + } +}; + +const registrationFailure = async ( + cause: unknown, + path: string, + resources: ArtifactResourceScope, + progress: { + readonly backupPath: string | undefined; + readonly configured: boolean; + }, +): Promise => { + const { backupPath, configured } = progress; + if (cause instanceof ConfigurationError) return cause; + const cleanup = isClientConfigurationCleanupFailure(cause) + ? await retryClientConfigurationCleanup(cause, resources) + : undefined; + const primary = cleanup?.primary ?? cause; + const detail = primary instanceof Error ? primary.message : String(primary); + const error = new ConfigurationError( + `Could not register MCP settings at ${path}: ${detail}`, + { + cause, + ...(cleanup?.cleanup === undefined ? {} : { cleanup: cleanup.cleanup }), + diagnostics: { + settings_path: path, + configured, + ...(backupPath === undefined ? {} : { backup_path: backupPath }), + }, + }, + ); + if (cleanup !== undefined) + new AnalysisCleanupLeaseGroup(error, cleanup.cleanupLeases); + return error; +}; + +/** A semantic no-op is usable only while the admitted physical snapshot remains current. */ +const unchangedRegistration = async ( + prepared: Awaited>, + checkCancellation: () => void, + resources: ArtifactResourceScope, +): Promise => { + const { client, path, snapshot, parsed, updated } = prepared; + if ( + parsed.requiresRewrite === true || + !clientConfigurationValuesEqual(parsed.document, updated) || + (client.privateProfile === true && + process.platform !== "win32" && + ((snapshot?.state.mode ?? 0) & 0o777) !== 0o600) + ) + return undefined; + const current = await readRegularFileTextSnapshot(path, { + symlinks: "reject", + }); + if ( + snapshot === undefined || + current.text !== snapshot.text || + !sameRegularFileState(snapshot.state, current.state) + ) + throw new RegularFileChangedError(path); + checkCancellation(); + await verifyEffectiveRegistration(prepared, current.text); + const permissionsChanged = + client.privateProfile === true && process.platform !== "win32" + ? await securePrivateDirectory( + dirname(path), + checkCancellation, + resources, + ) + : false; + return { + status: permissionsChanged ? "configured" : "unchanged", + ...(client.privateProfile === true + ? { permissions: privateProfilePermissions() } + : {}), + }; +}; + +const verifyRegistration = async ( + publication: ClientConfigurationPublication, + prepared: Awaited>, + backupPath: string | undefined, + resources: ArtifactResourceScope, +): Promise => { + const { original, client } = prepared; + const settingsPath = client.configPath; + await publication.verify(async () => { + try { + const readback = await readRegularFileText(publication.path, { + symlinks: "reject", + }); + await publication.assertCurrent(readback); + await verifyEffectiveRegistration(prepared, readback); + } catch (cause: unknown) { + if (cause instanceof RegularFileChangedError || isMissing(cause)) + throw cause; + const failure = await registrationFailure( + cause, + settingsPath, + resources, + { backupPath, configured: false }, + ); + try { + await publication.restore(original); + } catch (rollbackCause: unknown) { + throw new ConfigurationError( + "Client settings verification failed and restoration could not be verified", + { + cause: rollbackCause, + cleanupPrimary: failure, + diagnostics: { + settings_path: settingsPath, + ...(backupPath === undefined ? {} : { backup_path: backupPath }), + }, + }, + ); + } + throw failure; + } + }); +}; + +/** fx's profile permissions are applied through the selected directory descriptor. */ +const securePrivateDirectory = async ( + path: string, + checkCancellation: () => void, + resources: ArtifactResourceScope, +): Promise => { + const owner = new OwnedFileHandle( + await open( + path, + constants.O_RDONLY | + (constants.O_DIRECTORY ?? 0) | + (constants.O_NOFOLLOW ?? 0), + ), + ); + const lease = AnalysisCleanupLease.forCloseOwner(owner, path); + let primary: unknown; + let changed = false; + try { + const before = await owner.handle.stat(); + const named = await lstat(path); + if ( + !before.isDirectory() || + named.isSymbolicLink() || + before.ino === 0 || + before.dev !== named.dev || + before.ino !== named.ino || + !named.isDirectory() + ) + throw new RegularFileChangedError(path); + changed = (before.mode & 0o777) !== 0o700; + checkCancellation(); + if (changed) await owner.handle.chmod(0o700); + const after = await owner.handle.stat(); + const current = await lstat(path); + if ( + (after.mode & 0o777) !== 0o700 || + current.isSymbolicLink() || + !current.isDirectory() || + current.dev !== after.dev || + current.ino !== after.ino + ) + throw new RegularFileChangedError(path); + } catch (cause: unknown) { + primary = cause; + } + try { + await lease.release(); + } catch (cause: unknown) { + const error = new ConfigurationError( + `Private client directory cleanup failed at ${path}`, + { + cause: + primary === undefined + ? cause + : new AggregateError( + [primary, cause], + "Private profile permission operation and directory cleanup failed", + ), + cleanup: { + reason: "Private profile directory handle could not be closed", + resources: [path], + }, + }, + ); + new AnalysisCleanupLeaseGroup(error, [lease]); + resources.retain({ kind: "lease", resource: path, lease }); + throw error; + } + if (primary !== undefined) throw primary; + return changed; +}; + +/** Mode requests cannot establish Windows ACL privacy. */ +const privateProfilePermissions = (): NonNullable< + McpClientRegistrationReceipt["permissions"] +> => + process.platform === "win32" + ? { + kind: "requested_windows_modes", + requestedDirectoryMode: 0o700, + requestedFileMode: 0o600, + aclVerification: "unavailable", + } + : { kind: "verified_posix_modes", directoryMode: 0o700, fileMode: 0o600 }; diff --git a/src/application/McpRegistrationCatalog.test.ts b/src/application/McpRegistrationCatalog.test.ts new file mode 100644 index 0000000000..28cc45f7a3 --- /dev/null +++ b/src/application/McpRegistrationCatalog.test.ts @@ -0,0 +1,524 @@ +import { mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { z } from "zod"; + +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { createTestProcessRegistry } from "../../tests/support/process/processFixture.js"; +import { resolveMcpRegistrationClientId } from "./McpRegistrationCatalog.js"; + +it.each([ + ["cline-vscode", "cline"], + ["codeium", "windsurf"], + ["cascade", "windsurf"], + ["droid", "factory-droid"], + ["factory", "factory-droid"], + ["gemini", "gemini-cli"], + ["github-copilot", "vscode"], + ["grok", "grok-build"], + ["hermes-agent", "hermes"], + ["kilo", "kilo-code"], + ["kilocode", "kilo-code"], + ["kimi", "kimi-code"], + ["kiro", "kiro-cli"], + ["mastra", "mastracode"], + ["pi-agent", "pi"], +])("retains helper alias %s as %s", (alias, expected) => { + expect(resolveMcpRegistrationClientId(alias.toUpperCase())).toBe(expected); +}); + +it("does not treat prototype names or unknown strings as client identities", () => { + expect(resolveMcpRegistrationClientId("__proto__")).toBeUndefined(); + expect(resolveMcpRegistrationClientId("constructor")).toBeUndefined(); + expect(resolveMcpRegistrationClientId("unknown-client")).toBeUndefined(); +}); + +const clientSchema = z.object({ + id: z.string(), + displayName: z.string(), + configPath: z.string(), + configPaths: z.array(z.string()).optional(), + scope: z.enum(["global", "project"]), + format: z.string(), + privateProfile: z.literal(true).optional(), + inlineConfiguration: z + .object({ origin: z.literal("KILO_CONFIG_CONTENT"), parsed: z.unknown() }) + .optional(), +}); +const resultSchema = z.discriminatedUnion("ok", [ + z.object({ ok: z.literal(true), value: z.array(clientSchema) }), + z.object({ + ok: z.literal(false), + error: z.object({ tag: z.string(), message: z.string() }), + }), +]); + +const catalogObservationsSchema = z.object({ + empty: resultSchema, + layeredKilo: resultSchema, + layeredOpenCode: resultSchema, + explicit: resultSchema, + detected: resultSchema, + project: resultSchema, + onlyGlobal: resultSchema, + defaultProject: resultSchema, + projectAmp: resultSchema, + unknown: resultSchema, + blockedCandidate: resultSchema.nullable(), + unknownDetection: resultSchema, + cancelled: resultSchema, + ancestorLayers: resultSchema.nullable(), + selectedLayers: resultSchema, + projectReadonly: resultSchema.nullable(), + noRepository: resultSchema.nullable(), + disabled: resultSchema.nullable(), + invalidInline: resultSchema, + invalidGit: resultSchema.nullable(), + unterminatedGit: resultSchema.nullable(), + linkedWorktree: resultSchema.nullable(), + missingTarget: resultSchema.nullable(), + cancelledGit: resultSchema.nullable(), + repeatedFile: resultSchema, + repeatedDirectory: resultSchema, + whitespaceWorktree: resultSchema.nullable(), +}); + +const FAKE_GIT_PROGRAM = String.raw`const fs = require("node:fs"); +const state = JSON.parse(fs.readFileSync(process.env.REA_TEST_GIT_STATE,"utf8")); +fs.appendFileSync(process.env.REA_TEST_GIT_CALLS,JSON.stringify(process.argv.slice(2))+"\n"); +if (state.invalid) process.stdout.write(Buffer.from([255,10])); +else if (state.unterminated) process.stdout.write(state.root); +else if (state.hang) { fs.writeFileSync(state.hang,"ready"); setInterval(()=>{},1000); } +else { + const key=process.argv[3]; + process.stdout.write(key==="--show-toplevel"?state.root+"\n":key==="--git-dir"?state.git+"\n":key==="--git-common-dir"?state.common+"\n":"worktree "+state.primary+"\0HEAD inert\0\0"); +} +`; + +const CATALOG_PROGRAM = ` + import { homedir } from 'node:os'; + import { join, relative, isAbsolute, delimiter } from 'node:path'; + import { mkdir, writeFile, rm, realpath, readFile, lstat } from 'node:fs/promises'; + const home = process.env.HOME; + if (!home || await realpath(home) !== await realpath(homedir())) throw Error('Fixture HOME not selected before catalog import.'); + for (const key of ['USERPROFILE','XDG_CONFIG_HOME','XDG_DATA_HOME','XDG_CACHE_HOME','XDG_STATE_HOME','APPDATA','LOCALAPPDATA']) { + const path = relative(home, process.env[key]); + if (path.startsWith('..') || isAbsolute(path)) throw Error('Fixture location escaped: ' + key); + } + const { resolveMcpRegistrationClients } = await import(${JSON.stringify(new URL("./McpRegistrationCatalog.ts", import.meta.url).href)}); + const { ArtifactResourceScope } = await import(${JSON.stringify(new URL("../artifacts/ArtifactResourceScope.ts", import.meta.url).href)}); + const resources = new ArtifactResourceScope(); + try { + const posix = process.platform !== 'win32'; + const cwd = join(home, 'project'); + await mkdir(cwd); + const selection = { agents: [], global: true, home, cwd, platform: process.platform, environment: process.env }; + const observe = async (changes = {}) => { + const result = await resources.run(() => resolveMcpRegistrationClients({ ...selection, ...changes }, resources)); + if (!result.ok) return { ok: false, error: { tag: result.error._tag, message: result.error.message } }; + for (const client of result.value) for (const source of [client.configPath,...client.configPaths ?? []]) { + const path = relative(home, source); + if (path.startsWith('..') || isAbsolute(path)) throw Error('Catalog path escaped temporary HOME: ' + client.id); + } + return result; + }; + // Only the authored POSIX Git executable and newline-path controls are omitted on Windows. + const observeProject = changes => posix ? observe({agents:['kilo'],global:false,...changes}) : null; + const empty = await observe(); + const layeredRoot = join(home, 'layered', 'kilo'); + await mkdir(layeredRoot, { recursive: true }); + for (const name of ['config.json','kilo.json','kilo.jsonc','opencode.json','opencode.jsonc']) await writeFile(join(layeredRoot,name),'{}'); + const layeredKilo = await observe({ agents:['kilo'], environment:{...process.env,XDG_CONFIG_HOME:join(home,'layered')} }); + const layeredOpenCode = await observe({ agents:['opencode'] }); + const customCodex = join(home, 'selected-codex'); + const explicit = await observe({ agents: ['CODEX','Amp','gemini','fx','codeium','windsurf'], environment: { ...process.env, CODEX_HOME: customCodex } }); + await mkdir(join(home, '.claude')); + await mkdir(join(home, '.cursor')); + const detected = await observe(); + const gitBin = join(home, 'git-bin'); + await mkdir(gitBin); + const gitState = join(home, 'git-state.json'); + const gitCalls = join(home, 'git-calls.jsonl'); + await mkdir(join(cwd, '.git')); + const gitProgram = ${JSON.stringify("#!" + process.execPath + "\n" + FAKE_GIT_PROGRAM)}; + await writeFile(join(gitBin,'git'),gitProgram,{mode:0o755}); + const gitEnvironment = {...process.env, PATH:gitBin+delimiter+process.env.PATH, REA_TEST_GIT_STATE:gitState, REA_TEST_GIT_CALLS:gitCalls}; + const state = {root:cwd,git:join(cwd,'.git'),common:join(cwd,'.git')}; + await writeFile(gitState,JSON.stringify(state)); + selection.environment = gitEnvironment; + await mkdir(join(cwd, '.github')); + await writeFile(join(cwd, '.github', 'mcp.json'), '{}'); + await mkdir(join(cwd, '.kilocode')); + await writeFile(join(cwd, '.kilocode', 'kilo.jsonc'), '{}'); + const project = await observe({ agents: ['github-copilot-cli',...(posix?['kilo']:[]),'opencode'], global: false }); + const nested = join(cwd,'nested'); + await mkdir(join(nested,'.kilocode'),{recursive:true}); + await writeFile(join(cwd,'kilo.json'),'{}'); + await writeFile(join(nested,'kilo.jsonc'),'{}'); + await writeFile(join(cwd,'opencode.json'),'{}'); + await writeFile(join(nested,'opencode.jsonc'),'{}'); + await writeFile(join(nested,'.kilocode','kilo.jsonc'),'{}'); + const ancestorLayers = await observeProject({cwd:nested}); + const overrideFile = join(home,'override.json'); + const overrideDirectory = join(home,'override-dir'); + await writeFile(overrideFile,'{}'); + await mkdir(overrideDirectory); + await writeFile(join(overrideDirectory,'kilo.jsonc'),'{}'); + await writeFile(join(overrideDirectory,'opencode.json'),'{}'); + const selectedLayers = await observe({agents:['kilo'],environment:{...gitEnvironment,KILO_CONFIG:overrideFile,KILO_CONFIG_DIR:overrideDirectory,KILO_CONFIG_CONTENT:'{"metadata":"selected","mcp":{"peer":{"type":"local","command":["peer"]}}}'}}); + const projectReadonly = await observeProject({cwd:nested,environment:{...gitEnvironment,KILO_CONFIG:overrideFile,KILO_CONFIG_DIR:overrideDirectory,KILO_CONFIG_CONTENT:'{"metadata":"readonly"}'}}); + const noRepository = await observeProject({cwd:home}); + const disabled = await observeProject({environment:{...gitEnvironment,KILO_DISABLE_PROJECT_CONFIG:'true'}}); + const invalidInline = await observe({agents:['kilo'],environment:{...gitEnvironment,KILO_CONFIG_CONTENT:'{'}}); + await writeFile(gitState,JSON.stringify({...state,invalid:true})); + const invalidGit = await observeProject({}); + await writeFile(gitState,JSON.stringify({...state,unterminated:true})); + const unterminatedGit = await observeProject({}); + const primary = join(home,'primary'); + await mkdir(join(primary,'.git'),{recursive:true}); + await mkdir(join(primary,'nested','.kilo'),{recursive:true}); + await writeFile(join(primary,'nested','.kilo','opencode.json'),'{}'); + await writeFile(gitState,JSON.stringify({...state,common:join(primary,'.git'),primary})); + const linkedWorktree = await observeProject({cwd:nested}); + await writeFile(gitState,JSON.stringify(state)); + const emptyRoot = join(home,'empty-worktree'); + await mkdir(join(emptyRoot,'.git'),{recursive:true}); + await writeFile(gitState,JSON.stringify({root:emptyRoot,git:join(emptyRoot,'.git'),common:join(emptyRoot,'.git')})); + const missingTarget = await observeProject({cwd:emptyRoot,environment:{...gitEnvironment,KILO_CONFIG_DIR:overrideDirectory}}); + let cancelledGit = null; + if(posix) { + const ready = join(home,'git-ready'); + await writeFile(gitState,JSON.stringify({...state,hang:ready})); + const duringGit = new AbortController(); + const cancellation = observeProject({signal:duringGit.signal}); + try { + const end = Date.now()+3000; + while(true) { try { await lstat(ready); break; } catch(error) { if(error.code!=='ENOENT')throw error; } if(Date.now()>=end)throw Error('Inert Git did not become ready');await new Promise(resolve=>setTimeout(resolve,10)); } + } finally { duringGit.abort({fixture:'during-readonly-git'}); } + cancelledGit = await cancellation; + await writeFile(gitState,JSON.stringify(state)); + const gitQueries = (await readFile(gitCalls,'utf8')).trimEnd().split('\\n').map(line=>JSON.parse(line)); + for(const args of gitQueries)if(![['rev-parse','--show-toplevel'],['rev-parse','--git-dir'],['rev-parse','--git-common-dir'],['worktree','list','--porcelain','-z']].some(expected=>JSON.stringify(expected)===JSON.stringify(args)))throw Error('Unexpected Git command'); + } + let whitespaceWorktree = null; + if(posix) { + const whitespaceRoot = join(home,' worktree'+String.fromCharCode(10)+'with-space '); + await mkdir(join(whitespaceRoot,'.git'),{recursive:true}); + await writeFile(gitState,JSON.stringify({root:whitespaceRoot,git:join(whitespaceRoot,'.git'),common:join(whitespaceRoot,'.git')})); + whitespaceWorktree = await observeProject({cwd:whitespaceRoot}); + await writeFile(gitState,JSON.stringify(state)); + } + const repeatedFile = await observe({agents:['kilo'],environment:{...gitEnvironment,XDG_CONFIG_HOME:join(home,'layered'),KILO_CONFIG:join(layeredRoot,'kilo.json')}}); + await mkdir(join(home,'.kilocode')); + await mkdir(join(home,'.kilo')); + await writeFile(join(home,'.kilo','opencode.json'),'{}'); + const repeatedDirectory = await observe({agents:['kilo'],environment:{...gitEnvironment,KILO_CONFIG_DIR:join(home,'.kilocode')}}); + const onlyGlobal = await observe({ agents: ['cursor','goose'], global: false }); + const defaultProject = await observe({ global: false }); + const projectAmp = await observe({ agents: ['amp'], global: false }); + const unknown = await observe({ agents: ['unknown-client'] }); + await writeFile(join(cwd, '.kilo'), 'blocks candidate directory'); + const blockedCandidate = await observeProject({}); + await writeFile(join(home, '.cline'), 'blocks detection directory'); + const unknownDetection = await observe(); + const controller = new AbortController(); controller.abort({ fixture: 'cancel' }); + const cancelled = await observe({ agents: ['amp'], signal: controller.signal }); + console.log(JSON.stringify({ empty, layeredKilo, layeredOpenCode, explicit, detected, project, onlyGlobal, defaultProject, projectAmp, unknown, blockedCandidate, unknownDetection, cancelled, ancestorLayers, selectedLayers, projectReadonly, noRepository, disabled, invalidInline, invalidGit, unterminatedGit, linkedWorktree, missingTarget, cancelledGit, repeatedFile, repeatedDirectory, whitespaceWorktree })); + } finally { await resources.close(); if(resources.hasPending) throw Error("Git cleanup remained owned."); } + `; + +const REVIEWED_CLIENT_IDS = [ + "antigravity", + "cline", + "cline-cli", + "claude-code", + "claude-desktop", + "codex", + "cursor", + "factory-droid", + "fx", + "gemini-cli", + "goose", + "github-copilot-cli", + "grok-build", + "hermes", + "junie", + "kilo-code", + "kimi-code", + "kiro-cli", + "mastracode", + "mcporter", + "opencode", + "pi", + "vscode", + "windsurf", + "zed", + "amp", +]; + +it("resolves selected-environment paths and scopes without external catalog readers", async () => { + const home = await createTestTempDirectory("rea-registration-catalog-"); + const processes = createTestProcessRegistry(); + try { + await mkdir(join(home, "config")); + const child = await processes.run( + process.execPath, + ["--import", "tsx", "--input-type=module", "--eval", CATALOG_PROGRAM], + { + timeoutMs: 50_000, + env: { + PATH: process.env.PATH, + SystemRoot: process.env.SystemRoot, + WINDIR: process.env.WINDIR, + TMPDIR: process.env.TMPDIR, + TMP: process.env.TMP, + TEMP: process.env.TEMP, + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: join(home, "config"), + XDG_DATA_HOME: join(home, "data"), + XDG_CACHE_HOME: join(home, "cache"), + XDG_STATE_HOME: join(home, "state"), + APPDATA: join(home, "appdata"), + LOCALAPPDATA: join(home, "localappdata"), + }, + }, + ); + expect(child.exitCode, child.stderr).toBe(0); + const result = catalogObservationsSchema.parse(JSON.parse(child.stdout)); + assertKiloNativeLayers(result, home); + expect(result.empty.ok).toBe(true); + if (!result.empty.ok) throw new Error(result.empty.error.message); + expect(result.empty.value.map((client) => client.id)).toEqual( + REVIEWED_CLIENT_IDS, + ); + expect(result.layeredKilo.ok).toBe(true); + if (!result.layeredKilo.ok) + throw new Error(result.layeredKilo.error.message); + const kiloLayers = [ + "config.json", + "kilo.json", + "kilo.jsonc", + "opencode.json", + "opencode.jsonc", + ].map((name) => join(home, "layered", "kilo", name)); + expect(result.layeredKilo.value[0]).toMatchObject({ + configPath: kiloLayers.at(-1), + configPaths: kiloLayers, + }); + expect(result.layeredOpenCode.ok).toBe(true); + if (!result.layeredOpenCode.ok) + throw new Error(result.layeredOpenCode.error.message); + expect(result.layeredOpenCode.value[0]?.configPaths).toEqual( + ["config.json", "opencode.json", "opencode.jsonc"].map((name) => + join(home, "config", "opencode", name), + ), + ); + expect(result.explicit.ok).toBe(true); + if (!result.explicit.ok) throw new Error(result.explicit.error.message); + expect(result.explicit.value.map((client) => client.id)).toEqual([ + "codex", + "amp", + "gemini-cli", + "fx", + "windsurf", + ]); + expect(result.explicit.value[0]?.configPath).toBe( + join(home, "selected-codex", "config.toml"), + ); + expect( + result.explicit.value.find((client) => client.id === "fx"), + ).toMatchObject({ format: "fx", privateProfile: true }); + expect(result.detected.ok).toBe(true); + if (!result.detected.ok) throw new Error(result.detected.error.message); + expect(result.detected.value.map((client) => client.id)).toEqual([ + "claude-code", + "cursor", + "amp", + ]); + expect(result.project.ok, JSON.stringify(result.project)).toBe(true); + if (!result.project.ok) throw new Error(result.project.error.message); + expect(result.project.value[0]?.format).toBe("copilot_cli_project"); + expect( + result.project.value.map(({ configPath, scope }) => ({ + configPath, + scope, + })), + ).toEqual([ + { + configPath: join(home, "project", ".github", "mcp.json"), + scope: "project", + }, + ...(process.platform === "win32" + ? [] + : [ + { + configPath: join(home, "project", ".kilocode", "kilo.jsonc"), + scope: "project", + }, + ]), + { + configPath: join(home, "project", "opencode.jsonc"), + scope: "project", + }, + ]); + for (const refused of [ + result.onlyGlobal, + result.defaultProject, + result.projectAmp, + result.unknown, + ...(result.blockedCandidate === null ? [] : [result.blockedCandidate]), + result.unknownDetection, + ]) { + expect(refused.ok).toBe(false); + if (refused.ok) throw new Error("Expected preflight refusal."); + expect(refused.error.tag).toBe("ConfigurationError"); + } + expect(result.cancelled).toMatchObject({ + ok: false, + error: { tag: "AnalysisCancelledError" }, + }); + } finally { + await processes.shutdown(); + await rm(home, { recursive: true, force: true }); + } +}, 60_000); + +const assertKiloNativeLayers = ( + result: z.infer, + home: string, +): void => { + if (process.platform === "win32") { + for (const name of [ + "ancestorLayers", + "projectReadonly", + "noRepository", + "disabled", + "invalidGit", + "unterminatedGit", + "linkedWorktree", + "missingTarget", + "cancelledGit", + "whitespaceWorktree", + "blockedCandidate", + ] as const) + expect(result[name]).toBeNull(); + return; + } + const client = ( + name: + | "ancestorLayers" + | "selectedLayers" + | "projectReadonly" + | "linkedWorktree", + ) => { + const value = result[name]; + if (value === null) throw Error(`Missing POSIX ${name} observation`); + if (!value.ok) throw Error(value.error.message); + const entry = value.value[0]; + if (!entry) throw Error(`No ${name} client`); + return entry; + }; + const project = join(home, "project"); + const native = [ + join(project, "kilo.json"), + join(project, "nested", "kilo.jsonc"), + join(project, "opencode.json"), + join(project, "nested", "opencode.jsonc"), + join(project, "nested", ".kilocode", "kilo.jsonc"), + join(project, ".kilocode", "kilo.jsonc"), + ]; + expect(client("ancestorLayers")).toMatchObject({ + configPath: native.at(-1), + configPaths: native, + }); + expect(client("selectedLayers")).toMatchObject({ + configPath: join(home, "override-dir", "opencode.json"), + configPaths: [ + join(home, "override.json"), + join(home, "override-dir", "kilo.jsonc"), + join(home, "override-dir", "opencode.json"), + ], + inlineConfiguration: { origin: "KILO_CONFIG_CONTENT" }, + }); + expect(client("projectReadonly")).toMatchObject({ + configPath: native.at(-1), + configPaths: [ + join(home, "override.json"), + ...native, + join(home, "override-dir", "kilo.jsonc"), + join(home, "override-dir", "opencode.json"), + ], + }); + expect(client("linkedWorktree")).toMatchObject({ + configPath: native.at(-1), + configPaths: [ + ...native.slice(0, 4), + join(home, "primary", "nested", ".kilo", "opencode.json"), + ...native.slice(4), + ], + }); + if (result.missingTarget === null) + throw Error("Missing POSIX planned target observation"); + expect(result.missingTarget.ok).toBe(true); + if (!result.missingTarget.ok) throw Error(result.missingTarget.error.message); + const planned = join(home, "empty-worktree", ".kilo", "kilo.jsonc"); + expect(result.missingTarget.value[0]).toMatchObject({ + configPath: planned, + configPaths: [ + planned, + join(home, "override-dir", "kilo.jsonc"), + join(home, "override-dir", "opencode.json"), + ], + }); + expect(result.cancelledGit).toMatchObject({ + ok: false, + error: { tag: "AnalysisCancelledError" }, + }); + for (const name of [ + "repeatedFile", + "repeatedDirectory", + "whitespaceWorktree", + ] as const) { + const entry = result[name]; + if (entry === null) throw Error(`Missing POSIX ${name} observation`); + if (!entry.ok) throw Error(entry.error.message); + } + if (result.repeatedFile.ok) + expect(result.repeatedFile.value[0]).toMatchObject({ + configPath: join(home, "layered", "kilo", "kilo.json"), + configPaths: [ + ...[ + "config.json", + "kilo.json", + "kilo.jsonc", + "opencode.json", + "opencode.jsonc", + ].map((name) => join(home, "layered", "kilo", name)), + join(home, "layered", "kilo", "kilo.json"), + ], + }); + if (result.repeatedDirectory.ok) + expect(result.repeatedDirectory.value[0]).toMatchObject({ + configPath: join(home, ".kilocode", "kilo.json"), + configPaths: [ + join(home, ".kilocode", "kilo.json"), + join(home, ".kilo", "opencode.json"), + ], + }); + if (result.whitespaceWorktree?.ok) + expect(result.whitespaceWorktree.value[0]?.configPath).toBe( + join(home, " worktree\nwith-space ", ".kilo", "kilo.jsonc"), + ); + for (const name of [ + "noRepository", + "disabled", + "invalidInline", + "invalidGit", + "unterminatedGit", + ] as const) + expect(result[name]).toMatchObject({ + ok: false, + error: { tag: "ConfigurationError" }, + }); +}; diff --git a/src/application/McpRegistrationCatalog.ts b/src/application/McpRegistrationCatalog.ts new file mode 100644 index 0000000000..4ddd82cd27 --- /dev/null +++ b/src/application/McpRegistrationCatalog.ts @@ -0,0 +1,620 @@ +import { + kiloLocation, + kiloProjectLocation, +} from "./McpRegistrationKiloLocations.js"; +import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { dirname, join, win32 } from "node:path"; + +import { analysisErrorWithCancellation } from "../domain/analysisErrorCancellation.js"; +import { ConfigurationError } from "../domain/configurationErrors.js"; +import { err, ok, type Result } from "../domain/result.js"; +import type { ClientConfigurationFormat } from "./ClientConfigurationDocument.js"; +import { + supportedClients, + supportedClientRegistrationProfile, +} from "./SupportedClients.js"; +import type { + ClientRegistrationConnectionProfile, + ClientRegistrationProfile, + NativeStdioIncompatibility, +} from "./ClientRegistrationProfile.js"; +import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { + bindRegularFileCleanup, + readRegularFileText, +} from "./RegularFileRead.js"; +import { clientConfigurationRecordSchema } from "./ClientConfigurationDocument.js"; +import { + selectedPath, + fromSetup, + present, + fixedProjectLocation, + clineLocation, + clineCliLocation, + fxLocation, + gooseLocation, + junieLocation, + kiroLocation, + mastraLocation, + mcporterLocation, + zedLocation, + ampLocation, + type McpRegistrationLocationContext, + type McpRegistrationGlobalLocation, + type McpRegistrationLocation, + type McpRegistrationInlineConfiguration, +} from "./McpRegistrationLocations.js"; + +interface ClientDescriptorBase { + readonly displayName: string; + readonly format: ClientConfigurationFormat; + readonly globalLocation: ( + context: McpRegistrationLocationContext, + ) => Promise; + readonly projectLocation?: ( + context: McpRegistrationLocationContext, + ) => Promise; + readonly projectFormat?: ClientConfigurationFormat; +} + +type ClientDescriptor = ClientDescriptorBase & + ( + | { + readonly registrationProfile: ClientRegistrationProfile; + readonly registrationConnection?: never; + } + | { + readonly registrationConnection: ClientRegistrationConnectionProfile; + readonly registrationProfile?: never; + } + ); + +/** mcporter getUrl's native nullish priority; an empty first value suppresses later aliases. */ +const MCPORTER_URL_FIELDS = [ + "baseUrl", + "base_url", + "url", + "serverUrl", + "server_url", +] as const; +const MCPORTER_STDIO_INCOMPATIBILITIES: readonly NativeStdioIncompatibility[] = + [ + // RawEntrySchema admits only optional strings, including an empty string. + ...MCPORTER_URL_FIELDS.map((field): NativeStdioIncompatibility => ({ + kind: "invalid-string", + field, + })), + ...MCPORTER_URL_FIELDS.map((field, index): NativeStdioIncompatibility => + index === 0 + ? { kind: "truthy", field } + : { + kind: "all", + conditions: [ + ...MCPORTER_URL_FIELDS.slice(0, index).map( + (prior): NativeStdioIncompatibility => ({ + kind: "nullish", + field: prior, + }), + ), + { kind: "truthy", field }, + ], + }, + ), + ]; + +/** Reviewed add-mcp 2.6.1 client contracts plus Incur's separate Amp contract. */ +export const MCP_REGISTRATION_CLIENTS = { + antigravity: { + displayName: "Antigravity", + format: "json", + globalLocation: fromSetup("antigravity"), + registrationProfile: supportedClientRegistrationProfile("antigravity"), + }, + cline: { + displayName: "Cline VSCode Extension", + format: "json", + globalLocation: clineLocation, + registrationConnection: { kind: "cline" }, + }, + "cline-cli": { + displayName: "Cline CLI", + format: "json", + globalLocation: clineCliLocation, + registrationConnection: { kind: "cline" }, + }, + "claude-code": { + displayName: "Claude Code", + format: "claude_code", + globalLocation: fromSetup("claude_code"), + registrationProfile: supportedClientRegistrationProfile("claude_code"), + projectLocation: fixedProjectLocation([".mcp.json"]), + }, + "claude-desktop": { + displayName: "Claude Desktop", + format: "json", + globalLocation: fromSetup("claude_desktop"), + registrationProfile: supportedClientRegistrationProfile("claude_desktop"), + }, + codex: { + displayName: "Codex", + format: "toml", + globalLocation: fromSetup("codex"), + registrationProfile: supportedClientRegistrationProfile("codex"), + projectLocation: fixedProjectLocation([".codex/config.toml"]), + }, + cursor: { + displayName: "Cursor", + format: "json", + globalLocation: fromSetup("cursor"), + registrationProfile: supportedClientRegistrationProfile("cursor"), + projectLocation: fixedProjectLocation([".cursor/mcp.json"]), + }, + "factory-droid": { + displayName: "Factory Droid", + format: "json", + globalLocation: fromSetup("droid"), + registrationProfile: supportedClientRegistrationProfile("droid"), + projectLocation: fixedProjectLocation([".factory/mcp.json"]), + }, + fx: { + displayName: "fx", + format: "fx", + globalLocation: fxLocation, + registrationConnection: { + kind: "flat", + fields: ["url", "args"], + stdio: [ + { kind: "optional-enum", field: "type", values: ["local", "stdio"] }, + { + kind: "all", + conditions: [ + { kind: "missing", field: "type" }, + { kind: "present", field: "url" }, + ], + }, + ], + }, + }, + "gemini-cli": { + displayName: "Gemini CLI", + format: "json", + globalLocation: fromSetup("gemini_cli"), + registrationProfile: supportedClientRegistrationProfile("gemini_cli"), + projectLocation: fixedProjectLocation([".gemini/settings.json"]), + }, + goose: { + displayName: "Goose", + format: "goose", + globalLocation: gooseLocation, + registrationConnection: { + kind: "flat", + fields: ["uri"], + stdio: [{ kind: "required-enum", field: "type", values: ["stdio"] }], + }, + }, + "github-copilot-cli": { + displayName: "GitHub Copilot CLI", + format: "copilot_cli", + globalLocation: fromSetup("copilot_cli"), + registrationProfile: supportedClientRegistrationProfile("copilot_cli"), + projectLocation: fixedProjectLocation([".mcp.json", ".github/mcp.json"]), + projectFormat: "copilot_cli_project", + }, + "grok-build": { + displayName: "Grok Build", + format: "grok", + globalLocation: fromSetup("grok_build"), + registrationProfile: supportedClientRegistrationProfile("grok_build"), + projectLocation: fixedProjectLocation([".grok/config.toml"]), + }, + hermes: { + displayName: "Hermes Agent", + format: "hermes", + globalLocation: fromSetup("hermes"), + registrationProfile: supportedClientRegistrationProfile("hermes"), + }, + junie: { + displayName: "Junie", + format: "json", + globalLocation: junieLocation, + projectLocation: fixedProjectLocation([".junie/mcp/mcp.json"]), + registrationConnection: { + kind: "flat", + fields: ["url"], + stdio: [{ kind: "present", field: "url" }], + }, + }, + "kilo-code": { + displayName: "Kilo Code", + format: "kilo", + globalLocation: kiloLocation, + projectLocation: kiloProjectLocation, + registrationConnection: { + kind: "flat", + fields: ["url"], + stdio: [{ kind: "required-enum", field: "type", values: ["local"] }], + }, + }, + "kimi-code": { + displayName: "Kimi Code", + format: "kimi", + globalLocation: fromSetup("kimi"), + registrationProfile: supportedClientRegistrationProfile("kimi"), + projectLocation: fixedProjectLocation([".kimi-code/mcp.json"]), + }, + "kiro-cli": { + displayName: "Kiro CLI", + format: "json", + globalLocation: kiroLocation, + projectLocation: fixedProjectLocation([".kiro/settings/mcp.json"]), + registrationConnection: { + kind: "flat", + fields: ["type", "url"], + stdio: [ + { kind: "optional-enum", field: "type", values: ["stdio"] }, + { kind: "present", field: "url" }, + ], + }, + }, + mastracode: { + displayName: "Mastra Code", + format: "json", + globalLocation: mastraLocation, + projectLocation: fixedProjectLocation([".mastracode/mcp.json"]), + registrationConnection: { + kind: "flat", + fields: ["url"], + stdio: [{ kind: "string", field: "url" }], + }, + }, + mcporter: { + displayName: "MCPorter", + format: "json", + globalLocation: mcporterLocation, + projectLocation: fixedProjectLocation(["config/mcporter.json"]), + registrationConnection: { + kind: "flat", + fields: [...MCPORTER_URL_FIELDS, "executable"], + stdio: MCPORTER_STDIO_INCOMPATIBILITIES, + }, + }, + opencode: { + displayName: "OpenCode", + format: "opencode", + globalLocation: fromSetup("opencode"), + registrationProfile: supportedClientRegistrationProfile("opencode"), + projectLocation: fixedProjectLocation([ + "opencode.jsonc", + "opencode.json", + ".opencode/opencode.jsonc", + ".opencode/opencode.json", + ]), + }, + pi: { + displayName: "Pi", + format: "pi", + globalLocation: fromSetup("pi"), + registrationProfile: supportedClientRegistrationProfile("pi"), + projectLocation: fixedProjectLocation([".pi/mcp.json"]), + }, + vscode: { + displayName: "VS Code", + format: "vscode", + globalLocation: fromSetup("vscode"), + registrationProfile: supportedClientRegistrationProfile("vscode"), + projectLocation: fixedProjectLocation([".vscode/mcp.json"]), + }, + windsurf: { + displayName: "Windsurf", + format: "json", + globalLocation: fromSetup("windsurf"), + registrationProfile: supportedClientRegistrationProfile("windsurf"), + }, + zed: { + displayName: "Zed", + format: "zed", + globalLocation: zedLocation, + projectLocation: fixedProjectLocation([".zed/settings.json"]), + registrationConnection: { + kind: "flat", + fields: ["url"], + stdio: [{ kind: "present", field: "url" }], + }, + }, + amp: { + displayName: "Amp", + format: "amp", + globalLocation: ampLocation, + registrationConnection: { + kind: "flat", + fields: ["url"], + stdio: [{ kind: "present", field: "url" }], + }, + }, +} as const satisfies Record; + +export type McpRegistrationClientId = keyof typeof MCP_REGISTRATION_CLIENTS; + +const ALIASES = { + "cline-vscode": "cline", + codeium: "windsurf", + cascade: "windsurf", + droid: "factory-droid", + factory: "factory-droid", + gemini: "gemini-cli", + "github-copilot": "vscode", + grok: "grok-build", + "hermes-agent": "hermes", + kilo: "kilo-code", + kilocode: "kilo-code", + kimi: "kimi-code", + kiro: "kiro-cli", + mastra: "mastracode", + "pi-agent": "pi", +} as const satisfies Record; + +const isClientId = (value: string): value is McpRegistrationClientId => + Object.hasOwn(MCP_REGISTRATION_CLIENTS, value); + +export const resolveMcpRegistrationClientId = ( + input: string, +): McpRegistrationClientId | undefined => { + const lower = input.toLowerCase(); + if (isClientId(lower)) return lower; + return Object.entries(ALIASES).find(([alias]) => alias === lower)?.[1]; +}; + +export interface McpRegistrationClient { + readonly registrationProfile: ClientRegistrationProfile; + readonly id: McpRegistrationClientId; + readonly displayName: string; + readonly configPath: string; + /** Complete known file layers in native precedence; configPath alone grants write authority. */ + readonly configPaths?: readonly string[]; + readonly inlineConfiguration?: McpRegistrationInlineConfiguration; + readonly scope: "global" | "project"; + readonly format: ClientConfigurationFormat; + readonly privateProfile?: true; +} + +export interface McpRegistrationSelection { + readonly agents: readonly string[]; + readonly global: boolean; + readonly cwd: string; + readonly home: string; + readonly platform: NodeJS.Platform; + readonly environment: Readonly; + readonly signal?: AbortSignal; +} + +type CatalogFailure = ConfigurationError | AnalysisCancelledError; +const checkCancellation = (signal: AbortSignal | undefined): void => { + if (signal?.aborted) + throw new AnalysisCancelledError("MCP registration selection", { + cause: signal.reason, + }); +}; + +const selectedIds = (agents: readonly string[]): McpRegistrationClientId[] => { + const ids: McpRegistrationClientId[] = []; + for (const agent of agents) { + const id = resolveMcpRegistrationClientId(agent); + if (id === undefined) + throw new ConfigurationError( + `Unknown MCP client ${agent}. Select a supported --agent value.`, + ); + if (!ids.includes(id)) ids.push(id); + } + return ids; +}; + +/** Existing setup bindings retain their exact profile; additional clients bind the descriptor key. */ +export const mcpClientRegistrationProfile = ( + id: McpRegistrationClientId, +): ClientRegistrationProfile => { + const descriptor: ClientDescriptor = MCP_REGISTRATION_CLIENTS[id]; + return ( + descriptor.registrationProfile ?? { + nativeClient: id, + connection: descriptor.registrationConnection, + } + ); +}; + +const projectTarget = async ( + id: McpRegistrationClientId, + context: McpRegistrationLocationContext, +): Promise => { + const descriptor: ClientDescriptor = MCP_REGISTRATION_CLIENTS[id]; + if (descriptor.projectLocation === undefined) + throw new ConfigurationError( + `${descriptor.displayName} has no project-scoped registration. Remove --no-global or select a project-capable client; no settings were written.`, + ); + const resolved = await descriptor.projectLocation(context); + return { + id, + displayName: descriptor.displayName, + registrationProfile: mcpClientRegistrationProfile(id), + ...resolved, + scope: "project", + format: descriptor.projectFormat ?? descriptor.format, + }; +}; + +const globalTarget = async ( + id: McpRegistrationClientId, + context: McpRegistrationLocationContext, +): Promise<{ client: McpRegistrationClient; markers: readonly string[] }> => { + const descriptor: ClientDescriptor = MCP_REGISTRATION_CLIENTS[id]; + const resolved = await descriptor.globalLocation(context); + return { + client: { + id, + displayName: descriptor.displayName, + registrationProfile: mcpClientRegistrationProfile(id), + configPath: selectedPath(resolved.configPath, context), + ...(resolved.configPaths === undefined + ? {} + : { + configPaths: resolved.configPaths.map((path) => + selectedPath(path, context), + ), + }), + scope: "global", + format: descriptor.format, + ...(resolved.inlineConfiguration === undefined + ? {} + : { inlineConfiguration: resolved.inlineConfiguration }), + ...(id === "fx" ? { privateProfile: true } : {}), + }, + markers: resolved.markers.map((marker) => selectedPath(marker, context)), + }; +}; + +const validateJunieGlobal = async ( + client: McpRegistrationClient, + context: McpRegistrationLocationContext, +): Promise => { + const env = context.environment; + if (env.JUNIE_MCP_DEFAULT_LOCATIONS?.toLowerCase() === "false") + throw new ConfigurationError( + "Junie's default MCP locations are disabled by JUNIE_MCP_DEFAULT_LOCATIONS.", + ); + if (env.JUNIE_CONFIG_LOCATION) + throw new ConfigurationError( + "Cannot determine Junie's MCP location selection while JUNIE_CONFIG_LOCATION is set. Inspect the additional Junie configuration before registering.", + ); + if (env.JUNIE_CONFIG_DEFAULT_LOCATIONS?.toLowerCase() !== "false") { + const paths = context.platform === "win32" ? win32 : { dirname, join }; + const configPath = selectedPath( + paths.join( + paths.dirname(paths.dirname(client.configPath)), + "config.json", + ), + context, + ); + let content: string | undefined; + try { + content = await readRegularFileText(configPath, { + resources: context.resources, + signal: context.signal, + }); + } catch (cause: unknown) { + if ( + !(cause instanceof Error && "code" in cause && cause.code === "ENOENT") + ) { + const failure = new ConfigurationError( + "Cannot inspect Junie's user configuration to determine its MCP locations.", + { cause }, + ); + bindRegularFileCleanup(cause, failure); + throw failure; + } + } + if (content !== undefined) { + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch (cause: unknown) { + throw new ConfigurationError( + "Cannot parse Junie's user configuration to determine its MCP locations.", + { cause }, + ); + } + const record = clientConfigurationRecordSchema.safeParse(parsed); + if ( + !record.success || + (record.data["mcp-default-locations"] !== undefined && + typeof record.data["mcp-default-locations"] !== "boolean") + ) + throw new ConfigurationError( + "Junie's user configuration does not establish whether default MCP locations are enabled.", + ); + if (record.data["mcp-default-locations"] === false) + throw new ConfigurationError( + "Junie's user configuration disables its default MCP locations.", + ); + } + } +}; + +/** Resolve all selected paths before any registration writer receives authority. */ +export const resolveMcpRegistrationClients = async ( + selection: McpRegistrationSelection, + resources: ArtifactResourceScope, +): Promise> => { + try { + checkCancellation(selection.signal); + const ids = selectedIds(selection.agents); + const context: McpRegistrationLocationContext = { + ...selection, + resources, + setup: selection.global + ? supportedClients( + selection.home, + selection.platform, + selection.environment, + ) + : [], + }; + // Default selection includes global-only Amp and cannot become project writes. + if (!selection.global && ids.length === 0) + throw new ConfigurationError( + "Default MCP registration includes global-only Amp. Select project-capable --agent values or remove --no-global; no settings were written.", + ); + if (ids.length > 0) { + const clients: McpRegistrationClient[] = []; + for (const id of ids) { + checkCancellation(selection.signal); + clients.push( + selection.global + ? (await globalTarget(id, context)).client + : await projectTarget(id, context), + ); + } + if (selection.global) { + const junie = clients.find((client) => client.id === "junie"); + if (junie) await validateJunieGlobal(junie, context); + } + checkCancellation(selection.signal); + return ok(clients); + } + const all: McpRegistrationClient[] = []; + const detected: McpRegistrationClient[] = []; + for (const id of Object.keys(MCP_REGISTRATION_CLIENTS).filter(isClientId)) { + checkCancellation(selection.signal); + if (id === "amp") continue; + const { client, markers } = await globalTarget(id, context); + all.push(client); + // Examine every marker: unreadability cannot turn a partial observation into absence. + const statuses: boolean[] = []; + for (const marker of markers) statuses.push(await present(marker)); + if (statuses.some(Boolean)) detected.push(client); + } + const amp = (await globalTarget("amp", context)).client; + checkCancellation(selection.signal); + const selected = [...(detected.length === 0 ? all : detected), amp]; + const junie = selected.find((client) => client.id === "junie"); + if (junie) await validateJunieGlobal(junie, context); + return ok(selected); + } catch (cause: unknown) { + const failure = + cause instanceof ConfigurationError || + cause instanceof AnalysisCancelledError + ? cause + : new ConfigurationError( + "Could not resolve MCP registration locations.", + { + cause, + }, + ); + if (selection.signal?.aborted === true) + analysisErrorWithCancellation( + failure, + new AnalysisCancelledError("MCP registration selection", { + cause: selection.signal.reason, + }), + ); + return err(failure); + } +}; diff --git a/src/application/McpRegistrationConfiguration.ts b/src/application/McpRegistrationConfiguration.ts new file mode 100644 index 0000000000..e7ff4adacc --- /dev/null +++ b/src/application/McpRegistrationConfiguration.ts @@ -0,0 +1,194 @@ +import { ConfigurationError } from "../domain/configurationErrors.js"; +import type { ClientRegistrationCommand } from "../domain/clientRegistrationCommand.js"; +import { PRODUCT_IDENTITY } from "../identity.js"; +import { + clientConfigurationValuesEqual, + clientServerPath, + clientServerForEdit, + legacyClientServerPath, + effectiveClientServer, + parseClientConfiguration, + serializeClientConfiguration, + withClientServers, + type ClientConfigurationDocument, +} from "./ClientConfigurationDocument.js"; +import { + clientConfigurationForWrite, + mergeClientConfigurationDocuments, + readConfigurationFile, +} from "./ClientConfigurationFiles.js"; +import { replaceClientRegistrationLauncher } from "./ClientRegistration.js"; +import { admitClientStdioConnection } from "./ClientRegistrationConnection.js"; +import type { McpRegistrationClient } from "./McpRegistrationCatalog.js"; +import { readRegularFileTextSnapshot } from "./RegularFileRead.js"; + +const isMissing = (cause: unknown): boolean => + cause instanceof Error && "code" in cause && cause.code === "ENOENT"; + +export const prepareRegistration = async ( + path: string, + client: McpRegistrationClient, + launcher: ClientRegistrationCommand, +) => { + const snapshot = await readRegularFileTextSnapshot(path, { + symlinks: "reject", + }).catch((cause: unknown) => { + if (isMissing(cause)) return undefined; + throw cause; + }); + const original = snapshot?.text; + const empty = serializeClientConfiguration({}, client.format); + if (!empty.ok) + throw new ConfigurationError( + "An empty client configuration could not be serialized.", + ); + const target = parseClientConfiguration( + original ?? empty.value, + client.format, + ); + const layers = await readRegistrationLayers(client, target); + const parsed = clientConfigurationForWrite(target, layers); + const paths = client.configPaths ?? [client.configPath]; + const targetIndex = paths.lastIndexOf(client.configPath); + const inherited = mergeClientConfigurationDocuments( + layers.slice(0, targetIndex), + ); + const desired = replaceClientRegistrationLauncher( + { dialect: parsed.dialect, profile: client.registrationProfile }, + [launcher.command, ...launcher.args], + clientServerForEdit(parsed, PRODUCT_IDENTITY.mcpServerKey), + inherited === undefined + ? undefined + : effectiveClientServer(inherited, PRODUCT_IDENTITY.mcpServerKey), + ); + if (!desired.ok) throw desired.error; + const updated = withClientServers( + parsed, + { + ...parsed.servers, + [PRODUCT_IDENTITY.mcpServerKey]: desired.value, + }, + PRODUCT_IDENTITY.mcpServerKey, + ); + const legacy = legacyClientServerPath(parsed, PRODUCT_IDENTITY.mcpServerKey); + const serialized = serializeClientConfiguration( + updated, + client.format, + original, + [ + clientServerPath(parsed, PRODUCT_IDENTITY.mcpServerKey), + ...(legacy === undefined ? [] : [legacy]), + ], + { removeShadowedMembersAt: legacy === undefined ? [] : [legacy] }, + ); + if (!serialized.ok) + throw new ConfigurationError( + `Client configuration member is shadowed at ${serialized.error.path.join(".")}.`, + ); + const candidate = parseClientConfiguration(serialized.value, client.format); + // Native loaders may visit one file at multiple precedence positions. A + // physical update changes every occurrence without deduplicating its order. + const candidateLayers = layers.map((document, index) => + paths[index] === client.configPath ? candidate : document, + ); + assertEffectiveLauncher(client, candidateLayers, launcher); + return { + path, + client, + launcher, + snapshot, + original, + parsed, + updated, + contents: serialized.value, + }; +}; + +/** File layers and inline authority remain distinct; only the selected target is replaced. */ +const readRegistrationLayers = async ( + client: McpRegistrationClient, + target: ClientConfigurationDocument, +): Promise => { + const layers: ClientConfigurationDocument[] = []; + const paths = client.configPaths ?? [client.configPath]; + if (!paths.includes(client.configPath)) + throw new ConfigurationError( + "The selected configuration is missing from the native load order.", + ); + for (const path of paths) { + if (path === client.configPath) { + layers.push(target); + continue; + } + const read = await readConfigurationFile(path, client.format); + if (!read.ok) + throw ( + read.error.cleanupFailure ?? new ConfigurationError(read.error.detail) + ); + // Retain an empty layer for missing files so target positions do not shift. + if (read.value !== undefined) layers.push(read.value); + else { + const empty = serializeClientConfiguration({}, client.format); + if (!empty.ok) + throw new ConfigurationError( + "An empty native configuration could not be serialized.", + ); + layers.push(parseClientConfiguration(empty.value, client.format)); + } + } + if (client.inlineConfiguration !== undefined) + layers.push(client.inlineConfiguration.parsed); + return layers; +}; + +const assertEffectiveLauncher = ( + client: McpRegistrationClient, + layers: readonly ClientConfigurationDocument[], + launcher: ClientRegistrationCommand, +): void => { + const effective = mergeClientConfigurationDocuments(layers); + const admitted = admitClientStdioConnection( + effective === undefined + ? undefined + : effectiveClientServer(effective, PRODUCT_IDENTITY.mcpServerKey), + { + dialect: effective?.dialect ?? client.format, + profile: client.registrationProfile, + }, + ); + if ( + !admitted.ok || + admitted.value.command !== launcher.command || + !clientConfigurationValuesEqual(admitted.value.args, launcher.args) + ) + throw new ConfigurationError( + admitted.ok + ? "A higher-priority configuration overrides the selected stdio launcher. Update that source before registering this command." + : admitted.error.cause.message, + { + ...(admitted.ok ? {} : { cause: admitted.error.cause }), + diagnostics: { + settings_path: client.configPath, + ...(client.inlineConfiguration === undefined + ? {} + : { inline_origin: client.inlineConfiguration.origin }), + }, + }, + ); +}; + +/** Re-admit native layers around the caller's identity-verified target snapshot. */ +export const verifyEffectiveRegistration = async ( + prepared: Awaited>, + targetText: string, +): Promise => { + const { client, launcher } = prepared; + assertEffectiveLauncher( + client, + await readRegistrationLayers( + client, + parseClientConfiguration(targetText, client.format), + ), + launcher, + ); +}; diff --git a/src/application/McpRegistrationGit.test.ts b/src/application/McpRegistrationGit.test.ts new file mode 100644 index 0000000000..aeb7f121b2 --- /dev/null +++ b/src/application/McpRegistrationGit.test.ts @@ -0,0 +1,199 @@ +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { ConfigurationError } from "../domain/configurationErrors.js"; +import * as owned from "../process/OwnedCommand.js"; +import type { ProviderProcessSnapshot } from "../process/ProviderProcess.js"; +import { resolveKiloRegistrationWorktree } from "./McpRegistrationGit.js"; +import { resolveMcpRegistrationClients } from "./McpRegistrationCatalog.js"; +import * as locations from "./McpRegistrationLocations.js"; +import type { McpRegistrationLocationContext } from "./McpRegistrationLocations.js"; + +let resources: ArtifactResourceScope; +beforeEach(() => { + resources = new ArtifactResourceScope(); + vi.spyOn(owned, "prepareOwnedCommand").mockResolvedValue(undefined); + vi.spyOn(owned, "runOwnedCommand").mockRejectedValue( + new Error("An inert Git outcome must be selected by the fixture"), + ); +}); +afterEach(async () => { + try { + await resources.close(); + } finally { + vi.mocked(owned.prepareOwnedCommand).mockRestore(); + vi.mocked(owned.runOwnedCommand).mockRestore(); + } +}); + +const context = async ( + signal?: AbortSignal, +): Promise => { + const root = await createTestTempDirectory("rea-registration-git-"); + await mkdir(join(root, ".git")); + return { + cwd: root, + home: root, + platform: process.platform, + environment: {}, + setup: [], + resources, + ...(signal === undefined ? {} : { signal }), + }; +}; + +it.each(["process", "timeout", "output-limit"] as const)( + "keeps observed %s failure primary when cancellation arrives during Git completion", + async (reason) => { + const abort = new AbortController(); + const opaque = { operator: "cancel", detail: new Error("opaque reason") }; + const failure = new owned.OwnedCommandFailure( + reason, + "Physical Git observation failed", + null, + null, + { cancellation: { reason: opaque }, cause: new Error("physical cause") }, + ); + vi.mocked(owned.runOwnedCommand).mockImplementation(async () => { + abort.abort(opaque); + throw failure; + }); + const observed: unknown = await resolveKiloRegistrationWorktree( + await context(abort.signal), + ).catch((cause: unknown) => cause); + expect(observed).toBeInstanceOf(ConfigurationError); + if (!(observed instanceof ConfigurationError)) + throw new Error("Physical Git failure lost its primary classification"); + expect(observed.cause).toBe(failure); + expect(observed.secondaryFailures).toHaveLength(1); + expect(observed.secondaryFailures[0]).toBeInstanceOf( + AnalysisCancelledError, + ); + expect(observed.secondaryFailures[0]?.cause).toBe(opaque); + expect(failure.cancellation?.reason).toBe(opaque); + }, +); + +it("retains primary cancellation and its opaque caller reason", async () => { + const opaque = { operator: "cancel", detail: new Error("opaque reason") }; + vi.mocked(owned.runOwnedCommand).mockRejectedValue( + new owned.OwnedCommandFailure( + "cancelled", + "Git observation cancelled", + null, + null, + { + cancellation: { reason: opaque }, + }, + ), + ); + const observed: unknown = await resolveKiloRegistrationWorktree( + await context(), + ).catch((cause: unknown) => cause); + expect(observed).toBeInstanceOf(AnalysisCancelledError); + if (!(observed instanceof AnalysisCancelledError)) + throw new Error("Primary cancellation lost its classification"); + expect(observed.cause).toBe(opaque); +}); + +it("retains the exact incomplete UTF-8 snapshot behind the Git observation failure", async () => { + const snapshot: ProviderProcessSnapshot = { + stdout: { text: "\uFFFD", invalidUtf8: true, bytes: 1, observedBytes: 1 }, + stderr: { text: "native diagnostic", bytes: 17, observedBytes: 17 }, + exitCode: 0, + signal: null, + }; + vi.mocked(owned.runOwnedCommand).mockResolvedValue(snapshot); + const observed: unknown = await resolveKiloRegistrationWorktree( + await context(), + ).catch((cause: unknown) => cause); + expect(observed).toBeInstanceOf(ConfigurationError); + if (!(observed instanceof ConfigurationError)) + throw new Error( + "Incomplete Git observation lost its configuration failure", + ); + expect(observed.cause).toBeInstanceOf(Error); + if (!(observed.cause instanceof Error)) + throw new Error("Incomplete Git observation lost its immediate failure"); + expect(observed.cause.cause).toBe(snapshot); +}); + +it("preserves an incomplete Git observation and a late cancellation through selection", async () => { + const controller = new AbortController(); + const reason = { operator: "cancel incomplete observation" }; + const snapshot: ProviderProcessSnapshot = { + stdout: { text: "\uFFFD", invalidUtf8: true, bytes: 1, observedBytes: 1 }, + stderr: { text: "native diagnostic", bytes: 17, observedBytes: 17 }, + exitCode: 0, + signal: null, + }; + vi.mocked(owned.runOwnedCommand).mockImplementation(async () => { + controller.abort(reason); + return snapshot; + }); + const selected = await context(controller.signal); + const result = await resolveMcpRegistrationClients( + { + agents: ["kilo-code"], + global: false, + cwd: selected.cwd, + home: selected.home, + platform: selected.platform, + environment: selected.environment, + signal: controller.signal, + }, + resources, + ); + if (result.ok) throw new Error("Incomplete observation must fail"); + expect(result.error).toBeInstanceOf(ConfigurationError); + expect(result.error.cause).toBeInstanceOf(Error); + expect((result.error.cause as Error).cause).toBe(snapshot); + expect(result.error.secondaryFailures).toEqual([ + expect.objectContaining({ _tag: "AnalysisCancelledError", cause: reason }), + ]); +}); + +it("preserves a native location access failure and late cancellation through selection", async () => { + const controller = new AbortController(); + const reason = { operator: "cancel location discovery" }; + const physical = new Error("client path could not be inspected"); + const failure = new ConfigurationError("Selected path unavailable", { + cause: physical, + }); + const present = vi + .spyOn(locations, "present") + .mockImplementationOnce(async () => { + controller.abort(reason); + throw failure; + }); + try { + const selected = await context(controller.signal); + const result = await resolveMcpRegistrationClients( + { + agents: ["kilo-code"], + global: true, + cwd: selected.cwd, + home: selected.home, + platform: selected.platform, + environment: selected.environment, + signal: controller.signal, + }, + resources, + ); + if (result.ok) throw new Error("Location access failure was lost"); + expect(result.error).toBe(failure); + expect(result.error.cause).toBe(physical); + expect(result.error.secondaryFailures).toEqual([ + expect.objectContaining({ + _tag: "AnalysisCancelledError", + cause: reason, + }), + ]); + } finally { + present.mockRestore(); + } +}); diff --git a/src/application/McpRegistrationGit.ts b/src/application/McpRegistrationGit.ts new file mode 100644 index 0000000000..2022d1e0ee --- /dev/null +++ b/src/application/McpRegistrationGit.ts @@ -0,0 +1,275 @@ +import { randomUUID } from "node:crypto"; +import { lstat, realpath } from "node:fs/promises"; +import { dirname, isAbsolute, relative, sep } from "node:path"; + +import { + AnalysisCleanupLease, + AnalysisCleanupLeaseGroup, +} from "../artifacts/AnalysisCleanupLease.js"; +import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { analysisErrorWithCancellation } from "../domain/analysisErrorCancellation.js"; +import { ConfigurationError } from "../domain/configurationErrors.js"; +import { absoluteCallerPath } from "../filesystem/callerPath.js"; +import { + OwnedCommandFailure, + prepareOwnedCommand, + runOwnedCommand, +} from "../process/OwnedCommand.js"; +import type { McpRegistrationLocationContext } from "./McpRegistrationLocations.js"; + +/** Git's observed active and primary worktree roots; neither grants mutation authority. */ +export interface KiloRegistrationWorktree { + readonly activeDirectory: string; + readonly root: string; + readonly primaryRoot: string; +} + +export const pathWithin = (root: string, path: string): boolean => { + const part = relative(root, path); + return part !== ".." && !part.startsWith(`..${sep}`) && !isAbsolute(part); +}; + +const retainGitCleanup = ( + cause: unknown, + context: McpRegistrationLocationContext, +): readonly AnalysisCleanupLease[] => { + if (!(cause instanceof OwnedCommandFailure)) return []; + const leases: AnalysisCleanupLease[] = []; + const preparation = cause.preparationFailure; + const cleanup = preparation?.cleanupFailure; + if (cleanup !== undefined) { + leases.push( + new AnalysisCleanupLease(async () => { + if (!cleanup.cleanupIncomplete) return; + if (preparation?.cleanupOwner === undefined) throw cleanup; + await preparation.cleanupOwner.close(); + if (cleanup.cleanupIncomplete) throw cleanup; + }, cleanup), + ); + } + if (cause.cleanupOwner !== undefined || cause.cleanupFailure !== null) { + const supervisor = cause.cleanupOwner; + leases.push( + new AnalysisCleanupLease(async () => { + if (supervisor === undefined) throw cause; + const stopped = await supervisor.stop(); + if (stopped.status === "incomplete") + throw new ConfigurationError(stopped.reason, { + cleanup: { reason: stopped.reason, resources: cause.resources }, + }); + supervisor.dispose(); + }, cause.resources), + ); + } + for (const lease of leases) + context.resources.retain({ + kind: "lease", + resource: "Kilo read-only Git discovery", + lease, + }); + return leases; +}; + +const gitFailure = ( + cause: unknown, + context: McpRegistrationLocationContext, +): ConfigurationError | AnalysisCancelledError => { + const leases = retainGitCleanup(cause, context); + const cleanup = + leases.length === 0 + ? undefined + : { + reason: "Read-only Git discovery cleanup remains incomplete.", + resources: leases.flatMap((lease) => lease.cleanupResources ?? []), + }; + const cancellation = + cause instanceof OwnedCommandFailure ? cause.cancellation : undefined; + const primaryCancellation = + cause instanceof OwnedCommandFailure && cause.reason === "cancelled"; + const cancelled = + cancellation === undefined + ? undefined + : new AnalysisCancelledError("Kilo project root discovery", { + cause: cancellation.reason, + ...(!primaryCancellation || cleanup === undefined ? {} : { cleanup }), + }); + const failure = + primaryCancellation && cancelled !== undefined + ? cancelled + : new ConfigurationError( + "Kilo project root could not be established by read-only Git metadata. Repair Git or repository access, or register globally; no client settings were written.", + { cause, ...(cleanup === undefined ? {} : { cleanup }) }, + ); + if (cancelled !== undefined && failure !== cancelled) + analysisErrorWithCancellation(failure, cancelled); + if (leases.length > 0) new AnalysisCleanupLeaseGroup(failure, leases); + return failure; +}; + +const gitOutput = async ( + context: McpRegistrationLocationContext, + cwd: string, + arguments_: readonly string[], +): Promise => { + try { + await prepareOwnedCommand(context.signal); + const result = await runOwnedCommand( + { + command: "git", + arguments: arguments_, + cwd, + runId: randomUUID(), + expectedCommand: null, + hostEnvironment: context.environment, + sweepTokenOwnedProcesses: true, + }, + { timeoutMs: 10_000, diagnosticBytes: 1024 * 1024 }, + { signal: context.signal }, + ); + if ( + result.stdout.invalidUtf8 || + result.diagnosticTruncated || + result.outputIncomplete + ) + throw new Error( + "Git did not provide a complete UTF-8 metadata observation.", + { cause: result }, + ); + return result.stdout.text; + } catch (cause: unknown) { + throw gitFailure(cause, context); + } +}; + +/** Each rev-parse invocation returns one path, so embedded whitespace remains path data. */ +const gitPath = async ( + context: McpRegistrationLocationContext, + cwd: string, + field: string, +): Promise => { + const text = await gitOutput(context, cwd, ["rev-parse", field]); + if (!text.endsWith("\n")) + throw new ConfigurationError( + "Git metadata path has no protocol terminator; Kilo project root remains unknown.", + ); + let value = text.slice(0, -1); + if (context.platform === "win32" && value.endsWith("\r")) + value = value.slice(0, -1); + if (value.length === 0) + throw new ConfigurationError( + "Git returned an empty path; Kilo project root remains unknown.", + ); + return absoluteCallerPath(value, { cwd, platform: context.platform }); +}; + +const nearestGitDirectory = async ( + cwd: string, +): Promise => { + let directory = cwd; + while (true) { + try { + await lstat(`${directory}${directory.endsWith(sep) ? "" : sep}.git`); + return directory; + } catch (cause: unknown) { + if ( + !(cause instanceof Error && "code" in cause && cause.code === "ENOENT") + ) + throw cause; + } + const parent = dirname(directory); + if (parent === directory) return undefined; + directory = parent; + } +}; + +const primaryRoot = async ( + context: McpRegistrationLocationContext, + root: string, + gitDirectory: string, + commonDirectory: string, +): Promise => { + if (gitDirectory === commonDirectory) return root; + // Kilo's primaryPaths uses the first non-bare porcelain worktree record. + const listing = await gitOutput(context, root, [ + "worktree", + "list", + "--porcelain", + "-z", + ]); + const record = listing.split("\0\0", 1)[0]?.split("\0"); + const field = record?.find((item) => item.startsWith("worktree ")); + if (field === undefined || record?.includes("bare")) + throw new ConfigurationError( + "Kilo primary worktree is unknown; no project settings were written.", + ); + const value = field.slice("worktree ".length); + if (value.length === 0 || !listing.includes("\0\0")) + throw new ConfigurationError( + "Git returned incomplete worktree metadata; no project settings were written.", + ); + return directoryPath( + absoluteCallerPath(value, { cwd: root, platform: context.platform }), + ); +}; + +const directoryPath = async (path: string): Promise => { + const resolved = await realpath(path); + if (!(await lstat(resolved)).isDirectory()) + throw new ConfigurationError( + "Git metadata did not identify a directory; repair repository access or register globally. No settings were written.", + ); + return resolved; +}; + +/** Match the native nearest-.git and authoritative rev-parse worktree boundary. */ +export const resolveKiloRegistrationWorktree = async ( + context: McpRegistrationLocationContext, +): Promise => { + try { + const activeDirectory = await directoryPath( + absoluteCallerPath(context.cwd, { platform: context.platform }), + ); + const gitCwd = await nearestGitDirectory(activeDirectory); + if (gitCwd === undefined) + throw new ConfigurationError( + "Kilo project registration needs a verified Git worktree boundary. Open a repository or register globally; no client settings were written.", + ); + const root = await directoryPath( + await gitPath(context, gitCwd, "--show-toplevel"), + ); + const gitDirectory = await directoryPath( + await gitPath(context, gitCwd, "--git-dir"), + ); + const commonDirectory = await directoryPath( + await gitPath(context, gitCwd, "--git-common-dir"), + ); + if (!pathWithin(root, activeDirectory)) + throw new ConfigurationError( + "Git's worktree does not contain the selected directory; Kilo project scope remains unknown.", + ); + return { + activeDirectory, + root, + primaryRoot: await primaryRoot( + context, + root, + gitDirectory, + commonDirectory, + ), + }; + } catch (cause: unknown) { + const failure = + cause instanceof ConfigurationError || + cause instanceof AnalysisCancelledError + ? cause + : gitFailure(cause, context); + if (context.signal?.aborted === true) + analysisErrorWithCancellation( + failure, + new AnalysisCancelledError("Kilo project root discovery", { + cause: context.signal.reason, + }), + ); + throw failure; + } +}; diff --git a/src/application/McpRegistrationKiloLocations.ts b/src/application/McpRegistrationKiloLocations.ts new file mode 100644 index 0000000000..0fc1132ce6 --- /dev/null +++ b/src/application/McpRegistrationKiloLocations.ts @@ -0,0 +1,295 @@ +import { dirname, join, relative } from "node:path"; +import { ConfigurationError } from "../domain/configurationErrors.js"; +import { parseClientConfiguration } from "./ClientConfigurationDocument.js"; +import { + resolveKiloRegistrationWorktree, + pathWithin, +} from "./McpRegistrationGit.js"; +import { + selectedPath, + platformPaths, + present, + type McpRegistrationLocationContext, + type McpRegistrationLocation, + type McpRegistrationGlobalLocation, + type McpRegistrationInlineConfiguration, +} from "./McpRegistrationLocations.js"; + +/** Names from Kilo v7.8.27 loadGlobal and config-directory merge order. */ +const KILO_GLOBAL_FILES = [ + "config.json", + "kilo.json", + "kilo.jsonc", + "opencode.json", + "opencode.jsonc", +] as const; +const KILO_DIRECTORY_FILES = [ + "kilo.jsonc", + "kilo.json", + "opencode.jsonc", + "opencode.json", +] as const; +interface KiloFileLayer { + readonly path: string; + readonly authority: "global" | "project" | "readonly"; +} +interface KiloConfigDirectory { + readonly path: string; + readonly authority: KiloFileLayer["authority"]; +} + +const observedFiles = async ( + directory: string, + names: readonly string[], + authority: KiloFileLayer["authority"], +): Promise => { + const files: KiloFileLayer[] = []; + for (const name of names) { + const path = join(directory, name); + if (await present(path)) files.push({ path, authority }); + } + return files; +}; +const kiloInline = ( + context: McpRegistrationLocationContext, +): McpRegistrationInlineConfiguration | undefined => { + const text = context.environment.KILO_CONFIG_CONTENT; + if (!text) return undefined; + try { + return { + origin: "KILO_CONFIG_CONTENT", + parsed: parseClientConfiguration(text, "kilo"), + }; + } catch (cause: unknown) { + throw new ConfigurationError( + "KILO_CONFIG_CONTENT cannot be admitted as Kilo configuration. Repair that selected inline configuration; no settings were written.", + { cause }, + ); + } +}; +const kiloRoot = (context: McpRegistrationLocationContext): string => + selectedPath(join(platformPaths(context).config, "kilo"), context); +const kiloHomeDirectories = async ( + context: McpRegistrationLocationContext, +): Promise => { + const directories: KiloConfigDirectory[] = []; + for (const name of [".kilocode", ".kilo"]) { + const path = selectedPath(join(context.home, name), context); + if (await present(path)) directories.push({ path, authority: "global" }); + } + return directories; +}; +const directoryFiles = async ( + directories: readonly KiloConfigDirectory[], + custom: string | undefined, + planned?: KiloFileLayer, +): Promise => { + const seen = new Set(); + const layers: KiloFileLayer[] = []; + for (const directory of directories) { + if (seen.has(directory.path)) continue; + seen.add(directory.path); + if ( + !directory.path.endsWith(".kilo") && + !directory.path.endsWith(".kilocode") && + directory.path !== custom + ) + continue; + if (planned !== undefined && dirname(planned.path) === directory.path) + layers.push(planned); + layers.push( + ...(await observedFiles( + directory.path, + KILO_DIRECTORY_FILES, + directory.authority, + )), + ); + } + return layers; +}; +const kiloExplicitFile = ( + context: McpRegistrationLocationContext, +): string | undefined => + context.environment.KILO_CONFIG + ? selectedPath(context.environment.KILO_CONFIG, context) + : undefined; +const kiloExplicitDirectory = ( + context: McpRegistrationLocationContext, +): string | undefined => + context.environment.KILO_CONFIG_DIR + ? selectedPath(context.environment.KILO_CONFIG_DIR, context) + : undefined; + +export const kiloLocation = async ( + context: McpRegistrationLocationContext, +): Promise => { + const root = kiloRoot(context); + const layers = await observedFiles(root, KILO_GLOBAL_FILES, "global"); + const explicit = kiloExplicitFile(context); + if (explicit !== undefined) { + await present(explicit); + layers.push({ path: explicit, authority: "global" }); + } + const custom = kiloExplicitDirectory(context); + const directories: KiloConfigDirectory[] = [ + { path: root, authority: "global" }, + ...(await kiloHomeDirectories(context)), + ]; + if (custom !== undefined) + directories.push({ path: custom, authority: "global" }); + const observed = await directoryFiles(directories, custom); + let configPath = observed.at(-1)?.path ?? layers.at(-1)?.path; + if ( + custom !== undefined && + ![...layers, ...observed].some((layer) => dirname(layer.path) === custom) + ) { + await present(custom); + configPath = join(custom, "kilo.json"); + layers.push( + ...(await directoryFiles(directories, custom, { + path: configPath, + authority: "global", + })), + ); + } else layers.push(...observed); + configPath ??= join(root, "kilo.json"); + const inlineConfiguration = kiloInline(context); + return { + configPath, + configPaths: + layers.length === 0 ? [configPath] : layers.map((layer) => layer.path), + ...(inlineConfiguration === undefined ? {} : { inlineConfiguration }), + markers: [ + root, + join( + platformPaths(context).vscode, + "globalStorage", + "kilocode.kilo-code", + ), + ], + }; +}; + +const ancestorsThrough = (directory: string, root: string): string[] => { + const directories: string[] = []; + let current = directory; + while (true) { + directories.push(current); + if (current === root) return directories; + const parent = dirname(current); + if (parent === current) + throw new ConfigurationError( + "Kilo worktree boundary is not an ancestor; no project settings were written.", + ); + current = parent; + } +}; +const projectDirectories = async ( + directories: readonly string[], + authority: KiloFileLayer["authority"], +): Promise => { + const found: KiloConfigDirectory[] = []; + for (const directory of directories) + for (const name of [".kilocode", ".kilo"]) { + const path = join(directory, name); + if (await present(path)) found.push({ path, authority }); + } + return found; +}; + +/** Native project read order, with global/primary authorities retained read-only. */ +export const kiloProjectLocation = async ( + context: McpRegistrationLocationContext, +): Promise => { + if ( + ["true", "1"].includes( + context.environment.KILO_DISABLE_PROJECT_CONFIG?.toLowerCase() ?? "", + ) + ) + throw new ConfigurationError( + "KILO_DISABLE_PROJECT_CONFIG disables project configuration. Remove that selected override or register globally; no project settings were written.", + ); + const worktree = await resolveKiloRegistrationWorktree(context); + const active = ancestorsThrough(worktree.activeDirectory, worktree.root); + const root = kiloRoot(context); + const layers = await observedFiles(root, KILO_GLOBAL_FILES, "readonly"); + const explicit = kiloExplicitFile(context); + if (explicit !== undefined) { + await present(explicit); + layers.push({ + path: explicit, + authority: pathWithin(worktree.root, explicit) ? "project" : "readonly", + }); + } + // ConfigPaths.files reverses nearest-first [jsonc,json] matches, first Kilo then OpenCode. + for (const name of ["kilo", "opencode"]) + for (const directory of [...active].reverse()) + layers.push( + ...(await observedFiles( + directory, + [`${name}.json`, `${name}.jsonc`], + "project", + )), + ); + const primary: KiloConfigDirectory[] = []; + if (worktree.primaryRoot !== worktree.root) { + const mirrored = join( + worktree.primaryRoot, + relative(worktree.root, worktree.activeDirectory), + ); + primary.push( + ...(await projectDirectories( + ancestorsThrough(mirrored, worktree.primaryRoot), + "readonly", + )), + ); + } + const nativeProject = await projectDirectories(active, "project"); + const custom = kiloExplicitDirectory(context); + const directories: KiloConfigDirectory[] = [ + { path: root, authority: "readonly" }, + ...primary, + ...nativeProject, + ...(await kiloHomeDirectories(context).then((items) => + items.map((item) => ({ ...item, authority: "readonly" as const })), + )), + ]; + if (custom !== undefined) + directories.push({ + path: custom, + authority: pathWithin(worktree.root, custom) ? "project" : "readonly", + }); + const directoryLayers = await directoryFiles(directories, custom); + const writable = [...layers, ...directoryLayers].filter( + (layer) => layer.authority === "project", + ); + let configPath = writable.at(-1)?.path; + if (configPath === undefined) { + // Native updateProjectConfig's creation target must enter its future native position. + const planned = join(worktree.activeDirectory, ".kilo"); + const activeIndex = directories.findIndex( + (directory) => directory.authority === "project", + ); + directories.splice(activeIndex < 0 ? 1 + primary.length : activeIndex, 0, { + path: planned, + authority: "project", + }); + configPath = join(planned, "kilo.jsonc"); + const ordered: KiloFileLayer[] = []; + const seen = new Set(); + for (const directory of directories) { + if (seen.has(directory.path)) continue; + seen.add(directory.path); + if (directory.path === planned) + ordered.push({ path: configPath, authority: "project" }); + else ordered.push(...(await directoryFiles([directory], custom))); + } + layers.push(...ordered); + } else layers.push(...directoryLayers); + const inlineConfiguration = kiloInline(context); + return { + configPath, + configPaths: layers.map((layer) => layer.path), + ...(inlineConfiguration === undefined ? {} : { inlineConfiguration }), + }; +}; diff --git a/src/application/McpRegistrationLocations.ts b/src/application/McpRegistrationLocations.ts new file mode 100644 index 0000000000..a2b5987f25 --- /dev/null +++ b/src/application/McpRegistrationLocations.ts @@ -0,0 +1,260 @@ +import { lstat } from "node:fs/promises"; +import { dirname, join, posix, win32 } from "node:path"; + +import { ConfigurationError } from "../domain/configurationErrors.js"; +import { absoluteCallerPath } from "../filesystem/callerPath.js"; +import type { SetupClient, SupportedClientName } from "./SupportedClients.js"; +import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import type { ClientConfigurationDocument } from "./ClientConfigurationDocument.js"; + +export interface McpRegistrationLocationContext { + readonly home: string; + readonly cwd: string; + readonly platform: NodeJS.Platform; + readonly environment: Readonly; + readonly setup: readonly SetupClient[]; + readonly resources: ArtifactResourceScope; + readonly signal?: AbortSignal; +} + +export interface McpRegistrationInlineConfiguration { + readonly origin: "KILO_CONFIG_CONTENT"; + readonly parsed: ClientConfigurationDocument; +} + +export interface McpRegistrationLocation { + readonly configPath: string; + /** Complete known file layers in native precedence; configPath alone grants write authority. */ + readonly configPaths?: readonly string[]; + readonly inlineConfiguration?: McpRegistrationInlineConfiguration; +} + +export interface McpRegistrationGlobalLocation extends McpRegistrationLocation { + readonly markers: readonly string[]; +} + +export const selectedPath = ( + path: string, + context: McpRegistrationLocationContext, +): string => + absoluteCallerPath(path, { cwd: context.cwd, platform: context.platform }); + +export const fromSetup = + (name: SupportedClientName) => + async ( + context: McpRegistrationLocationContext, + ): Promise => { + const client = context.setup.find((candidate) => candidate.name === name); + if (client === undefined) + throw new ConfigurationError( + `The registration catalog has no path owner for ${name}.`, + ); + if (client.configPathError !== undefined) + throw new ConfigurationError(client.configPathError); + return { + configPath: selectedPath(client.configPath, context), + ...(client.configPaths === undefined + ? {} + : { + configPaths: client.configPaths.map((path) => + selectedPath(path, context), + ), + }), + markers: [selectedPath(client.markerPath ?? client.configPath, context)], + }; + }; + +export const platformPaths = (context: McpRegistrationLocationContext) => { + const { home, platform, environment } = context; + const config = environment.XDG_CONFIG_HOME || join(home, ".config"); + const appSupport = + platform === "win32" + ? environment.APPDATA || win32.join(home, "AppData", "Roaming") + : platform === "darwin" + ? join(home, "Library", "Application Support") + : config; + return { config, appSupport, vscode: join(appSupport, "Code", "User") }; +}; + +const location = ( + path: string, + marker = dirname(path), +): McpRegistrationGlobalLocation => ({ + configPath: path, + markers: [marker], +}); + +export const present = async (path: string): Promise => { + try { + await lstat(path); + return true; + } catch (cause: unknown) { + if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") + return false; + throw new ConfigurationError( + `Cannot determine the registration location at ${path}. Repair its access or path before registering.`, + { cause }, + ); + } +}; + +export const firstExisting = async ( + candidates: readonly string[], + fallback: string, +): Promise => { + for (const candidate of candidates) + if (await present(candidate)) return candidate; + return fallback; +}; + +export const clineLocation = async ( + context: McpRegistrationLocationContext, +): Promise => + location( + join( + platformPaths(context).vscode, + "globalStorage", + "saoudrizwan.claude-dev", + "settings", + "cline_mcp_settings.json", + ), + ); +export const clineCliLocation = async ( + context: McpRegistrationLocationContext, +): Promise => { + const { environment, home } = context; + const explicitPath = environment.CLINE_MCP_SETTINGS_PATH?.trim(); + if (explicitPath) return location(explicitPath); + const explicitData = environment.CLINE_DATA_DIR?.trim(); + if (explicitData) + return location(join(explicitData, "settings", "cline_mcp_settings.json")); + const explicitDirectory = environment.CLINE_DIR?.trim(); + if (explicitDirectory) + return location( + join(explicitDirectory, "data", "settings", "cline_mcp_settings.json"), + ); + const envHome = environment.HOME?.trim(); + const profile = environment.USERPROFILE?.trim(); + const drive = environment.HOMEDRIVE?.trim(); + const path = environment.HOMEPATH?.trim(); + // Cline falls back to os.homedir(), then to the literal "~". On POSIX, + // a present HOME is returned by os.homedir() even if empty or "~". + // Otherwise use the supplied account home only when it was not selected + // from a rejected environment value; that value cannot prove the OS home. + const rejectedHome = + context.platform !== "win32" && environment.HOME !== undefined; + const fallbackUncertain = + (context.platform === "win32" && + (environment.USERPROFILE !== undefined || + Boolean(environment.HOMEDRIVE && environment.HOMEPATH) || + Boolean(environment.HOME))) || + (context.platform !== "win32" && + environment.HOME === undefined && + environment.USERPROFILE !== undefined); + const nativeHome = rejectedHome + ? "~" + : fallbackUncertain + ? undefined + : home.trim() || "~"; + const clineHome = + (envHome && envHome !== "~" ? envHome : undefined) || + profile || + (drive && path ? `${drive}${path}` : undefined) || + nativeHome; + if (!clineHome) + throw new ConfigurationError( + "Cannot determine Cline CLI's system home from the selected environment. Set CLINE_MCP_SETTINGS_PATH, CLINE_DATA_DIR, or CLINE_DIR to select its configuration location.", + ); + return location( + join(clineHome, ".cline", "data", "settings", "cline_mcp_settings.json"), + ); +}; +export const gooseLocation = async ( + context: McpRegistrationLocationContext, +): Promise => { + const { home, platform, environment } = context; + const pathApi = platform === "win32" ? win32 : posix; + const root = environment.GOOSE_PATH_ROOT; + if (root && pathApi.isAbsolute(root)) { + const selected = pathApi.join(root, "config", "config.yaml"); + return location(selected, selected); + } + const xdg = environment.XDG_CONFIG_HOME; + const path = + platform === "win32" + ? join( + platformPaths(context).appSupport, + "Block", + "goose", + "config", + "config.yaml", + ) + : join( + xdg && posix.isAbsolute(xdg) ? xdg : join(home, ".config"), + "goose", + "config.yaml", + ); + return location(path, path); +}; +export const junieLocation = async ( + context: McpRegistrationLocationContext, +): Promise => { + const root = context.environment.JUNIE_HOME || join(context.home, ".junie"); + return location(join(root, "mcp", "mcp.json"), root); +}; +/** Fixed relative target precedence used by clients with a single project document. */ +export const fixedProjectLocation = + (candidates: readonly [string, ...string[]]) => + async ( + context: McpRegistrationLocationContext, + ): Promise => { + const paths = candidates.map((path) => selectedPath(path, context)); + return { + configPath: await firstExisting( + paths, + selectedPath(candidates[0], context), + ), + }; + }; +export const kiroLocation = async ( + context: McpRegistrationLocationContext, +): Promise => + location( + join(context.home, ".kiro", "settings", "mcp.json"), + join(context.home, ".kiro"), + ); +export const mcporterLocation = async ( + context: McpRegistrationLocationContext, +): Promise => { + const root = join(context.home, ".mcporter"); + const primary = join(root, "mcporter.json"); + return location( + await firstExisting([primary, join(root, "mcporter.jsonc")], primary), + root, + ); +}; +export const zedLocation = async ( + context: McpRegistrationLocationContext, +): Promise => + location( + context.platform === "darwin" + ? join(context.home, ".config", "zed", "settings.json") + : join( + platformPaths(context).appSupport, + context.platform === "win32" ? "Zed" : "zed", + "settings.json", + ), + ); + +export const fxLocation = async ( + context: McpRegistrationLocationContext, +): Promise => + location(join(context.home, ".fx", "mcp.json")); +export const mastraLocation = async ( + context: McpRegistrationLocationContext, +): Promise => + location(join(context.home, ".mastracode", "mcp.json")); +export const ampLocation = async ( + context: McpRegistrationLocationContext, +): Promise => + location(join(context.home, ".config", "amp", "settings.json")); diff --git a/src/application/Setup.configuration.test.ts b/src/application/Setup.configuration.test.ts index bcd5f04842..2c0b5bff85 100644 --- a/src/application/Setup.configuration.test.ts +++ b/src/application/Setup.configuration.test.ts @@ -10,6 +10,7 @@ import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js"; import { discoverSetupState } from "./SetupPlan.js"; import type { MsixPackageView } from "./MsixPackageStorage.js"; import { + supportedClientRegistrationProfile, registrationWriteBlocker, supportedClients, type SetupClient, @@ -35,6 +36,7 @@ const msixClaudeDesktop = ( processView: MsixPackageView["processView"], ): SetupClient => ({ name: "claude_desktop", + registrationProfile: supportedClientRegistrationProfile("claude_desktop"), displayName: "Claude Desktop", configPath: PRIVATE_CONFIG, quitBeforeSetup: true, @@ -111,7 +113,12 @@ describe("setup workflow", () => { it("blocks malformed client configuration before Hopper or file mutation", async () => { const host = new FakeSetupHost(); host.clients = [ - { name: "codex", displayName: "Codex", configPath: "/codex.toml" }, + { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + displayName: "Codex", + configPath: "/codex.toml", + }, ]; host.clientInspections.set("codex", { status: "invalid", @@ -137,10 +144,17 @@ describe("setup workflow", () => { host.clients = [ { name: "claude_desktop", + registrationProfile: + supportedClientRegistrationProfile("claude_desktop"), displayName: "Claude Desktop", configPath: "/claude.json", }, - { name: "cursor", displayName: "Cursor", configPath: "/cursor.json" }, + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + displayName: "Cursor", + configPath: "/cursor.json", + }, ]; host.clientInspections.set("claude_desktop", { status: "invalid", @@ -163,7 +177,12 @@ describe("setup workflow", () => { it("plans a client update when Hopper installation changes its desired environment", async () => { const host = new FakeSetupHost(); host.clients = [ - { name: "codex", displayName: "Codex", configPath: "/codex.toml" }, + { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + displayName: "Codex", + configPath: "/codex.toml", + }, ]; host.clientInspections.set("codex", { status: "already_current" }); @@ -191,7 +210,13 @@ describe("optional Hopper installation failures", () => { // registrations and skill (#5113). const host = new FakeSetupHost(); host.hopperInstallSucceeds = false; - host.clients = [{ name: "cursor", configPath: "/cursor.json" }]; + host.clients = [ + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath: "/cursor.json", + }, + ]; const result = await runSetup( { ...options(true, true), @@ -212,7 +237,13 @@ describe("optional Hopper installation failures", () => { it("keeps the Hopper failure when a client write also fails", async () => { const host = new FakeSetupHost(); host.hopperInstallSucceeds = false; - host.clients = [{ name: "cursor", configPath: "/cursor.json" }]; + host.clients = [ + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath: "/cursor.json", + }, + ]; host.clientResults.set("cursor", { status: "failed", reason: "write" }); const result = await runSetup( { ...options(true, true), clientIds: ["cursor"] }, @@ -232,7 +263,13 @@ describe("optional Hopper installation failures", () => { const host = new FakeSetupHost(); host.hopperInstallSucceeds = false; host.skill = "failed"; - host.clients = [{ name: "cursor", configPath: "/cursor.json" }]; + host.clients = [ + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath: "/cursor.json", + }, + ]; const result = await runSetup( { ...options(true, true), @@ -273,7 +310,13 @@ describe("optional Hopper installation failures", () => { skillFailure.hopperInstallSucceeds = false; skillFailure.hopperInstallFailure = ready.hopperInstallFailure; skillFailure.skill = "failed"; - skillFailure.clients = [{ name: "cursor", configPath: "/cursor.json" }]; + skillFailure.clients = [ + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath: "/cursor.json", + }, + ]; expect( await runSetup( { @@ -345,7 +388,13 @@ describe("configured Linux Hopper selection", () => { it("offers shared skill intent when the detected personal copy is current", async () => { const host = new FakeSetupHost(); host.hopper = "/Applications/Hopper"; - host.clients = [{ name: "claude_code", configPath: "/claude.json" }]; + host.clients = [ + { + name: "claude_code", + registrationProfile: supportedClientRegistrationProfile("claude_code"), + configPath: "/claude.json", + }, + ]; host.skillNeedsInstall = (clients) => Promise.resolve(clients.length === 0); const result = await runSetup( { ...options(false), structured: false }, @@ -380,8 +429,18 @@ describe("setup workflow action selection", () => { host.hopper = "/Applications/Hopper"; host.skill = "unchanged"; host.clients = [ - { name: "codex", displayName: "Codex", configPath: "/codex.toml" }, - { name: "cursor", displayName: "Cursor", configPath: "/cursor.json" }, + { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + displayName: "Codex", + configPath: "/codex.toml", + }, + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + displayName: "Cursor", + configPath: "/cursor.json", + }, ]; const result = await runSetup( @@ -408,8 +467,18 @@ describe("setup workflow action selection", () => { host.skill = "unchanged"; host.clients = []; host.availableClients = [ - { name: "codex", displayName: "Codex", configPath: "/codex.toml" }, - { name: "cursor", displayName: "Cursor", configPath: "/cursor.json" }, + { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + displayName: "Codex", + configPath: "/codex.toml", + }, + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + displayName: "Cursor", + configPath: "/cursor.json", + }, ]; host.clientInspections.set("cursor", { status: "invalid", @@ -436,7 +505,13 @@ describe("setup workflow action selection", () => { const host = new FakeSetupHost(); host.hopper = "/Applications/Hopper"; host.skill = "unchanged"; - host.clients = [{ name: "codex", configPath: "/codex.toml" }]; + host.clients = [ + { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + configPath: "/codex.toml", + }, + ]; host.productRegistrations = [ { client: "codex", @@ -462,7 +537,13 @@ describe("setup workflow action selection", () => { const host = new FakeSetupHost("win32"); host.scopedDoctorHealthy = true; host.skill = "unchanged"; - host.clients = [{ name: "codex", configPath: "C:\\config\\codex.toml" }]; + host.clients = [ + { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + configPath: "C:\\config\\codex.toml", + }, + ]; const result = await runSetup( { ...options(true), clientIds: ["codex"], installSkill: false }, @@ -512,7 +593,14 @@ describe("setup configuration failure recovery", () => { ] as const)("explains %s configuration recovery", async (reason, message) => { const host = new FakeSetupHost(); host.hopper = "/Applications/Hopper"; - host.clients = [{ name: "internal_client_key", configPath: "/agent.json" }]; + host.clients = [ + { + name: "internal_client_key", + // Source-only recovery actor models Cursor JSON registration. + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath: "/agent.json", + }, + ]; host.clientResults.set("internal_client_key", { status: "failed", reason }); // Exercise registration recovery without selecting a synthetic skill client. const result = await runSetup( @@ -546,7 +634,13 @@ describe("setup configuration failure recovery", () => { it("names the recovery backup that holds the restored original after a readback failure", async () => { const host = new FakeSetupHost(); host.hopper = "/Applications/Hopper"; - host.clients = [{ name: "cursor", configPath: "/agent.json" }]; + host.clients = [ + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath: "/agent.json", + }, + ]; const backupPath = `/agent.json.rea.backup.${"a".repeat(64)}.recovery`; host.clientResults.set("cursor", { status: "failed", @@ -604,9 +698,15 @@ describe("MSIX Claude Desktop setup", () => { const vscode = join(ROAMING, "Code", "User", "mcp.json"); host.clients = [ msixClaudeDesktop("redirected"), - { name: "vscode", displayName: "VS Code", configPath: vscode }, + { + name: "vscode", + registrationProfile: supportedClientRegistrationProfile("vscode"), + displayName: "VS Code", + configPath: vscode, + }, { name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), displayName: "Codex", configPath: join(PROFILE, ".codex", "config.toml"), }, @@ -678,7 +778,12 @@ describe("MSIX Claude Desktop setup", () => { host.hopper = join(PROFILE, "Hopper"); host.registrationCommandBlocker = "Install REA outside the package."; host.clients = [ - { name: "codex", displayName: "Codex", configPath: "/codex.toml" }, + { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + displayName: "Codex", + configPath: "/codex.toml", + }, ]; const blocked = await runSetup( @@ -704,7 +809,13 @@ describe("setup tool family scope", () => { const host = new FakeSetupHost(); host.doctorHealthy = true; host.skill = "unchanged"; - host.clients = [{ name: "cursor", configPath: "/cursor.json" }]; + host.clients = [ + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath: "/cursor.json", + }, + ]; const plan = await runSetup( { @@ -736,7 +847,13 @@ describe("setup tool family scope", () => { const host = new FakeSetupHost("darwin"); host.version = "14.5"; host.skill = "unchanged"; - host.clients = [{ name: "cursor", configPath: "/cursor.json" }]; + host.clients = [ + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath: "/cursor.json", + }, + ]; const result = await runSetup( { diff --git a/src/application/Setup.desktopBuild.test.ts b/src/application/Setup.desktopBuild.test.ts index f7903a7da2..9b4ac8e812 100644 --- a/src/application/Setup.desktopBuild.test.ts +++ b/src/application/Setup.desktopBuild.test.ts @@ -8,6 +8,7 @@ import type { DesktopBuildSetupHost, } from "./SetupDesktopBuild.js"; import type { SetupOptions, SetupProviderEnvironment } from "./SetupTypes.js"; +import { supportedClientRegistrationProfile } from "./SupportedClients.js"; const ARCHIVE = "/home/ada/.cache/rea/build-computer/rea-6.8.0.tar.xz"; const SETTINGS = { @@ -39,7 +40,13 @@ const desktopHost = (facts: Partial = {}) => { }, }; const host = Object.assign(new FakeSetupHost("linux"), { desktopBuild }); - host.clients = [{ name: "claude_code", configPath: "/claude.json" }]; + host.clients = [ + { + name: "claude_code", + registrationProfile: supportedClientRegistrationProfile("claude_code"), + configPath: "/claude.json", + }, + ]; host.hopper = "/opt/hopper/bin/Hopper"; host.skill = "unchanged"; return { host, calls }; diff --git a/src/application/Setup.initialSnapshot.test.ts b/src/application/Setup.initialSnapshot.test.ts index e281473dc0..bd61e8ecad 100644 --- a/src/application/Setup.initialSnapshot.test.ts +++ b/src/application/Setup.initialSnapshot.test.ts @@ -2,12 +2,19 @@ import { expect, it } from "vitest"; import { runSetup } from "./Setup.js"; import { FakeSetupHost, options } from "./Setup.fixture.js"; +import { supportedClientRegistrationProfile } from "./SupportedClients.js"; it("refreshes doctor after approved setup changes", async () => { const host = new FakeSetupHost(); host.doctorHealthy = false; host.skill = "unchanged"; - host.clients = [{ name: "codex", configPath: "/codex.json" }]; + host.clients = [ + { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + configPath: "/codex.json", + }, + ]; const configureClient = host.configureClient; host.configureClient = (client, providerEnvironment, command) => { host.doctorHealthy = true; diff --git a/src/application/Setup.planning.test.ts b/src/application/Setup.planning.test.ts index 0299ddda94..7e58a9c434 100644 --- a/src/application/Setup.planning.test.ts +++ b/src/application/Setup.planning.test.ts @@ -8,6 +8,7 @@ import { FakeSetupHost, options } from "./Setup.fixture.js"; import { SetupSkillInstallError } from "./SetupSkill.js"; import { err, ok } from "../domain/result.js"; import { HopperReleaseError } from "./HopperReleaseFeed.js"; +import { supportedClientRegistrationProfile } from "./SupportedClients.js"; const linkedSkillFailure = () => new SetupSkillInstallError({ @@ -64,8 +65,18 @@ describe("selected skill admission", () => { const host = new FakeSetupHost(); host.hopper = "/Applications/Hopper"; host.clients = [ - { name: "claude_code", configPath: "/claude.json" }, - { name: "codex", configPath: "/codex.toml", format: "toml" }, + { + name: "claude_code", + registrationProfile: + supportedClientRegistrationProfile("claude_code"), + configPath: "/claude.json", + }, + { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + configPath: "/codex.toml", + format: "toml", + }, ]; host.validateSkillDirectories = async (ids) => { host.skillDirectoryValidations.push(ids); @@ -109,7 +120,14 @@ describe("selected skill admission", () => { async ({ acceptAll }) => { const host = new FakeSetupHost(); host.hopper = "/Applications/Hopper"; - host.clients = [{ name: "claude_code", configPath: "/claude.json" }]; + host.clients = [ + { + name: "claude_code", + registrationProfile: + supportedClientRegistrationProfile("claude_code"), + configPath: "/claude.json", + }, + ]; const offered: string[] = []; const result = await runSetup( { ...options(false), structured: false }, @@ -157,7 +175,13 @@ describe("selected skill admission", () => { packageFamily: "deb", hopperSetupSupported: true, }; - host.clients = [{ name: "claude_code", configPath: "/claude.json" }]; + host.clients = [ + { + name: "claude_code", + registrationProfile: supportedClientRegistrationProfile("claude_code"), + configPath: "/claude.json", + }, + ]; let linked = false; host.validateSkillDirectories = async () => linked ? err(linkedSkillFailure()) : ok(undefined); @@ -187,7 +211,13 @@ describe("selected skill admission", () => { describe("skill plan destinations", () => { it("plans a Claude-only skill in its personal location", async () => { const host = new FakeSetupHost(); - host.clients = [{ name: "claude_code", configPath: "/claude.json" }]; + host.clients = [ + { + name: "claude_code", + registrationProfile: supportedClientRegistrationProfile("claude_code"), + configPath: "/claude.json", + }, + ]; const result = await runSetup( { ...options(false), clientIds: ["claude_code"] }, @@ -206,8 +236,17 @@ describe("skill plan destinations", () => { it("plans both shared and personal skill roots for mixed clients", async () => { const host = new FakeSetupHost(); host.clients = [ - { name: "claude_code", configPath: "/claude.json" }, - { name: "codex", configPath: "/codex.toml", format: "toml" }, + { + name: "claude_code", + registrationProfile: supportedClientRegistrationProfile("claude_code"), + configPath: "/claude.json", + }, + { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + configPath: "/codex.toml", + format: "toml", + }, ]; const result = await runSetup( @@ -232,7 +271,13 @@ describe("setup workflow", () => { host.registrationCommand = []; host.registrationCommandBlocker = "REA could not determine the current executable entrypoint."; - host.clients = [{ name: "codex", configPath: "C:\\config\\codex.toml" }]; + host.clients = [ + { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + configPath: "C:\\config\\codex.toml", + }, + ]; const result = await runSetup( { ...options(true), clientIds: ["codex"], installSkill: false }, @@ -247,7 +292,13 @@ describe("setup workflow", () => { it("keeps a relative Node registration out of setup defaults when cwd happens to match", async () => { const host = new FakeSetupHost(); - host.clients = [{ name: "cursor", configPath: "/cursor.json" }]; + host.clients = [ + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath: "/cursor.json", + }, + ]; host.productRegistrations = [ { client: "cursor", @@ -299,15 +350,25 @@ describe("setup client selection", () => { host.skill = "unchanged"; const codex = { name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), configPath: "/codex.toml", format: "toml" as const, }; host.clients = [ codex, - { name: "grok_bot", configPath: "/grokbot", format: "unsupported" }, + { + name: "grok_bot", + registrationProfile: supportedClientRegistrationProfile("grok_bot"), + configPath: "/grokbot", + format: "unsupported", + }, ]; host.availableClients = [ - { name: "cursor", configPath: "/cursor.json" }, + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath: "/cursor.json", + }, ...host.clients, ]; @@ -334,13 +395,20 @@ describe("setup client selection", () => { expect(host.hopperInstalls).toBe(0); expect(host.skillInstalls).toBe(0); }); +}); +describe("automatic client readiness and selection identity", () => { it.each([undefined, false])( "omits manual clients from automatic readiness while preserving detection (skill: %s)", async (installSkill) => { const host = new FakeSetupHost(); host.clients = [ - { name: "grok_bot", configPath: "/grokbot", format: "unsupported" }, + { + name: "grok_bot", + registrationProfile: supportedClientRegistrationProfile("grok_bot"), + configPath: "/grokbot", + format: "unsupported", + }, ]; const result = await runSetup( { @@ -367,8 +435,17 @@ describe("setup client selection", () => { host.hopper = "/Applications/Hopper"; host.skill = "unchanged"; host.clients = [ - { name: "cursor", configPath: "/cursor.json" }, - { name: "codex", configPath: "/codex.toml", format: "toml" }, + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath: "/cursor.json", + }, + { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + configPath: "/codex.toml", + format: "toml", + }, ]; const inspected: string[] = []; const configured: string[] = []; @@ -419,7 +496,13 @@ describe("setup client selection", () => { describe("setup plan application", () => { it("returns a complete plan without mutation", async () => { const host = new FakeSetupHost(); - host.clients = [{ name: "cursor", configPath: "/cursor.json" }]; + host.clients = [ + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath: "/cursor.json", + }, + ]; const result = await runSetup( { ...options(false), clientIds: ["cursor"] }, host, @@ -484,7 +567,13 @@ describe("setup plan application", () => { it("completes an empty interactive selection without changes", async () => { const host = new FakeSetupHost(); - host.clients = [{ name: "codex", configPath: "/codex.toml" }]; + host.clients = [ + { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + configPath: "/codex.toml", + }, + ]; const result = await runSetup( { ...options(false), structured: false }, @@ -500,7 +589,9 @@ describe("setup plan application", () => { expect(host.hopperInstalls).toBe(0); expect(host.doctorCalls).toBe(2); }); +}); +describe("explicit setup authorization and dry runs", () => { it("requires the Hopper flag for unattended setup", async () => { const host = new FakeSetupHost(); const result = await runSetup(options(true), host); @@ -513,7 +604,13 @@ describe("setup plan application", () => { const host = new FakeSetupHost(); host.hopper = "/Applications/Hopper"; host.skill = "unchanged"; - host.clients = [{ name: "codex", configPath: "/codex.toml" }]; + host.clients = [ + { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + configPath: "/codex.toml", + }, + ]; const result = await runSetup( { ...options(false), dryRun: true, clientIds: ["codex"] }, @@ -566,8 +663,17 @@ describe("setup plan application", () => { const host = new FakeSetupHost(); host.hopper = "/Applications/Hopper"; host.clients = [ - { name: "claude", configPath: "/claude.json" }, - { name: "cursor", configPath: "/cursor.json" }, + { + name: "claude", + // Source-only planning actor models the Claude Code registration contract. + registrationProfile: supportedClientRegistrationProfile("claude_code"), + configPath: "/claude.json", + }, + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath: "/cursor.json", + }, ]; const result = await runSetup(options(true), host); expect(result.status).toBe("ready"); @@ -583,6 +689,7 @@ describe("setup workflow lifecycle and provider planning", () => { host.clients = [ { name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), displayName: "Codex", configPath: "/codex.toml", }, @@ -634,7 +741,13 @@ describe("setup workflow lifecycle and provider planning", () => { host.javaHome = "/usr/lib/jvm/jdk-21"; host.doctorHealthy = true; host.skill = "unchanged"; - host.clients = [{ name: "cursor", configPath: "/cursor.json" }]; + host.clients = [ + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath: "/cursor.json", + }, + ]; const plan = await runSetup( { ...options(false), clientIds: ["cursor"] }, @@ -668,7 +781,13 @@ describe("setup workflow lifecycle and provider planning", () => { const host = new FakeSetupHost(); host.hopper = "/Applications/Hopper"; host.skill = "unchanged"; - host.clients = [{ name: "codex", configPath: "/codex.toml" }]; + host.clients = [ + { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + configPath: "/codex.toml", + }, + ]; host.clientResults.set("codex", { status: "unchanged" }); const result = await runSetup( @@ -694,6 +813,7 @@ describe("setup workflow lifecycle and provider planning", () => { host.clients = [ { name: "grok_bot", + registrationProfile: supportedClientRegistrationProfile("grok_bot"), displayName: "Grok Bot", configPath: "/home/user/.grokbot", format: "unsupported", diff --git a/src/application/Setup.recovery.test.ts b/src/application/Setup.recovery.test.ts index 5b9193ac57..af3c33771e 100644 --- a/src/application/Setup.recovery.test.ts +++ b/src/application/Setup.recovery.test.ts @@ -18,12 +18,19 @@ import { setupCleanupError, SetupCleanupOwner } from "./SetupCleanupOwner.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { err, ok } from "../domain/result.js"; import { SetupSkillInstallError } from "./SetupSkill.js"; +import { supportedClientRegistrationProfile } from "./SupportedClients.js"; describe("setup workflow", () => { it("rejects Node 25 before applying setup actions", async () => { const host = new FakeSetupHost(); host.nodeVersion = "25.1.0"; - host.clients = [{ name: "codex", configPath: "/codex.toml" }]; + host.clients = [ + { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + configPath: "/codex.toml", + }, + ]; const result = await runSetup( { ...options(true, true), installSkill: true, clientIds: ["codex"] }, @@ -41,9 +48,24 @@ describe("setup workflow", () => { const host = new FakeSetupHost(); host.hopper = "/Applications/Hopper"; host.clients = [ - { name: "first", configPath: "/first.json" }, - { name: "second", configPath: "/second.json" }, - { name: "third", configPath: "/third.json" }, + { + name: "first", + // Source-only recovery actors model Cursor JSON registration. + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath: "/first.json", + }, + { + name: "second", + // Source-only recovery actor models Cursor JSON registration. + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath: "/second.json", + }, + { + name: "third", + // Source-only recovery actor models Cursor JSON registration. + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath: "/third.json", + }, ]; host.clientResults.set("first", { status: "failed", reason: "write" }); host.clientResults.set("second", { status: "configured" }); @@ -72,7 +94,13 @@ describe("setup workflow", () => { it("reports a failed config rollback with its destination and recovery backup", async () => { const host = new FakeSetupHost(); host.hopper = "/Applications/Hopper"; - host.clients = [{ name: "codex", configPath: "/fixture/codex.json" }]; + host.clients = [ + { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + configPath: "/fixture/codex.json", + }, + ]; host.clientResults.set("codex", { status: "failed", reason: "rollback", @@ -102,7 +130,9 @@ describe("setup workflow", () => { expect(result.remediation).toContain("permission denied"); expect(result.remediation).toContain("/fixture/codex.json.rea.backup"); }); +}); +describe("setup recovery and readiness guidance", () => { it("explains skill installation recovery", async () => { const host = new FakeSetupHost(); host.hopper = "/Applications/Hopper"; @@ -151,7 +181,13 @@ describe("setup workflow", () => { host.hopper = "/Applications/Hopper"; host.doctorHealthy = false; host.scopedDoctorHealthy = true; - host.clients = [{ name: "codex", configPath: "/codex.toml" }]; + host.clients = [ + { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + configPath: "/codex.toml", + }, + ]; const readinessScope = { clients: ["codex"], providers: [] as string[], @@ -199,7 +235,13 @@ describe("setup failure cleanup projection", () => { }); } const host = new SetupHostWithCleanupFailure(); - host.clients = [{ name: "codex", configPath }]; + host.clients = [ + { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + configPath, + }, + ]; host.inspectClientConfiguration = async () => { throw primary; }; @@ -280,7 +322,13 @@ describe("setup failure cleanup projection", () => { }; } const host = new HostWithRetainedResource(); - host.clients = [{ name: "codex", configPath }]; + host.clients = [ + { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + configPath, + }, + ]; host.inspectClientConfiguration = async () => { throw primary; }; @@ -423,7 +471,13 @@ describe("setup scoped readiness", () => { const platformHost = new FakeSetupHost("win32"); platformHost.hopper = "/Applications/Hopper"; platformHost.skill = "unchanged"; - platformHost.clients = [{ name: "codex", configPath: "/codex.toml" }]; + platformHost.clients = [ + { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + configPath: "/codex.toml", + }, + ]; const windowsAgent = await runSetup( { ...options(true), clientIds: ["codex"] }, platformHost, @@ -449,7 +503,13 @@ describe("setup scoped readiness", () => { const host = new FakeSetupHost(); host.hopper = "/Applications/Hopper"; host.skill = "unchanged"; - host.clients = [{ name: "codex", configPath: "/codex.toml" }]; + host.clients = [ + { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + configPath: "/codex.toml", + }, + ]; host.productRegistrations = [ { client: "codex", @@ -479,7 +539,13 @@ describe("setup scoped readiness", () => { const host = new FakeSetupHost("win32"); host.hopper = "C:\\Hopper\\Hopper.exe"; host.unsupportedHopperVersion = true; - host.clients = [{ name: "codex", configPath: "C:\\codex.toml" }]; + host.clients = [ + { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + configPath: "C:\\codex.toml", + }, + ]; host.scopedDoctorHealthy = true; const result = await runSetup( @@ -523,7 +589,13 @@ describe("setup scoped readiness", () => { host.javaHome = "/usr/lib/jvm/jdk-21"; host.doctorHealthy = true; host.skill = "unchanged"; - host.clients = [{ name: "codex", configPath: "/codex.toml" }]; + host.clients = [ + { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + configPath: "/codex.toml", + }, + ]; const result = await runSetup( { ...options(true), clientIds: ["codex"] }, @@ -567,6 +639,7 @@ const unhealthyManualClientHost = ( host.clients = [ { name: "grok_bot", + registrationProfile: supportedClientRegistrationProfile("grok_bot"), displayName: "Grok Bot", configPath: "/fixture/home/.grokbot", format: "unsupported", @@ -837,7 +910,13 @@ describe("setup cancellation", () => { async (completed) => { const host = new FakeSetupHost(); host.hopper = "/Applications/Hopper"; - host.clients = [{ name: "codex", configPath: "/fixture/codex.toml" }]; + host.clients = [ + { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + configPath: "/fixture/codex.toml", + }, + ]; const controller = new AbortController(); const reason = new Error("cancel after client write"); host.configureClient = async () => { @@ -923,7 +1002,13 @@ describe("setup cancellation", () => { describe("setup cancellation during installation and cleanup", () => { it("carries the caller's cancellation into the Hopper install", async () => { const host = new FakeSetupHost("darwin"); - host.clients = [{ name: "codex", configPath: "/codex.toml" }]; + host.clients = [ + { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + configPath: "/codex.toml", + }, + ]; const controller = new AbortController(); const reason = new Error("stop setup"); let received: AbortSignal | undefined; @@ -956,7 +1041,9 @@ describe("setup cancellation during installation and cleanup", () => { }); expect(host.configurations).toBe(0); }); +}); +describe("setup cancellation during final cleanup", () => { it("reports cancellation during final cleanup with the completed setup result", async () => { const clients = { codex: { status: "configured", backupPath: "/fixture/codex.toml.bak" }, @@ -972,8 +1059,16 @@ describe("setup cancellation during installation and cleanup", () => { const host = new FakeSetupHost(); host.hopper = "/Applications/Hopper"; host.clients = [ - { name: "codex", configPath: "/fixture/codex.toml" }, - { name: "cursor", configPath: "/fixture/cursor.json" }, + { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + configPath: "/fixture/codex.toml", + }, + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath: "/fixture/cursor.json", + }, ]; for (const [name, result] of Object.entries(clients)) host.clientResults.set(name, result); @@ -1020,7 +1115,13 @@ describe("setup cancellation during installation and cleanup", () => { const reason = new Error("cancel during final cleanup"); const host = new FakeSetupHost(); host.hopper = "/Applications/Hopper"; - host.clients = [{ name: "codex", configPath: "/fixture/codex.toml" }]; + host.clients = [ + { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + configPath: "/fixture/codex.toml", + }, + ]; host.clientResults.set("codex", { status: "configured", backupPath: "/fixture/codex.bak", diff --git a/src/application/SetupClientConfiguration.nativeSyntax.test.ts b/src/application/SetupClientConfiguration.nativeSyntax.test.ts new file mode 100644 index 0000000000..ed4b7aeee1 --- /dev/null +++ b/src/application/SetupClientConfiguration.nativeSyntax.test.ts @@ -0,0 +1,164 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { z } from "zod"; + +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { createTestProcessRegistry } from "../../tests/support/process/processFixture.js"; + +const CONFIGURATION_PROGRAM = ` + import { homedir } from 'node:os'; + import { isAbsolute, join, relative, resolve } from 'node:path'; + import { mkdir, readFile, readdir, realpath, stat, writeFile } from 'node:fs/promises'; + const home = process.env.HOME; + if (!home || await realpath(homedir()) !== await realpath(home)) + throw Error('Fixture HOME must be selected before importing setup owners.'); + const insideHome = (path) => { + const selected = relative(home, resolve(path)); + if (selected.startsWith('..') || isAbsolute(selected)) throw Error('Fixture path escapes HOME.'); + return path; + }; + for (const key of ['USERPROFILE','XDG_CONFIG_HOME','XDG_DATA_HOME','XDG_CACHE_HOME','XDG_STATE_HOME','APPDATA','LOCALAPPDATA']) + insideHome(process.env[key] ?? ''); + const { configureClientConfiguration, clientConfigurationAligned, inspectClientConfiguration } = + await import(${JSON.stringify(new URL("./SetupClientConfiguration.ts", import.meta.url).href)}); + const { currentClientRegistrationCommand } = + await import(${JSON.stringify(new URL("./ClientRegistrationIdentity.ts", import.meta.url).href)}); + const { supportedClientRegistrationProfile } = + await import(${JSON.stringify(new URL("./SupportedClients.ts", import.meta.url).href)}); + const command = currentClientRegistrationCommand(); + if (command.length === 0) throw Error('Fixture process has no owned current entrypoint.'); + const exercise = async (name, format) => { + const directory = insideHome(join(home, 'config', name)); + const configPath = insideHome(join(directory, 'mcp.json')); + await mkdir(directory, { recursive: true }); + const client = { name, format, configPath, registrationProfile: supportedClientRegistrationProfile(name) }; + await writeFile(configPath, '{}'); + const initial = await configureClientConfiguration(client, {}, command); + if (initial.status !== 'configured') throw Error('Initial fixture configuration failed: ' + JSON.stringify(initial)); + const original = JSON.parse(await readFile(configPath, 'utf8')); + const strictControl = { + aligned: await clientConfigurationAligned(client, {}, command), + inspection: (await inspectClientConfiguration(client, {}, command)).status, + status: (await configureClientConfiguration(client, {}, command)).status, + }; + const jsonc = '// retained original source\\n' + JSON.stringify(original, null, 2).replace(/}$/, ',\\n}') + '\\n'; + await writeFile(configPath, jsonc); + const before = { + aligned: await clientConfigurationAligned(client, {}, command), + inspection: (await inspectClientConfiguration(client, {}, command)).status, + }; + const filesBefore = await readdir(directory); + const repaired = await configureClientConfiguration(client, {}, command); + const after = await readFile(configPath, 'utf8'); + let strict = false, document; + try { document = JSON.parse(after); strict = true; } catch {} + const afterState = await stat(configPath); + const filesAfter = await readdir(directory); + const repeat = await configureClientConfiguration(client, {}, command); + const repeatState = await stat(configPath); + return { + strictControl, before, status: repaired.status, jsonc, after, strict, original, document, + alignedAfter: await clientConfigurationAligned(client, {}, command), + backup: repaired.backupPath === undefined ? null : await readFile(insideHome(repaired.backupPath), 'utf8'), + repeatStatus: repeat.status, + stableRepeat: afterState.ino === repeatState.ino && afterState.mtimeMs === repeatState.mtimeMs, + filesBefore, filesAfter, filesRepeat: await readdir(directory), + }; + }; + console.log(JSON.stringify({ command, copilot: await exercise('copilot_cli', 'copilot_cli'), standard: await exercise('cursor', 'json') })); +`; + +const observedConfiguration = z.object({ + strictControl: z.object({ + aligned: z.boolean(), + inspection: z.string(), + status: z.string(), + }), + before: z.object({ aligned: z.boolean(), inspection: z.string() }), + status: z.string(), + jsonc: z.string(), + after: z.string(), + strict: z.boolean(), + original: z.unknown(), + document: z.unknown().optional(), + alignedAfter: z.boolean(), + backup: z.string().nullable(), + repeatStatus: z.string(), + stableRepeat: z.boolean(), + filesBefore: z.array(z.string()), + filesAfter: z.array(z.string()), + filesRepeat: z.array(z.string()), +}); + +it("repairs equal-launcher Copilot JSONC syntax while strict JSON and standard JSONC remain idempotent", async () => { + const home = await createTestTempDirectory("rea-setup-native-syntax-"); + const entrypoint = join(home, "native-syntax.mjs"); + await mkdir(join(home, "config")); + await writeFile(entrypoint, CONFIGURATION_PROGRAM); + const processes = createTestProcessRegistry(); + try { + const child = await processes.run( + process.execPath, + ["--import", "tsx", entrypoint], + { + timeoutMs: 20_000, + env: { + PATH: process.env.PATH, + SystemRoot: process.env.SystemRoot, + WINDIR: process.env.WINDIR, + TMPDIR: process.env.TMPDIR, + TMP: process.env.TMP, + TEMP: process.env.TEMP, + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: join(home, "config"), + XDG_DATA_HOME: join(home, "data"), + XDG_CACHE_HOME: join(home, "cache"), + XDG_STATE_HOME: join(home, "state"), + APPDATA: join(home, "appdata"), + LOCALAPPDATA: join(home, "localappdata"), + }, + }, + ); + expect(child.exitCode, child.stderr).toBe(0); + const { copilot, standard, command } = z + .object({ + command: z.array(z.string()).min(1), + copilot: observedConfiguration, + standard: observedConfiguration, + }) + .parse(JSON.parse(child.stdout)); + expect(command.at(-1)).toBe("mcp"); + expect(copilot.strictControl).toEqual({ + aligned: true, + inspection: "already_current", + status: "unchanged", + }); + expect(copilot.before).toEqual({ aligned: false, inspection: "update" }); + expect(copilot.status).toBe("configured"); + expect(copilot.strict).toBe(true); + expect(copilot.document).toEqual(copilot.original); + expect(copilot.backup).toBe(copilot.jsonc); + expect(copilot.alignedAfter).toBe(true); + expect(copilot.repeatStatus).toBe("unchanged"); + expect(copilot.stableRepeat).toBe(true); + expect(copilot.filesRepeat).toEqual(copilot.filesAfter); + expect(standard.strictControl).toEqual({ + aligned: true, + inspection: "already_current", + status: "unchanged", + }); + expect(standard.before).toEqual({ + aligned: true, + inspection: "already_current", + }); + expect(standard.status).toBe("unchanged"); + expect(standard.after).toBe(standard.jsonc); + expect(standard.filesAfter).toEqual(standard.filesBefore); + expect(standard.repeatStatus).toBe("unchanged"); + expect(standard.stableRepeat).toBe(true); + } finally { + await processes.shutdown(); + } +}); diff --git a/src/application/SetupClientConfiguration.ts b/src/application/SetupClientConfiguration.ts index 18aaf9cd8f..d21551f4f4 100644 --- a/src/application/SetupClientConfiguration.ts +++ b/src/application/SetupClientConfiguration.ts @@ -878,6 +878,7 @@ const registrationCurrent = ( parsed: ClientConfigurationDocument, desired: unknown, ): boolean => + parsed.requiresRewrite !== true && (parsed.dialect !== "dsh" || dshDocument(parsed.document).ready) && clientConfigurationValuesEqual( parsed.servers[PRODUCT_IDENTITY.mcpServerKey], diff --git a/src/application/SetupPlan.test.ts b/src/application/SetupPlan.test.ts index 114aa344cf..dd90ff09d4 100644 --- a/src/application/SetupPlan.test.ts +++ b/src/application/SetupPlan.test.ts @@ -12,6 +12,7 @@ import { createTestWorkspace, removeTestWorkspace, } from "../../tests/support/workspace/workspaceFixture.js"; +import { supportedClientRegistrationProfile } from "./SupportedClients.js"; /** Keep a workspace child under its root before any filesystem call. */ const pathInside = (root: string, ...parts: string[]): string => { @@ -75,7 +76,13 @@ it.skipIf(process.platform === "win32")( ]); await rm(alias); const host = new FakeSetupHost(); - host.clients = [{ name: "cursor", configPath }]; + host.clients = [ + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath, + }, + ]; const discovery = await discoverSetupState({ host, providerConfigured: true, @@ -139,7 +146,13 @@ it.each([ }), ); const host = new FakeSetupHost(); - host.clients = [{ name: "cursor", configPath }]; + host.clients = [ + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath, + }, + ]; const discovery = await discoverSetupState({ host, providerConfigured: true, @@ -184,9 +197,14 @@ it("offers detected missing and manual clients without treating absent launcher ); const host = new FakeSetupHost(); host.clients = [ - { name: "cursor", configPath }, + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath, + }, { name: "grok_bot", + registrationProfile: supportedClientRegistrationProfile("grok_bot"), configPath: grokbotPath, format: "unsupported", }, @@ -201,7 +219,7 @@ it("offers detected missing and manual clients without treating absent launcher expect(discovery.defaultClientIds).toEqual([]); expect(discovery.clientStates).toEqual([ expect.objectContaining({ - client: { name: "cursor", configPath }, + client: host.clients[0], status: "needs_configuration", configured: false, }), diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts index 6bf7e8dbf4..2ac9ee3e07 100644 --- a/src/application/SupportedClients.ts +++ b/src/application/SupportedClients.ts @@ -1,3 +1,7 @@ +import type { + ClientRegistrationConnectionProfile, + ClientRegistrationProfile, +} from "./ClientRegistrationProfile.js"; import { dirname, isAbsolute, join, resolve, win32 } from "node:path"; import { fileURLToPath } from "node:url"; import { existsSync, lstatSync, readFileSync } from "node:fs"; @@ -17,6 +21,7 @@ import { /** One supported client configuration location. */ export interface SetupClient { readonly name: string; + readonly registrationProfile: ClientRegistrationProfile; readonly displayName?: string; readonly configPath: string; /** User configuration files in increasing precedence, including the write target. */ @@ -91,25 +96,25 @@ interface ClientLocation { readonly msixPackage?: MsixPackageView; } -type ClientDefinition = ClientDefinitionBase & - ( - | { - readonly configPath: ClientPath; - readonly markerPath: ClientPath; - readonly location?: undefined; - } - | { - /** Resolve configuration and marker together when they share probes. */ - readonly location: ( - context: ClientPathContext, - ) => Result; - readonly configPath?: undefined; - readonly markerPath?: undefined; - } - ); +type ClientDefinition = ClientDefinitionBase & ClientDefinitionLocation; +type ClientDefinitionLocation = + | { + readonly configPath: ClientPath; + readonly markerPath: ClientPath; + readonly location?: undefined; + } + | { + /** Resolve configuration and marker together when they share probes. */ + readonly location: ( + context: ClientPathContext, + ) => Result; + readonly configPath?: undefined; + readonly markerPath?: undefined; + }; interface ClientDefinitionBase { readonly name: string; + readonly registrationProfile: ClientRegistrationProfile; readonly displayName: string; readonly discoveryPaths?: (context: ClientPathContext) => readonly string[]; readonly sharedRegistration?: true; @@ -554,10 +559,37 @@ const devinDirectory = ({ home, platform, env }: ClientPathContext): string => const freebuffProfileDirectory = ({ home }: ClientPathContext): string => join(home, ".config", "freebuff-desktop"); +type ClientDefinitionInput = Omit & + ClientDefinitionLocation & { + readonly registrationConnection: ClientRegistrationConnectionProfile; + }; + +/** Bind native identity once while retaining the original definition's literal name. */ +const defineClient = ( + definition: Definition, +) => { + const { registrationConnection, ...metadata } = definition; + return { + ...metadata, + registrationProfile: { + nativeClient: definition.name, + connection: registrationConnection, + }, + }; +}; + /** Stable product metadata used to derive setup discovery and documentation. */ export const SUPPORTED_CLIENT_DEFINITIONS = [ - { + defineClient({ name: "claude_code", + registrationConnection: { + kind: "flat", + fields: ["type", "url"], + stdio: [ + { kind: "optional-enum", field: "type", values: ["stdio"] }, + { kind: "present", field: "url" }, + ], + }, displayName: "Claude Code", skillPath: (context: ClientPathContext) => join(claudeCodeMarkerDirectory(context), "skills"), @@ -565,39 +597,69 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ join(claudeCodeConfigDirectory(context), ".claude.json"), markerPath: claudeCodeMarkerDirectory, format: "claude_code", - }, - { + }), + defineClient({ name: "claude_desktop", + registrationConnection: { + kind: "flat", + fields: ["type"], + stdio: [{ kind: "optional-enum", field: "type", values: ["stdio"] }], + }, displayName: "Claude Desktop", location: claudeDesktopLocation, quitBeforeSetup: true, format: "json", - }, - { + }), + defineClient({ name: "codex", + registrationConnection: { + kind: "flat", + fields: ["url"], + stdio: [{ kind: "non-nullish", field: "url" }], + }, displayName: "Codex", configPath: (context: ClientPathContext) => appendCallerPath(codexDirectory(context), "config.toml"), markerPath: codexDirectory, format: "toml", - }, - { + }), + defineClient({ name: "cursor", + registrationConnection: { + kind: "flat", + fields: ["type", "url"], + stdio: [ + { kind: "optional-enum", field: "type", values: ["stdio"] }, + { kind: "present", field: "url" }, + ], + }, displayName: "Cursor", configPath: [".cursor", "mcp.json"], markerPath: [".cursor"], format: "json", - }, - { + }), + defineClient({ name: "codebuddy", + registrationConnection: { + kind: "stdio-only", + documentedFields: ["env", "type"], + }, displayName: "CodeBuddy", skillPath: [".codebuddy", "skills"], configPath: [".codebuddy", "mcp.json"], markerPath: [".codebuddy"], format: "json", - }, - { + }), + defineClient({ name: "gemini_cli", + registrationConnection: { + kind: "flat", + fields: ["type", "url", "httpUrl"], + stdio: [ + { kind: "truthy", field: "httpUrl" }, + { kind: "truthy", field: "url" }, + ], + }, displayName: "Gemini CLI", geminiSettings, configPath: ({ home, env }: ClientPathContext) => @@ -607,111 +669,197 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ skillPath: ({ home, env }: ClientPathContext) => join(env.GEMINI_CLI_HOME || home, ".agents", "skills"), format: "json", - }, - { + }), + defineClient({ name: "windsurf", + registrationConnection: { + kind: "flat", + fields: ["serverUrl", "url"], + stdio: [ + { kind: "present", field: "serverUrl" }, + { kind: "present", field: "url" }, + ], + }, displayName: "Windsurf", configPath: [".codeium", "windsurf", "mcp_config.json"], markerPath: [".codeium", "windsurf"], format: "json", - }, - { + }), + defineClient({ name: "devin", + registrationConnection: { + kind: "flat", + fields: ["url", "transport"], + stdio: [ + { kind: "optional-enum", field: "transport", values: ["stdio"] }, + { kind: "present", field: "url" }, + ], + }, displayName: "Devin", configPath: ({ home, platform, env }: ClientPathContext) => join(devinDirectory({ home, platform, env }), "mcp_config.json"), markerPath: devinDirectory, format: "json", - }, - { + }), + defineClient({ name: "opencode", + registrationConnection: { + kind: "flat", + fields: ["url"], + stdio: [{ kind: "required-enum", field: "type", values: ["local"] }], + }, displayName: "OpenCode", configPath: existingOpenCodeConfigPath, configPaths: openCodeConfigurationPaths, markerPath: opencodeDirectory, format: "opencode", - }, - { + }), + defineClient({ name: "antigravity", + registrationConnection: { + kind: "flat", + fields: ["serverUrl", "url"], + stdio: [ + { kind: "present", field: "serverUrl" }, + { kind: "present", field: "url" }, + ], + }, displayName: "Antigravity", skillPath: [".gemini", "config", "skills"], configPath: [".gemini", "config", "mcp_config.json"], markerPath: [".gemini", "config"], format: "json", - }, - { + }), + defineClient({ name: "copilot_cli", + registrationConnection: { + kind: "flat", + fields: ["url"], + stdio: [ + { kind: "optional-enum", field: "type", values: ["stdio", "local"] }, + { kind: "present", field: "url" }, + ], + }, displayName: "GitHub Copilot CLI", configPath: (context: ClientPathContext) => join(copilotDirectory(context), "mcp-config.json"), markerPath: copilotDirectory, format: "copilot_cli", - }, - { + }), + defineClient({ name: "commandcode", + registrationConnection: { kind: "commandcode" }, displayName: "Command Code", configPath: (context: ClientPathContext) => join(commandCodeDirectory(context), "mcp.json"), markerPath: commandCodeDirectory, format: "commandcode", - }, - { + }), + defineClient({ name: "qwen_code", + registrationConnection: { + kind: "flat", + fields: ["type", "httpUrl", "url"], + stdio: [ + { kind: "truthy", field: "httpUrl" }, + { kind: "truthy", field: "url" }, + { kind: "equals", field: "type", value: "sdk" }, + ], + }, displayName: "Qwen Code", configPath: (context: ClientPathContext) => join(qwenCodeDirectory(context), "settings.json"), markerPath: qwenCodeDirectory, format: "json", - }, - { + }), + defineClient({ name: "kimi", + registrationConnection: { + kind: "flat", + fields: ["type", "url", "transport"], + stdio: [ + { kind: "optional-enum", field: "type", values: ["stdio"] }, + { kind: "optional-enum", field: "transport", values: ["stdio"] }, + { kind: "present", field: "url" }, + ], + }, displayName: "Kimi Code", configPath: (context: ClientPathContext) => join(kimiDirectory(context), "mcp.json"), markerPath: kimiDirectory, format: "kimi", - }, - { + }), + defineClient({ name: "vscode", + registrationConnection: { + kind: "flat", + fields: ["url", "transport"], + stdio: [ + { kind: "required-enum", field: "type", values: ["stdio"] }, + { kind: "optional-enum", field: "transport", values: ["stdio"] }, + { kind: "present", field: "url" }, + ], + }, displayName: "VS Code", configPath: (context: ClientPathContext) => join(vscodeUserDirectory(context), "mcp.json"), markerPath: vscodeUserDirectory, format: "vscode", - }, - { + }), + defineClient({ name: "trae", + registrationConnection: { + kind: "flat", + fields: ["url"], + stdio: [{ kind: "present", field: "url" }], + }, displayName: "Trae", skillPath: [".trae", "skills"], configPath: (context: ClientPathContext) => join(traeUserDirectory(context), "mcp.json"), markerPath: traeUserDirectory, format: "json", - }, - { + }), + defineClient({ name: "workbuddy", + registrationConnection: { kind: "stdio-only", documentedFields: ["env"] }, displayName: "WorkBuddy", skillPath: [".workbuddy", "skills"], configPath: [".workbuddy", "mcp.json"], markerPath: [".workbuddy"], format: "json", - }, - { + }), + defineClient({ name: "lm_studio", + registrationConnection: { + kind: "flat", + fields: ["url"], + stdio: [{ kind: "present", field: "url" }], + }, displayName: "LM Studio", location: lmStudioLocation, format: "lm_studio", - }, - { + }), + defineClient({ name: "grok_build", + registrationConnection: { + kind: "flat", + fields: ["url"], + stdio: [{ kind: "non-nullish", field: "url" }], + }, displayName: "Grok Build", configPath: (context: ClientPathContext) => join(grokDirectory(context), "config.toml"), markerPath: grokDirectory, format: "grok", - }, - { + }), + defineClient({ name: "omp", + registrationConnection: { + kind: "flat", + fields: ["url"], + stdio: [{ kind: "optional-enum", field: "type", values: ["stdio"] }], + }, displayName: "OMP", configPath: (context: ClientPathContext) => { const directory = ompAgentDirectory(context); @@ -719,9 +867,23 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ }, markerPath: ompAgentDirectory, format: "omp", - }, - { + }), + defineClient({ name: "pi", + registrationConnection: { + kind: "flat", + fields: ["url"], + stdio: [ + { kind: "optional-enum", field: "type", values: ["stdio"] }, + { + kind: "all", + conditions: [ + { kind: "missing", field: "type" }, + { kind: "string", field: "url" }, + ], + }, + ], + }, displayName: "Pi", configPath: (context: ClientPathContext) => (context.platform === "win32" ? win32 : { join }).join( @@ -730,9 +892,17 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ ), markerPath: piAgentDirectory, format: "pi", - }, - { + }), + defineClient({ name: "hermes", + registrationConnection: { + kind: "flat", + fields: ["url", "transport"], + stdio: [ + { kind: "optional-enum", field: "transport", values: ["stdio"] }, + { kind: "present", field: "url" }, + ], + }, displayName: "Hermes", skillPath: (context: ClientPathContext) => appendCallerPath(hermesDirectory(context), "skills"), @@ -740,9 +910,19 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ appendCallerPath(hermesDirectory(context), "config.yaml"), markerPath: hermesDirectory, format: "hermes", - }, - { + }), + defineClient({ name: "qoder", + registrationConnection: { + kind: "flat", + fields: ["type", "url", "qoder_url", "tcp"], + stdio: [ + { kind: "optional-enum", field: "type", values: ["stdio"] }, + { kind: "present", field: "url" }, + { kind: "present", field: "qoder_url" }, + { kind: "present", field: "tcp" }, + ], + }, displayName: "Qoder", skillPath: (context: ClientPathContext) => join(qoderDirectory(context), "skills"), @@ -750,9 +930,17 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ join(qoderDirectory(context), "settings.json"), markerPath: qoderDirectory, format: "json", - }, - { + }), + defineClient({ name: "droid", + registrationConnection: { + kind: "flat", + fields: ["type", "url"], + stdio: [ + { kind: "optional-enum", field: "type", values: ["stdio"] }, + { kind: "present", field: "url" }, + ], + }, displayName: "Factory Droid", skillPath: (context: ClientPathContext) => join(factoryDirectory(context), "skills"), @@ -760,9 +948,17 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ join(factoryDirectory(context), "mcp.json"), markerPath: factoryDirectory, format: "json", - }, - { + }), + defineClient({ name: "zcode", + registrationConnection: { + kind: "flat", + fields: ["type", "url"], + stdio: [ + { kind: "optional-enum", field: "type", values: ["stdio"] }, + { kind: "present", field: "url" }, + ], + }, zcodeFallbackPath: ({ home }: ClientPathContext) => join(home, ".agents", "mcp.json"), displayName: "ZCode", @@ -773,9 +969,14 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ join(home, ".zcode", "cli"), ], format: "zcode", - }, - { + }), + defineClient({ name: "dsh", + registrationConnection: { + kind: "flat", + fields: ["url"], + stdio: [{ kind: "required-enum", field: "transport", values: ["stdio"] }], + }, displayName: "DeepSeek Harness", // The home Cordis patch applies to every profile; the skill directory is // shared agent storage Harness discovers. @@ -784,9 +985,17 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ configPath: dshPatchDirectory, markerPath: dshHomeDirectory, format: "dsh", - }, - { + }), + defineClient({ name: "freebuff", + registrationConnection: { + kind: "flat", + fields: ["type", "url"], + stdio: [ + { kind: "optional-enum", field: "type", values: ["stdio"] }, + { kind: "present", field: "url" }, + ], + }, displayName: "FreeBuff", // FreeBuff discovers personal skills in the shared user agents directory. skillPath: ({ home }: ClientPathContext) => join(home, ".agents", "skills"), @@ -801,16 +1010,36 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ // proves neither product is installed; see clientOwnershipEvidencePaths. sharedRegistration: true, format: "json", - }, - { + }), + defineClient({ name: "grok_bot", + registrationConnection: { + kind: "unavailable", + reason: + "Grok Bot has no supported configuration writer. Configure its MCP connection manually.", + }, displayName: "Grok Bot", configPath: grokBotDirectory, markerPath: grokBotDirectory, format: "unsupported", - }, + }), ] as const satisfies readonly ClientDefinition[]; +export type SupportedClientName = + (typeof SUPPORTED_CLIENT_DEFINITIONS)[number]["name"]; + +/** Setup and registration consume the same already-bound native profile object. */ +export const supportedClientRegistrationProfile = ( + name: SupportedClientName, +): ClientRegistrationProfile => { + const definition = SUPPORTED_CLIENT_DEFINITIONS.find( + (candidate) => candidate.name === name, + ); + if (definition === undefined) + throw new Error(`No supported client definition for ${name}`); + return definition.registrationProfile; +}; + /** * Paths whose presence means the product itself is applicable, so doctor and * setup should inspect the client. Layered sources count: OpenCode still loads @@ -909,6 +1138,7 @@ export const supportedClients = ( if (!location.ok) return { name: definition.name, + registrationProfile: definition.registrationProfile, displayName: definition.displayName, format: definition.format, configPath: location.error.path, @@ -920,6 +1150,7 @@ export const supportedClients = ( const { configPath, markerPath, msixPackage } = location.value; return { name: definition.name, + registrationProfile: definition.registrationProfile, displayName: definition.displayName, configPath, ...(definition.geminiSettings === undefined @@ -952,6 +1183,7 @@ export const supportedClients = ( if (cause instanceof HermesClientPathError) return { name: definition.name, + registrationProfile: definition.registrationProfile, displayName: definition.displayName, format: definition.format, configPath: cause.path, @@ -960,6 +1192,7 @@ export const supportedClients = ( if (definition.name !== "pi") throw cause; return { name: definition.name, + registrationProfile: definition.registrationProfile, displayName: definition.displayName, format: definition.format, configPath: env.PI_CODING_AGENT_DIR ?? "", diff --git a/src/application/HermesConfigurationAliases.ts b/src/application/YamlConfigurationAliases.ts similarity index 77% rename from src/application/HermesConfigurationAliases.ts rename to src/application/YamlConfigurationAliases.ts index e4456f7bf3..9f3b8a8283 100644 --- a/src/application/HermesConfigurationAliases.ts +++ b/src/application/YamlConfigurationAliases.ts @@ -4,6 +4,7 @@ import { isMap, isNode, isSeq, + isScalar, visit, type Document, type Node, @@ -33,7 +34,7 @@ const copyAliasComments = (source: Alias, copy: Node): Node => { const aliasTarget = (source: Alias, context: AliasCopies): Node => { const target = context.targets.get(source); if (target === undefined) - throw new TypeError("Cannot preserve an unresolved Hermes YAML alias"); + throw new TypeError("Cannot preserve an unresolved YAML alias"); return target; }; @@ -41,7 +42,7 @@ const aliasTarget = (source: Alias, context: AliasCopies): Node => { const validateAliasValue = (source: Node, context: AliasCopies): void => { if (context.validated.has(source)) return; if (context.visiting.has(source)) - throw new TypeError("Cannot preserve cyclic Hermes YAML aliases"); + throw new TypeError("Cannot preserve cyclic YAML aliases"); context.visiting.add(source); visit(source, { Alias: (_key, node) => @@ -62,7 +63,7 @@ const newAnchorName = (anchor: string, context: AliasCopies): string => { /** Reuse the surviving owner; a later same-name definition must not capture it. */ const survivingAlias = (target: Node, context: AliasCopies): Alias => { if (isAlias(target) || target.anchor === undefined) - throw new TypeError("Invalid Hermes YAML alias target"); + throw new TypeError("Invalid YAML alias target"); if (!context.reusedNames.has(target.anchor)) return new Alias(target.anchor); let name = context.survivingNames.get(target); if (name === undefined) { @@ -88,7 +89,7 @@ const copyAliasValue = ( ); } if (context.visiting.has(source)) - throw new TypeError("Cannot preserve cyclic Hermes YAML aliases"); + throw new TypeError("Cannot preserve cyclic YAML aliases"); const retained = context.retained.get(source); if ( retained !== undefined && @@ -98,7 +99,7 @@ const copyAliasValue = ( return new Alias(retained.anchor); const copy = cloned ?? source.clone(); if (!isNode(copy) || isAlias(copy)) - throw new TypeError("Invalid Hermes YAML value copy"); + throw new TypeError("Invalid YAML value copy"); context.copied.add(source); if (source.anchor !== undefined) { copy.anchor = newAnchorName(source.anchor, context); @@ -126,7 +127,7 @@ const copyAliasValue = ( }; /** Preserve other consumers before removing anchored configuration nodes. */ -export const preserveHermesAliasValues = ( +export const preserveYamlAliasValues = ( document: Document, previous: readonly unknown[], ): ReadonlySet => { @@ -194,3 +195,40 @@ export const preserveHermesAliasValues = ( }); return context.copied; }; + +/** Copy original bindings so moved aliases cannot bind to later anchors. */ +export const copyYamlAliasSource = ( + source: unknown, + document: Document, + visiting = new Set(), +): unknown => { + const node = isAlias(source) ? source.resolve(document) : source; + if (isAlias(source) && node === undefined) + throw new TypeError("Cannot edit an unresolved YAML alias"); + if (!isNode(node)) return node; + if (visiting.has(node)) + throw new TypeError("Cannot edit cyclic YAML aliases"); + visiting.add(node); + const copied = node.clone(); + if (isScalar(copied) || isMap(copied) || isSeq(copied)) delete copied.anchor; + if (isSeq(node) && isSeq(copied)) + copied.items = node.items.map((item) => + copyYamlAliasSource(item, document, visiting), + ); + else if (isMap(node) && isMap(copied)) + copied.items.forEach((pair, index) => { + const original = node.items[index]; + if (original === undefined) return; + pair.key = copyYamlAliasSource(original.key, document, visiting); + pair.value = copyYamlAliasSource(original.value, document, visiting); + }); + if (isAlias(source)) { + delete copied.commentBefore; + delete copied.comment; + if (source.commentBefore !== undefined) + copied.commentBefore = source.commentBefore; + if (source.comment !== undefined) copied.comment = source.comment; + } + visiting.delete(node); + return copied; +}; diff --git a/src/application/YamlConfigurationSource.ts b/src/application/YamlConfigurationSource.ts new file mode 100644 index 0000000000..58c127fd67 --- /dev/null +++ b/src/application/YamlConfigurationSource.ts @@ -0,0 +1,143 @@ +import { + isMap, + isNode, + isScalar, + isSeq, + type Node, + type ScalarTag, + type Tags, +} from "yaml"; +import { retainYamlSequenceAnnotations } from "./YamlSequenceAnnotations.js"; + +export const yamlNumericValuesEqual = ( + left: bigint | number, + right: bigint | number, +): boolean => { + if (typeof left === "bigint" && typeof right === "bigint") + return left === right; + if (typeof left === "number" && typeof right === "number") + return left === right || (Number.isNaN(left) && Number.isNaN(right)); + const integer = typeof left === "bigint" ? left : right; + const number = typeof left === "number" ? left : right; + return ( + Number.isFinite(number) && + Number.isInteger(number) && + BigInt(number) === integer + ); +}; + +/** Keep source annotations and unchanged scalars when updating a value. */ +export const retainYamlConfigurationSource = ( + previous: unknown, + next: Node, + retainedSources: ReadonlySet, +): Node => { + if ( + isScalar(previous) && + isScalar(next) && + (previous.value === next.value || + (typeof previous.value === "bigint" && + typeof next.value === "number" && + yamlNumericValuesEqual(previous.value, next.value))) + ) { + if (!retainedSources.has(previous)) return previous; + const copied = previous.clone(); + if (!isScalar(copied)) throw new TypeError("Invalid YAML scalar copy"); + delete copied.anchor; + delete copied.comment; + delete copied.commentBefore; + delete copied.spaceBefore; + return copied; + } + // An anchor moved to a surviving consumer owns its original comments. + // Otherwise retain annotations on the replacement without retaining the + // old anchor, tag, or scalar spelling for the changed value. + if (isNode(previous) && !retainedSources.has(previous)) { + if (previous.comment !== undefined) next.comment = previous.comment; + if (previous.commentBefore !== undefined) + next.commentBefore = previous.commentBefore; + if (previous.spaceBefore !== undefined) + next.spaceBefore = previous.spaceBefore; + } + if (isMap(previous) && isMap(next)) { + const entries = new Map( + previous.items.flatMap((pair) => + isScalar(pair.key) && typeof pair.key.value === "string" + ? [[pair.key.value, pair] as const] + : [], + ), + ); + for (const pair of next.items) { + const key = isScalar(pair.key) ? pair.key.value : undefined; + const prior = typeof key === "string" ? entries.get(key) : undefined; + if (prior === undefined) continue; + pair.key = isNode(pair.key) + ? retainYamlConfigurationSource(prior.key, pair.key, retainedSources) + : prior.key; + if (isNode(pair.value)) + pair.value = retainYamlConfigurationSource( + prior.value, + pair.value, + retainedSources, + ); + } + } else if (isSeq(previous) && isSeq(next)) { + const stringItems = (items: readonly unknown[]) => + items.every((item) => isScalar(item) && typeof item.value === "string"); + if (stringItems(previous.items) && stringItems(next.items)) + retainYamlSequenceAnnotations( + previous, + next, + (item) => (isScalar(item) ? item.value : undefined), + (source, target) => + retainYamlConfigurationSource(source, target, retainedSources), + (source) => !retainedSources.has(source), + ); + else + next.items = next.items.map((item, index) => + isNode(item) + ? retainYamlConfigurationSource( + previous.items[index], + item, + retainedSources, + ) + : item, + ); + } + return next; +}; + +/** Keep a source spelling only while the selected schema resolves its value unchanged. */ +const sameResolvedScalarValue = (left: unknown, right: unknown): boolean => + Object.is(left, right) || + (left instanceof Date && + right instanceof Date && + left.getTime() === right.getTime()); + +export const retainYamlScalarSource = (tag: Tags[number]): Tags[number] => { + if (typeof tag === "string" || tag.collection !== undefined) return tag; + const { stringify } = tag; + if (stringify === undefined) return tag; + return { + ...tag, + stringify: (item, context, ...rest) => { + const { source } = item; + if (source === undefined) return stringify(item, context, ...rest); + const reader = context.doc.schema.tags.find( + (candidate): candidate is ScalarTag => + candidate.default === true && candidate.test?.test(source) === true, + ); + const read = reader?.resolve( + source, + () => undefined, + context.doc.options, + ); + return sameResolvedScalarValue( + isScalar(read) ? read.value : read, + item.value, + ) + ? source + : stringify(item, context, ...rest); + }, + }; +}; diff --git a/src/cli.ts b/src/cli.ts index 2b04626d16..c369c4e5cc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,7 +1,8 @@ -import { existsSync, readFileSync, writeFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import { writeAmpRegistrationBackup } from "./cli/ampRegistrationBackup.js"; +import { parseClientRegistrationCommand } from "./domain/clientRegistrationCommand.js"; +import { + serveMcpRegistration, + closeCliMcpRegistrationResources, +} from "./cli/mcpRegistration.js"; import { registerToolFamilies } from "./contracts/toolFamilyIds.js"; import { registerGpuCommands } from "./cli/gpuCommands.js"; import { registerNativeFunctionMatchingCommands } from "./cli/nativeFunctionMatchingCommands.js"; @@ -26,27 +27,15 @@ import { registerBinaryDiagnosticsCommands } from "./cli/binaryDiagnosticsComman import { registerFridaCommands } from "./cli/fridaCommands.js"; import type { FridaInstrumentationPort } from "./application/frida/FridaInstrumentationPort.js"; import { Cli, z } from "incur"; -import { redactExplicitText } from "./domain/explicitSensitiveValues.js"; import { adaptCliOutputArgumentsForIncur, - classifyMcpAddDiscovery, - expandMcpAddEqualsArguments, - declaredSensitiveValues, - readMcpAddArguments, normalizeCliArguments, - splitIncurBuiltinArguments, } from "./cliBuiltinArguments.js"; -import { - parseCliOutputArguments, - renderCliDocument, - renderCliOutputArgumentError, -} from "./cliOutput.js"; import { createLogger } from "./logger.js"; import { parseLogLevel } from "./config/processStart.js"; import { PRODUCT_IDENTITY } from "./identity.js"; import { currentClientRegistrationCommand } from "./application/ClientRegistrationIdentity.js"; -import { clientRegistrationCommandText } from "./cli/clientRegistrationCommandText.js"; import { registerSetupCommands } from "./cli/setupCommands.js"; import { registerCoreAnalysisCommands } from "./cli/coreAnalysisCommands.js"; import { registerUtilityCommands } from "./cli/utilityCommands.js"; @@ -103,6 +92,7 @@ export const closeCliResources = async (): Promise => { const results = await Promise.allSettled([ closeCliJavaScriptResources(), closeCliGuidanceResources(), + closeCliMcpRegistrationResources(), ]); const failures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : [], @@ -126,6 +116,9 @@ export const createCli = ( ): CliInstance => { const environment = snapshotEnvironment(selectedEnvironment); const analysis = createDirectAnalysis(environment); + const defaultRegistration = parseClientRegistrationCommand( + currentClientRegistrationCommand({ environment }), + ); // The executable reports an invalid level before every command except doctor. // Doctor keeps going so its readiness report can name REA_LOG_LEVEL. const level = @@ -146,9 +139,6 @@ export const createCli = ( .describe("Write measured command timing to stderr."), }), mcp: { - command: clientRegistrationCommandText( - currentClientRegistrationCommand({ environment }), - ), instructions: "Ask what software, artifact, protocol, or behavior the user wants to understand, then choose the available investigation capabilities that can produce evidence.", }, @@ -266,327 +256,14 @@ export const createCli = ( ); cli.serve = (arguments_ = process.argv.slice(2), options) => { if (complete(arguments_, options ?? {})) return Promise.resolve(); - let normalized = normalizeCliArguments(arguments_); - const { command, flags } = splitIncurBuiltinArguments(normalized); - const builtin = command[0] === PRODUCT_IDENTITY.cliBinary ? 1 : 0; - const discovery = classifyMcpAddDiscovery(flags); - if ( - command[builtin] === "mcp" && - command[builtin + 1] === "add" && - discovery !== "bypass" - ) { - const write = - options?.stdout ?? ((text: string) => process.stdout.write(text)); - if (discovery === "version") { - write(`${PRODUCT_IDENTITY.packageVersion}\n`); - return Promise.resolve(); - } - if (discovery === "schema") { - const output = parseCliOutputArguments(normalized); - if (!output.validation.ok) - write(renderCliOutputArgumentError(output.validation)); - else if (output.format === "csv" || output.format === "tsv") - write( - renderCliOutputArgumentError({ - ok: false, - format: "json", - code: "UNSUPPORTED_OUTPUT_COMBINATION", - message: - "CSV/TSV output cannot represent the mcp add schema discovery error. Use a document format instead.", - }), - ); - else - write( - renderCliDocument( - { - code: "SCHEMA_UNAVAILABLE", - message: - "Schema discovery is not supported for mcp add. Use 'rea mcp add --help' for its options.", - }, - output.format, - process.stdout.isTTY === true, - ), - ); - ( - options?.exit ?? - ((code: number) => { - process.exitCode = code; - }) - )(1); - return Promise.resolve(); - } - // Validate before reading settings, creating a backup or invoking Incur's - // writer. The same owner supplies the agent selection used below. - const parsedRegistration = readMcpAddArguments( - command.slice(builtin + 2), - ); - if (!parsedRegistration.validation.ok) { - const failure = parsedRegistration.validation.error; - const message = redactExplicitText( - failure.kind === "missing_value" - ? `Missing value for flag: ${failure.flag}` - : `Unexpected argument: ${failure.token}`, - declaredSensitiveValues(command), - ); - const field = - failure.kind === "missing_value" ? failure.field : "arguments"; - const output = parseCliOutputArguments(normalized); - if (!output.validation.ok) - write(renderCliOutputArgumentError(output.validation)); - else - write( - renderCliDocument( - { - code: "VALIDATION_ERROR", - message, - fieldErrors: [ - { - code: "invalid_value", - missing: false, - path: field, - message, - }, - ], - }, - output.format, - process.stdout.isTTY === true, - ), - ); - ( - options?.exit ?? - ((code: number) => { - process.exitCode = code; - }) - )(1); - return Promise.resolve(); - } - // Incur's mcp add parser only understands spaced `--agent amp` forms; an - // equals form like `--agent=amp` is silently ignored, which would drop - // the requested agent and register against the default target. Normalize - // equals forms back to spaced forms before handing over to Incur. - // Shared with the project-scope guard through - // `expandMcpAddEqualsArguments`, so both read the same agent selection. - const serveArguments = expandMcpAddEqualsArguments(normalized); - // Incur's Amp registration overwrites an existing rea entry (dropping its - // environment map) and reports success even when the settings file - // prevents a save. Guard that path: reject a settings file REA cannot - // update, back up the file, preserve the caller's environment, and verify - // the registration landed. - // SyncMcp.register also writes Amp when no agents are selected. Mode - // switches return before registration and must not touch its settings. - // Use the same normalized arguments for selection and dispatch, including - // non-Amp equals forms. Incur consumes command values before agent flags. - normalized = serveArguments; - const registration = splitIncurBuiltinArguments(serveArguments); - const { agents } = parsedRegistration; - const ampRequested = - ![ - "--help", - "-h", - "--llms", - "--llms-full", - "--mcp", - "--update", - "--incur-update-check", - ].some((flag) => registration.flags.has(flag)) && - (agents.length === 0 || agents.includes("amp")); - if (ampRequested) { - const settingsPath = join(homedir(), ".config", "amp", "settings.json"); - let priorEnv: unknown; - let guardError: string | undefined; - let settingsText: string | undefined; - let parsedSettings: Record | undefined; - try { - if (existsSync(settingsPath)) - settingsText = readFileSync(settingsPath, "utf8"); - } catch (cause) { - guardError = `Unable to read Amp settings.json: ${ - cause instanceof Error ? cause.message : String(cause) - }`; - } - if (settingsText !== undefined) { - try { - const parsed: unknown = JSON.parse(settingsText); - if ( - typeof parsed !== "object" || - parsed === null || - Array.isArray(parsed) - ) - guardError = "Amp settings.json must contain a JSON object."; - else parsedSettings = parsed as Record; - } catch (cause) { - guardError = `Amp settings.json is not valid JSON: ${ - cause instanceof Error ? cause.message : String(cause) - }`; - } - } - if (parsedSettings !== undefined && settingsText !== undefined) { - const servers = parsedSettings["amp.mcpServers"]; - if ( - servers !== undefined && - (typeof servers !== "object" || - servers === null || - Array.isArray(servers)) - ) - guardError = - "amp.mcpServers must be an object in Amp settings.json."; - else { - const entry = - typeof servers === "object" && servers !== null - ? (servers as Record)["rea"] - : undefined; - if (typeof entry === "object" && entry !== null) - priorEnv = (entry as Record)["env"]; - } - } - const reportValidationError = (message: string): void => { - const output = parseCliOutputArguments(normalized); - if (!output.validation.ok) - write(renderCliOutputArgumentError(output.validation)); - else - write( - renderCliDocument( - { - code: "VALIDATION_ERROR", - message, - fieldErrors: [ - { - code: "invalid_value", - missing: false, - path: "amp", - message, - }, - ], - }, - output.format, - process.stdout.isTTY === true, - ), - ); - ( - options?.exit ?? - ((code: number) => { - process.exitCode = code; - }) - )(1); - }; - if (guardError !== undefined) { - reportValidationError(`${guardError} Fix it before registering.`); - return Promise.resolve(); - } - - const runRegistration = (backupPath?: string): Promise => { - let failed = false; - const result = serveStrict( - serveArguments, - { - ...options, - exit: (code) => { - failed ||= code !== 0; - if (options?.exit !== undefined) options.exit(code); - else process.exitCode = code; - }, - }, - (tokens, selected) => - serveDeclared(tokens, selected, (declared, declaredOptions) => - serve( - adaptCliOutputArgumentsForIncur(declared), - declaredOptions, - ), - ), - ); - return result.then(() => { - if (failed) return; - let failure: string | undefined; - try { - if (!existsSync(settingsPath)) - failure = "Amp settings.json was not created."; - else { - const text = readFileSync(settingsPath, "utf8"); - const parsed = JSON.parse(text) as Record; - const servers = parsed["amp.mcpServers"]; - if ( - typeof servers !== "object" || - servers === null || - Array.isArray(servers) || - !("rea" in servers) - ) - failure = - "Amp settings.json does not contain the rea registration after mcp add."; - else { - const entry = (servers as Record)["rea"]; - if ( - priorEnv !== undefined && - typeof entry === "object" && - entry !== null && - (entry as Record)["env"] === undefined - ) { - (entry as Record)["env"] = priorEnv; - writeFileSync( - settingsPath, - `${JSON.stringify(parsed, null, 2)}\n`, - ); - } - } - } - } catch (cause) { - failure = cause instanceof Error ? cause.message : String(cause); - } - if (failure !== undefined) { - const output = parseCliOutputArguments(normalized); - if (output.validation.ok) - write( - renderCliDocument( - { - code: "VALIDATION_ERROR", - message: failure, - fieldErrors: [ - { - code: "invalid_value", - missing: false, - path: "amp", - message: failure, - }, - ], - }, - output.format, - process.stdout.isTTY === true, - ), - ); - ( - options?.exit ?? - ((code: number) => { - process.exitCode = code; - }) - )(1); - } else if (backupPath !== undefined) - process.stderr.write( - `Amp MCP registration backup retained at ${backupPath}\n`, - ); - }); - }; - - if (settingsText === undefined) return runRegistration(); - return writeAmpRegistrationBackup(settingsPath, settingsText).then( - (backup) => { - if (!backup.ok) { - const { kind, path, detail } = backup.error; - reportValidationError( - `Unable to create Amp settings backup (${kind}) at ${path}: ${detail}`, - ); - return; - } - return runRegistration(backup.value); - }, - (cause: unknown) => { - const detail = - cause instanceof Error ? cause.message : String(cause); - reportValidationError( - `Unable to create Amp settings backup: ${detail}`, - ); - }, - ); - } - } + const normalized = normalizeCliArguments(arguments_); + const registration = serveMcpRegistration( + normalized, + environment, + defaultRegistration, + options ?? {}, + ); + if (registration !== undefined) return registration; const result = serveStrict(normalized, options ?? {}, (tokens, selected) => serveDeclared(tokens, selected, (declared, declaredOptions) => reportUnknownMcpSubcommand(cli.name, declared, declaredOptions) diff --git a/src/cli/cliResultOutput.ts b/src/cli/cliResultOutput.ts index 9109c66d9e..7eb5ee5cf0 100644 --- a/src/cli/cliResultOutput.ts +++ b/src/cli/cliResultOutput.ts @@ -59,6 +59,10 @@ export interface CliResultOutputMetadata { readonly duration: string; readonly format: string; readonly renderOutput?: boolean; + /** Render commands that do not have an Incur output fallback. */ + readonly forceOutput?: boolean; + /** The producer owns a failure document whose complete facts must survive filtering. */ + readonly failure?: boolean; } /** Optional executable-owned result surface for one CLI invocation. */ @@ -426,8 +430,9 @@ export const createCliResultOutput = ( const filterPaths = options.filterOutput ? Filter.parse(options.filterOutput) : undefined; - const operationFailure = - format === "jsonl" || options.fullOutput || filterPaths !== undefined + const operationFailure = metadata.failure + ? value + : format === "jsonl" || options.fullOutput || filterPaths !== undefined ? cliOperationFailurePayload(value) : undefined; const failure = options.fullOutput ? operationFailure : undefined; @@ -629,6 +634,7 @@ export const createCliResultOutput = ( // Delegate other human formats to Incur. Markdown stays here so admission // and emitted table text use the same safe formatter. if ( + !metadata.forceOutput && format !== "md" && format !== "json" && format !== "jsonl" && @@ -806,6 +812,7 @@ export const createCliResultOutput = ( // document from Incur's error formatter. const formattedText = format === "md" || + (metadata.forceOutput === true && format !== "json") || ((operationFailure !== undefined || ownsFilteredOutput) && format !== "json") ? formatCliOutput(document, format) diff --git a/src/cli/clientRegistrationCommandText.ts b/src/cli/clientRegistrationCommandText.ts deleted file mode 100644 index 2aa864d244..0000000000 --- a/src/cli/clientRegistrationCommandText.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** Incur takes a command string and splits quoted segments without backslash escapes. */ -export const clientRegistrationCommandText = ( - command: readonly string[], -): string => - command.map((argument) => `'${argument.replaceAll("'", `'"'"'`)}'`).join(" "); diff --git a/src/cli/completions.ts b/src/cli/completions.ts index 0660b52e73..52e6f0cba5 100644 --- a/src/cli/completions.ts +++ b/src/cli/completions.ts @@ -11,7 +11,7 @@ import { } from "../cliBuiltinArguments.js"; import { CLI_OUTPUT_FORMATS, parseCliOutputArguments } from "../cliOutput.js"; import { bashCompletionScript } from "./bashCompletionRegistration.js"; -import { MCP_BUILTIN_SUBCOMMANDS } from "./mcpBuiltinCommands.js"; +import { MCP_SUBCOMMANDS } from "./mcpBuiltinCommands.js"; import { MCP_DOCTOR_ALIAS, MCP_DOCTOR_OPTIONS } from "../mcpDoctorOptions.js"; type Commands = Parameters[0]; @@ -86,7 +86,7 @@ const completionCommands = (cli: CompletionCli): Commands => { commands.set("mcp", { _group: true, description: "Register as MCP server", - commands: MCP_BUILTIN_SUBCOMMANDS, + commands: MCP_SUBCOMMANDS, }); commands.set("completions", { _group: true, diff --git a/src/cli/mcpBuiltinCommands.ts b/src/cli/mcpBuiltinCommands.ts index 809ec0396a..a73be21507 100644 --- a/src/cli/mcpBuiltinCommands.ts +++ b/src/cli/mcpBuiltinCommands.ts @@ -1,4 +1,4 @@ -import { z } from "incur"; +import { mcpRegistrationOptionsSchema } from "../contracts/mcpRegistration.js"; import { adaptCliOutputArgumentsForIncur, @@ -15,18 +15,14 @@ import type { CliInstance } from "./types.js"; type ServeOptions = NonNullable[1]>; -/** Pinned Incur 0.6.1 `mcp` builtin subcommands; Incur does not export them. */ -export const MCP_BUILTIN_SUBCOMMANDS = new Map([ +/** Named MCP commands shared by completion and unknown-command guidance. */ +export const MCP_SUBCOMMANDS = new Map([ [ "add", { description: "Register as MCP server", alias: { command: "c" }, - options: z.object({ - agent: z.string().optional(), - command: z.string().optional(), - noGlobal: z.boolean().optional(), - }), + options: mcpRegistrationOptionsSchema, }, ], ["doctor", { description: "Validate MCP server startup and tool listing" }], @@ -70,7 +66,7 @@ export const reportUnknownMcpSubcommand = ( // Incur shows group help for an empty token and owns flag-shaped globals. !subcommand || subcommand.startsWith("-") || - MCP_BUILTIN_SUBCOMMANDS.has(subcommand) || + MCP_SUBCOMMANDS.has(subcommand) || // Incur serves these builtins before its `mcp` subcommand branch. An // output flag's value is data, even when it is spelled like a builtin. incurArguments.some( @@ -84,7 +80,7 @@ export const reportUnknownMcpSubcommand = ( if (!output.formatExplicit || !output.validation.ok) return false; const sensitive = declaredSensitiveValues(command); const text = (value: string): string => redactExplicitText(value, sensitive); - const suggestion = [...MCP_BUILTIN_SUBCOMMANDS.keys()].find((candidate) => + const suggestion = [...MCP_SUBCOMMANDS.keys()].find((candidate) => candidate.startsWith(subcommand.toLowerCase()), ); const corrected = arguments_.map((token, index) => diff --git a/src/cli/mcpRegistration.ts b/src/cli/mcpRegistration.ts new file mode 100644 index 0000000000..f8204508f8 --- /dev/null +++ b/src/cli/mcpRegistration.ts @@ -0,0 +1,411 @@ +import { Writable } from "node:stream"; + +import { Help, z } from "incur"; + +import { + registerMcpClients, + type McpClientRegistrationOutcome, +} from "../application/McpClientRegistration.js"; +import { resolveMcpRegistrationClients } from "../application/McpRegistrationCatalog.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { + classifyMcpAddDiscovery, + declaredSensitiveValues, + readMcpAddArguments, + splitIncurBuiltinArguments, +} from "../cliBuiltinArguments.js"; +import { + parseCliOutputArguments, + renderCliDocument, + renderCliOutputArgumentError, + type CliOutputArgumentValidation, +} from "../cliOutput.js"; +import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js"; +import { + mcpRegistrationOptionsSchema, + mcpRegistrationReportSchema, + type McpRegistrationReport, +} from "../contracts/mcpRegistration.js"; +import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { + parseClientRegistrationCommand, + type ClientRegistrationCommand, + type ClientRegistrationCommandIssue, +} from "../domain/clientRegistrationCommand.js"; +import { ConfigurationError } from "../domain/configurationErrors.js"; +import { redactExplicitText } from "../domain/explicitSensitiveValues.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { PRODUCT_IDENTITY } from "../identity.js"; +import { createCliResultOutput } from "./cliResultOutput.js"; +import { withCommandCancellation } from "./commandCancellation.js"; +import type { CliInstance } from "./types.js"; + +let retainedResources: ArtifactResourceScope | undefined; + +/** Close the exact registration cleanup owners, retaining failures for retry. */ +export const closeCliMcpRegistrationResources = async (): Promise => { + const resources = retainedResources; + if (resources === undefined) return; + await resources.close(); + if (retainedResources === resources) retainedResources = undefined; +}; + +type ServeOptions = NonNullable[1]>; +interface RegistrationRequest { + readonly agents: readonly string[]; + readonly global: boolean; + readonly launcher: ClientRegistrationCommand; +} +interface RegistrationInputIssue { + readonly code: "invalid_value" | "unsupported_transport"; + readonly message: string; + readonly field: string; +} +interface RegistrationPresentation { + write(text: string): void; + document(value: unknown, failure?: boolean): Promise; + validation( + message: string, + field: string, + error?: AnalysisError, + code?: RegistrationInputIssue["code"], + ): Promise; + fail(): void; +} + +const precedingFlags = [ + "--llms", + "--llms-full", + "--mcp", + "--update", + "--incur-update-check", +]; + +/** Route only the named REA registration operation; other Incur commands remain theirs. */ +export const serveMcpRegistration = ( + arguments_: readonly string[], + environment: Readonly, + defaultCommand: Result< + ClientRegistrationCommand, + ClientRegistrationCommandIssue + >, + options: ServeOptions, +): Promise | undefined => { + const split = splitIncurBuiltinArguments(arguments_); + const offset = split.command[0] === PRODUCT_IDENTITY.cliBinary ? 1 : 0; + if ( + split.command[offset] !== "mcp" || + split.command[offset + 1] !== "add" || + precedingFlags.some((flag) => split.flags.has(flag)) + ) + return undefined; + return (async () => { + const presentation = createRegistrationPresentation( + arguments_, + split.command, + options, + ); + if ( + presentation === undefined || + (await serveRegistrationDiscovery(split.flags, presentation)) + ) + return Promise.resolve(); + const request = readRegistrationRequest( + split.command.slice(offset + 2), + defaultCommand, + ); + if (!request.ok) { + await presentation.validation( + request.error.message, + request.error.field, + undefined, + request.error.code, + ); + return Promise.resolve(); + } + return withCommandCancellation((signal) => + executeRegistration(request.value, environment, signal, presentation), + ); + })(); +}; + +/** Bind one invocation's rendering, explicit redaction and exit behavior. */ +const createRegistrationPresentation = ( + arguments_: readonly string[], + command: readonly string[], + options: ServeOptions, +): RegistrationPresentation | undefined => { + const write = + options.stdout ?? ((text: string) => process.stdout.write(text)); + const fail = () => { + if (options.exit !== undefined) options.exit(1); + else process.exitCode = 1; + }; + const output = parseCliOutputArguments(arguments_); + const validation: CliOutputArgumentValidation = + output.validation.ok && (output.format === "csv" || output.format === "tsv") + ? { + ok: false, + format: "json", + code: "UNSUPPORTED_OUTPUT_COMBINATION", + message: + "MCP registration returns a complete document. Use --format json, yaml, md, toon, or jsonl; CSV/TSV requires a selected row collection.", + } + : output.validation; + if (!validation.ok) { + write(renderCliOutputArgumentError(validation)); + fail(); + return undefined; + } + const destination = + options.stdout === undefined + ? process.stdout + : new Writable({ + write(chunk: Buffer, _encoding, callback) { + try { + write(chunk.toString()); + callback(); + } catch (error) { + callback( + error instanceof Error ? error : new Error(String(error)), + ); + } + }, + }); + const result = createCliResultOutput(output, destination); + const document = async (value: unknown, failure = false) => { + const handled = await result?.write(value, { + command: "mcp add", + duration: "0ms", + format: output.format, + forceOutput: true, + failure, + }); + if (!handled) + write( + renderCliDocument(value, output.format, process.stdout.isTTY === true), + ); + if (result?.error !== undefined) fail(); + }; + return { + write, + document, + fail, + async validation(message, field, error, code = "invalid_value") { + const retained = redactExplicitText( + message, + declaredSensitiveValues(command), + ); + await document( + { + code: "VALIDATION_ERROR", + message: retained, + ...(error === undefined + ? {} + : { error: projectAnalysisError(error) }), + fieldErrors: [ + { + code, + missing: false, + path: field, + message: retained, + }, + ], + }, + true, + ); + fail(); + }, + }; +}; + +/** Discovery never resolves configuration locations or acquires resource owners. */ +const serveRegistrationDiscovery = async ( + flags: ReadonlySet, + presentation: RegistrationPresentation, +): Promise => { + if (flags.has("--help") || flags.has("-h")) { + presentation.write( + `${Help.formatCommand("rea mcp add", { + description: + "Register exact stdio launcher arguments in selected client settings, with backups and verified publication.", + options: mcpRegistrationOptionsSchema, + alias: { command: "c" }, + hint: "No package acquisition or server execution. --no-global never writes global settings.", + })}\n`, + ); + return true; + } + const discovery = classifyMcpAddDiscovery(flags); + if (discovery === "version") { + presentation.write(`${PRODUCT_IDENTITY.packageVersion}\n`); + return true; + } + if (discovery !== "schema") return false; + await presentation.document({ + inputSchema: z.toJSONSchema(mcpRegistrationOptionsSchema), + outputSchema: z.toJSONSchema(mcpRegistrationReportSchema), + }); + return true; +}; + +const readRegistrationRequest = ( + arguments_: readonly string[], + defaultCommand: Result< + ClientRegistrationCommand, + ClientRegistrationCommandIssue + >, +): Result => { + const request = readMcpAddArguments(arguments_); + if (!request.validation.ok) { + const issue = request.validation.error; + return err({ + code: "invalid_value", + message: + issue.kind === "missing_value" + ? `Missing value for flag: ${issue.flag}` + : `Unexpected argument: ${issue.token}`, + field: issue.kind === "missing_value" ? issue.field : "arguments", + }); + } + const launcher = + request.commandText === undefined + ? defaultCommand + : parseClientRegistrationCommand(request.commandText); + return launcher.ok + ? ok({ + agents: request.agents, + global: request.global, + launcher: launcher.value, + }) + : err({ + code: + launcher.error.kind === "unsupported_transport" + ? "unsupported_transport" + : "invalid_value", + message: launcher.error.message, + field: "command", + }); +}; + +const executeRegistration = async ( + request: RegistrationRequest, + environment: Readonly, + signal: AbortSignal, + presentation: RegistrationPresentation, +): Promise => { + const resources = (retainedResources ??= new ArtifactResourceScope()); + const outcomes = await resources + .run(async () => { + const targets = await resolveMcpRegistrationClients( + { + agents: request.agents, + global: request.global, + cwd: process.cwd(), + home: homeDirectoryFromEnvironment(environment, process.platform), + platform: process.platform, + environment, + signal, + }, + resources, + ); + if (!targets.ok) { + await presentation.validation( + targets.error.message, + "agent", + targets.error, + ); + return undefined; + } + return registerMcpClients( + targets.value, + request.launcher, + signal, + resources, + ); + }) + .catch(async (cause: unknown) => { + if (!(cause instanceof ArtifactReaderFailure)) throw cause; + const error = new ConfigurationError( + "Registration cannot continue until retained resource cleanup completes.", + { + cause, + cleanup: ArtifactReaderFailure.cleanupObservation( + cause, + resources.cleanupResources, + ), + }, + ); + await presentation.validation(error.message, "registration", error); + return undefined; + }); + if (outcomes !== undefined) + await presentRegistration(outcomes, request.launcher, presentation); +}; + +const clientReport = ( + outcome: McpClientRegistrationOutcome, +): McpRegistrationReport["clients"][number] => { + const labels = { + client: outcome.client, + config_path: outcome.configPath, + scope: outcome.scope, + }; + if (outcome.status === "not_started") + return { ...labels, status: "not_started" }; + if (!outcome.result.ok) + return { + ...labels, + status: "failed", + error: { + ...projectAnalysisError(outcome.result.error), + reason: outcome.result.error.message, + }, + }; + const receipt = outcome.result.value; + const permissions = + receipt.permissions === undefined + ? {} + : { permissions: receipt.permissions }; + return receipt.status === "unchanged" + ? { ...labels, ...permissions, status: "unchanged" } + : { + ...labels, + ...permissions, + status: "configured", + ...(receipt.backupPath === undefined + ? {} + : { backup_path: receipt.backupPath }), + }; +}; + +const presentRegistration = async ( + outcomes: readonly McpClientRegistrationOutcome[], + launcher: ClientRegistrationCommand, + presentation: RegistrationPresentation, +): Promise => { + const clients = outcomes.map(clientReport); + const report: McpRegistrationReport = { + name: "rea", + command: launcher.command, + args: [...launcher.args], + ok: clients.every( + (client) => + client.status === "configured" || client.status === "unchanged", + ), + clients, + }; + await presentation.document(report, !report.ok); + for (const outcome of outcomes) + if ( + outcome.status === "completed" && + outcome.result.ok && + outcome.result.value.backupPath !== undefined + ) + process.stderr.write( + `${outcome.client === "amp" ? "Amp" : outcome.client} MCP registration backup retained at ${outcome.result.value.backupPath}\n`, + ); + if (!report.ok) presentation.fail(); +}; diff --git a/src/cliAmpRegistration.test.ts b/src/cliAmpRegistration.test.ts new file mode 100644 index 0000000000..1d58ca913a --- /dev/null +++ b/src/cliAmpRegistration.test.ts @@ -0,0 +1,101 @@ +import { spawnSync } from "node:child_process"; +import { mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { createTestTempDirectory } from "../tests/fixtures/temporaryDirectory.js"; + +it("admits exact Amp command facts and rejects invalid commands before publication", async () => { + const home = await createTestTempDirectory("rea-amp-source-command-"); + try { + await mkdir(join(home, "config")); + const script = ` + import { homedir } from 'node:os'; + import { resolve, sep, join } from 'node:path'; + import { mkdir, readFile, writeFile, rm, realpath } from 'node:fs/promises'; + const home = process.env.HOME; + if (!home || await realpath(homedir()) !== await realpath(home)) + throw Error('Fixture HOME was not selected before loading registration writers.'); + for (const key of ['USERPROFILE','XDG_CONFIG_HOME','XDG_DATA_HOME','XDG_CACHE_HOME','XDG_STATE_HOME','APPDATA','LOCALAPPDATA']) { + const value = resolve(process.env[key] ?? ''); + if (value !== home && !value.startsWith(home + sep)) + throw Error('Fixture config location escapes selected HOME: ' + key); + } + const { createCli, closeCliResources } = await import(${JSON.stringify(new URL("./cli.ts", import.meta.url).href)}); + const original = { 'amp.mcpServers': { rea: { command: 'stale-executable', args: ['stale-argument'], env: { KEEP: '1' }, disabled: false }, peer: { command: 'peer' } }, theme: 'dark' }; + const directory = join(home, '.config', 'amp'); + const path = join(directory, 'settings.json'); + const cases = ['""', "'unclosed", 'fixture-server "unclosed', '"/fixture/server path" "" --mcp', String.raw\`'C:\\Program Files\\server.exe' 'C:\\data\\file'\`, 'fixture-server --mcp']; + const results = []; + try { + for (const command of cases) { + await rm(directory, { recursive: true, force: true }); + await mkdir(directory, { recursive: true }); + const before = JSON.stringify(original) + '\\n'; + await writeFile(path, before); + let output = '', exitCode = 0; + await createCli(process.env).serve(['mcp','add','--agent','amp','--command',command,'--json'], { stdout(text) { output += text; }, exit(code) { exitCode = code; } }); + const backup = await readFile(path + '.rea.backup', 'utf8').catch(error => { if (error.code === 'ENOENT') return null; throw error; }); + results.push({ command, exitCode, output, before, after: await readFile(path, 'utf8'), backup }); + } + } finally { await closeCliResources(); } + console.log(JSON.stringify(results)); + `; + const child = spawnSync( + process.execPath, + ["--import", "tsx", "--input-type=module", "--eval", script], + { + encoding: "utf8", + timeout: 20_000, + env: { + PATH: process.env.PATH, + SystemRoot: process.env.SystemRoot, + WINDIR: process.env.WINDIR, + TMPDIR: process.env.TMPDIR, + TMP: process.env.TMP, + TEMP: process.env.TEMP, + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: join(home, "config"), + XDG_DATA_HOME: join(home, "data"), + XDG_CACHE_HOME: join(home, "cache"), + XDG_STATE_HOME: join(home, "state"), + APPDATA: join(home, "appdata"), + LOCALAPPDATA: join(home, "localappdata"), + REA_LOG_LEVEL: "silent", + }, + }, + ); + expect(child.error).toBeUndefined(); + expect(child.status, child.stderr).toBe(0); + const results = JSON.parse(child.stdout); + expect(results).toHaveLength(6); + for (const result of results.slice(0, 3)) { + expect(result.exitCode, result.command).toBe(1); + expect(result.output).toContain("VALIDATION_ERROR"); + expect(result.after).toBe(result.before); + expect(result.backup).toBeNull(); + } + const expected = [ + { command: "/fixture/server path", args: ["", "--mcp"] }, + { + command: String.raw`C:\Program Files\server.exe`, + args: [String.raw`C:\data\file`], + }, + { command: "fixture-server", args: ["--mcp"] }, + ]; + for (const [index, result] of results.slice(3).entries()) { + expect(result.exitCode, result.output).toBe(0); + const original = JSON.parse(result.before); + expect(JSON.parse(result.after)).toEqual({ + ...original, + "amp.mcpServers": { + ...original["amp.mcpServers"], + rea: { ...original["amp.mcpServers"].rea, ...expected[index] }, + }, + }); + expect(result.backup).toBe(result.before); + } + } finally { + await rm(home, { recursive: true, force: true }); + } +}, 30_000); diff --git a/src/cliBuiltinArguments.ts b/src/cliBuiltinArguments.ts index 6b21088995..ff82fea8d4 100644 --- a/src/cliBuiltinArguments.ts +++ b/src/cliBuiltinArguments.ts @@ -205,29 +205,6 @@ export const adaptCliOutputArgumentsForIncur = ( return adapted; }; -/** - * Expand equals-form `mcp add` valued options into spaced forms. Single - * owner: the project-scope guard and the `mcp add` dispatch in `cli.ts` - * both consume this, so `--agent=amp` and `--agent amp` select the same - * agent in both places. An empty `--flag=` form is left intact for the - * caller's missing-value handling. - */ -export const expandMcpAddEqualsArguments = ( - arguments_: readonly string[], -): string[] => - arguments_.flatMap((token) => { - if ( - token.startsWith("--agent=") || - token.startsWith("--command=") || - token.startsWith("-c=") - ) { - const separator = token.indexOf("="); - const value = token.slice(separator + 1); - return value === "" ? [token] : [token.slice(0, separator), value]; - } - return [token]; - }); - type McpAddArgumentError = | { readonly kind: "missing_value"; @@ -237,7 +214,7 @@ type McpAddArgumentError = | { readonly kind: "unexpected_argument"; readonly token: string }; /** - * Own token consumption for the pinned registration builtin. Agent selection + * Own token consumption for the REA registration operation. Agent selection * remains observable even for invalid input so the launcher's scope refusal * and dispatch cannot interpret a command value as an agent selector. */ @@ -245,9 +222,13 @@ export const readMcpAddArguments = ( command: IncurBuiltinArgumentSplit["command"], ): { readonly agents: readonly string[]; + readonly commandText: string | undefined; + readonly global: boolean; readonly validation: Result; } => { const agents: string[] = []; + let commandText: string | undefined; + let global = true; let missing: McpAddArgumentError | undefined; let unexpected: McpAddArgumentError | undefined; for (let index = 0; index < command.length; index += 1) { @@ -257,7 +238,7 @@ export const readMcpAddArguments = ( const equals = token.indexOf("="); const value = equals < 0 ? command[++index] : token.slice(equals + 1); // Spaced flags cannot satisfy another option's missing value. Equals - // values retain Incur's existing expansion/discovery behavior. + // values remain caller-selected data, including a leading dash. if ( value === undefined || value === "" || @@ -269,33 +250,19 @@ export const readMcpAddArguments = ( field: flag === "--agent" ? "agent" : "command", }; if (flag === "--agent" && value) agents.push(value); - } else if (token !== "--no-global") - unexpected ??= { kind: "unexpected_argument", token }; + if (flag === "--command" || flag === "-c") commandText = value; + } else if (token === "--no-global") global = false; + else unexpected ??= { kind: "unexpected_argument", token }; } const failure = missing ?? unexpected; return { agents, + commandText, + global, validation: failure === undefined ? ok(undefined) : err(failure), }; }; -/** - * Detect a project-scoped Amp registration before Incur dispatches it. - * Incur's `mcp add` parses `--no-global` into `global: false` and honors it - * for its package-runner agents, but its Amp writer always writes the global - * settings file and ignores the flag. The guard reads the agent selection - * dispatch reads: equals forms expand to spaced forms first through - * `expandMcpAddEqualsArguments`, `--command`/`-c` consumes its value, and - * Amp registers by default when no agent is given, so a bare project-scoped - * request would still write global Amp settings. Discovery requests are not - * registrations: `classifyMcpAddDiscovery` below is the single owner of the - * discovery classification, so the guard cannot drift from the routing it - * mirrors. - */ -// ponytail: reject instead of implementing project-scoped Amp registration. -// Ceiling: `mcp add --no-global` with Amp fails until Incur supports Amp -// project scope. Upgrade: drop this guard and its rea.mjs wiring once the -// dependency writes project Amp settings, and cover that path instead. /** * How `mcp add` answers a request instead of registering it. Single owner * for the discovery flags: the Amp-scope guard and the `mcp add` routing in @@ -324,18 +291,16 @@ export const classifyMcpAddDiscovery = ( return undefined; }; +/** The lightweight launcher and full dispatcher share the same parsed scope fact. */ export const isProjectScopedAmpRegistration = ( arguments_: readonly string[], ): boolean => { - const { command, flags } = splitIncurBuiltinArguments( - expandMcpAddEqualsArguments(arguments_), - ); + const { command, flags } = splitIncurBuiltinArguments(arguments_); if (command[0] !== "mcp" || command[1] !== "add") return false; if (classifyMcpAddDiscovery(flags) !== undefined) return false; const rest = command.slice(2); - if (!rest.includes("--no-global")) return false; - const { agents } = readMcpAddArguments(rest); - return agents.length === 0 || agents.includes("amp"); + const { agents, global } = readMcpAddArguments(rest); + return !global && (agents.length === 0 || agents.includes("amp")); }; /** Select production doctor using the same normalized builtin argument owner as CLI dispatch. */ diff --git a/src/cliMcpRegistration.test.ts b/src/cliMcpRegistration.test.ts new file mode 100644 index 0000000000..3868727f1b --- /dev/null +++ b/src/cliMcpRegistration.test.ts @@ -0,0 +1,383 @@ +import { createRequire } from "node:module"; +import { mkdir, writeFile } from "node:fs/promises"; +import { delimiter, join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { beforeAll, expect, it } from "vitest"; +import { z } from "zod"; + +import { mcpRegistrationReportSchema } from "./contracts/mcpRegistration.js"; +import { + createTestWorkspace, + removeTestWorkspace, +} from "../tests/support/workspace/workspaceFixture.js"; +import { createTestProcessRegistry } from "../tests/support/process/processFixture.js"; + +const require = createRequire(import.meta.url); +const CHILD_PROGRAM = ` + import { homedir } from 'node:os'; + import { join, relative, isAbsolute, delimiter } from 'node:path'; + import { mkdir, writeFile, readFile, readdir, realpath, stat } from 'node:fs/promises'; + const ambient = process.env.HOME; + if (!ambient || await realpath(ambient) !== await realpath(homedir())) throw Error('Actual temporary HOME was not selected before importing writers.'); + const root = process.env.REA_REGISTRATION_FIXTURE_ROOT; + const inside = (parent, path) => { const part = relative(parent, path); if (part.startsWith('..') || isAbsolute(part)) throw Error('Fixture path escaped owned root.'); }; + inside(root, ambient); + for (const key of ['USERPROFILE','XDG_CONFIG_HOME','XDG_DATA_HOME','XDG_CACHE_HOME','XDG_STATE_HOME','APPDATA','LOCALAPPDATA']) inside(ambient, process.env[key]); + const project = join(root, 'project'); + await mkdir(project); process.chdir(project); + const bin = join(root, 'bin'); await mkdir(bin); + const helperLog = join(root, 'helper-invocations'); + const witness = join(bin, 'acquisition.cjs'); + await writeFile(witness, "require('node:fs').appendFileSync(process.env.REA_REGISTRATION_HELPER_LOG, 'unexpected acquisition\\n'); process.exit(79);\\n"); + for (const runner of ['npx','pnpx','bunx']) { + if (process.platform === 'win32') await writeFile(join(bin, runner + '.cmd'), '@"' + process.execPath + '" "' + witness + '"\\r\\n'); + else await writeFile(join(bin, runner), '#!' + process.execPath + '\\nrequire(' + JSON.stringify(witness) + ');\\n', { mode: 0o700 }); + } + const { createCli, closeCliResources } = await import(${JSON.stringify(new URL("./cli.ts", import.meta.url).href)}); + const { resolveMcpRegistrationClients } = await import(${JSON.stringify(new URL("./application/McpRegistrationCatalog.ts", import.meta.url).href)}); + const { ArtifactResourceScope } = await import(${JSON.stringify(new URL("./artifacts/ArtifactResourceScope.ts", import.meta.url).href)}); + const preflightResources = new ArtifactResourceScope(); + const { parse: parseToml } = await import(${JSON.stringify(pathToFileURL(require.resolve("smol-toml")).href)}); + const { parse: parseYaml } = await import(${JSON.stringify(pathToFileURL(require.resolve("yaml")).href)}); + const environmentFor = async (name) => { + const home = join(root, name); await mkdir(home); + const environment = { ...process.env, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: join(home,'config'), XDG_DATA_HOME: join(home,'data'), XDG_CACHE_HOME: join(home,'cache'), XDG_STATE_HOME: join(home,'state'), APPDATA: join(home,'appdata'), LOCALAPPDATA: join(home,'localappdata'), PATH: bin + delimiter + process.env.PATH, REA_REGISTRATION_HELPER_LOG: helperLog, REA_TEST_CALLER_VISIBLE: 'fixture-only' }; + for (const key of ['HOME','USERPROFILE','XDG_CONFIG_HOME','XDG_DATA_HOME','XDG_CACHE_HOME','XDG_STATE_HOME','APPDATA','LOCALAPPDATA']) inside(home, environment[key]); + return environment; + }; + const serve = async (environment, arguments_) => { + let output = '', exitCode = 0; + await createCli(environment).serve(arguments_, { stdout(text) { output += text; }, exit(code) { exitCode = code; } }); + const document = JSON.parse(output); + if (Array.isArray(document.clients)) for (const client of document.clients) { inside(environment.HOME, client.config_path); if (client.backup_path) inside(environment.HOME, client.backup_path); } + return { exitCode, document }; + }; + const invoke = async (environment, agents, command, global = true) => { + // Check the producing paths before granting the public handler any write authority. + const targets = await resolveMcpRegistrationClients({ agents, global, cwd: project, home: environment.HOME, platform: process.platform, environment }, preflightResources); + if (targets.ok) for (const target of targets.value) inside(global ? environment.HOME : project, target.configPath); + return serve(environment, ['mcp','add', ...agents.flatMap(agent => ['--agent', agent]), ...(command === undefined ? [] : ['--command',command]), ...(global ? [] : ['--no-global']), '--json']); + }; + const exists = async (path) => readFile(path,'utf8').then(() => true, error => { if (error.code === 'ENOENT') return false; throw error; }); + const tree = async (path) => (await readdir(path,{recursive:true})).sort(); + const command = '"/fixture/server path" "" --mcp'; + let result; + try { + const defaultsEnvironment = await environmentFor('defaults'); + const schema = await serve(defaultsEnvironment, ['mcp','add','--schema','--json']); + const all = await invoke(defaultsEnvironment, [], undefined); + const native = []; + for (const client of all.document.clients ?? []) { + const text = await readFile(client.config_path, 'utf8'); + const parsed = ['codex','grok-build'].includes(client.client) ? parseToml(text) : client.client === 'hermes' ? parseYaml(text,{version:'1.1'}) : client.client === 'goose' ? parseYaml(text,{version:'1.2'}) : JSON.parse(text); + const key = client.client === 'amp' ? 'amp.mcpServers' : ['codex','grok-build','hermes'].includes(client.client) ? 'mcp_servers' : ['kilo-code','opencode'].includes(client.client) ? 'mcp' : client.client === 'goose' ? 'extensions' : client.client === 'zed' ? 'context_servers' : client.client === 'vscode' ? 'servers' : 'mcpServers'; + const entry = parsed[key]?.rea; + const words = client.client === 'goose' ? [entry?.cmd,...entry?.args ?? []] : Array.isArray(entry?.command) ? entry.command : [entry?.command,...entry?.args ?? []]; + native.push({ client: client.client, command: words[0], args: words.slice(1), ambientPersisted: Object.values(entry ?? {}).some(value => value && typeof value === 'object' && value.REA_TEST_CALLER_VISIBLE !== undefined) }); + } + const repeatedAll = await invoke(defaultsEnvironment, [], undefined); + const mixedEnvironment = await environmentFor('mixed'); + const cursorPath = join(mixedEnvironment.HOME,'.cursor','mcp.json'); + const ampPath = join(mixedEnvironment.HOME,'.config','amp','settings.json'); + const cursorOriginal = JSON.stringify({mcpServers:{rea:{command:'old',args:['old'],env:{KEEP:'fixture'},disabled:true,custom:{policy:'retained'}},peer:{command:'peer'}},theme:'dark'}) + '\\n'; + const ampOriginal = JSON.stringify({'amp.mcpServers':{rea:{command:'old',args:['old'],env:{KEEP:'amp-fixture'},disabled:false},peer:{command:'peer'}},theme:'dark'}) + '\\n'; + await mkdir(join(mixedEnvironment.HOME,'.cursor'),{recursive:true}); await writeFile(cursorPath,cursorOriginal); + await mkdir(join(mixedEnvironment.HOME,'.config','amp'),{recursive:true}); await writeFile(ampPath,ampOriginal); + const mixed = await invoke(mixedEnvironment,['cursor','amp'],command); + const mixedBytes = [await readFile(cursorPath,'utf8'),await readFile(ampPath,'utf8')]; + const inodeBefore = [(await stat(cursorPath)).ino,(await stat(ampPath)).ino]; + const mixedRepeat = await invoke(mixedEnvironment,['cursor','amp'],command); + const inodeAfter = [(await stat(cursorPath)).ino,(await stat(ampPath)).ino]; + const mixedChecks = { cursor:JSON.parse(mixedBytes[0]),amp:JSON.parse(mixedBytes[1]),backupsMatch:(await readFile(cursorPath+'.rea.backup','utf8'))===cursorOriginal && (await readFile(ampPath+'.rea.backup','utf8'))===ampOriginal, inodePreserved:JSON.stringify(inodeBefore)===JSON.stringify(inodeAfter), backupFiles:(await tree(mixedEnvironment.HOME)).filter(path=>path.includes('.rea.backup')) }; + const failureEnvironment = await environmentFor('partial'); + const broken = join(failureEnvironment.HOME,'.cursor','mcp.json'); + await mkdir(join(failureEnvironment.HOME,'.cursor')); await writeFile(broken,'{"mcpServers":[]}'); + const failedThenSuccess = await invoke(failureEnvironment,['cursor','amp'],command); + const successThenFailed = await invoke(failureEnvironment,['amp','cursor'],'"/fixture/server path" "" --changed'); + const malformedRetained = await readFile(broken,'utf8'); + const runnerEnvironment = await environmentFor('runner-launchers'); + const runners = []; + for (const runner of ['npx','pnpx','bunx']) { + const invocation = await invoke(runnerEnvironment,['cursor'],runner + ' "" package --mcp'); + const entry = JSON.parse(await readFile(join(runnerEnvironment.HOME,'.cursor','mcp.json'),'utf8')).mcpServers.rea; + runners.push({runner,invocation,entry}); + } + const refusedEnvironment = await environmentFor('refused'); + const refusedBefore = await tree(refusedEnvironment.HOME); + const refused = []; + for (const agents of [['cursor','goose'],['cursor','amp'],[]]) refused.push(await invoke(refusedEnvironment,agents,command,false)); + const refusedAfter = await tree(refusedEnvironment.HOME); + const remoteEnvironment = await environmentFor('remote-refused'); + const remoteBefore = await tree(remoteEnvironment.HOME); + const remote = []; + for (const source of ['https://fixture.invalid/mcp','https://fixture-user:fixture-secret@fixture.invalid/mcp']) remote.push(await invoke(remoteEnvironment,['cursor'],source)); + const remoteDidNotWrite = JSON.stringify(remoteBefore) === JSON.stringify(await tree(remoteEnvironment.HOME)); + result = { remote,remoteDidNotWrite, schema,all,native,repeatedAll,mixed,mixedRepeat,mixedChecks,failedThenSuccess,successThenFailed,malformedRetained,runners,refused,refusalDidNotWrite:JSON.stringify(refusedBefore)===JSON.stringify(refusedAfter),ambientUnchanged:(await tree(ambient)).length===0,helperAcquisition:await exists(helperLog),expectedDefault:{command:process.execPath,args:[process.argv[1],'mcp']} }; + } finally { await closeCliResources(); await preflightResources.close(); } + console.log(JSON.stringify(result)); +`; + +const invocationSchema = z.object({ + exitCode: z.number(), + document: z.unknown(), +}); +const observationsSchema = z.object({ + schema: invocationSchema, + all: invocationSchema, + repeatedAll: invocationSchema, + mixed: invocationSchema, + mixedRepeat: invocationSchema, + failedThenSuccess: invocationSchema, + successThenFailed: invocationSchema, + native: z.array( + z.object({ + client: z.string(), + command: z.string(), + args: z.array(z.string()), + ambientPersisted: z.boolean(), + }), + ), + expectedDefault: z.object({ command: z.string(), args: z.array(z.string()) }), + mixedChecks: z.object({ + cursor: z.unknown(), + amp: z.unknown(), + backupsMatch: z.boolean(), + inodePreserved: z.boolean(), + backupFiles: z.array(z.string()), + }), + runners: z.array( + z.object({ + runner: z.enum(["npx", "pnpx", "bunx"]), + invocation: invocationSchema, + entry: z.unknown(), + }), + ), + malformedRetained: z.string(), + refused: z.array(invocationSchema), + remote: z.array(invocationSchema), + remoteDidNotWrite: z.boolean(), + refusalDidNotWrite: z.boolean(), + ambientUnchanged: z.boolean(), + helperAcquisition: z.boolean(), +}); + +const captureRegistration = async () => { + const { root } = await createTestWorkspace("rea-cli-registration-matrix-"); + const processes = createTestProcessRegistry(); + try { + const home = join(root, "ambient"); + await mkdir(home); + const entry = join(root, "entry.mjs"); + await writeFile(entry, CHILD_PROGRAM); + const child = await processes.run( + process.execPath, + ["--import", "tsx", entry], + { + timeoutMs: 20_000, + env: { + PATH: join(root, "bin") + delimiter + process.env.PATH, + SystemRoot: process.env.SystemRoot, + WINDIR: process.env.WINDIR, + TMPDIR: process.env.TMPDIR, + TMP: process.env.TMP, + TEMP: process.env.TEMP, + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: join(home, "config"), + XDG_DATA_HOME: join(home, "data"), + XDG_CACHE_HOME: join(home, "cache"), + XDG_STATE_HOME: join(home, "state"), + APPDATA: join(home, "appdata"), + LOCALAPPDATA: join(home, "localappdata"), + REA_REGISTRATION_FIXTURE_ROOT: root, + REA_LOG_LEVEL: "silent", + }, + }, + ); + expect(child.exitCode, child.stderr).toBe(0); + return observationsSchema.parse(JSON.parse(child.stdout)); + } finally { + await processes.shutdown(); + await removeTestWorkspace(root); + } +}; + +let observed: z.infer; +beforeAll(async () => { + observed = await captureRegistration(); +}, 30_000); + +it("validates named input/output schemas against every registration report", () => { + const schemas = z + .object({ + inputSchema: z.record(z.string(), z.unknown()), + outputSchema: z.record(z.string(), z.unknown()), + }) + .parse(observed.schema.document); + expect(observed.schema.exitCode).toBe(0); + const ajv = new Ajv2020({ strict: true, allErrors: true }); + const input = ajv.compile(schemas.inputSchema); + const output = ajv.compile(schemas.outputSchema); + expect( + input({ + agent: ["cursor", "amp"], + command: '"/fixture/server path" "" --mcp', + }), + JSON.stringify(input.errors), + ).toBe(true); + expect(input({ agent: "amp" })).toBe(false); + for (const invocation of [ + observed.all, + observed.repeatedAll, + observed.mixed, + observed.mixedRepeat, + observed.failedThenSuccess, + observed.successThenFailed, + ...observed.runners.map(({ invocation }) => invocation), + ]) + expect(output(invocation.document), JSON.stringify(output.errors)).toBe( + true, + ); +}); + +it("preserves default and mixed launcher facts, metadata, backups and no-op identity", () => { + const all = mcpRegistrationReportSchema.parse(observed.all.document); + expect(observed.all.exitCode).toBe(0); + expect(all.ok).toBe(true); + expect(all.clients).toHaveLength(26); + expect(all.clients.every((client) => client.status === "configured")).toBe( + true, + ); + expect({ command: all.command, args: all.args }).toEqual( + observed.expectedDefault, + ); + for (const native of observed.native) { + expect( + { command: native.command, args: native.args }, + native.client, + ).toEqual(observed.expectedDefault); + expect(native.ambientPersisted, native.client).toBe(false); + } + expect(observed.native).toHaveLength(26); + const repeated = mcpRegistrationReportSchema.parse( + observed.repeatedAll.document, + ); + expect(observed.repeatedAll.exitCode).toBe(0); + expect( + repeated.clients.every((client) => client.status === "unchanged"), + ).toBe(true); + const mixed = mcpRegistrationReportSchema.parse(observed.mixed.document); + expect(observed.mixed.exitCode).toBe(0); + expect({ command: mixed.command, args: mixed.args }).toEqual({ + command: "/fixture/server path", + args: ["", "--mcp"], + }); + expect( + mixed.clients.map((client) => ({ + client: client.client, + status: client.status, + })), + ).toEqual([ + { client: "cursor", status: "configured" }, + { client: "amp", status: "configured" }, + ]); + expect(observed.mixedChecks.cursor).toMatchObject({ + mcpServers: { + rea: { + command: "/fixture/server path", + args: ["", "--mcp"], + env: { KEEP: "fixture" }, + disabled: true, + custom: { policy: "retained" }, + }, + peer: { command: "peer" }, + }, + theme: "dark", + }); + expect(observed.mixedChecks.amp).toMatchObject({ + "amp.mcpServers": { + rea: { + command: "/fixture/server path", + args: ["", "--mcp"], + env: { KEEP: "amp-fixture" }, + disabled: false, + }, + peer: { command: "peer" }, + }, + theme: "dark", + }); + expect(observed.mixedChecks.backupsMatch).toBe(true); + expect(observed.mixedChecks.inodePreserved).toBe(true); + expect(observed.mixedChecks.backupFiles).toHaveLength(2); + expect( + mcpRegistrationReportSchema + .parse(observed.mixedRepeat.document) + .clients.every((client) => client.status === "unchanged"), + ).toBe(true); +}); + +it("retains actionable failures and completed siblings in either order", () => { + const firstFailure = mcpRegistrationReportSchema.parse( + observed.failedThenSuccess.document, + ); + const lastFailure = mcpRegistrationReportSchema.parse( + observed.successThenFailed.document, + ); + expect(observed.failedThenSuccess.exitCode).toBe(1); + expect(firstFailure.ok).toBe(false); + expect(firstFailure.clients.map((client) => client.status)).toEqual([ + "failed", + "configured", + ]); + expect(observed.successThenFailed.exitCode).toBe(1); + expect(lastFailure.ok).toBe(false); + expect(lastFailure.clients.map((client) => client.status)).toEqual([ + "configured", + "failed", + ]); + expect(observed.malformedRetained).toBe('{"mcpServers":[]}'); + for (const client of [...firstFailure.clients, ...lastFailure.clients]) + if (client.status === "failed") + expect(client.error.reason).toContain(".cursor"); +}); + +it("refuses remote transport and project-scope escalation before writes", () => { + for (const invocation of observed.remote) { + expect(invocation.exitCode).toBe(1); + expect(invocation.document).toMatchObject({ + code: "VALIDATION_ERROR", + fieldErrors: [ + expect.objectContaining({ + code: "unsupported_transport", + path: "command", + }), + ], + }); + expect(JSON.stringify(invocation.document)).not.toContain("fixture-secret"); + expect(JSON.stringify(invocation.document)).not.toContain("fixture-user"); + } + expect(observed.remoteDidNotWrite).toBe(true); + for (const refused of observed.refused) { + expect(refused.exitCode).toBe(1); + expect(refused.document).toMatchObject({ code: "VALIDATION_ERROR" }); + } + expect(observed.refusalDidNotWrite).toBe(true); +}); + +it("preserves runner launcher data without acquisition or ambient writes", () => { + for (const { runner, invocation, entry } of observed.runners) { + expect(invocation.exitCode).toBe(0); + const report = mcpRegistrationReportSchema.parse(invocation.document); + expect({ command: report.command, args: report.args }).toEqual({ + command: runner, + args: ["", "package", "--mcp"], + }); + expect(entry).toMatchObject({ + command: runner, + args: ["", "package", "--mcp"], + }); + } + expect(observed.ambientUnchanged).toBe(true); + expect(observed.helperAcquisition).toBe(false); +}); diff --git a/src/contracts/mcpRegistration.ts b/src/contracts/mcpRegistration.ts new file mode 100644 index 0000000000..23ec86e0d8 --- /dev/null +++ b/src/contracts/mcpRegistration.ts @@ -0,0 +1,70 @@ +import { z } from "zod"; +import { processArgumentSchema } from "../domain/process/processArgument.js"; + +/** REA-owned registration options; commands are launch data, never helper acquisition. */ +export const mcpRegistrationOptionsSchema = z.object({ + agent: z + .array(z.string()) + .optional() + .describe( + "Select client IDs or documented aliases; repeat --agent to select more than one.", + ), + command: processArgumentSchema + .min(1) + .optional() + .describe( + "Stdio launcher text: space-separated words, single/double quote fragments, literal backslashes. Empty quoted arguments are preserved; NUL is rejected. HTTP(S) URL sources are unsupported; configure remote transports in client settings.", + ), + noGlobal: z + .boolean() + .optional() + .describe( + "Write only project-scoped client settings; reject selections requiring global writes.", + ), +}); +const permissionsSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("verified_posix_modes"), + directoryMode: z.literal(0o700), + fileMode: z.literal(0o600), + }), + z.object({ + kind: z.literal("requested_windows_modes"), + requestedDirectoryMode: z.literal(0o700), + requestedFileMode: z.literal(0o600), + aclVerification: z.literal("unavailable"), + }), +]); +const labels = { + client: z.string(), + config_path: z.string(), + scope: z.enum(["global", "project"]), + permissions: permissionsSchema.optional(), +}; +const clientOutcomeSchema = z.discriminatedUnion("status", [ + z.object({ + ...labels, + status: z.literal("configured"), + backup_path: z.string().optional(), + }), + z.object({ ...labels, status: z.literal("unchanged") }), + z.object({ + ...labels, + status: z.literal("failed"), + error: z.looseObject({ + code: z.string(), + message: z.string(), + reason: z.string(), + }), + }), + z.object({ ...labels, status: z.literal("not_started") }), +]); +/** Exact selected argv and every selected client's completion, including partial work. */ +export const mcpRegistrationReportSchema = z.object({ + name: z.literal("rea"), + command: z.string().min(1), + args: z.array(z.string()), + ok: z.boolean(), + clients: z.array(clientOutcomeSchema), +}); +export type McpRegistrationReport = z.infer; diff --git a/src/domain/clientRegistrationCommand.test.ts b/src/domain/clientRegistrationCommand.test.ts new file mode 100644 index 0000000000..fe37b0061f --- /dev/null +++ b/src/domain/clientRegistrationCommand.test.ts @@ -0,0 +1,103 @@ +import { expect, it } from "vitest"; +import { parseClientRegistrationCommand } from "./clientRegistrationCommand.js"; + +it.each([ + { + text: '"/fixture/server path" "" --mcp', + command: "/fixture/server path", + args: ["", "--mcp"], + }, + { + text: String.raw`'C:\Program Files\server.exe' 'C:\data\file'`, + command: String.raw`C:\Program Files\server.exe`, + args: [String.raw`C:\data\file`], + }, + { + text: `fix'ture-'"server" 'a'"'"'b'`, + command: "fixture-server", + args: ["a'b"], + }, + { + text: "fixture-server '$TOKEN' ';' 'a\tb'", + command: "fixture-server", + args: ["$TOKEN", ";", "a\tb"], + }, +])( + "preserves exact registration words from $text", + ({ text, command, args }) => { + expect(parseClientRegistrationCommand(text)).toEqual({ + ok: true, + value: { command, args }, + }); + }, +); + +it.each([ + "", + " ", + '""', + "'' --mcp", + "'unfinished", + 'server "unfinished', + null, + [1], +])("refuses a registration without an admitted executable (%j)", (input) => + expect(parseClientRegistrationCommand(input).ok).toBe(false), +); + +it("preserves structured default words without command-text tokenization", () => { + const selected = [ + String.raw`C:\fixture\server.exe`, + "", + "a'b", + 'x"y', + "tab\there", + "$TOKEN", + ]; + const parsed = parseClientRegistrationCommand(selected); + expect(parsed).toEqual({ + ok: true, + value: { command: selected[0], args: selected.slice(1) }, + }); + expect(selected[1]).toBe(""); +}); + +it("refuses HTTP transport sources without echoing URL credentials", () => { + for (const source of [ + "https://fixture.invalid/mcp", + "HTTPS://fixture.invalid/mcp", + "http://fixture-user:fixture-secret@fixture.invalid/mcp", + ]) { + const parsed = parseClientRegistrationCommand(source); + expect(parsed.ok).toBe(false); + if (parsed.ok) throw new Error("Expected an unsupported transport"); + expect(parsed.error.kind).toBe("unsupported_transport"); + expect(parsed.error.message).toContain("stdio"); + expect(parsed.error.message).not.toContain("fixture-secret"); + expect(parsed.error.message).not.toContain("fixture-user"); + } + expect( + parseClientRegistrationCommand("server https://fixture.invalid/argument"), + ).toEqual({ + ok: true, + value: { command: "server", args: ["https://fixture.invalid/argument"] }, + }); +}); + +it.each([ + { input: "server\0suffix argument" }, + { input: "server 'arg\0suffix'" }, + { input: ["server\0suffix", "argument"] }, + { input: ["server", "arg\0suffix"] }, +])( + "refuses OS-unrepresentable NUL in authored command/arguments: $input", + ({ input }) => { + const parsed = parseClientRegistrationCommand(input); + expect(parsed).toMatchObject({ + ok: false, + error: { kind: "invalid_argument" }, + }); + if (parsed.ok) throw Error("Expected rejected process arguments"); + expect(parsed.error.message).toContain("NUL"); + }, +); diff --git a/src/domain/clientRegistrationCommand.ts b/src/domain/clientRegistrationCommand.ts new file mode 100644 index 0000000000..86356f4761 --- /dev/null +++ b/src/domain/clientRegistrationCommand.ts @@ -0,0 +1,91 @@ +import { err, ok, type Result } from "./result.js"; +import { processArgumentSchema } from "./process/processArgument.js"; + +/** Exact stdio launcher facts, independent of command-text presentation. */ +export interface ClientRegistrationCommand { + readonly command: string; + readonly args: readonly string[]; +} + +export interface ClientRegistrationCommandIssue { + readonly kind: + | "invalid_type" + | "invalid_argument" + | "empty_command" + | "unterminated_quote" + | "unsupported_transport"; + readonly message: string; +} + +/** + * Registration text separates words with ASCII spaces. Single/double quotes + * delimit adjacent fragments, including empty arguments. Backslashes and shell + * punctuation are literal data. Structured defaults bypass text tokenization. + */ +export const parseClientRegistrationCommand = ( + input: unknown, +): Result => { + let words: readonly string[]; + if (typeof input === "string") { + const parsed = registrationWords(input); + if (!parsed.ok) return parsed; + words = parsed.value; + } else if ( + Array.isArray(input) && + input.every((word): word is string => typeof word === "string") + ) { + words = input; + } else + return err({ + kind: "invalid_type", + message: + "The registration command must be command text or string arguments.", + }); + const [command, ...args] = words; + if (command === undefined || command === "") + return err({ + kind: "empty_command", + message: "The registration command needs a nonempty executable.", + }); + if (words.some((word) => !processArgumentSchema.safeParse(word).success)) + return err({ + kind: "invalid_argument", + message: + "The registration executable and arguments cannot contain NUL characters. Remove them before registering the client.", + }); + const source = URL.parse(command); + if (source?.protocol === "http:" || source?.protocol === "https:") + return err({ + kind: "unsupported_transport", + message: + "This command registers stdio launchers. HTTP(S) URLs are not executable commands; configure the remote MCP server in the client's settings instead.", + }); + return ok({ command, args }); +}; + +const registrationWords = ( + text: string, +): Result => { + const words: string[] = []; + let word: string | undefined; + let quote: "'" | '"' | undefined; + for (const character of text) { + if (quote !== undefined) { + if (character === quote) quote = undefined; + else word = `${word ?? ""}${character}`; + } else if (character === "'" || character === '"') { + quote = character; + word ??= ""; + } else if (character === " ") { + if (word !== undefined) words.push(word); + word = undefined; + } else word = `${word ?? ""}${character}`; + } + if (quote !== undefined) + return err({ + kind: "unterminated_quote", + message: "The registration command has an unclosed quote.", + }); + if (word !== undefined) words.push(word); + return ok(words); +}; diff --git a/tests/boundary/cli/ampRegistrationPreservation.test.ts b/tests/boundary/cli/ampRegistrationPreservation.test.ts index 507b8af71f..1f9faf34b4 100644 --- a/tests/boundary/cli/ampRegistrationPreservation.test.ts +++ b/tests/boundary/cli/ampRegistrationPreservation.test.ts @@ -1,33 +1,36 @@ -import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { + chmod, + mkdir, + readFile, + readdir, + stat, + writeFile, +} from "node:fs/promises"; import { join } from "node:path"; import { expect, it } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { writeAmpRegistrationBackup } from "../../../src/cli/ampRegistrationBackup.js"; +import { writeClientRegistrationBackup } from "../../../src/application/ClientRegistrationBackup.js"; +import { mcpRegistrationReportSchema } from "../../../src/contracts/mcpRegistration.js"; import { cliTest } from "../../support/cli/cliFixture.js"; +import { registrationEnvironment } from "../../support/cli/registrationEnvironment.js"; const ampSettings = (home: string) => join(home, ".config", "amp", "settings.json"); cliTest( - "mcp add preserves Amp environment and reports unreadable settings", + "packaged Amp refresh preserves policy and creates recovery only for changed launchers", async ({ cli }) => { const root = await createTestTempDirectory("rea-amp-registration-"); const home = join(root, "home"); - const environment = { - HOME: home, - USERPROFILE: home, - }; - const run = (arguments_: readonly string[]) => + const environment = await registrationEnvironment(home); + const run = (options: readonly string[]) => cli.run({ - arguments: [...arguments_, "--json"], + arguments: ["mcp", "add", "--agent=amp", ...options, "--json"], cwd: root, environment, timeoutMs: 20_000, }); - - // An existing rea registration keeps its environment map and the file is - // backed up before the refresh. const settingsPath = ampSettings(home); const original = JSON.stringify( { @@ -36,6 +39,7 @@ cliTest( command: "npx", args: ["-y", "rea-agents@6.8.0", "mcp"], env: { KEEP_ME: "1" }, + disabled: false, }, other: { command: "other" }, }, @@ -46,141 +50,122 @@ cliTest( ); await mkdir(join(home, ".config", "amp"), { recursive: true }); await writeFile(settingsPath, original); - - const refreshed = await run(["mcp", "add", "--agent", "amp"]); - expect(refreshed.exitCode, refreshed.stderr).toBe(0); + const first = await run([]); + expect(first.exitCode, first.stderr).toBe(0); + const firstReport = mcpRegistrationReportSchema.parse(first.json); + expect(firstReport.clients).toMatchObject([ + { + client: "amp", + status: "configured", + backup_path: `${settingsPath}.rea.backup`, + }, + ]); const after = JSON.parse(await readFile(settingsPath, "utf8")); - expect(after["amp.mcpServers"].rea).toMatchObject({ - command: expect.any(String), - env: { KEEP_ME: "1" }, + expect(after).toMatchObject({ + "amp.mcpServers": { + rea: { + command: firstReport.command, + args: firstReport.args, + env: { KEEP_ME: "1" }, + disabled: false, + }, + other: { command: "other" }, + }, + theme: "dark", }); - const registrationBackupPath = `${settingsPath}.rea.backup`; - expect(await readFile(registrationBackupPath, "utf8")).toBe(original); - expect(refreshed.stderr).toContain(registrationBackupPath); - expect(after.theme).toBe("dark"); - expect(after["amp.mcpServers"].other).toEqual({ command: "other" }); - - // The equals form --agent=amp is parsed as a spaced form before Incur sees - // it, so the Amp registration lands and keeps the environment map. - const settingsBeforeEquals = await readFile(settingsPath, "utf8"); - const equals = await run(["mcp", "add", "--agent=amp"]); + expect(await readFile(`${settingsPath}.rea.backup`, "utf8")).toBe(original); + const beforeEquals = await readFile(settingsPath, "utf8"); + const beforeState = await stat(settingsPath); + const equals = await run([]); expect(equals.exitCode, equals.stderr).toBe(0); - const afterEquals = JSON.parse(await readFile(settingsPath, "utf8")); - expect(afterEquals["amp.mcpServers"].rea).toMatchObject({ - command: expect.any(String), - env: { KEEP_ME: "1" }, - }); - expect(afterEquals["amp.mcpServers"].other).toEqual({ command: "other" }); - expect(await readFile(registrationBackupPath, "utf8")).toBe(original); - const recoveryBackupPath = equals.stderr.match( - /Amp MCP registration backup retained at (.+)/, - )?.[1]; - expect(recoveryBackupPath).toContain(".recovery"); - if (recoveryBackupPath === undefined) - throw new Error("Amp did not report the current recovery backup path."); - expect(await readFile(recoveryBackupPath, "utf8")).toBe( - settingsBeforeEquals, + expect( + mcpRegistrationReportSchema.parse(equals.json).clients, + ).toMatchObject([{ client: "amp", status: "unchanged" }]); + expect(await readFile(settingsPath, "utf8")).toBe(beforeEquals); + expect((await stat(settingsPath)).ino).toBe(beforeState.ino); + expect(equals.stderr).not.toContain("backup retained"); + expect(await readdir(join(home, ".config", "amp"))).toEqual([ + "settings.json", + "settings.json.rea.backup", + ]); + const changed = await run(["--command", '"fixture replacement" "" --mcp']); + expect(changed.exitCode, changed.stderr).toBe(0); + const changedReceipt = mcpRegistrationReportSchema.parse(changed.json) + .clients[0]; + if ( + changedReceipt?.status !== "configured" || + changedReceipt.backup_path === undefined + ) + throw Error("Missing changed-launcher recovery receipt."); + expect(changedReceipt.backup_path).toContain(".recovery"); + expect(await readFile(changedReceipt.backup_path, "utf8")).toBe( + beforeEquals, ); - - // A malformed settings file is rejected instead of reporting success. - const broken = join(root, "broken"); - const brokenSettings = ampSettings(broken); - const malformed = '{ "amp.mcpServers": { "rea": {} '; - await mkdir(join(broken, ".config", "amp"), { recursive: true }); - await writeFile(brokenSettings, malformed); - const rejected = await cli.run({ - arguments: ["mcp", "add", "--agent", "amp", "--json"], - cwd: root, - environment: { HOME: broken, USERPROFILE: broken }, - timeoutMs: 20_000, - }); - expect(rejected.exitCode, rejected.stderr).toBe(1); - expect(rejected.json).toMatchObject({ - code: "VALIDATION_ERROR", - fieldErrors: expect.arrayContaining([ - expect.objectContaining({ - code: "invalid_value", - missing: false, - path: "amp", - }), - ]), - }); - expect(await readFile(brokenSettings, "utf8")).toBe(malformed); - - // JSON null is valid JSON but not a settings object; reject it as a - // validation error instead of letting property access throw. - const nullHome = join(root, "null-settings"); - const nullSettings = ampSettings(nullHome); - await mkdir(join(nullHome, ".config", "amp"), { recursive: true }); - await writeFile(nullSettings, "null\n"); - const nullRejected = await cli.run({ - arguments: ["mcp", "add", "--agent", "amp", "--json"], - cwd: root, - environment: { HOME: nullHome, USERPROFILE: nullHome }, - timeoutMs: 20_000, - }); - expect(nullRejected.exitCode, nullRejected.stderr).toBe(1); - expect(nullRejected.json).toMatchObject({ - code: "VALIDATION_ERROR", - message: expect.stringContaining("must contain a JSON object"), - }); - expect(await readFile(nullSettings, "utf8")).toBe("null\n"); - - // An array-valued amp.mcpServers is rejected instead of claiming success. - const arrayHome = join(root, "array"); - const arraySettings = ampSettings(arrayHome); - const array = '{"amp.mcpServers": []}\n'; - await mkdir(join(arrayHome, ".config", "amp"), { recursive: true }); - await writeFile(arraySettings, array); - const arrayRejected = await cli.run({ - arguments: ["mcp", "add", "--agent", "amp", "--json"], - cwd: root, - environment: { HOME: arrayHome, USERPROFILE: arrayHome }, - timeoutMs: 20_000, - }); - expect(arrayRejected.exitCode, arrayRejected.stderr).toBe(1); - expect(arrayRejected.json).toMatchObject({ - code: "VALIDATION_ERROR", - fieldErrors: expect.arrayContaining([ - expect.objectContaining({ - code: "invalid_value", - missing: false, - path: "amp", - }), - ]), - }); - expect(await readFile(arraySettings, "utf8")).toBe(array); - - // A backup write failure is reported as a filesystem problem, not as bad - // JSON. - const backupHome = join(root, "backup-error"); - const backupSettings = ampSettings(backupHome); - const backupPath = `${backupSettings}.rea.backup`; - await mkdir(join(backupHome, ".config", "amp"), { recursive: true }); - await writeFile(backupSettings, '{"amp.mcpServers": {}}\n'); - await mkdir(backupPath); - const backupRejected = await cli.run({ - arguments: ["mcp", "add", "--agent", "amp", "--json"], - cwd: root, - environment: { HOME: backupHome, USERPROFILE: backupHome }, - timeoutMs: 20_000, - }); - expect(backupRejected.exitCode, backupRejected.stderr).toBe(1); - expect(backupRejected.json).toMatchObject({ - code: "VALIDATION_ERROR", - message: expect.stringContaining("Unable to create Amp settings backup"), + expect(await readFile(`${settingsPath}.rea.backup`, "utf8")).toBe(original); + expect( + JSON.parse(await readFile(settingsPath, "utf8"))["amp.mcpServers"].rea, + ).toMatchObject({ + command: "fixture replacement", + args: ["", "--mcp"], + env: { KEEP_ME: "1" }, + disabled: false, }); }, ); -it("keeps the first Amp registration backup and secures later recovery copies", async () => { +cliTest( + "packaged Amp reports malformed owned documents and unavailable backups as client failures", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-amp-rejected-"); + for (const [index, source] of [ + '{ "amp.mcpServers": { "rea": {} ', + "null\n", + '{"amp.mcpServers": []}\n', + '{"amp.mcpServers": {}}\n', + ].entries()) { + const home = join(root, String(index)); + const environment = await registrationEnvironment(home); + const settingsPath = ampSettings(home); + await mkdir(join(home, ".config", "amp"), { recursive: true }); + await writeFile(settingsPath, source); + if (index === 3) await mkdir(`${settingsPath}.rea.backup`); + const rejected = await cli.run({ + arguments: ["mcp", "add", "--agent", "amp", "--json"], + cwd: root, + environment, + timeoutMs: 20_000, + }); + expect(rejected.exitCode, rejected.stderr).toBe(1); + expect(mcpRegistrationReportSchema.parse(rejected.json)).toMatchObject({ + ok: false, + clients: [ + { + client: "amp", + status: "failed", + error: { code: expect.any(String), message: expect.any(String) }, + }, + ], + }); + expect(await readFile(settingsPath, "utf8")).toBe(source); + expect(await readdir(join(home, ".config", "amp"))).toEqual( + index === 3 + ? ["settings.json", "settings.json.rea.backup"] + : ["settings.json"], + ); + } + }, +); + +it("keeps the first registration backup and secures later recovery copies", async () => { const root = await createTestTempDirectory("rea-amp-backup-permissions-"); const settingsPath = join(root, "settings.json"); const settingsText = '{"amp.mcpServers":{"rea":{"env":{"TOKEN":"secret"}}}}\n'; await writeFile(settingsPath, settingsText); - - const backup = await writeAmpRegistrationBackup(settingsPath, settingsText); + const backup = await writeClientRegistrationBackup( + settingsPath, + settingsText, + ); if (!backup.ok) throw new Error( `${backup.error.kind} ${backup.error.path}: ${backup.error.detail}`, @@ -189,11 +174,10 @@ it("keeps the first Amp registration backup and secures later recovery copies", expect(await readFile(backupPath, "utf8")).toBe(settingsText); if (process.platform !== "win32") expect((await stat(backupPath)).mode & 0o777).toBe(0o600); - await chmod(backupPath, 0o644); const updatedSettings = "updated settings"; await writeFile(settingsPath, updatedSettings); - const updatedBackup = await writeAmpRegistrationBackup( + const updatedBackup = await writeClientRegistrationBackup( settingsPath, updatedSettings, ); @@ -201,29 +185,22 @@ it("keeps the first Amp registration backup and secures later recovery copies", throw new Error( `${updatedBackup.error.kind} ${updatedBackup.error.path}: ${updatedBackup.error.detail}`, ); - const updatedBackupPath = updatedBackup.value; - expect(updatedBackupPath).not.toBe(backupPath); - expect(await readFile(updatedBackupPath, "utf8")).toBe(updatedSettings); + expect(updatedBackup.value).not.toBe(backupPath); + expect(await readFile(updatedBackup.value, "utf8")).toBe(updatedSettings); expect(await readFile(backupPath, "utf8")).toBe(settingsText); - if (process.platform !== "win32") - expect((await stat(updatedBackupPath)).mode & 0o777).toBe(0o600); - if (process.platform !== "win32") + if (process.platform !== "win32") { + expect((await stat(updatedBackup.value)).mode & 0o777).toBe(0o600); expect((await stat(backupPath)).mode & 0o777).toBe(0o600); + } }); -it("returns a tagged failure when the Amp backup path is occupied", async () => { +it("returns a tagged failure when the registration backup path is occupied", async () => { const root = await createTestTempDirectory("rea-amp-backup-occupied-"); const settingsPath = join(root, "settings.json"); const backupPath = `${settingsPath}.rea.backup`; await writeFile(settingsPath, "{}\n"); await mkdir(backupPath); - - const result = await writeAmpRegistrationBackup(settingsPath, "{}\n"); - expect(result).toMatchObject({ - ok: false, - error: { - kind: "occupied", - path: backupPath, - }, - }); + expect( + await writeClientRegistrationBackup(settingsPath, "{}\n"), + ).toMatchObject({ ok: false, error: { kind: "occupied", path: backupPath } }); }); diff --git a/tests/boundary/cli/defaultAmpRegistration.test.ts b/tests/boundary/cli/defaultAmpRegistration.test.ts index 65e2cdc86b..b735f5581c 100644 --- a/tests/boundary/cli/defaultAmpRegistration.test.ts +++ b/tests/boundary/cli/defaultAmpRegistration.test.ts @@ -1,72 +1,28 @@ -import { - mkdir, - readFile, - readdir, - rm, - stat, - writeFile, -} from "node:fs/promises"; -import { delimiter, join } from "node:path"; +import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; +import { join } from "node:path"; import { expect, it } from "vitest"; import { Parser, z } from "incur"; +import { decode } from "@toon-format/toon"; +import { mcpRegistrationReportSchema } from "../../../src/contracts/mcpRegistration.js"; import { splitIncurBuiltinArguments } from "../../../src/cliBuiltinArguments.js"; - import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; +import { registrationEnvironment } from "../../support/cli/registrationEnvironment.js"; const fixture = async (initial: string) => { const home = await createTestTempDirectory("rea-default-amp-"); - const bin = join(home, "bin"); - const log = join(home, "runner.jsonl"); - const script = join(home, "runner.cjs"); const directory = join(home, ".config", "amp"); const settings = join(directory, "settings.json"); - await mkdir(bin); await mkdir(directory, { recursive: true }); await writeFile(settings, initial); - await writeFile(log, ""); - await writeFile( - script, - `require("node:fs").appendFileSync(process.env.REA_TEST_RUNNER_LOG, -JSON.stringify(process.argv.slice(2)) + "\\n"); -if (process.env.REA_TEST_RUNNER_FAIL) { - console.error("fixture runner failure"); - process.exitCode = 23; -} else console.log("│ ✓ Fixture Client: fixture │"); -`, - ); - for (const runner of ["npx", "bun", "bunx", "pnpx"]) - await writeFile( - join(bin, process.platform === "win32" ? `${runner}.cmd` : runner), - process.platform === "win32" - ? `@echo off\r\n"%REA_TEST_NODE%" "%REA_TEST_RUNNER_SCRIPT%" %*\r\n` - : `#!/bin/sh\nexec "$REA_TEST_NODE" "$REA_TEST_RUNNER_SCRIPT" "$@"\n`, - { mode: 0o700 }, - ); - const environment: NodeJS.ProcessEnv = { - HOME: home, - USERPROFILE: home, - PATH: [bin, process.env.PATH ?? ""].join(delimiter), - REA_TEST_NODE: process.execPath, - REA_TEST_RUNNER_SCRIPT: script, - REA_TEST_RUNNER_LOG: log, - npm_config_user_agent: "npm/11 node/v24.22.1", - npm_execpath: "", + return { + home, + directory, + settings, + environment: await registrationEnvironment(home), }; - for (const key of [ - "XDG_CONFIG_HOME", - "XDG_DATA_HOME", - "XDG_CACHE_HOME", - "XDG_STATE_HOME", - "TMPDIR", - ]) { - environment[key] = join(home, key); - await mkdir(environment[key]!); - } - return { home, log, directory, settings, environment }; }; - const original = `${JSON.stringify({ "amp.mcpServers": { rea: { command: "old", env: { KEEP_ME: "1" } }, @@ -74,64 +30,69 @@ const original = `${JSON.stringify({ }, theme: "dark", })}\n`; +const report = (stdout: string, json: boolean) => + mcpRegistrationReportSchema.parse(json ? JSON.parse(stdout) : decode(stdout)); cliTest( - "default registration preserves Amp settings and recovery in both output modes", + "packaged default registration preserves Amp settings and repeats without another backup", async ({ cli }) => { - for (const output of [[], ["--json"]]) { + for (const json of [false, true]) { const f = await fixture(original); - for (let attempt = 0; attempt < 2; attempt += 1) { - const before = await readFile(f.settings, "utf8"); - const result = await cli.run({ + const run = () => + cli.run({ arguments: [ "mcp", "add", "--command", "fixture-server --mcp", - ...output, + ...(json ? ["--json"] : []), ], cwd: f.home, environment: f.environment, timeoutMs: 20_000, }); - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout).toContain("Amp"); - expect(JSON.parse(await readFile(f.settings, "utf8"))).toEqual({ - "amp.mcpServers": { - rea: { - command: "fixture-server", - args: ["--mcp"], - env: { KEEP_ME: "1" }, - }, - peer: { command: "peer" }, - }, - theme: "dark", - }); - const backup = result.stderr.trim().split(" retained at ")[1]!; - expect(await readFile(backup, "utf8")).toBe(before); - if (process.platform !== "win32") - expect((await stat(backup)).mode & 0o777).toBe(0o600); - expect(await readFile(`${f.settings}.rea.backup`, "utf8")).toBe( - original, - ); - } - const calls = (await readFile(f.log, "utf8")).trim().split("\n"); - expect(calls.map((line) => JSON.parse(line))).toEqual( - Array.from({ length: 2 }, () => [ - "add-mcp", - "fixture-server --mcp", - "--name", - "rea", - "-y", - "-g", - ]), + const first = await run(); + expect(first.exitCode, first.stderr).toBe(0); + const outcome = report(first.stdout, json).clients.find( + (client) => client.client === "amp", ); + expect(outcome?.status).toBe("configured"); + if (outcome?.status !== "configured" || outcome.backup_path === undefined) + throw Error("Missing Amp backup receipt."); + expect(await readFile(outcome.backup_path, "utf8")).toBe(original); + if (process.platform !== "win32") + expect((await stat(outcome.backup_path)).mode & 0o777).toBe(0o600); + expect(JSON.parse(await readFile(f.settings, "utf8"))).toEqual({ + "amp.mcpServers": { + rea: { + command: "fixture-server", + args: ["--mcp"], + env: { KEEP_ME: "1" }, + }, + peer: { command: "peer" }, + }, + theme: "dark", + }); + const before = await stat(f.settings); + const repeated = await run(); + expect(repeated.exitCode, repeated.stderr).toBe(0); + expect( + report(repeated.stdout, json).clients.find( + (client) => client.client === "amp", + ), + ).toMatchObject({ status: "unchanged" }); + expect((await stat(f.settings)).ino).toBe(before.ino); + expect(repeated.stderr).not.toContain("backup retained"); + expect(await readdir(f.directory)).toEqual([ + "settings.json", + "settings.json.rea.backup", + ]); } }, ); cliTest( - "default registration refuses invalid settings and unavailable backups before the runner", + "packaged default registration reports an Amp failure without claiming its old entry changed", async ({ cli }) => { for (const initial of [ "broken JSON\n", @@ -141,21 +102,23 @@ cliTest( ]) { const f = await fixture(initial); if (initial === original) await mkdir(`${f.settings}.rea.backup`); - for (const output of [[], ["--json"]]) { + for (const json of [false, true]) { const result = await cli.run({ - arguments: ["mcp", "add", ...output], + arguments: ["mcp", "add", ...(json ? ["--json"] : [])], cwd: f.home, environment: f.environment, timeoutMs: 20_000, }); expect(result.exitCode).toBe(1); - expect(result.stdout).toContain("VALIDATION_ERROR"); - if (output.length > 0) - expect(JSON.parse(result.stdout)).toMatchObject({ - code: "VALIDATION_ERROR", - }); + const document = report(result.stdout, json); + expect(document.ok).toBe(false); + expect( + document.clients.find((client) => client.client === "amp"), + ).toMatchObject({ + status: "failed", + error: { code: expect.any(String), message: expect.any(String) }, + }); expect(await readFile(f.settings, "utf8")).toBe(initial); - expect(await readFile(f.log, "utf8")).toBe(""); expect(await readdir(f.directory)).toEqual( initial === original ? ["settings.json", "settings.json.rea.backup"] @@ -167,17 +130,16 @@ cliTest( ); cliTest( - "non-Amp selections, command values and discovery do not touch Amp settings", + "unselected malformed Amp settings do not block named clients or discovery", async ({ cli }) => { const f = await fixture("broken JSON\n"); - const selections = [ - ["--agent", "claude-code"], - ["--agent=claude-code"], - ["--agent", "cursor", "--agent", "claude-code"], - ]; - for (const selection of selections) { + for (const arguments_ of [ + ["mcp", "add", "--agent=cursor", "--agent", "claude-code", "--json"], + ["mcp", "add", "--llms"], + ["mcp", "add", "--llms-full"], + ]) { const result = await cli.run({ - arguments: ["mcp", "add", ...selection, "--json"], + arguments: arguments_, cwd: f.home, environment: f.environment, timeoutMs: 20_000, @@ -186,85 +148,19 @@ cliTest( expect(await readFile(f.settings, "utf8")).toBe("broken JSON\n"); expect(await readdir(f.directory)).toEqual(["settings.json"]); } - // A following flag is not a command value. Reject it before Amp settings. - for (const selection of [ - ["--command", "--agent", "amp", "--agent", "claude-code"], - ["-c", "--agent", "amp", "--agent", "claude-code"], - ]) { - const result = await cli.run({ - arguments: ["mcp", "add", ...selection, "--json"], - cwd: f.home, - environment: f.environment, - timeoutMs: 20_000, - }); - expect(result.exitCode, result.stdout).toBe(1); - expect(result.stdout).toContain("Missing value for flag:"); - expect(await readFile(f.settings, "utf8")).toBe("broken JSON\n"); - expect(await readdir(f.directory)).toEqual(["settings.json"]); - } - const calls = (await readFile(f.log, "utf8")).trim().split("\n"); - expect(calls).toHaveLength(selections.length); - for (const line of calls) expect(JSON.parse(line)).toContain("claude-code"); - await writeFile(f.log, ""); - for (const flag of [ - "--help", - "-h", - "--llms", - "--llms-full", - "--schema", - "--version", - ]) { - const result = await cli.run({ - arguments: ["mcp", "add", flag, "--json"], - cwd: f.home, - environment: f.environment, - timeoutMs: 20_000, - }); - expect(result.exitCode).toBe(flag === "--schema" ? 1 : 0); - expect(result.stdout).not.toContain("VALIDATION_ERROR"); - expect(await readFile(f.settings, "utf8")).toBe("broken JSON\n"); - expect(await readdir(f.directory)).toEqual(["settings.json"]); - } - expect(await readFile(f.log, "utf8")).toBe(""); }, ); cliTest( - "default runner failure retains the original settings and failure status", + "timing globals retain registration and owned-document failure semantics", async ({ cli }) => { - for (const output of [[], ["--json"]]) { - const f = await fixture(original); - const result = await cli.run({ - arguments: ["mcp", "add", ...output], - cwd: f.home, - environment: { ...f.environment, REA_TEST_RUNNER_FAIL: "1" }, - timeoutMs: 20_000, - }); - expect(result.exitCode).toBe(1); - expect(result.stdout).toContain("MCP_ADD_FAILED"); - expect(result.stdout).not.toContain("VALIDATION_ERROR"); - expect(result.stdout).not.toContain("Registered rea"); - expect(result.stderr).not.toContain("backup retained"); - expect(await readFile(f.settings, "utf8")).toBe(original); - expect(await readFile(`${f.settings}.rea.backup`, "utf8")).toBe(original); - expect((await readFile(f.log, "utf8")).trim().split("\n")).toHaveLength( - 1, - ); - } - }, -); - -cliTest( - "timing globals cannot bypass Amp preservation or validation", - async ({ cli }) => { - const cases = [ + for (const arguments_ of [ ["--no-timings", "mcp", "add"], ["--timings=false", "mcp", "add", "--json"], ["mcp", "--timings=true", "add"], ["mcp", "add", "--agent", "--no-timings", "amp", "--json"], ["mcp", "add", "--agent", "--timings=false", "amp"], - ]; - for (const arguments_ of cases) { + ]) { const f = await fixture(original); const run = () => cli.run({ @@ -281,39 +177,16 @@ cliTest( ).toEqual({ KEEP_ME: "1" }); expect(await readFile(`${f.settings}.rea.backup`, "utf8")).toBe(original); await writeFile(f.settings, "broken JSON\n"); - await writeFile(f.log, ""); const rejected = await run(); expect(rejected.exitCode).toBe(1); - expect(rejected.stdout).toContain("VALIDATION_ERROR"); + expect( + report(rejected.stdout, arguments_.includes("--json")).clients.find( + (client) => client.client === "amp", + ), + ).toMatchObject({ status: "failed" }); expect(await readFile(f.settings, "utf8")).toBe("broken JSON\n"); - expect(await readFile(f.log, "utf8")).toBe(""); expect(await readFile(`${f.settings}.rea.backup`, "utf8")).toBe(original); } - const f = await fixture("broken JSON\n"); - for (const arguments_ of [ - [ - "--no-timings", - "mcp", - "add", - "--agent", - "--timings=false", - "claude-code", - ], - ["--timings=false", "mcp", "add", "--help"], - ]) { - const result = await cli.run({ - arguments: arguments_, - cwd: f.home, - environment: f.environment, - timeoutMs: 20_000, - }); - expect(result.exitCode, result.stdout).toBe(0); - expect(await readFile(f.settings, "utf8")).toBe("broken JSON\n"); - expect(await readdir(f.directory)).toEqual(["settings.json"]); - } - const calls = (await readFile(f.log, "utf8")).trim().split("\n"); - expect(calls).toHaveLength(1); - expect(JSON.parse(calls[0]!)).toContain("claude-code"); }, ); @@ -338,7 +211,6 @@ it("timing splitting matches Incur after output values and before the terminator { validate: false }, ).rest; expect(splitIncurBuiltinArguments(command).command).toEqual(expected); - // The builtin output pass consumes this flag-shaped value before globals. expect( splitIncurBuiltinArguments(["--filter-output", flag, ...command]) .command, @@ -348,36 +220,38 @@ it("timing splitting matches Incur after output values and before the terminator }); cliTest( - "expanded discovery values never inspect or back up Amp settings", + "equals-form flag-shaped command values remain launcher data", async ({ cli }) => { - for (const initial of [undefined, "broken JSON\n", original]) { - const f = await fixture(initial ?? ""); - if (initial === undefined) await rm(f.settings); - for (const flag of ["--help", "--llms"]) { - const result = await cli.run({ - arguments: ["mcp", "add", `--command=${flag}`], - cwd: f.home, - environment: f.environment, - timeoutMs: 20_000, - }); - expect(result.exitCode, result.stdout).toBe(0); - expect(result.stdout).not.toContain("VALIDATION_ERROR"); - expect(result.stdout).not.toContain("Registered rea"); - expect(result.stdout.length).toBeGreaterThan(0); - expect(result.stderr).not.toContain("backup retained"); - expect(await readdir(f.directory)).toEqual( - initial === undefined ? [] : ["settings.json"], - ); - if (initial !== undefined) - expect(await readFile(f.settings, "utf8")).toBe(initial); - expect(await readFile(f.log, "utf8")).toBe(""); - } + const f = await fixture(original); + for (const command of ["--help", "--llms"]) { + const result = await cli.run({ + arguments: [ + "mcp", + "add", + "--agent", + "amp", + `--command=${command}`, + "--json", + ], + cwd: f.home, + environment: f.environment, + timeoutMs: 20_000, + }); + expect(result.exitCode, result.stdout).toBe(0); + expect(report(result.stdout, true)).toMatchObject({ + command, + args: [], + ok: true, + }); + expect( + JSON.parse(await readFile(f.settings, "utf8"))["amp.mcpServers"].rea, + ).toMatchObject({ command, args: [], env: { KEEP_ME: "1" } }); } }, ); cliTest( - "invalid registration never starts a package runner or changes settings", + "invalid registration preserves settings and explicit sensitive-value handling", async ({ cli }) => { const f = await fixture(original); const before = await stat(f.settings); @@ -405,7 +279,6 @@ cliTest( expect(await readFile(f.settings, "utf8")).toBe(original); expect((await stat(f.settings)).mtimeMs).toBe(before.mtimeMs); expect(await readdir(f.directory)).toEqual(["settings.json"]); - expect(await readFile(f.log, "utf8")).toBe(""); } }, ); diff --git a/tests/boundary/cli/mcpAddDiscovery.test.ts b/tests/boundary/cli/mcpAddDiscovery.test.ts index 79031303c0..6d20faece2 100644 --- a/tests/boundary/cli/mcpAddDiscovery.test.ts +++ b/tests/boundary/cli/mcpAddDiscovery.test.ts @@ -1,26 +1,26 @@ import { access, mkdir, readFile, writeFile } from "node:fs/promises"; -import { join, resolve } from "node:path"; - +import { join } from "node:path"; +import { Ajv2020 } from "ajv/dist/2020.js"; import { expect } from "vitest"; +import { z } from "zod"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; +import { registrationEnvironment } from "../../support/cli/registrationEnvironment.js"; cliTest( - "mcp add discovery does not create or change Amp settings", - async ({ processes }) => { + "packaged mcp add advertises named schemas without changing settings", + async ({ cli }) => { const home = await createTestTempDirectory("rea-mcp-discovery-"); const path = join(home, ".config", "amp", "settings.json"); - const run = (args: string[]) => - processes.run(process.execPath, [resolve("scripts/rea.mjs"), ...args], { - env: { - ...process.env, - HOME: home, - XDG_CONFIG_HOME: join(home, ".config"), - }, + const environment = await registrationEnvironment(home); + const run = (arguments_: readonly string[]) => + cli.run({ + arguments: arguments_, + cwd: home, + environment, timeoutMs: 30_000, }); - const schema = await run([ "mcp", "add", @@ -29,12 +29,25 @@ cliTest( "--schema", "--json", ]); - expect(schema.exitCode).toBe(1); - expect(JSON.parse(schema.stdout)).toMatchObject({ - code: "SCHEMA_UNAVAILABLE", - }); + expect(schema.exitCode, schema.stderr).toBe(0); + const named = z + .object({ + inputSchema: z.record(z.string(), z.unknown()), + outputSchema: z.record(z.string(), z.unknown()), + }) + .parse(schema.json); + expect(named.inputSchema.$schema).toBe( + "https://json-schema.org/draft/2020-12/schema", + ); + expect(named.outputSchema.$schema).toBe( + "https://json-schema.org/draft/2020-12/schema", + ); + const ajv = new Ajv2020({ strict: true, allErrors: true }); + const input = ajv.compile(named.inputSchema); + const output = ajv.compile(named.outputSchema); + expect(input({ agent: ["amp"] }), JSON.stringify(input.errors)).toBe(true); + expect(input({ agent: "amp" })).toBe(false); await expect(access(path)).rejects.toMatchObject({ code: "ENOENT" }); - for (const format of ["csv", "tsv"]) { const result = await run([ "mcp", @@ -45,19 +58,16 @@ cliTest( "--format", format, "--filter-output", - "rows", + "clients", "--columns", - "code", + "client", ]); expect(result.exitCode).toBe(1); - expect(JSON.parse(result.stdout)).toMatchObject({ + expect(result.json).toMatchObject({ error: { code: "UNSUPPORTED_OUTPUT_COMBINATION" }, }); await expect(access(path)).rejects.toMatchObject({ code: "ENOENT" }); } - - // A flag-shaped output value remains data; the CLI rejects the invalid - // format before dispatching registration or treating it as discovery. const invalidFormat = await run([ "mcp", "add", @@ -67,22 +77,120 @@ cliTest( "--schema", ]); expect(invalidFormat.exitCode).toBe(1); - expect(invalidFormat.stdout).not.toContain("SCHEMA_UNAVAILABLE"); + expect(invalidFormat.stdout).not.toContain("inputSchema"); await expect(access(path)).rejects.toMatchObject({ code: "ENOENT" }); - await mkdir(join(home, ".config", "amp"), { recursive: true }); const original = '{"existing":true}\n'; await writeFile(path, original); for (const flag of ["--schema", "--version", "--help"]) { const result = await run(["mcp", "add", "--agent", "amp", flag]); - expect(result.stdout).not.toContain("Registered rea"); - expect(result.exitCode).toBe(flag === "--schema" ? 1 : 0); + expect(result.exitCode, result.stderr).toBe(0); expect(await readFile(path, "utf8")).toBe(original); } - const registration = await run(["mcp", "add", "--agent", "amp"]); - expect(registration.exitCode).toBe(0); + const registered = await run(["mcp", "add", "--agent", "amp", "--json"]); + expect(registered.exitCode, registered.stderr).toBe(0); + expect(output(registered.json), JSON.stringify(output.errors)).toBe(true); + expect(registered.json).toMatchObject({ + ok: true, + clients: [{ client: "amp", status: "configured" }], + }); expect( JSON.parse(await readFile(path, "utf8"))["amp.mcpServers"].rea, ).toBeDefined(); }, ); + +cliTest( + "mcp add applies output controls to its registration document", + async ({ cli }) => { + const home = await createTestTempDirectory("rea-mcp-output-"); + const environment = await registrationEnvironment(home); + const run = (arguments_: readonly string[]) => + cli.run({ + arguments: arguments_, + cwd: home, + environment, + timeoutMs: 30_000, + }); + const filtered = await run([ + "mcp", + "add", + "--agent", + "amp", + "--json", + "--filter-output", + "ok", + ]); + expect(filtered.exitCode, filtered.stderr).toBe(0); + expect(filtered.json).toBe(true); + const schema = await run([ + "mcp", + "add", + "--schema", + "--json", + "--filter-output", + "outputSchema.type", + ]); + expect(schema.exitCode, schema.stderr).toBe(0); + expect(schema.json).toEqual({ outputSchema: { type: "object" } }); + const failure = await run([ + "mcp", + "add", + "--agent", + "not-a-client", + "--json", + "--filter-output", + "message", + ]); + expect(failure.exitCode).toBe(1); + expect(failure.json).toMatchObject({ + code: "VALIDATION_ERROR", + message: expect.any(String), + fieldErrors: [{ path: "agent" }], + }); + const path = join(home, ".config", "amp", "settings.json"); + await writeFile(path, "{malformed"); + const partial = await run([ + "mcp", + "add", + "--agent", + "amp", + "--json", + "--filter-output", + "ok", + ]); + expect(partial.exitCode).toBe(1); + expect(partial.json).toMatchObject({ + ok: false, + clients: [ + { + client: "amp", + status: "failed", + error: { reason: expect.any(String) }, + }, + ], + }); + await writeFile(path, "{}"); + const counted = await run([ + "mcp", + "add", + "--agent", + "amp", + "--token-count", + ]); + expect(counted.exitCode, counted.stderr).toBe(0); + expect(counted.stdout.trim()).toMatch(/^\d+$/u); + const windowed = await run([ + "mcp", + "add", + "--agent", + "amp", + "--token-limit", + "2", + ]); + expect(windowed.exitCode, windowed.stderr).toBe(0); + expect(windowed.stdout).toMatch( + /\[truncated: showing tokens 0–2 of \d+\]/u, + ); + }, +); diff --git a/tests/boundary/cli/mcpAddOptionValidation.test.ts b/tests/boundary/cli/mcpAddOptionValidation.test.ts index 336be78001..ea9b6563cc 100644 --- a/tests/boundary/cli/mcpAddOptionValidation.test.ts +++ b/tests/boundary/cli/mcpAddOptionValidation.test.ts @@ -11,21 +11,18 @@ import { expect } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; +import { registrationEnvironment } from "../../support/cli/registrationEnvironment.js"; cliTest("mcp add rejects valued options without a value", async ({ cli }) => { const root = await createTestTempDirectory("rea-mcp-add-value-"); const home = join(root, "home"); const settingsPath = (selectedHome: string) => join(selectedHome, ".config", "amp", "settings.json"); - const run = (arguments_: readonly string[], selectedHome = home) => + const run = async (arguments_: readonly string[], selectedHome = home) => cli.run({ arguments: [...arguments_, "--json"], cwd: root, - environment: { - HOME: selectedHome, - USERPROFILE: selectedHome, - XDG_CONFIG_HOME: join(selectedHome, ".config"), - }, + environment: await registrationEnvironment(selectedHome), timeoutMs: 20_000, }); @@ -103,15 +100,12 @@ cliTest( const root = await createTestTempDirectory("rea-mcp-add-argument-"); const home = join(root, "home"); const settingsPath = join(home, ".config", "amp", "settings.json"); + const environment = await registrationEnvironment(home); const run = (arguments_: readonly string[]) => cli.run({ arguments: [...arguments_, "--json"], cwd: root, - environment: { - HOME: home, - USERPROFILE: home, - XDG_CONFIG_HOME: join(home, ".config"), - }, + environment, timeoutMs: 20_000, }); @@ -178,15 +172,12 @@ cliTest( async ({ cli }) => { const root = await createTestTempDirectory("rea-mcp-add-field-"); const home = join(root, "home"); + const environment = await registrationEnvironment(home); const run = (arguments_: readonly string[]) => cli.run({ arguments: [...arguments_, "--json"], cwd: root, - environment: { - HOME: home, - USERPROFILE: home, - XDG_CONFIG_HOME: join(home, ".config"), - }, + environment, timeoutMs: 20_000, }); @@ -205,10 +196,49 @@ cliTest( expect.objectContaining({ code: "invalid_value", path: field }), ], }); - expect( - (result.json as { message?: string }).message, - "the message and the field must describe the same error", - ).toContain("Missing value for flag"); + expect(result.json).toMatchObject({ + message: expect.stringContaining("Missing value for flag"), + }); } }, ); + +cliTest( + "packaged --no-global refuses a global-only member before writing any selected client", + async ({ cli }) => { + const home = await createTestTempDirectory("rea-mcp-project-scope-"); + const project = join(home, "project"); + await mkdir(project); + const environment = await registrationEnvironment(home); + const result = await cli.run({ + arguments: [ + "mcp", + "add", + "--agent", + "cursor", + "--agent", + "goose", + "--no-global", + "--json", + ], + cwd: project, + environment, + timeoutMs: 20_000, + }); + expect(result.exitCode, result.stderr).toBe(1); + expect(result.json).toMatchObject({ + code: "VALIDATION_ERROR", + message: expect.stringContaining("project-scoped"), + }); + const goose = + process.platform === "win32" + ? join(home, "appdata", "Block", "goose", "config", "config.yaml") + : join(home, ".config", "goose", "config.yaml"); + for (const path of [ + join(home, ".cursor", "mcp.json"), + join(project, ".cursor", "mcp.json"), + goose, + ]) + await expect(access(path)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); diff --git a/tests/boundary/cli/packageRunnerRegistration.test.ts b/tests/boundary/cli/packageRunnerRegistration.test.ts index cd842bad29..4329be78b4 100644 --- a/tests/boundary/cli/packageRunnerRegistration.test.ts +++ b/tests/boundary/cli/packageRunnerRegistration.test.ts @@ -6,6 +6,8 @@ import { z } from "zod"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; +import { registrationEnvironment } from "../../support/cli/registrationEnvironment.js"; +import { mcpRegistrationReportSchema } from "../../../src/contracts/mcpRegistration.js"; const ompConfigurationSchema = z.object({ mcpServers: z.object({ @@ -32,7 +34,7 @@ appendFileSync(process.env.REA_TEST_RUNNER_LOG, JSON.stringify({ console.log("controlled runner fixture"); `, ); - for (const runner of ["npx", "bunx"]) { + for (const runner of ["npx", "bunx", "pnpx"]) { await writeFile( join(bin, process.platform === "win32" ? `${runner}.cmd` : runner), process.platform === "win32" @@ -63,17 +65,16 @@ for (const { runner, userAgent } of [ async ({ cli }) => { const home = await createTestTempDirectory("rea-package-runner-"); const controlledRunner = await controlledRunnerFixture(home); + const environment = await registrationEnvironment(home, { + ...controlledRunner.environment, + npm_command: "exec", + npm_config_user_agent: userAgent, + }); const run = (arguments_: readonly string[]) => cli.run({ arguments: arguments_, cwd: home, - environment: { - ...controlledRunner.environment, - HOME: home, - USERPROFILE: home, - npm_command: "exec", - npm_config_user_agent: userAgent, - }, + environment, timeoutMs: 20_000, }); const setup = await run([ @@ -96,9 +97,25 @@ for (const { runner, userAgent } of [ expect([command, ...args]).toEqual( currentClientRegistrationCommand({ entrypointPath: resolve("scripts/rea.mjs"), - environment: controlledRunner.environment, + environment, }), ); + const registration = await run([ + "mcp", + "add", + "--agent", + "amp", + "--json", + ]); + expect(registration.exitCode, registration.stderr).toBe(0); + const registered = mcpRegistrationReportSchema.parse(registration.json); + expect([registered.command, ...registered.args]).toEqual([ + command, + ...args, + ]); + expect(registered.clients).toMatchObject([ + { client: "amp", status: "configured" }, + ]); const doctor = await run(["doctor", "--client", "omp", "--json"]); expect(doctor.json).toMatchObject({ identity: { registrations: [{ client: "omp", state: "aligned" }] }, diff --git a/tests/boundary/filesystem/clientConfigurationBom.test.ts b/tests/boundary/filesystem/clientConfigurationBom.test.ts index 6d3882674e..5ec2ebf9eb 100644 --- a/tests/boundary/filesystem/clientConfigurationBom.test.ts +++ b/tests/boundary/filesystem/clientConfigurationBom.test.ts @@ -15,7 +15,10 @@ import { inspectClientConfiguration, } from "../../../src/application/SetupClientConfiguration.js"; import { systemUninstallHost } from "../../../src/application/Uninstall.js"; -import { supportedClients } from "../../../src/application/SupportedClients.js"; +import { + supportedClientRegistrationProfile, + supportedClients, +} from "../../../src/application/SupportedClients.js"; import { CURRENT_ENTRYPOINT_PATH } from "../../../src/process/CurrentEntrypointPath.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -60,9 +63,14 @@ describe.each(["vscode", "copilot_cli", "opencode", "commandcode"])( }, ); const updated = await readFile(client.configPath, "utf8"); - expect( - updated.startsWith(`${bom}// Keep the client preferences.\r\n`), - ).toBe(true); + if (client.format === "copilot_cli") { + // Copilot consumes strict JSON: source comments/BOM are grammar, not metadata. + expect(() => JSON.parse(updated)).not.toThrow(); + } else { + expect( + updated.startsWith(`${bom}// Keep the client preferences.\r\n`), + ).toBe(true); + } expect( parseClientConfiguration(updated, client.format).document, ).toEqual({ @@ -123,7 +131,12 @@ it.each(['\uFEFF{"servers": {"rea": @}}', '\uFEFF{"setting":\uFEFFtrue}'])( "rea-client-config-bom-invalid-", ); const configPath = join(home, "mcp.json"); - const client = { name: "vscode", format: "vscode", configPath } as const; + const client = { + name: "vscode", + registrationProfile: supportedClientRegistrationProfile("vscode"), + format: "vscode", + configPath, + } as const; await writeFile(configPath, original); const invalidOffset = original.includes("@") ? original.indexOf("@") : 12; expect(() => parseClientConfiguration(original, client.format)).toThrow( diff --git a/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts b/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts index 6a8732d9f4..94bbfd436d 100644 --- a/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts +++ b/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts @@ -26,6 +26,7 @@ import { snapshotEnvironment } from "../../../src/process/snapshotEnvironment.js import { ok } from "../../../src/domain/result.js"; import { MCP_STARTUP_POLICY } from "../../../src/mcpStartupPolicy.js"; +import { supportedClientRegistrationProfile } from "../../../src/application/SupportedClients.js"; const staleOwnedCommand = ["npx", "-y", "rea-agents@6.6.0", "mcp"] as const; @@ -84,7 +85,12 @@ it("preserves owner-parsed registration facts and revalidates later inspections" }, }); await writeFile(configPath, original); - const client = { name: "kimi", format: "kimi", configPath } as const; + const client = { + name: "kimi", + registrationProfile: supportedClientRegistrationProfile("kimi"), + format: "kimi", + configPath, + } as const; let providerTimeoutMs = 17; const observedBudgets = new Set(); const resolveBudget = async ( @@ -173,7 +179,12 @@ it.skipIf(process.platform !== "win32").each([ const configPath = join(root, "cordis.patch.yml"); const source = `- insert:\n - id: rea-mcp\n name: "@deepseek-ai/dsh-mcp-client"\n config:\n serverName: rea\n transport: stdio\n command: old-rea\n args: [mcp]\n env:\n # retain preceding rationale\n ${key}: "hopper" # retain inline rationale\n${duplicate ? ` ${override}: "ghidra" # retain winning rationale\n` : ""} CUSTOM: !!js caller.custom\n`; await writeFile(configPath, source); - const client = { name: "dsh", format: "dsh", configPath } as const; + const client = { + name: "dsh", + registrationProfile: supportedClientRegistrationProfile("dsh"), + format: "dsh", + configPath, + } as const; const result = await configureClientConfiguration( client, { [override]: value }, @@ -226,7 +237,12 @@ it.skipIf(process.platform !== "win32")( }, }), ); - const client = { name: "kimi", format: "kimi", configPath } as const; + const client = { + name: "kimi", + registrationProfile: supportedClientRegistrationProfile("kimi"), + format: "kimi", + configPath, + } as const; const command = currentClientRegistrationCommand(); expect( await configureClientConfiguration(client, {}, command), @@ -256,7 +272,12 @@ it const configPath = join(root, "cordis.patch.yml"); const source = `- insert:\n - id: rea-mcp\n name: "@deepseek-ai/dsh-mcp-client"\n config:\n serverName: rea\n transport: stdio\n command: old-rea\n args: [mcp]\n env:\n rea_analysis_provider: ${stored}\n CUSTOM: !!js caller.custom\n`; await writeFile(configPath, source); - const client = { name: "dsh", format: "dsh", configPath } as const; + const client = { + name: "dsh", + registrationProfile: supportedClientRegistrationProfile("dsh"), + format: "dsh", + configPath, + } as const; const overrides = { REA_ANALYSIS_PROVIDER: "ghidra" }; const configured = await configureClientConfiguration(client, overrides, [ "rea", @@ -344,7 +365,12 @@ it.skipIf(process.platform !== "win32")( }); await writeFile(configPath, original); const result = await configureClientConfiguration( - { name: "cursor", format: "json", configPath }, + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + format: "json", + configPath, + }, {}, ["rea", "mcp"], ); @@ -366,7 +392,12 @@ it.each([ async (name, format, serversKey, environmentKey) => { const root = await createTestTempDirectory("rea-setup-environment-"); const configPath = join(root, "config"); - const client = { name, format, configPath }; + const client = { + name, + registrationProfile: supportedClientRegistrationProfile(name), + format, + configPath, + }; const custom = { REA_MCP_INPUT_SCHEMA_PROFILE: "compact", REA_MCP_OUTPUT_SCHEMA_PROFILE: "off", @@ -674,7 +705,12 @@ it("rejects a non-string caller environment key before editing configuration", a }); expect( await configureClientConfiguration( - { name: "cursor", format: "json", configPath }, + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + format: "json", + configPath, + }, {}, ["rea", "mcp"], ), @@ -700,6 +736,7 @@ it("does not hide an invalid JEB endpoint in an untouched configuration layer", await writeFile(configPath, "{}\n"); const client = { name: "opencode", + registrationProfile: supportedClientRegistrationProfile("opencode"), format: "opencode", configPath, configPaths: [lower, configPath], @@ -718,7 +755,12 @@ it("does not hide an invalid JEB endpoint in an untouched configuration layer", it("retains legacy OpenCode environment when migrating into a native server table", async () => { const root = await createTestTempDirectory("rea-opencode-environment-"); const configPath = join(root, "opencode.json"); - const client = { name: "opencode", format: "opencode", configPath } as const; + const client = { + name: "opencode", + registrationProfile: supportedClientRegistrationProfile("opencode"), + format: "opencode", + configPath, + } as const; const custom = { REA_MCP_INPUT_SCHEMA_PROFILE: "compact" }; await writeFile( configPath, @@ -902,7 +944,12 @@ it.each([ mcpServers: { rea: { command: "rea", args: ["mcp"], env } }, }); await writeFile(configPath, original); - const client = { name: "cursor", format: "json", configPath } as const; + const client = { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + format: "json", + configPath, + } as const; expect( await inspectClientConfiguration(client, {}, ["rea", "mcp"]), ).toMatchObject({ @@ -933,7 +980,12 @@ it("refuses an unidentified launch command before creating a client configuratio const home = await createTestTempDirectory("rea-registration-unidentified-"); const configPath = join(home, "missing", "mcp.json"); const result = await configureClientConfiguration( - { name: "claude_code", configPath, format: "json" }, + { + name: "claude_code", + registrationProfile: supportedClientRegistrationProfile("claude_code"), + configPath, + format: "json", + }, {}, [], ); diff --git a/tests/boundary/filesystem/clientConfigurationLayeredEnvironment.test.ts b/tests/boundary/filesystem/clientConfigurationLayeredEnvironment.test.ts index 69d254d3e4..681d37e1ba 100644 --- a/tests/boundary/filesystem/clientConfigurationLayeredEnvironment.test.ts +++ b/tests/boundary/filesystem/clientConfigurationLayeredEnvironment.test.ts @@ -20,6 +20,7 @@ import { readClientConfigurationFiles, } from "../../../src/application/ClientConfigurationFiles.js"; import { clientRegistrationEnvironmentDetails } from "../../../src/application/ClientRegistrationEnvironment.js"; +import { supportedClientRegistrationProfile } from "../../../src/application/SupportedClients.js"; const command = currentClientRegistrationCommand({ platform: process.platform, @@ -68,6 +69,7 @@ it.each(["missing", "empty", "legacy", "native"] as const)( ); const client = { name: "opencode", + registrationProfile: supportedClientRegistrationProfile("opencode"), format: "opencode", configPath: upper, configPaths: [lower, upper], @@ -125,6 +127,7 @@ const layered = async (lower: unknown, upper: unknown) => { await writeFile(path, sources[index]!); const client = { name: "opencode", + registrationProfile: supportedClientRegistrationProfile("opencode"), format: "opencode", configPath: paths[1]!, configPaths: paths, diff --git a/tests/boundary/filesystem/clientConfigurationOverrideOrigin.test.ts b/tests/boundary/filesystem/clientConfigurationOverrideOrigin.test.ts index 6e83335828..075056f028 100644 --- a/tests/boundary/filesystem/clientConfigurationOverrideOrigin.test.ts +++ b/tests/boundary/filesystem/clientConfigurationOverrideOrigin.test.ts @@ -3,11 +3,17 @@ import { writeFile } from "node:fs/promises"; import { expect, it } from "vitest"; import { readClientConfigurationFiles } from "../../../src/application/ClientConfigurationFiles.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { supportedClientRegistrationProfile } from "../../../src/application/SupportedClients.js"; it("attributes case-folded Windows overrides without disclosing values", async () => { const root = await createTestTempDirectory("rea-config-override-origin-"); const configPath = join(root, "client.json"); - const client = { name: "cursor", format: "json", configPath } as const; + const client = { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + format: "json", + configPath, + } as const; const result = await readClientConfigurationFiles( client, undefined, @@ -43,6 +49,7 @@ it("keeps a malformed stored environment attributed to its file", async () => { ); const result = await readClientConfigurationFiles({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), format: "json", configPath, }); @@ -76,7 +83,12 @@ it.each([ }), ); const result = await readClientConfigurationFiles( - { name: "cursor", format: "json", configPath }, + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + format: "json", + configPath, + }, undefined, process.platform, overrides, diff --git a/tests/boundary/filesystem/clientConfigurationTransactions.test.ts b/tests/boundary/filesystem/clientConfigurationTransactions.test.ts index 198dca97f4..e580993fb1 100644 --- a/tests/boundary/filesystem/clientConfigurationTransactions.test.ts +++ b/tests/boundary/filesystem/clientConfigurationTransactions.test.ts @@ -26,6 +26,7 @@ import { } from "../../../src/application/Uninstall.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { ClientConfigurationPublication } from "../../../src/application/ClientConfigurationPublication.js"; +import { supportedClientRegistrationProfile } from "../../../src/application/SupportedClients.js"; it .skipIf(process.platform === "win32") @@ -75,6 +76,7 @@ it.skipIf(process.platform === "win32")( const client = { name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), format: "json", configPath: existing, } as const; @@ -131,7 +133,12 @@ describe("uninstall backups after edits made since setup", () => { const editedSinceSetup = async (prefix: string) => { const root = await createTestTempDirectory(prefix); const configPath = join(root, "config.json"); - const client = { name: "cursor", format: "json", configPath } as const; + const client = { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + format: "json", + configPath, + } as const; const beforeSetup = '{"keep":"before setup"}\n'; await writeFile(configPath, beforeSetup); expect( @@ -274,7 +281,12 @@ it("accepts a literal UTF-8 replacement character without changing its value", a ]); await writeFile(configPath, original); const result = await configureClientConfiguration( - { name: "cursor", format: "json", configPath }, + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + format: "json", + configPath, + }, {}, ["rea", "mcp"], ); @@ -290,7 +302,12 @@ it("previews and creates a current recovery backup while preserving the first ba const configPath = join(root, "config.json"); const firstOriginal = '{"keep":"first"}\n'; const currentOriginal = '{"keep":"current"}\n'; - const client = { name: "cursor", format: "json", configPath } as const; + const client = { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + format: "json", + configPath, + } as const; await writeFile(configPath, currentOriginal); await writeFile(`${configPath}.rea.backup`, firstOriginal); @@ -316,7 +333,12 @@ it("blocks configuration when the planned content-addressed backup is occupied", const configPath = join(root, "config.json"); const firstOriginal = '{"keep":"first"}\n'; const currentOriginal = '{"keep":"current"}\n'; - const client = { name: "cursor", format: "json", configPath } as const; + const client = { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + format: "json", + configPath, + } as const; await writeFile(configPath, currentOriginal); await writeFile(`${configPath}.rea.backup`, firstOriginal); const recoveryPath = `${configPath}.rea.backup.${createHash("sha256") @@ -353,6 +375,9 @@ describe("client configuration write failures", () => { expect( await configure({ name: format === "toml" ? "codex" : "cursor", + registrationProfile: supportedClientRegistrationProfile( + format === "toml" ? "codex" : "cursor", + ), format, configPath: join(root, `config.${format}`), }), @@ -379,7 +404,12 @@ describe("TOML client configuration comparison", () => { const configPath = join(root, "config.toml"); const original = `unrelated = ${value}\n[mcp_servers.rea]\ncommand = "npx"\nargs = ["-y", "rea-agents@6.1.0", "mcp"]\nstartup_timeout_sec = ${value}\n`; await writeFile(configPath, original); - const client = { name: "codex", format: "toml", configPath } as const; + const client = { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + format: "toml", + configPath, + } as const; expect( await configureClientConfiguration(client, {}, ["rea", "mcp"]), ).toEqual({ @@ -471,7 +501,12 @@ describe("Codex configuration text preservation", () => { async ({ original, preserved }) => { const root = await createTestTempDirectory("rea-codex-preserve-"); const configPath = join(root, "config.toml"); - const client = { name: "codex", format: "toml", configPath } as const; + const client = { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + format: "toml", + configPath, + } as const; await writeFile(configPath, original); expect( @@ -503,7 +538,12 @@ describe("Codex configuration text preservation", () => { it("retains the first backup after uninstall and a later setup", async () => { const root = await createTestTempDirectory("rea-codex-backup-"); const configPath = join(root, "config.toml"); - const client = { name: "codex", format: "toml", configPath } as const; + const client = { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + format: "toml", + configPath, + } as const; const original = 'notify = ["a", "b"]\n'; await writeFile(configPath, original); await configureClientConfiguration(client, {}, ["rea", "mcp"]); @@ -518,7 +558,12 @@ describe("Codex configuration text preservation", () => { it("refuses malformed Codex TOML unchanged without creating a backup", async () => { const root = await createTestTempDirectory("rea-codex-malformed-"); const configPath = join(root, "config.toml"); - const client = { name: "codex", format: "toml", configPath } as const; + const client = { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + format: "toml", + configPath, + } as const; const original = "# Keep this explanation.\n[mcp_servers.rea]\ncommand = [broken\n"; await writeFile(configPath, original); @@ -553,6 +598,7 @@ it ( await host.removeClient({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), format: "json", configPath, }) diff --git a/tests/boundary/filesystem/clientLifecycle.test.ts b/tests/boundary/filesystem/clientLifecycle.test.ts index 8d8daee111..143c3f5271 100644 --- a/tests/boundary/filesystem/clientLifecycle.test.ts +++ b/tests/boundary/filesystem/clientLifecycle.test.ts @@ -18,7 +18,10 @@ import { parse as parseJsonc, type ParseError } from "jsonc-parser"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { ClientConfigurationPublication } from "../../../src/application/ClientConfigurationPublication.js"; import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js"; -import { supportedClients } from "../../../src/application/SupportedClients.js"; +import { + supportedClientRegistrationProfile, + supportedClients, +} from "../../../src/application/SupportedClients.js"; import { PRODUCT_IDENTITY } from "../../../src/identity.js"; import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; @@ -94,7 +97,12 @@ describe("client configuration filesystem lifecycle", () => { ); expect( await configureClientConfiguration( - { name: "codex", configPath, format: "toml" }, + { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + configPath, + format: "toml", + }, { HOPPER_LAUNCHER_PATH: "/Hopper Path", GHIDRA_INSTALL_DIR: "/opt/ghidra", @@ -113,7 +121,12 @@ describe("client configuration filesystem lifecycle", () => { expect(configured).toContain('JAVA_HOME = "/opt/jdk-21"'); expect( await configureClientConfiguration( - { name: "codex", configPath, format: "toml" }, + { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + configPath, + format: "toml", + }, { HOPPER_LAUNCHER_PATH: "/Hopper Path", GHIDRA_INSTALL_DIR: "/opt/ghidra", @@ -136,7 +149,12 @@ describe("client configuration filesystem lifecycle", () => { const original = await readFile(configPath, "utf8"); expect( await configureClientConfiguration( - { name: "codex", configPath, format: "toml" }, + { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + configPath, + format: "toml", + }, { HOPPER_LAUNCHER_PATH: "/Hopper Path", GHIDRA_INSTALL_DIR: "/opt/ghidra", @@ -159,7 +177,12 @@ describe("Codex MCP tool deadlines", () => { await mkdir(dirname(configPath), { recursive: true }); expect( await configureClientConfiguration( - { name: "codex", configPath, format: "toml" }, + { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + configPath, + format: "toml", + }, { REA_GHIDRA_STARTUP_TIMEOUT_MS: "2700001" }, ["rea", "mcp"], ), @@ -182,7 +205,12 @@ describe("Codex MCP tool deadlines", () => { ); expect( await configureClientConfiguration( - { name: "codex", configPath, format: "toml" }, + { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + configPath, + format: "toml", + }, {}, ["rea", "mcp"], ), @@ -205,7 +233,12 @@ describe("client configuration filesystem lifecycle", () => { expect( await configureClientConfiguration( - { name: "codex", configPath, format: "toml" }, + { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + configPath, + format: "toml", + }, undefined, ["rea", "mcp"], ), @@ -224,6 +257,7 @@ describe("client configuration filesystem lifecycle", () => { expect( await configureClientConfiguration({ name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), configPath, format: "toml", }), @@ -278,7 +312,12 @@ describe("Grok Build server table edits", () => { }; expect( await configureClientConfiguration( - { name: "grok_build", configPath, format: "grok" }, + { + name: "grok_build", + registrationProfile: supportedClientRegistrationProfile("grok_build"), + configPath, + format: "grok", + }, environment, ["rea", "mcp"], ), @@ -326,7 +365,12 @@ describe("Grok Build server table edits", () => { }); expect( await configureClientConfiguration( - { name: "grok_build", configPath, format: "grok" }, + { + name: "grok_build", + registrationProfile: supportedClientRegistrationProfile("grok_build"), + configPath, + format: "grok", + }, environment, ["rea", "mcp"], ), @@ -377,7 +421,12 @@ describe("Grok Build disabled server list", () => { ); expect( await configureClientConfiguration( - { name: "grok_build", configPath, format: "grok" }, + { + name: "grok_build", + registrationProfile: supportedClientRegistrationProfile("grok_build"), + configPath, + format: "grok", + }, undefined, ["rea", "mcp"], ), @@ -393,7 +442,12 @@ describe("Grok Build disabled server list", () => { }); expect( await configureClientConfiguration( - { name: "grok_build", configPath, format: "grok" }, + { + name: "grok_build", + registrationProfile: supportedClientRegistrationProfile("grok_build"), + configPath, + format: "grok", + }, undefined, ["rea", "mcp"], ), @@ -401,7 +455,12 @@ describe("Grok Build disabled server list", () => { await writeFile(configPath, 'disabled_mcp_servers = ["rea"] # note\n'); expect( await configureClientConfiguration( - { name: "grok_build", configPath, format: "grok" }, + { + name: "grok_build", + registrationProfile: supportedClientRegistrationProfile("grok_build"), + configPath, + format: "grok", + }, undefined, ["rea", "mcp"], ), @@ -434,7 +493,12 @@ describe("Grok Build disabled server list", () => { ); expect( await configureClientConfiguration( - { name: "grok_build", configPath, format: "grok" }, + { + name: "grok_build", + registrationProfile: supportedClientRegistrationProfile("grok_build"), + configPath, + format: "grok", + }, undefined, ["rea", "mcp"], ), @@ -447,7 +511,12 @@ describe("Grok Build disabled server list", () => { await writeFile(configPath, 'disabled_mcp_servers = ["""rea"""] # note\n'); expect( await configureClientConfiguration( - { name: "grok_build", configPath, format: "grok" }, + { + name: "grok_build", + registrationProfile: supportedClientRegistrationProfile("grok_build"), + configPath, + format: "grok", + }, undefined, ["rea", "mcp"], ), @@ -491,7 +560,12 @@ describe("Grok Build text that resembles a server table", () => { await writeFile(configPath, original); expect( await configureClientConfiguration( - { name: "grok_build", configPath, format: "grok" }, + { + name: "grok_build", + registrationProfile: supportedClientRegistrationProfile("grok_build"), + configPath, + format: "grok", + }, undefined, ["rea", "mcp"], ), @@ -516,7 +590,12 @@ describe("Grok Build text that resembles a server table", () => { }); expect( await configureClientConfiguration( - { name: "grok_build", configPath, format: "grok" }, + { + name: "grok_build", + registrationProfile: supportedClientRegistrationProfile("grok_build"), + configPath, + format: "grok", + }, undefined, ["rea", "mcp"], ), @@ -558,7 +637,12 @@ describe("Grok Build text that resembles a server table", () => { ); expect( await configureClientConfiguration( - { name: "grok_build", configPath, format: "grok" }, + { + name: "grok_build", + registrationProfile: supportedClientRegistrationProfile("grok_build"), + configPath, + format: "grok", + }, undefined, ["rea", "mcp"], ), @@ -613,7 +697,12 @@ describe("Grok Build multiline and escaped headers", () => { ); expect( await configureClientConfiguration( - { name: "grok_build", configPath, format: "grok" }, + { + name: "grok_build", + registrationProfile: supportedClientRegistrationProfile("grok_build"), + configPath, + format: "grok", + }, undefined, ["rea", "mcp"], ), @@ -634,7 +723,12 @@ describe("Grok Build multiline and escaped headers", () => { }); expect( await configureClientConfiguration( - { name: "grok_build", configPath, format: "grok" }, + { + name: "grok_build", + registrationProfile: supportedClientRegistrationProfile("grok_build"), + configPath, + format: "grok", + }, undefined, ["rea", "mcp"], ), @@ -670,7 +764,12 @@ describe("Grok Build multiline and escaped headers", () => { await writeFile(configPath, original); expect( await configureClientConfiguration( - { name: "grok_build", configPath, format: "grok" }, + { + name: "grok_build", + registrationProfile: supportedClientRegistrationProfile("grok_build"), + configPath, + format: "grok", + }, undefined, ["rea", "mcp"], ), @@ -713,7 +812,12 @@ describe("Grok Build inline and dotted assignments", () => { await writeFile(configPath, inline); expect( await configureClientConfiguration( - { name: "grok_build", configPath, format: "grok" }, + { + name: "grok_build", + registrationProfile: supportedClientRegistrationProfile("grok_build"), + configPath, + format: "grok", + }, undefined, ["rea", "mcp"], ), @@ -737,7 +841,12 @@ describe("Grok Build inline and dotted assignments", () => { ); expect( await configureClientConfiguration( - { name: "grok_build", configPath, format: "grok" }, + { + name: "grok_build", + registrationProfile: supportedClientRegistrationProfile("grok_build"), + configPath, + format: "grok", + }, undefined, ["rea", "mcp"], ), @@ -757,7 +866,12 @@ describe("Grok Build inline and dotted assignments", () => { expect(configuredInline).not.toContain("enabled"); expect( await configureClientConfiguration( - { name: "grok_build", configPath, format: "grok" }, + { + name: "grok_build", + registrationProfile: supportedClientRegistrationProfile("grok_build"), + configPath, + format: "grok", + }, undefined, ["rea", "mcp"], ), @@ -804,7 +918,12 @@ describe("Grok Build inline and dotted assignments", () => { ); expect( await configureClientConfiguration( - { name: "grok_build", configPath, format: "grok" }, + { + name: "grok_build", + registrationProfile: supportedClientRegistrationProfile("grok_build"), + configPath, + format: "grok", + }, undefined, ["rea", "mcp"], ), @@ -838,7 +957,12 @@ describe("Grok Build closed inline tables", () => { await writeFile(configPath, original); expect( await configureClientConfiguration( - { name: "grok_build", configPath, format: "grok" }, + { + name: "grok_build", + registrationProfile: supportedClientRegistrationProfile("grok_build"), + configPath, + format: "grok", + }, undefined, ["rea", "mcp"], ), @@ -859,7 +983,12 @@ describe("Grok Build closed inline tables", () => { }); expect( await configureClientConfiguration( - { name: "grok_build", configPath, format: "grok" }, + { + name: "grok_build", + registrationProfile: supportedClientRegistrationProfile("grok_build"), + configPath, + format: "grok", + }, undefined, ["rea", "mcp"], ), @@ -886,7 +1015,12 @@ describe("Grok Build closed inline tables", () => { await writeFile(configPath, "mcp_servers = { }\n"); expect( await configureClientConfiguration( - { name: "grok_build", configPath, format: "grok" }, + { + name: "grok_build", + registrationProfile: supportedClientRegistrationProfile("grok_build"), + configPath, + format: "grok", + }, undefined, ["rea", "mcp"], ), @@ -918,7 +1052,12 @@ describe("Grok Build closed inline tables", () => { ); expect( await configureClientConfiguration( - { name: "grok_build", configPath, format: "grok" }, + { + name: "grok_build", + registrationProfile: supportedClientRegistrationProfile("grok_build"), + configPath, + format: "grok", + }, undefined, ["rea", "mcp"], ), @@ -956,7 +1095,12 @@ describe("Grok Build scalar and nested values", () => { ); expect( await configureClientConfiguration( - { name: "grok_build", configPath, format: "grok" }, + { + name: "grok_build", + registrationProfile: supportedClientRegistrationProfile("grok_build"), + configPath, + format: "grok", + }, undefined, ["rea", "mcp"], ), @@ -981,7 +1125,12 @@ describe("Grok Build scalar and nested values", () => { ); expect( await configureClientConfiguration( - { name: "grok_build", configPath, format: "grok" }, + { + name: "grok_build", + registrationProfile: supportedClientRegistrationProfile("grok_build"), + configPath, + format: "grok", + }, undefined, ["rea", "mcp"], ), @@ -1024,7 +1173,12 @@ describe("Grok Build scalar and nested values", () => { await writeFile(configPath, original); expect( await configureClientConfiguration( - { name: "grok_build", configPath, format: "grok" }, + { + name: "grok_build", + registrationProfile: supportedClientRegistrationProfile("grok_build"), + configPath, + format: "grok", + }, undefined, ["rea", "mcp"], ), @@ -1052,7 +1206,12 @@ describe("Grok Build newlines and leading marks", () => { ); expect( await configureClientConfiguration( - { name: "grok_build", configPath, format: "grok" }, + { + name: "grok_build", + registrationProfile: supportedClientRegistrationProfile("grok_build"), + configPath, + format: "grok", + }, undefined, ["rea", "mcp"], ), @@ -1088,7 +1247,12 @@ describe("Grok Build newlines and leading marks", () => { await writeFile(configPath, original); expect( await configureClientConfiguration( - { name: "grok_build", configPath, format: "grok" }, + { + name: "grok_build", + registrationProfile: supportedClientRegistrationProfile("grok_build"), + configPath, + format: "grok", + }, undefined, ["rea", "mcp"], ), @@ -1119,7 +1283,12 @@ describe("Grok Build newlines and leading marks", () => { ); expect( await configureClientConfiguration( - { name: "grok_build", configPath, format: "grok" }, + { + name: "grok_build", + registrationProfile: supportedClientRegistrationProfile("grok_build"), + configPath, + format: "grok", + }, undefined, ["rea", "mcp"], ), @@ -1147,7 +1316,12 @@ describe("Grok Build inline comments", () => { await writeFile(configPath, original); expect( await configureClientConfiguration( - { name: "grok_build", configPath, format: "grok" }, + { + name: "grok_build", + registrationProfile: supportedClientRegistrationProfile("grok_build"), + configPath, + format: "grok", + }, undefined, ["rea", "mcp"], ), @@ -1184,7 +1358,12 @@ describe("Grok Build inline comments", () => { await writeFile(configPath, original); expect( await configureClientConfiguration( - { name: "grok_build", configPath, format: "grok" }, + { + name: "grok_build", + registrationProfile: supportedClientRegistrationProfile("grok_build"), + configPath, + format: "grok", + }, undefined, ["rea", "mcp"], ), @@ -1220,7 +1399,12 @@ describe("Grok Build inline comments", () => { ); expect( await configureClientConfiguration( - { name: "grok_build", configPath, format: "grok" }, + { + name: "grok_build", + registrationProfile: supportedClientRegistrationProfile("grok_build"), + configPath, + format: "grok", + }, { HOPPER_LAUNCHER_PATH: "/tmp/path\u007fwith-del" }, ["rea", "mcp"], ), @@ -1386,7 +1570,12 @@ describe("Codex configuration comments", () => { expect( await configureClientConfiguration( - { name: "codex", configPath, format: "toml" }, + { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + configPath, + format: "toml", + }, {}, [PRODUCT_IDENTITY.cliBinary, "mcp"], ), @@ -2186,12 +2375,19 @@ describe("client configuration filesystem failure reporting", () => { }; return publication; }, - }).removeClient({ name: "cursor", format: "json", configPath: config }); + }).removeClient({ + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + format: "json", + configPath: config, + }); expect(result.status).toBe("failed"); expect(await readFile(config, "utf8")).toBe(current); expect(await readFile(`${config}.rea.backup`, "utf8")).toBe(initial); }); +}); +describe("managed skill removal outcomes", () => { it("reports a managed-path removal failure", async () => { const home = await createTestTempDirectory("rea-uninstall-remove-"); const skillRoot = join(home, ".agents/skills/reverse-engineer-anything"); diff --git a/tests/boundary/filesystem/clientNativeConnectionAdmission.test.ts b/tests/boundary/filesystem/clientNativeConnectionAdmission.test.ts new file mode 100644 index 0000000000..bc13078a2f --- /dev/null +++ b/tests/boundary/filesystem/clientNativeConnectionAdmission.test.ts @@ -0,0 +1,194 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { z } from "zod"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { createTestProcessRegistry } from "../../support/process/processFixture.js"; + +const PROGRAM = ` + import { homedir } from 'node:os'; + import { isAbsolute, join, relative, resolve } from 'node:path'; + import { access, mkdir, readFile, realpath, writeFile } from 'node:fs/promises'; + const home = process.env.HOME; + if (!home || await realpath(homedir()) !== await realpath(home)) + throw Error('Fixture HOME must be selected before importing registration owners.'); + const insideHome = (path) => { + const selected = relative(home, resolve(path)); + if (selected.startsWith('..') || isAbsolute(selected)) throw Error('Fixture path escapes HOME.'); + return path; + }; + for (const key of ['USERPROFILE','XDG_CONFIG_HOME','XDG_DATA_HOME','XDG_CACHE_HOME','XDG_STATE_HOME','APPDATA','LOCALAPPDATA','GEMINI_CLI_SYSTEM_SETTINGS_PATH','GEMINI_CLI_SYSTEM_DEFAULTS_PATH','GEMINI_CLI_TRUSTED_FOLDERS_PATH']) + insideHome(process.env[key] ?? ''); + process.chdir(home); + const { readClientRegistrationStatuses } = await import(${JSON.stringify(new URL("../../../src/application/ClientRegistrationStatus.ts", import.meta.url).href)}); + const { currentClientRegistrationCommand } = await import(${JSON.stringify(new URL("../../../src/application/ClientRegistrationIdentity.ts", import.meta.url).href)}); + const { CURRENT_ENTRYPOINT_PATH } = await import(${JSON.stringify(new URL("../../../src/process/CurrentEntrypointPath.ts", import.meta.url).href)}); + const { supportedClients } = await import(${JSON.stringify(new URL("../../../src/application/SupportedClients.ts", import.meta.url).href)}); + const { registerMcpClient } = await import(${JSON.stringify(new URL("../../../src/application/McpClientRegistration.ts", import.meta.url).href)}); + const { mcpClientRegistrationProfile } = await import(${JSON.stringify(new URL("../../../src/application/McpRegistrationCatalog.ts", import.meta.url).href)}); + const { ArtifactResourceScope } = await import(${JSON.stringify(new URL("../../../src/artifacts/ArtifactResourceScope.ts", import.meta.url).href)}); + const { ConfigurationError } = await import(${JSON.stringify(new URL("../../../src/domain/configurationErrors.ts", import.meta.url).href)}); + const [command, ...args] = currentClientRegistrationCommand(); + if (!command || !CURRENT_ENTRYPOINT_PATH) throw Error('Missing owned fixture entrypoint.'); + const gemini = supportedClients(home, process.platform, process.env).find(client => client.name === 'gemini_cli'); + if (!gemini) throw Error('Missing supported Gemini profile.'); + const configPath = insideHome(gemini.configPath); + await mkdir(join(configPath, '..'), { recursive: true }); + const row = { command, args, env: { REA_MCP_INPUT_SCHEMA_PROFILE: 'compact' } }; + const cases = [ + ['ignored transport', { transport: { operator: true } }], + ['HTTP selected', { httpUrl: 'https://fixture.invalid/http' }], + ['malformed truthy HTTP selector', { httpUrl: { operator: true } }], + ['SSE selected', { url: 'https://fixture.invalid/sse' }], + ['empty URL selectors', { httpUrl: '', url: '', transport: { operator: true } }], + ]; + const statuses = []; + for (const [label, fields] of cases) { + const original = JSON.stringify({ mcpServers: { rea: { ...row, ...fields } } }); + await writeFile(configPath, original); + const status = (await readClientRegistrationStatuses(home, CURRENT_ENTRYPOINT_PATH, { + environment: process.env, selectedClientIds: ['gemini_cli'], + })).find(status => status.client === 'gemini_cli'); + if (!status) throw Error('Missing Gemini status.'); + statuses.push({ label, state: status.state, identity: status.command_identity, + sourceUnchanged: await readFile(configPath, 'utf8') === original }); + } + const directory = insideHome(join(home, 'layered')); + await mkdir(directory); + const target = insideHome(join(directory, 'target.json')); + const higher = insideHome(join(directory, 'readonly.json')); + const source = JSON.stringify({ mcpServers: { rea: { ...row, httpUrl: 'https://fixture.invalid/selected' } } }); + await writeFile(higher, source); + const client = { id: 'gemini-cli', displayName: 'Gemini CLI', format: 'json', + registrationProfile: mcpClientRegistrationProfile('gemini-cli'), + scope: 'global', configPath: target, configPaths: [target, higher] }; + const exists = async path => { + try { await access(path); return true; } + catch (cause) { if (cause?.code === 'ENOENT') return false; throw cause; } + }; + const resources = new ArtifactResourceScope(); + try { + const conflict = await registerMcpClient(client, { command, args }, undefined, resources); + const refused = { ok: conflict.ok, typed: !conflict.ok && conflict.error instanceof ConfigurationError, + targetExists: await exists(target), backupExists: await exists(target + '.rea.backup'), + higherUnchanged: await readFile(higher, 'utf8') === source }; + const harmless = JSON.stringify({ mcpServers: { rea: { ...row, operator: { preserve: true } } } }); + await writeFile(higher, harmless); + const control = await registerMcpClient(client, { command, args }, undefined, resources); + console.log(JSON.stringify({ statuses, refused, control: { ok: control.ok, + status: control.ok ? control.value.status : control.error.message, + targetExists: await exists(target), higherUnchanged: await readFile(higher, 'utf8') === harmless } })); + } finally { await resources.close(); } +`; + +const observed = z.object({ + statuses: z.array( + z.object({ + label: z.string(), + state: z.string(), + identity: z.string(), + sourceUnchanged: z.boolean(), + }), + ), + refused: z.object({ + ok: z.boolean(), + typed: z.boolean(), + targetExists: z.boolean(), + backupExists: z.boolean(), + higherUnchanged: z.boolean(), + }), + control: z.object({ + ok: z.boolean(), + status: z.string(), + targetExists: z.boolean(), + higherUnchanged: z.boolean(), + }), +}); + +it("keeps owned Gemini launcher facts separate from native connection admission through status and publication", async () => { + const home = await createTestTempDirectory("rea-native-connection-"); + const entrypoint = join(home, "native-connection.mjs"); + await writeFile(entrypoint, PROGRAM); + const processes = createTestProcessRegistry(); + try { + const child = await processes.run( + process.execPath, + ["--import", "tsx", entrypoint], + { + timeoutMs: 20_000, + env: { + PATH: process.env.PATH, + SystemRoot: process.env.SystemRoot, + WINDIR: process.env.WINDIR, + TMPDIR: process.env.TMPDIR, + TMP: process.env.TMP, + TEMP: process.env.TEMP, + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: join(home, "config"), + XDG_DATA_HOME: join(home, "data"), + XDG_CACHE_HOME: join(home, "cache"), + XDG_STATE_HOME: join(home, "state"), + APPDATA: join(home, "appdata"), + LOCALAPPDATA: join(home, "localappdata"), + GEMINI_CLI_SYSTEM_SETTINGS_PATH: join(home, "system-settings.json"), + GEMINI_CLI_SYSTEM_DEFAULTS_PATH: join(home, "system-defaults.json"), + GEMINI_CLI_TRUSTED_FOLDERS_PATH: join(home, "trusted-folders.json"), + GEMINI_CLI_TRUST_WORKSPACE: "true", + }, + }, + ); + expect(child.exitCode, child.stderr).toBe(0); + const result = observed.parse(JSON.parse(child.stdout)); + expect(result).toEqual({ + statuses: [ + { + label: "ignored transport", + state: "aligned", + identity: "owned", + sourceUnchanged: true, + }, + { + label: "HTTP selected", + state: "unknown", + identity: "owned", + sourceUnchanged: true, + }, + { + label: "malformed truthy HTTP selector", + state: "unknown", + identity: "owned", + sourceUnchanged: true, + }, + { + label: "SSE selected", + state: "unknown", + identity: "owned", + sourceUnchanged: true, + }, + { + label: "empty URL selectors", + state: "aligned", + identity: "owned", + sourceUnchanged: true, + }, + ], + refused: { + ok: false, + typed: true, + targetExists: false, + backupExists: false, + higherUnchanged: true, + }, + control: { + ok: true, + status: "configured", + targetExists: true, + higherUnchanged: true, + }, + }); + } finally { + await processes.shutdown(); + } +}); diff --git a/tests/boundary/filesystem/clientPartialRegistrationProfiles.test.ts b/tests/boundary/filesystem/clientPartialRegistrationProfiles.test.ts new file mode 100644 index 0000000000..67d117b1db --- /dev/null +++ b/tests/boundary/filesystem/clientPartialRegistrationProfiles.test.ts @@ -0,0 +1,101 @@ +import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +import { expect, it } from "vitest"; + +import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; +import { + configureClientConfiguration, + inspectClientConfiguration, +} from "../../../src/application/SetupClientConfiguration.js"; +import { supportedClients } from "../../../src/application/SupportedClients.js"; +import { systemUninstallHost } from "../../../src/application/Uninstall.js"; +import { PRODUCT_IDENTITY } from "../../../src/identity.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it.each(["codebuddy", "workbuddy"] as const)( + "%s retains ownership independently of its partial native write profile", + async (name) => { + const home = await createTestTempDirectory("rea-partial-client-profile-"); + const environment = { + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: join(home, "xdg"), + APPDATA: join(home, "appdata"), + LOCALAPPDATA: join(home, "localappdata"), + }; + const client = supportedClients(home, process.platform, environment).find( + (candidate) => candidate.name === name, + ); + if (client === undefined) throw new Error(`Missing native client ${name}`); + const command = [ + "npx", + "-y", + PRODUCT_IDENTITY.registrationPackageSpecifier, + "mcp", + ]; + await mkdir(dirname(client.configPath), { recursive: true }); + expect( + await configureClientConfiguration(client, {}, command), + ).toMatchObject({ status: "configured" }); + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "unchanged", + }); + + // This field has no established native interpretation in the documented + // stdio-only profile. Its presence does not erase the parsed REA launcher. + const source = `${JSON.stringify({ + mcpServers: { + rea: { + command: command[0], + args: command.slice(1), + caller_native_policy: { selected: true }, + }, + peer: { command: "peer", args: [] }, + }, + })}\n`; + await writeFile(client.configPath, source); + const directoryEntries = await readdir(dirname(client.configPath)); + expect(await inspectClientConfiguration(client, {}, command)).toMatchObject( + { + status: "invalid", + remediation: expect.stringContaining("Write applicability"), + }, + ); + expect( + await configureClientConfiguration(client, {}, command), + ).toMatchObject({ status: "failed", reason: "readback" }); + expect(await readFile(client.configPath, "utf8")).toBe(source); + expect(await readdir(dirname(client.configPath))).toEqual(directoryEntries); + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + environment, + selectedClientIds: [name], + }) + ).find((status) => status.client === name), + ).toMatchObject({ + state: "unknown", + command_identity: "owned", + command, + }); + + const uninstall = systemUninstallHost( + home, + undefined, + environment, + process.platform, + ); + try { + expect(await uninstall.inspectClient(client)).toBeUndefined(); + expect(await uninstall.removeClient(client)).toMatchObject({ + status: "removed", + }); + expect(JSON.parse(await readFile(client.configPath, "utf8"))).toEqual({ + mcpServers: { peer: { command: "peer", args: [] } }, + }); + } finally { + expect(await uninstall.close?.()).toBeUndefined(); + } + }, +); diff --git a/tests/boundary/filesystem/clientRegistrationRefresh.test.ts b/tests/boundary/filesystem/clientRegistrationRefresh.test.ts index ddee962804..770d649269 100644 --- a/tests/boundary/filesystem/clientRegistrationRefresh.test.ts +++ b/tests/boundary/filesystem/clientRegistrationRefresh.test.ts @@ -4,6 +4,7 @@ import { expect, it } from "vitest"; import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js"; import { parseClientConfiguration } from "../../../src/application/ClientConfigurationDocument.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { supportedClientRegistrationProfile } from "../../../src/application/SupportedClients.js"; it.each([90, 120])( "preserves caller-selected Grok startup timeout %s when refreshing its launcher", @@ -12,7 +13,12 @@ it.each([90, 120])( const configPath = join(directory, "config.toml"); const source = `[mcp_servers.rea]\ncommand = "npx"\nargs = ["-y", "rea-agents@6.1.0", "mcp"]\nstartup_timeout_sec = ${timeout}\ncustom_policy = "caller"\n\n[mcp_servers.rea.env]\nPATH = "/caller/bin:/usr/bin"\nCUSTOM = "preserved"\n`; await writeFile(configPath, source); - const client = { name: "grok_build", configPath, format: "grok" } as const; + const client = { + name: "grok_build", + registrationProfile: supportedClientRegistrationProfile("grok_build"), + configPath, + format: "grok", + } as const; expect( await configureClientConfiguration(client, {}, ["rea", "mcp"]), ).toMatchObject({ status: "configured" }); diff --git a/tests/boundary/filesystem/clientRegistrationStatus.test.ts b/tests/boundary/filesystem/clientRegistrationStatus.test.ts index 9a0d4c355c..7c7daa9202 100644 --- a/tests/boundary/filesystem/clientRegistrationStatus.test.ts +++ b/tests/boundary/filesystem/clientRegistrationStatus.test.ts @@ -20,6 +20,7 @@ import { ConfigurationError } from "../../../src/domain/configurationErrors.js"; import { err } from "../../../src/domain/result.js"; import { PRODUCT_IDENTITY } from "../../../src/identity.js"; import { CURRENT_ENTRYPOINT_PATH } from "../../../src/process/CurrentEntrypointPath.js"; +import { supportedClientRegistrationProfile } from "../../../src/application/SupportedClients.js"; beforeEach(clearClientLocationEnvironment); afterEach(() => { @@ -389,29 +390,53 @@ describe("commandcode registration status", () => { ); }; - const ownedArgs = (): readonly string[] => [ - "-y", - PRODUCT_IDENTITY.registrationPackageSpecifier, - "mcp", - ]; + const entry = resolve("scripts/rea.mjs"); + const ownedArgs = (): readonly string[] => [entry, "mcp"]; - it.each([undefined, "sse"])( - "reports transport %j as invalid", - async (transport) => { + // Published command-code1.88.5 file loading accepts defaults and type aliases; + // its management/add-json parser has a different, stricter grammar. + it.each([ + { fields: {}, state: "aligned" }, + { fields: { type: "stdio" }, state: "aligned" }, + { + fields: { + transport: "STDIO", + type: "sse", + url: "https://fixture.invalid", + }, + state: "aligned", + }, + { + fields: { transport: "sse", url: "https://fixture.invalid" }, + state: "unknown", + }, + { fields: { transport: { operator: true } }, state: "invalid" }, + ] as const)( + "reports native transport fields $fields as $state", + async ({ fields, state }) => { const home = await createTestTempDirectory("rea-commandcode-transport-"); - await writeCommandcodeRegistration(home, { - ...(transport === undefined ? {} : { transport }), - command: "npx", + const registration = { + ...fields, + command: process.execPath, args: [...ownedArgs()], - }); - - expect(await readClientRegistrationStatuses(home)).toEqual([ + }; + await writeCommandcodeRegistration(home, registration); + const original = await readFile( + join(home, ".commandcode/mcp.json"), + "utf8", + ); + expect(await readClientRegistrationStatuses(home, entry)).toEqual([ expect.objectContaining({ client: "commandcode", - state: "invalid", - command: [], + state, + command_identity: "owned", + command: + state === "invalid" ? [] : [process.execPath, ...ownedArgs()], }), ]); + expect(await readFile(join(home, ".commandcode/mcp.json"), "utf8")).toBe( + original, + ); }, ); @@ -420,11 +445,11 @@ describe("commandcode registration status", () => { await writeCommandcodeRegistration(home, { transport: "stdio", enabled: false, - command: "npx", + command: process.execPath, args: [...ownedArgs()], }); - expect(await readClientRegistrationStatuses(home)).toEqual([ + expect(await readClientRegistrationStatuses(home, entry)).toEqual([ expect.objectContaining({ client: "commandcode", state: "stale", @@ -534,7 +559,12 @@ describe("Ghidra multi-function deadline registration advice", () => { expect(status?.advisory).toContain("max_functions"); } else expect(status?.advisory).toBeUndefined(); const configured = await configureClientConfiguration( - { name: "codex", format: "toml", configPath }, + { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + format: "toml", + configPath, + }, {}, [process.execPath, entry, "mcp"], ); diff --git a/tests/boundary/filesystem/codexClientPolicy.test.ts b/tests/boundary/filesystem/codexClientPolicy.test.ts index 926cedae33..2bf9f247f5 100644 --- a/tests/boundary/filesystem/codexClientPolicy.test.ts +++ b/tests/boundary/filesystem/codexClientPolicy.test.ts @@ -16,6 +16,7 @@ import { codexToolPolicy, codexToolPolicySource, } from "../../fixtures/codexToolPolicy.js"; +import { supportedClientRegistrationProfile } from "../../../src/application/SupportedClients.js"; it.each(["tables", "inline table"] as const)( "preserves Codex %s policy and status through refresh", @@ -25,7 +26,12 @@ it.each(["tables", "inline table"] as const)( await mkdir(dirname(configPath)); const original = codexToolPolicySource(layout); await writeFile(configPath, original); - const client = { name: "codex", configPath, format: "toml" } as const; + const client = { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + configPath, + format: "toml", + } as const; const status = async () => ( await readClientRegistrationStatuses(home, undefined, { @@ -103,7 +109,12 @@ it.each([ "", ].join("\n"); await writeFile(configPath, original); - const client = { name: "codex", configPath, format: "toml" } as const; + const client = { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + configPath, + format: "toml", + } as const; const inspection = await inspectClientConfiguration( client, {}, @@ -153,6 +164,7 @@ it.each([{ tools: ["binary_session"] }, { tools: ["*"] }, { tools: [] }])( await writeFile(configPath, original); const client = { name: "copilot_cli", + registrationProfile: supportedClientRegistrationProfile("copilot_cli"), configPath, format: "copilot_cli", } as const; diff --git a/tests/boundary/filesystem/geminiPolicySources.test.ts b/tests/boundary/filesystem/geminiPolicySources.test.ts index 00b215438f..dfdfa87d95 100644 --- a/tests/boundary/filesystem/geminiPolicySources.test.ts +++ b/tests/boundary/filesystem/geminiPolicySources.test.ts @@ -4,7 +4,10 @@ import { expect, it } from "vitest"; import { readConfigurationFile } from "../../../src/application/ClientConfigurationFiles.js"; import { readClientPolicyBlock } from "../../../src/application/GeminiClientSettings.js"; -import type { SetupClient } from "../../../src/application/SupportedClients.js"; +import { + supportedClientRegistrationProfile, + type SetupClient, +} from "../../../src/application/SupportedClients.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; const fixture = async () => { @@ -13,6 +16,7 @@ const fixture = async () => { await mkdir(dirname(configPath), { recursive: true }); const client: SetupClient = { name: "gemini_cli", + registrationProfile: supportedClientRegistrationProfile("gemini_cli"), configPath, format: "json", geminiSettings: { diff --git a/tests/boundary/filesystem/setupClientConfigCleanupOwnership.test.ts b/tests/boundary/filesystem/setupClientConfigCleanupOwnership.test.ts index ec0e4b29d1..dd16b032ac 100644 --- a/tests/boundary/filesystem/setupClientConfigCleanupOwnership.test.ts +++ b/tests/boundary/filesystem/setupClientConfigCleanupOwnership.test.ts @@ -38,6 +38,7 @@ import { import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js"; import { withVerifierCleanup } from "../../../scripts/lib/verifier-cleanup.mjs"; +import { supportedClientRegistrationProfile } from "../../../src/application/SupportedClients.js"; vi.mock("node:fs/promises", async (importOriginal) => { const actual = await importOriginal(); @@ -156,6 +157,7 @@ it("retains invalid UTF-8 diagnostics and the uninstall read owner when close fa expect( await host.removeClient({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), format: "json", configPath: path, }), @@ -177,7 +179,12 @@ it("retains invalid UTF-8 diagnostics and the uninstall read owner when close fa it("stops uninstall and retains the backup read owner when its close fails", async () => { const home = await createTestTempDirectory("rea-uninstall-backup-close-"); const path = join(home, "client.json"); - const client = { name: "cursor", format: "json", configPath: path } as const; + const client = { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + format: "json", + configPath: path, + } as const; await writeFile(path, '{"keep":"before setup"}\n'); expect( await configureClientConfiguration(client, {}, ["rea", "mcp"]), @@ -426,7 +433,12 @@ it("removes a shared registration once and reports that outcome to both clients" await mkdir(join(home, ".config", "freebuff-desktop"), { recursive: true }); expect( await configureClientConfiguration( - { name: "freebuff", format: "json", configPath: shared }, + { + name: "freebuff", + registrationProfile: supportedClientRegistrationProfile("freebuff"), + format: "json", + configPath: shared, + }, {}, ["rea", "mcp"], ), @@ -475,7 +487,12 @@ it("keeps one row per client when OpenCode is pointed at FreeBuff's file", async await mkdir(join(home, ".config", "freebuff-desktop"), { recursive: true }); expect( await configureClientConfiguration( - { name: "freebuff", format: "json", configPath: shared }, + { + name: "freebuff", + registrationProfile: supportedClientRegistrationProfile("freebuff"), + format: "json", + configPath: shared, + }, {}, ["rea", "mcp"], ), @@ -583,7 +600,12 @@ it("does not treat a dangling FreeBuff profile link as installed", async () => { await writeFile(cursor, '{"keep":"before setup"}\n'); expect( await configureClientConfiguration( - { name: "cursor", format: "json", configPath: cursor }, + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + format: "json", + configPath: cursor, + }, {}, ["rea", "mcp"], ), diff --git a/tests/boundary/filesystem/setupConfig.test.ts b/tests/boundary/filesystem/setupConfig.test.ts index 6aca056501..29072c9754 100644 --- a/tests/boundary/filesystem/setupConfig.test.ts +++ b/tests/boundary/filesystem/setupConfig.test.ts @@ -22,7 +22,10 @@ import { clientConfigurationAligned, } from "../../../src/application/SetupClientConfiguration.js"; import { resolveWindowsClaudeDesktopLocation } from "../../../src/application/ClaudeDesktopConfigLocation.js"; -import { supportedClients } from "../../../src/application/SupportedClients.js"; +import { + supportedClientRegistrationProfile, + supportedClients, +} from "../../../src/application/SupportedClients.js"; import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; import { failedConfigurationMessage } from "../../../src/application/SetupClients.js"; import { CURRENT_ENTRYPOINT_PATH } from "../../../src/process/CurrentEntrypointPath.js"; @@ -47,7 +50,12 @@ it.skipIf(process.platform === "win32")( }, }); await writeFile(configPath, original); - const client = { name: "cursor", format: "json", configPath } as const; + const client = { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + format: "json", + configPath, + } as const; expect(await clientConfigurationAligned(client, {}, [real, "mcp"])).toBe( true, ); @@ -96,7 +104,12 @@ it.skipIf(process.platform === "win32")( "", ].join("\n"); await writeFile(configPath, original); - const client = { name: "codex", format: "toml", configPath } as const; + const client = { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + format: "toml", + configPath, + } as const; expect( await configureClientConfiguration(client, {}, [real, "mcp"]), ).toMatchObject({ status: "configured" }); @@ -139,6 +152,7 @@ it.skipIf(process.platform === "win32").each([false, true])( expect( await configureClientConfiguration({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), format: "json", configPath: requested, }), @@ -176,6 +190,7 @@ it expect( await configureClientConfiguration({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), format: "json", configPath: requested, }), @@ -253,6 +268,7 @@ describe("JSON client configuration transaction: writing configuration through s await writeFile(configPath, original); const result = await configureClientConfiguration({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), configPath, format: "json", }); @@ -281,6 +297,7 @@ describe("JSON client configuration transaction: writing configuration through s expect( await configureClientConfiguration({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), configPath, format: "json", }), @@ -299,6 +316,7 @@ describe("JSON client configuration transaction: writing configuration through s expect( await configureClientConfiguration({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), configPath, format: "json", }), @@ -315,6 +333,7 @@ describe("JSON client configuration transaction: writing configuration through s expect( await configureClientConfiguration({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), configPath, format: "json", }), @@ -340,6 +359,7 @@ describe("JSON client configuration transaction: keeping matching configuration expect( await configureClientConfiguration({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), configPath, format: "json", }), @@ -372,7 +392,12 @@ describe("JSON client configuration transaction: keeping matching configuration expect( await configureClientConfiguration( - { name: "cursor", configPath, format: "json" }, + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath, + format: "json", + }, { GHIDRA_INSTALL_DIR: "/opt/ghidra", JAVA_HOME: "/usr/lib/jvm/jdk-21", @@ -396,6 +421,7 @@ describe("JSON client configuration migration and provider paths", () => { expect( await configureClientConfiguration({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), configPath, format: "json", }), @@ -418,7 +444,12 @@ describe("JSON client configuration migration and provider paths", () => { const directory = await createTestTempDirectory("rea-setup-"); const configPath = join(directory, "mcp.json"); const hopperPath = "/Applications/Hopper v6.app/Contents/MacOS/hopper"; - const client = { name: "cursor", configPath, format: "json" } as const; + const client = { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath, + format: "json", + } as const; const original = "{}\n"; await writeFile(configPath, original); expect( @@ -455,7 +486,12 @@ describe("JSON client configuration migration and provider paths", () => { it("adds exact BYO Ghidra and Java paths without installing dependencies", async () => { const directory = await createTestTempDirectory("rea-setup-"); const configPath = join(directory, "mcp.json"); - const client = { name: "cursor", configPath, format: "json" } as const; + const client = { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath, + format: "json", + } as const; const environment = { GHIDRA_INSTALL_DIR: "/opt/ghidra_12.1.4_PUBLIC", JAVA_HOME: "/usr/lib/jvm/jdk-21", @@ -480,7 +516,9 @@ describe("JSON client configuration migration and provider paths", () => { status: "unchanged", }); }); +}); +describe("invalid JSON client configuration preservation", () => { it("refuses malformed existing JSON without overwriting it", async () => { const directory = await createTestTempDirectory("rea-setup-"); const configPath = join(directory, "mcp.json"); @@ -488,6 +526,7 @@ describe("JSON client configuration migration and provider paths", () => { expect( await configureClientConfiguration({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), configPath, format: "json", }), @@ -506,6 +545,7 @@ describe("JSON client configuration migration and provider paths", () => { expect( await configureClientConfiguration({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), configPath, format: "json", }), @@ -528,6 +568,7 @@ describe("JSON client configuration migration and provider paths", () => { expect( await configureClientConfiguration({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), configPath, format: "json", }), @@ -563,6 +604,9 @@ describe("JSON client configuration shape diagnostics", () => { const configPath = join(directory, "mcp.json"); const client = { name: format === "json" ? "cursor" : "opencode", + registrationProfile: supportedClientRegistrationProfile( + format === "json" ? "cursor" : "opencode", + ), configPath, format, }; @@ -605,6 +649,7 @@ describe("JSON client configuration shape diagnostics", () => { const configPath = join(directory, "mcp.json"); const client = { name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), configPath, format: "json" as const, }; @@ -640,6 +685,8 @@ it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( try { const result = await configureClientConfiguration({ name: "claude_desktop", + registrationProfile: + supportedClientRegistrationProfile("claude_desktop"), format: "json", configPath, msixPackage: { diff --git a/tests/boundary/filesystem/windowsNpxRegistration.test.ts b/tests/boundary/filesystem/windowsNpxRegistration.test.ts index 1d190ade9a..1e52a5f643 100644 --- a/tests/boundary/filesystem/windowsNpxRegistration.test.ts +++ b/tests/boundary/filesystem/windowsNpxRegistration.test.ts @@ -13,6 +13,7 @@ import { currentClientRegistrationCommand } from "../../../src/application/Clien import { systemUninstallHost } from "../../../src/application/Uninstall.js"; import { PRODUCT_IDENTITY } from "../../../src/identity.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { supportedClientRegistrationProfile } from "../../../src/application/SupportedClients.js"; // These writer tests model setup running from REA's actual entrypoint, not Vitest's worker. vi.mock( @@ -74,6 +75,7 @@ it.each([ ); const client = { name: "claude_code", + registrationProfile: supportedClientRegistrationProfile("claude_code"), configPath, format: "claude_code", } as const; @@ -111,7 +113,12 @@ it("keeps the Windows direct launcher aligned through Codex TOML setup and remov const original = '\uFEFF# Keep this explanation.\nnotify = ["a", "b"]\nliteral = \'C:\\demo\\path\'\ndisabled_mcp_servers = ["rea"]\n\n[mcp_servers.other]\ncommand = "another-server"\n'; await writeFile(configPath, original); - const client = { name: "codex", configPath, format: "toml" } as const; + const client = { + name: "codex", + registrationProfile: supportedClientRegistrationProfile("codex"), + configPath, + format: "toml", + } as const; const command = currentClientRegistrationCommand({ platform: "win32" }); expect(await configureClientConfiguration(client, {}, command)).toMatchObject( { status: "configured" }, @@ -139,6 +146,7 @@ it("retains rea-named custom shell registrations without writing a backup", asyn const configPath = join(home, ".claude.json"); const client = { name: "claude_code", + registrationProfile: supportedClientRegistrationProfile("claude_code"), configPath, format: "claude_code", } as const; @@ -176,6 +184,7 @@ it.skipIf(process.platform !== "win32")( const configPath = join(home, ".claude.json"); const setupClient = { name: "claude_code", + registrationProfile: supportedClientRegistrationProfile("claude_code"), configPath, format: "claude_code", } as const; diff --git a/tests/process-global/configurationWriteDiagnostics.test.ts b/tests/process-global/configurationWriteDiagnostics.test.ts index 3c97292c11..a3fd6ea557 100644 --- a/tests/process-global/configurationWriteDiagnostics.test.ts +++ b/tests/process-global/configurationWriteDiagnostics.test.ts @@ -48,6 +48,7 @@ vi.mock("node:fs/promises", async (importOriginal) => { import { configureClientConfiguration } from "../../src/application/SetupClientConfiguration.js"; import { failedConfigurationMessage } from "../../src/application/SetupClients.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { supportedClientRegistrationProfile } from "../../src/application/SupportedClients.js"; afterEach(() => { selected.operation = undefined; @@ -67,6 +68,7 @@ it.each([ selected.operation = operation; const result = await configureClientConfiguration({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), configPath: path, format: "json", }); diff --git a/tests/process-global/setupConfigurationConflicts.test.ts b/tests/process-global/setupConfigurationConflicts.test.ts index defb160b61..6be1b320f4 100644 --- a/tests/process-global/setupConfigurationConflicts.test.ts +++ b/tests/process-global/setupConfigurationConflicts.test.ts @@ -66,6 +66,7 @@ vi.mock("../../src/process/BoundedFileBytes.js", async (importOriginal) => { import { configureClientConfiguration } from "../../src/application/SetupClientConfiguration.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { supportedClientRegistrationProfile } from "../../src/application/SupportedClients.js"; afterEach(() => { writers.before = undefined; @@ -85,6 +86,7 @@ it("reports a conflict when a file changes while the freshness snapshot is being expect( await configureClientConfiguration({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), configPath: path, format: "json", }), @@ -107,6 +109,7 @@ it.each(["before", "after"] as const)( expect( await configureClientConfiguration({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), configPath: path, format: "json", }), @@ -132,6 +135,7 @@ it("preserves an unrelated concurrent edit when a matching recovery backup alrea const result = await configureClientConfiguration({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), configPath: path, format: "json", }); @@ -146,6 +150,7 @@ it("preserves an unrelated concurrent edit when a matching recovery backup alrea writers.before = undefined; const retry = await configureClientConfiguration({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), configPath: path, format: "json", }); @@ -160,6 +165,7 @@ it("preserves an unrelated concurrent edit when a matching recovery backup alrea expect( await configureClientConfiguration({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), configPath: path, format: "json", }), @@ -175,6 +181,7 @@ it("does not overwrite a file created by another writer during first-run setup", expect( await configureClientConfiguration({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), configPath: path, format: "json", }), @@ -192,6 +199,7 @@ it("does not recreate a configuration deleted during setup", async () => { expect( await configureClientConfiguration({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), configPath: path, format: "json", }), @@ -209,6 +217,7 @@ it("preserves an external edit made after the atomic write without rolling it ba expect( await configureClientConfiguration({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), configPath: path, format: "json", }), @@ -230,6 +239,7 @@ it("does not adopt another writer's replacement merely because its bytes match", expect( await configureClientConfiguration({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), configPath: path, format: "json", }), @@ -251,6 +261,7 @@ it("detects a replacement of the original file even when its contents are identi expect( await configureClientConfiguration({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), configPath: path, format: "json", }), @@ -277,6 +288,7 @@ it.skipIf(process.platform === "win32")( expect( await configureClientConfiguration({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), configPath: path, format: "json", }), @@ -293,6 +305,7 @@ it.each(["absolute", "relative"])( const path = join(root, "config.json"); const client = { name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), configPath: path, format: "json", } as const; @@ -350,13 +363,23 @@ it.skipIf(process.platform === "win32")( }; const first = configureClientConfiguration( - { name: "cursor", configPath: realPath, format: "json" }, + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath: realPath, + format: "json", + }, { FIRST: "1" }, ); try { await started; const second = configureClientConfiguration( - { name: "cursor", configPath: aliasPath, format: "json" }, + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath: aliasPath, + format: "json", + }, { SECOND: "2" }, ); const secondReachedWriter = await new Promise((resolve) => { @@ -407,6 +430,7 @@ it.skipIf(process.platform === "win32")( expect( await configureClientConfiguration({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), configPath: path, format: "json", }), @@ -420,7 +444,12 @@ it.skipIf(process.platform === "win32")( it("releases a failed setup transaction so a queued request can configure the file", async () => { const root = await createTestTempDirectory("rea-config-queued-failure-"); const path = join(root, "config.json"); - const client = { name: "cursor", configPath: path, format: "json" } as const; + const client = { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath: path, + format: "json", + } as const; let markStarted!: () => void; const started = new Promise((resolve) => { markStarted = resolve; diff --git a/tests/process-global/setupConfigurationRollback.test.ts b/tests/process-global/setupConfigurationRollback.test.ts index f98d1d6e65..48357c8a41 100644 --- a/tests/process-global/setupConfigurationRollback.test.ts +++ b/tests/process-global/setupConfigurationRollback.test.ts @@ -156,6 +156,7 @@ import { systemUninstallHost } from "../../src/application/Uninstall.js"; import { ConfigurationError } from "../../src/domain/configurationErrors.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; import { createDeferred } from "../fixtures/binarySession.js"; +import { supportedClientRegistrationProfile } from "../../src/application/SupportedClients.js"; const ownReadHandle = async (path: string) => { const owner = new OwnedFileHandle(await open(path, "r")); @@ -177,7 +178,12 @@ it("reports a failed config rollback and retains its backup path", async () => { state.rejectRestore = true; const result = await configureClientConfiguration( - { name: "cursor", configPath: path, format: "json" }, + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath: path, + format: "json", + }, {}, ["rea", "mcp"], ); @@ -214,7 +220,12 @@ it("preserves the first backup and reports a verified backup of the current orig state.rejectRestore = true; const result = await configureClientConfiguration( - { name: "cursor", configPath: path, format: "json" }, + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath: path, + format: "json", + }, {}, ["rea", "mcp"], ); @@ -254,7 +265,12 @@ it("does not write config when a recovery copy disappears before verification", state.removeCopiedRecovery = true; const result = await configureClientConfiguration( - { name: "cursor", configPath: path, format: "json" }, + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath: path, + format: "json", + }, {}, ["rea", "mcp"], ); @@ -285,7 +301,12 @@ it("does not roll back or remove config when its initial read cleanup fails", as await expect( configureClientConfiguration( - { name: "cursor", configPath: path, format: "json" }, + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath: path, + format: "json", + }, {}, ["rea", "mcp"], ), @@ -302,7 +323,12 @@ it("retains first-run configuration for manual recovery without deleting by path const path = join(root, "client.json"); const result = await configureClientConfiguration( - { name: "cursor", configPath: path, format: "json" }, + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath: path, + format: "json", + }, {}, ["rea", "mcp"], ); @@ -350,7 +376,12 @@ it("retains readback cleanup ownership when rollback also fails", async () => { let caught: unknown; try { await host.configureClient( - { name: "cursor", configPath: path, format: "json" }, + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath: path, + format: "json", + }, {}, ["rea", "mcp"], ); @@ -395,7 +426,12 @@ it.each(["existing", "first-run"] as const)( }; const result = await configureClientConfiguration( - { name: "cursor", configPath: path, format: "json" }, + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath: path, + format: "json", + }, {}, ["rea", "mcp"], ); @@ -424,7 +460,12 @@ it("retains a replacement regular file when readback fails", async () => { fs.rename(replacement, publishedPath); const result = await configureClientConfiguration( - { name: "cursor", configPath: path, format: "json" }, + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath: path, + format: "json", + }, {}, ["rea", "mcp"], ); @@ -463,7 +504,12 @@ it("retains and retries both readback and publication descriptors after mixed cl try { await expect( host.configureClient( - { name: "cursor", configPath: path, format: "json" }, + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath: path, + format: "json", + }, {}, ["rea", "mcp"], ), @@ -505,7 +551,12 @@ it("rejects a replaced publication even when the replacement passes registration }; expect( await configureClientConfiguration( - { name: "cursor", configPath: path, format: "json" }, + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath: path, + format: "json", + }, {}, ["rea", "mcp"], ), @@ -528,7 +579,12 @@ it("retains concurrent edits to the published inode rather than overwriting them fs.writeFile(publishedPath, edited); expect( await configureClientConfiguration( - { name: "cursor", configPath: path, format: "json" }, + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath: path, + format: "json", + }, {}, ["rea", "mcp"], ), @@ -565,7 +621,12 @@ it("does not adopt a descriptor named by an opaque publication close rejection", try { await expect( host.configureClient( - { name: "cursor", configPath: path, format: "json" }, + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath: path, + format: "json", + }, {}, ["rea", "mcp"], ), @@ -612,6 +673,7 @@ it.each(["symlink", "regular", "valid-readback"] as const)( try { const result = await host.removeClient({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), configPath: path, format: "json", }); @@ -651,6 +713,7 @@ it("uninstall retains concurrent edits to the published inode and reports failur try { const result = await host.removeClient({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), configPath: path, format: "json", }); @@ -692,6 +755,7 @@ it("uninstall retains both readback and publication owners until cleanup retry", try { const result = await host.removeClient({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), configPath: path, format: "json", }); @@ -743,6 +807,7 @@ it("uninstall retains the exact owner of a failed initial config read", async () expect( await host.inspectClient({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), configPath: path, format: "json", }), @@ -785,6 +850,7 @@ it("uninstall close joins a publication already admitted by its host", async () const host = systemUninstallHost(root); const removal = host.removeClient({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), configPath: path, format: "json", }); @@ -805,7 +871,12 @@ it("uninstall close joins a publication already admitted by its host", async () expect(await closing).toBeUndefined(); expect(state.publicationHandles[0]!.fd).toBe(-1); await expect( - host.removeClient({ name: "cursor", configPath: path, format: "json" }), + host.removeClient({ + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath: path, + format: "json", + }), ).rejects.toThrow("closed"); }); @@ -845,7 +916,12 @@ it("preserves physical read origin through failed readback cleanup and rollback" const resources = new ArtifactResourceScope(); try { const failure: unknown = await configureClientConfiguration( - { name: "cursor", configPath: path, format: "json" }, + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath: path, + format: "json", + }, {}, ["rea", "mcp"], ).catch((cause: unknown) => cause); @@ -953,6 +1029,7 @@ it("uninstall progresses when a peer leaves a denied-close descriptor pending", const host = systemUninstallHost(root); const removal = host.removeClient({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), configPath: path, format: "json", }); @@ -968,6 +1045,8 @@ it("uninstall progresses when a peer leaves a denied-close descriptor pending", expect( await host.inspectClient({ name: "other", + // Source-only peer actor models Cursor JSON registration. + registrationProfile: supportedClientRegistrationProfile("cursor"), configPath: other, format: "json", }), @@ -1033,6 +1112,7 @@ it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( ( await host.removeClient({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), configPath: path, format: "json", }) @@ -1074,6 +1154,7 @@ it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( ( await host.removeClient({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), configPath: path, format: "json", }) @@ -1117,6 +1198,7 @@ it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( try { const result = await host.removeClient({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), configPath: path, format: "json", }); @@ -1161,7 +1243,12 @@ it.each(["setup", "uninstall"] as const)( expect( ( await configureClientConfiguration( - { name: "cursor", configPath: path, format: "json" }, + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath: path, + format: "json", + }, {}, ["rea", "mcp"], ) @@ -1174,6 +1261,7 @@ it.each(["setup", "uninstall"] as const)( ( await host.removeClient({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), configPath: path, format: "json", }) @@ -1196,7 +1284,12 @@ const runConfigurationChange = async ( ) => { if (operation === "setup") return configureClientConfiguration( - { name: "cursor", configPath: path, format: "json" }, + { + name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath: path, + format: "json", + }, {}, ["new-rea", "mcp"], ); @@ -1204,6 +1297,7 @@ const runConfigurationChange = async ( try { return await host.removeClient({ name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), configPath: path, format: "json", }); @@ -1330,6 +1424,7 @@ it.each(["setup", "uninstall"] as const)( try { const client = { name: "cursor", + registrationProfile: supportedClientRegistrationProfile("cursor"), configPath: path, format: "json" as const, }; diff --git a/tests/process-global/setupPolicySnapshot.test.ts b/tests/process-global/setupPolicySnapshot.test.ts index 9ac1e5338c..a51bb51dcd 100644 --- a/tests/process-global/setupPolicySnapshot.test.ts +++ b/tests/process-global/setupPolicySnapshot.test.ts @@ -32,7 +32,10 @@ import { configureClientConfiguration, inspectClientConfiguration, } from "../../src/application/SetupClientConfiguration.js"; -import type { SetupClient } from "../../src/application/SupportedClients.js"; +import { + supportedClientRegistrationProfile, + type SetupClient, +} from "../../src/application/SupportedClients.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; afterEach(() => { @@ -47,6 +50,7 @@ it.each(["inspect", "configure"] as const)( const configPath = join(home, ".gemini", "settings.json"); const client: SetupClient = { name: "gemini_cli", + registrationProfile: supportedClientRegistrationProfile("gemini_cli"), configPath, format: "json", geminiSettings: { @@ -81,7 +85,13 @@ it.each(["inspect", "configure"] as const)( it("rechecks registration ownership after preflight before creating any backup", async () => { const home = await createTestTempDirectory("rea-setup-ownership-recheck-"); const configPath = join(home, "settings.json"); - const client: SetupClient = { name: "test", configPath, format: "json" }; + const client: SetupClient = { + name: "test", + // Source-only ownership fixture models Cursor JSON registration. + registrationProfile: supportedClientRegistrationProfile("cursor"), + configPath, + format: "json", + }; await writeFile( configPath, '{"mcpServers":{"rea":{"command":"rea","args":["mcp"]}}}', @@ -121,6 +131,7 @@ it("blocks an inherited foreign row before planning creation of a missing write ); const client: SetupClient = { name: "opencode", + registrationProfile: supportedClientRegistrationProfile("opencode"), format: "opencode", configPath, configPaths: [lower, configPath], @@ -145,6 +156,7 @@ it.each(["other", "rea"])( const configPath = join(home, "opencode.json"); const client: SetupClient = { name: "opencode", + registrationProfile: supportedClientRegistrationProfile("opencode"), format: "opencode", configPath, configPaths: [lower, configPath], diff --git a/tests/support/cli/registrationEnvironment.ts b/tests/support/cli/registrationEnvironment.ts new file mode 100644 index 0000000000..c4118d7e4e --- /dev/null +++ b/tests/support/cli/registrationEnvironment.ts @@ -0,0 +1,61 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { delimiter, join } from "node:path"; +import { pathToFileURL } from "node:url"; + +/** Isolate every registration writer before the official Node entrypoint imports it. */ +export const registrationEnvironment = async ( + home: string, + additional: NodeJS.ProcessEnv = {}, +): Promise => { + await mkdir(home, { recursive: true }); + const guard = join(home, "registration-environment.mjs"); + await writeFile( + guard, + `import { homedir } from 'node:os'; +import { realpath } from 'node:fs/promises'; +import { relative, isAbsolute, sep } from 'node:path'; +const home = process.env.HOME; +if (!home || await realpath(home) !== await realpath(homedir())) + throw Error('Registration fixture HOME was not selected before imports.'); +for (const key of ['USERPROFILE','XDG_CONFIG_HOME','XDG_DATA_HOME','XDG_CACHE_HOME','XDG_STATE_HOME','APPDATA','LOCALAPPDATA']) { + const path = relative(home, process.env[key] ?? ''); + if (path === '..' || path.startsWith('..' + sep) || isAbsolute(path)) + throw Error('Registration fixture location escapes HOME: ' + key); +} +`, + { mode: 0o600 }, + ); + const bin = join(home, "registration-bin"); + const witness = join(bin, "unexpected-acquisition.cjs"); + const log = join(home, "unexpected-acquisition.log"); + await mkdir(bin, { recursive: true }); + await writeFile(log, ""); + await writeFile( + witness, + `require('node:fs').appendFileSync(process.env.REA_TEST_REGISTRATION_ACQUISITION_LOG, 'unexpected acquisition\\n'); process.exit(79);\n`, + ); + for (const name of ["npx", "pnpx", "bunx"]) { + await writeFile( + join(bin, process.platform === "win32" ? `${name}.cmd` : name), + process.platform === "win32" + ? `@"${process.execPath}" "${witness}"\r\n` + : `#!${process.execPath}\nrequire(${JSON.stringify(witness)});\n`, + { mode: 0o700 }, + ); + } + return { + ...additional, + PATH: additional.PATH ?? [bin, process.env.PATH ?? ""].join(delimiter), + REA_TEST_REGISTRATION_ACQUISITION_LOG: log, + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: join(home, ".config"), + XDG_DATA_HOME: join(home, "data"), + XDG_CACHE_HOME: join(home, "cache"), + XDG_STATE_HOME: join(home, "state"), + APPDATA: join(home, "appdata"), + LOCALAPPDATA: join(home, "localappdata"), + // A file URL encodes spaces; Node reads this one --import option directly. + NODE_OPTIONS: `--import=${pathToFileURL(guard).href}`, + }; +};