mirror of
https://github.com/morluto/rea.git
synced 2026-11-11 03:31:35 -10:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0282fadfba | ||
|
|
c489883f8b | ||
|
|
0c23ac5b56 | ||
|
|
2bad176294 | ||
|
|
0033698b0d | ||
|
|
cf5ba94c85 |
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-4
@@ -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" ||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<SetupClient["format"]>;
|
||||
type ClientServersKey = "mcp_servers" | "mcpServers" | "mcp" | "servers";
|
||||
export type ClientConfigurationFormat =
|
||||
| NonNullable<SetupClient["format"]>
|
||||
| "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.<name>` 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<string, unknown>;
|
||||
/** 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<string, unknown>;
|
||||
/** 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<string, unknown>,
|
||||
): "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<string, unknown> => {
|
||||
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<string, string>>,
|
||||
): Record<string, unknown> => {
|
||||
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.<name>` 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. */
|
||||
|
||||
@@ -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<ClientConfigurationDocument | undefined, ClientConfigurationFileError>
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" } },
|
||||
});
|
||||
});
|
||||
@@ -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<typeof opencodeLauncherSchema>,
|
||||
entry: z.output<typeof arrayLauncherSchema>,
|
||||
): z.output<typeof launcherSchema> => {
|
||||
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<ClientRegistration, ConfigurationError> => {
|
||||
@@ -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<string, unknown> | undefined;
|
||||
readonly connectionFields: ReadonlySet<string>;
|
||||
},
|
||||
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<Record<string, unknown>, 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<string, unknown> =
|
||||
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<Record<string, unknown>, 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),
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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<Result<string, RecoveryBackupPreservationFailure>> => {
|
||||
@@ -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<string, unknown>,
|
||||
): 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);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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, unknown>,
|
||||
): 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 });
|
||||
};
|
||||
@@ -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<string, HermesTimestampFacts | undefined>();
|
||||
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>,
|
||||
): 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<Node>(),
|
||||
): 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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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<string> => {
|
||||
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",
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,463 @@
|
||||
import {
|
||||
readFile,
|
||||
readdir,
|
||||
rename,
|
||||
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,
|
||||
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"],
|
||||
};
|
||||
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"]);
|
||||
});
|
||||
|
||||
it("cancels queued registration after the existing transaction settles", async () => {
|
||||
const { path } = await settingsFixture("{}");
|
||||
let unblock!: () => void;
|
||||
let entered!: () => void;
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
unblock = resolve;
|
||||
});
|
||||
const admitted = new Promise<void>((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 <Value>(operation: () => Promise<Value>): Promise<Value> => {
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,416 @@
|
||||
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 { 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<readonly McpClientRegistrationOutcome[]> => {
|
||||
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 = !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<McpClientRegistrationReceipt, McpClientRegistrationFailure>
|
||||
> => {
|
||||
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";
|
||||
return err(
|
||||
await registrationFailure(cause, settingsPath, resources, {
|
||||
backupPath,
|
||||
configured,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const registrationFailure = async (
|
||||
cause: unknown,
|
||||
path: string,
|
||||
resources: ArtifactResourceScope,
|
||||
progress: {
|
||||
readonly backupPath: string | undefined;
|
||||
readonly configured: boolean;
|
||||
},
|
||||
): Promise<ConfigurationError> => {
|
||||
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<ReturnType<typeof prepareRegistration>>,
|
||||
checkCancellation: () => void,
|
||||
resources: ArtifactResourceScope,
|
||||
): Promise<McpClientRegistrationReceipt | undefined> => {
|
||||
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<ReturnType<typeof prepareRegistration>>,
|
||||
backupPath: string | undefined,
|
||||
resources: ArtifactResourceScope,
|
||||
): Promise<void> => {
|
||||
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<boolean> => {
|
||||
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 };
|
||||
@@ -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<typeof catalogObservationsSchema>,
|
||||
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" },
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,529 @@
|
||||
import {
|
||||
kiloLocation,
|
||||
kiloProjectLocation,
|
||||
} from "./McpRegistrationKiloLocations.js";
|
||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.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 {
|
||||
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<McpRegistrationGlobalLocation>;
|
||||
readonly projectLocation?: (
|
||||
context: McpRegistrationLocationContext,
|
||||
) => Promise<McpRegistrationLocation>;
|
||||
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<string, ClientDescriptor>;
|
||||
|
||||
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<string, McpRegistrationClientId>;
|
||||
|
||||
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<NodeJS.ProcessEnv>;
|
||||
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<McpRegistrationClient> => {
|
||||
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)),
|
||||
};
|
||||
};
|
||||
|
||||
/** Resolve all selected paths before any registration writer receives authority. */
|
||||
export const resolveMcpRegistrationClients = async (
|
||||
selection: McpRegistrationSelection,
|
||||
resources: ArtifactResourceScope,
|
||||
): Promise<Result<readonly McpRegistrationClient[], CatalogFailure>> => {
|
||||
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),
|
||||
);
|
||||
}
|
||||
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);
|
||||
return ok([...(detected.length === 0 ? all : detected), amp]);
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
cause instanceof ConfigurationError ||
|
||||
cause instanceof AnalysisCancelledError
|
||||
? cause
|
||||
: new ConfigurationError(
|
||||
"Could not resolve MCP registration locations.",
|
||||
{ cause },
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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<ClientConfigurationDocument[]> => {
|
||||
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<ReturnType<typeof prepareRegistration>>,
|
||||
targetText: string,
|
||||
): Promise<void> => {
|
||||
const { client, launcher } = prepared;
|
||||
assertEffectiveLauncher(
|
||||
client,
|
||||
await readRegistrationLayers(
|
||||
client,
|
||||
parseClientConfiguration(targetText, client.format),
|
||||
),
|
||||
launcher,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
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 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<McpRegistrationLocationContext> => {
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
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<string> => {
|
||||
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<string> => {
|
||||
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<string | undefined> => {
|
||||
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<string> => {
|
||||
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<string> => {
|
||||
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<KiloRegistrationWorktree> => {
|
||||
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) {
|
||||
if (
|
||||
cause instanceof ConfigurationError ||
|
||||
cause instanceof AnalysisCancelledError
|
||||
)
|
||||
throw cause;
|
||||
throw gitFailure(cause, context);
|
||||
}
|
||||
};
|
||||
@@ -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<KiloFileLayer[]> => {
|
||||
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<KiloConfigDirectory[]> => {
|
||||
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<KiloFileLayer[]> => {
|
||||
const seen = new Set<string>();
|
||||
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<McpRegistrationGlobalLocation> => {
|
||||
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<KiloConfigDirectory[]> => {
|
||||
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<McpRegistrationLocation> => {
|
||||
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<string>();
|
||||
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 }),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,216 @@
|
||||
import { lstat } from "node:fs/promises";
|
||||
import { dirname, join, 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<NodeJS.ProcessEnv>;
|
||||
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<McpRegistrationGlobalLocation> => {
|
||||
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<boolean> => {
|
||||
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<string> => {
|
||||
for (const candidate of candidates)
|
||||
if (await present(candidate)) return candidate;
|
||||
return fallback;
|
||||
};
|
||||
|
||||
export const clineLocation = async (
|
||||
context: McpRegistrationLocationContext,
|
||||
): Promise<McpRegistrationGlobalLocation> =>
|
||||
location(
|
||||
join(
|
||||
platformPaths(context).vscode,
|
||||
"globalStorage",
|
||||
"saoudrizwan.claude-dev",
|
||||
"settings",
|
||||
"cline_mcp_settings.json",
|
||||
),
|
||||
);
|
||||
export const clineCliLocation = async (
|
||||
context: McpRegistrationLocationContext,
|
||||
): Promise<McpRegistrationGlobalLocation> =>
|
||||
location(
|
||||
join(
|
||||
context.environment.CLINE_DIR || join(context.home, ".cline"),
|
||||
"data",
|
||||
"settings",
|
||||
"cline_mcp_settings.json",
|
||||
),
|
||||
);
|
||||
export const gooseLocation = async (
|
||||
context: McpRegistrationLocationContext,
|
||||
): Promise<McpRegistrationGlobalLocation> => {
|
||||
const { home, platform } = context;
|
||||
const path =
|
||||
platform === "win32"
|
||||
? join(
|
||||
platformPaths(context).appSupport,
|
||||
"Block",
|
||||
"goose",
|
||||
"config",
|
||||
"config.yaml",
|
||||
)
|
||||
: join(
|
||||
platform === "darwin"
|
||||
? join(home, ".config")
|
||||
: platformPaths(context).config,
|
||||
"goose",
|
||||
"config.yaml",
|
||||
);
|
||||
return location(path, path);
|
||||
};
|
||||
export const junieLocation = async (
|
||||
context: McpRegistrationLocationContext,
|
||||
): Promise<McpRegistrationGlobalLocation> => {
|
||||
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<McpRegistrationLocation> => {
|
||||
const paths = candidates.map((path) => selectedPath(path, context));
|
||||
return {
|
||||
configPath: await firstExisting(
|
||||
paths,
|
||||
selectedPath(candidates[0], context),
|
||||
),
|
||||
};
|
||||
};
|
||||
export const kiroLocation = async (
|
||||
context: McpRegistrationLocationContext,
|
||||
): Promise<McpRegistrationGlobalLocation> =>
|
||||
location(
|
||||
join(context.home, ".kiro", "settings", "mcp.json"),
|
||||
join(context.home, ".kiro"),
|
||||
);
|
||||
export const mcporterLocation = async (
|
||||
context: McpRegistrationLocationContext,
|
||||
): Promise<McpRegistrationGlobalLocation> => {
|
||||
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<McpRegistrationGlobalLocation> =>
|
||||
location(
|
||||
join(
|
||||
platformPaths(context).appSupport,
|
||||
context.platform === "darwin" || context.platform === "win32"
|
||||
? "Zed"
|
||||
: "zed",
|
||||
"settings.json",
|
||||
),
|
||||
);
|
||||
|
||||
export const fxLocation = async (
|
||||
context: McpRegistrationLocationContext,
|
||||
): Promise<McpRegistrationGlobalLocation> =>
|
||||
location(join(context.home, ".fx", "mcp.json"));
|
||||
export const mastraLocation = async (
|
||||
context: McpRegistrationLocationContext,
|
||||
): Promise<McpRegistrationGlobalLocation> =>
|
||||
location(join(context.home, ".mastracode", "mcp.json"));
|
||||
export const ampLocation = async (
|
||||
context: McpRegistrationLocationContext,
|
||||
): Promise<McpRegistrationGlobalLocation> =>
|
||||
location(join(context.home, ".config", "amp", "settings.json"));
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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<DesktopBuildSetupFacts> = {}) => {
|
||||
},
|
||||
};
|
||||
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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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<ClientLocation, ClientPathFailure>;
|
||||
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<ClientLocation, ClientPathFailure>;
|
||||
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<ClientDefinitionBase, "registrationProfile"> &
|
||||
ClientDefinitionLocation & {
|
||||
readonly registrationConnection: ClientRegistrationConnectionProfile;
|
||||
};
|
||||
|
||||
/** Bind native identity once while retaining the original definition's literal name. */
|
||||
const defineClient = <const Definition extends ClientDefinitionInput>(
|
||||
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 ?? "",
|
||||
|
||||
+44
-6
@@ -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<Node> => {
|
||||
@@ -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<Node>(),
|
||||
): 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;
|
||||
};
|
||||
@@ -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>,
|
||||
): 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);
|
||||
},
|
||||
};
|
||||
};
|
||||
+17
-340
@@ -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<void> => {
|
||||
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<string, unknown> | 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<string, unknown>;
|
||||
} 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<string, unknown>)["rea"]
|
||||
: undefined;
|
||||
if (typeof entry === "object" && entry !== null)
|
||||
priorEnv = (entry as Record<string, unknown>)["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<void> => {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>)["rea"];
|
||||
if (
|
||||
priorEnv !== undefined &&
|
||||
typeof entry === "object" &&
|
||||
entry !== null &&
|
||||
(entry as Record<string, unknown>)["env"] === undefined
|
||||
) {
|
||||
(entry as Record<string, unknown>)["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)
|
||||
|
||||
@@ -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(" ");
|
||||
@@ -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<typeof Completions.complete>[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,
|
||||
|
||||
@@ -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<Parameters<CliInstance["serve"]>[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) =>
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
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 { 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<void> => {
|
||||
const resources = retainedResources;
|
||||
if (resources === undefined) return;
|
||||
await resources.close();
|
||||
if (retainedResources === resources) retainedResources = undefined;
|
||||
};
|
||||
|
||||
type ServeOptions = NonNullable<Parameters<CliInstance["serve"]>[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): void;
|
||||
validation(
|
||||
message: string,
|
||||
field: string,
|
||||
error?: AnalysisError,
|
||||
code?: RegistrationInputIssue["code"],
|
||||
): void;
|
||||
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<NodeJS.ProcessEnv>,
|
||||
defaultCommand: Result<
|
||||
ClientRegistrationCommand,
|
||||
ClientRegistrationCommandIssue
|
||||
>,
|
||||
options: ServeOptions,
|
||||
): Promise<void> | 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 ||
|
||||
serveRegistrationDiscovery(split.flags, presentation)
|
||||
)
|
||||
return Promise.resolve();
|
||||
const request = readRegistrationRequest(
|
||||
split.command.slice(offset + 2),
|
||||
defaultCommand,
|
||||
);
|
||||
if (!request.ok) {
|
||||
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 document = (value: unknown) => {
|
||||
write(
|
||||
renderCliDocument(value, output.format, process.stdout.isTTY === true),
|
||||
);
|
||||
};
|
||||
return {
|
||||
write,
|
||||
document,
|
||||
fail,
|
||||
validation(message, field, error, code = "invalid_value") {
|
||||
const retained = redactExplicitText(
|
||||
message,
|
||||
declaredSensitiveValues(command),
|
||||
);
|
||||
document({
|
||||
code: "VALIDATION_ERROR",
|
||||
message: retained,
|
||||
...(error === undefined ? {} : { error: projectAnalysisError(error) }),
|
||||
fieldErrors: [
|
||||
{
|
||||
code,
|
||||
missing: false,
|
||||
path: field,
|
||||
message: retained,
|
||||
},
|
||||
],
|
||||
});
|
||||
fail();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** Discovery never resolves configuration locations or acquires resource owners. */
|
||||
const serveRegistrationDiscovery = (
|
||||
flags: ReadonlySet<string>,
|
||||
presentation: RegistrationPresentation,
|
||||
): boolean => {
|
||||
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;
|
||||
presentation.document({
|
||||
inputSchema: z.toJSONSchema(mcpRegistrationOptionsSchema),
|
||||
outputSchema: z.toJSONSchema(mcpRegistrationReportSchema),
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const readRegistrationRequest = (
|
||||
arguments_: readonly string[],
|
||||
defaultCommand: Result<
|
||||
ClientRegistrationCommand,
|
||||
ClientRegistrationCommandIssue
|
||||
>,
|
||||
): Result<RegistrationRequest, RegistrationInputIssue> => {
|
||||
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<NodeJS.ProcessEnv>,
|
||||
signal: AbortSignal,
|
||||
presentation: RegistrationPresentation,
|
||||
): Promise<void> => {
|
||||
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) {
|
||||
presentation.validation(targets.error.message, "agent", targets.error);
|
||||
return undefined;
|
||||
}
|
||||
return registerMcpClients(
|
||||
targets.value,
|
||||
request.launcher,
|
||||
signal,
|
||||
resources,
|
||||
);
|
||||
})
|
||||
.catch((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,
|
||||
),
|
||||
},
|
||||
);
|
||||
presentation.validation(error.message, "registration", error);
|
||||
return undefined;
|
||||
});
|
||||
if (outcomes !== undefined)
|
||||
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 = (
|
||||
outcomes: readonly McpClientRegistrationOutcome[],
|
||||
launcher: ClientRegistrationCommand,
|
||||
presentation: RegistrationPresentation,
|
||||
): void => {
|
||||
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,
|
||||
};
|
||||
presentation.document(report);
|
||||
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();
|
||||
};
|
||||
@@ -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);
|
||||
+15
-50
@@ -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<void, McpAddArgumentError>;
|
||||
} => {
|
||||
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. */
|
||||
|
||||
@@ -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:[email protected]/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<typeof observationsSchema>;
|
||||
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);
|
||||
});
|
||||
@@ -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<typeof mcpRegistrationReportSchema>;
|
||||
@@ -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:[email protected]/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");
|
||||
},
|
||||
);
|
||||
@@ -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<ClientRegistrationCommand, ClientRegistrationCommandIssue> => {
|
||||
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<readonly string[], ClientRegistrationCommandIssue> => {
|
||||
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);
|
||||
};
|
||||
@@ -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", "[email protected]", "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 } });
|
||||
});
|
||||
@@ -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("");
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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,20 +77,23 @@ 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();
|
||||
|
||||
@@ -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" });
|
||||
},
|
||||
);
|
||||
@@ -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" }] },
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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", "[email protected]", "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<number>();
|
||||
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",
|
||||
},
|
||||
{},
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", "[email protected]", "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,
|
||||
})
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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", "[email protected]", "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" });
|
||||
|
||||
@@ -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"],
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<typeof import("node:fs/promises")>();
|
||||
@@ -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"],
|
||||
),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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<boolean>((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<void>((resolve) => {
|
||||
markStarted = resolve;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<NodeJS.ProcessEnv> => {
|
||||
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}`,
|
||||
};
|
||||
};
|
||||
Reference in new issue
Block a user