Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8adc2c6b1 | ||
|
|
7b90bc5a67 | ||
|
|
1f1bb95d53 | ||
|
|
3370bab50b | ||
|
|
69582df9c3 | ||
|
|
303008f35d | ||
|
|
55246a6425 | ||
|
|
4eb3b5335a | ||
|
|
fb3fa1dc5f | ||
|
|
ecd92970a4 | ||
|
|
ff34e7aeff | ||
|
|
53ba7e7b31 | ||
|
|
0cd9f47ff1 | ||
|
|
502e665212 | ||
|
|
b478a8b011 |
No files matched your search
@@ -66,6 +66,21 @@ copy's path and the restore failure.
|
|||||||
Setup also retains valid environment overrides in REA's existing server entry,
|
Setup also retains valid environment overrides in REA's existing server entry,
|
||||||
such as `REA_MCP_INPUT_SCHEMA_PROFILE` and `REA_MCP_MAX_RESPONSE_BYTES`.
|
such as `REA_MCP_INPUT_SCHEMA_PROFILE` and `REA_MCP_MAX_RESPONSE_BYTES`.
|
||||||
Detected provider settings replace only matching environment keys.
|
Detected provider settings replace only matching environment keys.
|
||||||
|
Other fields you added to that entry, such as OMP's `timeout` or Codex's
|
||||||
|
`tool_timeout_sec`, are kept. Setup rewrites only the launch keys (`command`,
|
||||||
|
`args`, `type` or `transport`, and the environment map) and REA's
|
||||||
|
client-specific keys. It removes remote-server keys such as `url`, `headers`,
|
||||||
|
`oauth_resource`, and `tcp` that contradict the local command, and an entry-level
|
||||||
|
`enabled: false`, ZCode's `enable: false`, or (outside Grok Build)
|
||||||
|
`disabled: true`, so the registration is active. A registration field that
|
||||||
|
does not parse, including a non-boolean `enabled` or `disabled` and a
|
||||||
|
malformed `startup_timeout_sec` or `tools`, is
|
||||||
|
removed too. A `tools` value is removed except on GitHub Copilot CLI, which
|
||||||
|
replaces it with `["*"]`; Codex rejects a `tools` sequence. An existing entry
|
||||||
|
without a `command` is a remote server and is replaced whole. DeepSeek Harness
|
||||||
|
keeps other fields on the existing Cordis server and replaces `serverName`,
|
||||||
|
`transport`, `command`, and `args`. A legacy OpenCode entry moved into the
|
||||||
|
native `mcp.servers` table keeps only its environment.
|
||||||
Setup and doctor report a malformed REA environment map or invalid runtime
|
Setup and doctor report a malformed REA environment map or invalid runtime
|
||||||
setting before setup writes files. Repair the named setting in the client
|
setting before setup writes files. Repair the named setting in the client
|
||||||
configuration; setup does not silently discard the rest of the map.
|
configuration; setup does not silently discard the rest of the map.
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ import {
|
|||||||
} from "jsonc-parser";
|
} from "jsonc-parser";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
|
||||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||||
|
import { registrationFieldAccepted } from "./ClientRegistrationShape.js";
|
||||||
import type { SetupClient } from "./SupportedClients.js";
|
import type { SetupClient } from "./SupportedClients.js";
|
||||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||||
|
|
||||||
@@ -1500,6 +1502,145 @@ export const clientRegistrationEntry = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launcher and transport keys REA owns in its entry. A key absent from REA's
|
||||||
|
* new entry is removed, so a stale `type: "http"` cannot outlive the stdio
|
||||||
|
* registration and an emptied environment map is not left behind.
|
||||||
|
*/
|
||||||
|
const REGISTRATION_LAUNCHER_KEYS: Readonly<Record<string, true>> = {
|
||||||
|
command: true,
|
||||||
|
args: true,
|
||||||
|
env: true,
|
||||||
|
environment: true,
|
||||||
|
type: true,
|
||||||
|
transport: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Remote-server keys that contradict the local command entry REA writes. */
|
||||||
|
const REGISTRATION_REMOTE_KEYS: Readonly<Record<string, true>> = {
|
||||||
|
url: true,
|
||||||
|
httpUrl: true,
|
||||||
|
serverUrl: true,
|
||||||
|
headers: true,
|
||||||
|
http_headers: true,
|
||||||
|
env_http_headers: true,
|
||||||
|
bearer_token_env_var: true,
|
||||||
|
oauth: true,
|
||||||
|
// Codex serializes `--oauth-resource` as `oauth_resource` and rejects it on
|
||||||
|
// a command entry (`oauth_resource is not supported for stdio`).
|
||||||
|
oauth_resource: true,
|
||||||
|
// Qwen treats `tcp` as a websocket endpoint and prefers it over `command`,
|
||||||
|
// so a command entry that still has `tcp` stays remote.
|
||||||
|
tcp: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Grok registration status ignores the entry `disabled` flag. */
|
||||||
|
const keepsGrokDisabledFlag = (dialect: ClientRegistrationDialect): boolean =>
|
||||||
|
dialect === "grok";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copilot rewrites `tools` as a string array. Other clients do not: Codex
|
||||||
|
* expects a map and rejects a sequence (`invalid type: sequence, expected a map`).
|
||||||
|
*/
|
||||||
|
const keepsCopilotTools = (dialect: ClientRegistrationDialect): boolean =>
|
||||||
|
dialect === "copilot_cli";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether setup replaces or removes an existing entry key. Setup registers an
|
||||||
|
* active server, so entry-level switches that turn it off are removed too,
|
||||||
|
* including ZCode's native `enable: false`. A registration-status field that
|
||||||
|
* does not parse is removed as well, using the same schemas doctor uses, so
|
||||||
|
* setup cannot report success for a value doctor will reject. Grok Build's
|
||||||
|
* boolean entry `disabled` is kept.
|
||||||
|
*/
|
||||||
|
const registrationManagedKey = (
|
||||||
|
dialect: ClientRegistrationDialect,
|
||||||
|
key: string,
|
||||||
|
value: unknown,
|
||||||
|
): boolean =>
|
||||||
|
Object.hasOwn(REGISTRATION_LAUNCHER_KEYS, key) ||
|
||||||
|
Object.hasOwn(REGISTRATION_REMOTE_KEYS, key) ||
|
||||||
|
!registrationFieldAccepted(dialect, key, value) ||
|
||||||
|
(key === "tools" && dialect !== "toml" && !keepsCopilotTools(dialect)) ||
|
||||||
|
(key === "enabled" && value === false) ||
|
||||||
|
(key === "enable" && value === false && dialect === "zcode") ||
|
||||||
|
(key === "disabled" && value === true && !keepsGrokDisabledFlag(dialect));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply REA's new entry over an existing local command entry. REA's keys take
|
||||||
|
* their new values in place, and every other key the user added (for example
|
||||||
|
* OMP `timeout` or Codex `tool_timeout_sec`) is kept. An existing entry without
|
||||||
|
* a `command` is a remote server, which REA replaces whole.
|
||||||
|
*/
|
||||||
|
export const retainClientRegistrationFields = (
|
||||||
|
dialect: ClientRegistrationDialect,
|
||||||
|
existing: unknown,
|
||||||
|
registration: Readonly<Record<string, unknown>>,
|
||||||
|
): Record<string, unknown> => {
|
||||||
|
const current = objectSchema.safeParse(existing);
|
||||||
|
if (!current.success || !Object.hasOwn(current.data, "command"))
|
||||||
|
return { ...registration };
|
||||||
|
const retained = Object.fromEntries(
|
||||||
|
Object.entries(current.data).flatMap(([key, value]): [string, unknown][] =>
|
||||||
|
Object.hasOwn(registration, key)
|
||||||
|
? [[key, registration[key]]]
|
||||||
|
: registrationManagedKey(dialect, key, value)
|
||||||
|
? []
|
||||||
|
: [[key, value]],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return { ...retained, ...registration };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Codex and Grok Build always receive REA's startup timeout. */
|
||||||
|
export const applyRegistrationStartupTimeout = (
|
||||||
|
clientName: string,
|
||||||
|
registration: Readonly<Record<string, unknown>>,
|
||||||
|
): Record<string, unknown> =>
|
||||||
|
clientName === "codex" || clientName === "grok_build"
|
||||||
|
? {
|
||||||
|
...registration,
|
||||||
|
startup_timeout_sec: MCP_STARTUP_POLICY.codexStartupTimeoutSeconds,
|
||||||
|
}
|
||||||
|
: { ...registration };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approval-plan text for the retention rules above. Rewritten keys are the
|
||||||
|
* ones the fresh entry sets beyond its launcher, so the sentence cannot name
|
||||||
|
* a field the writer does not replace.
|
||||||
|
*/
|
||||||
|
export const describeClientRegistrationRetention = (
|
||||||
|
clientName: string,
|
||||||
|
dialect: ClientRegistrationDialect,
|
||||||
|
): string => {
|
||||||
|
const replaced = Object.entries(
|
||||||
|
applyRegistrationStartupTimeout(
|
||||||
|
clientName,
|
||||||
|
clientRegistrationEntry(dialect, ["rea", "mcp"], {}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.filter(([key]) => !Object.hasOwn(REGISTRATION_LAUNCHER_KEYS, key))
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.map(([key, value]) => `${key} with ${JSON.stringify(value)}`);
|
||||||
|
const removed = keepsGrokDisabledFlag(dialect)
|
||||||
|
? "Remote-server keys, an enabled: false switch, and registration fields that do not parse are removed. A boolean disabled value is kept."
|
||||||
|
: "Remote-server keys, switches that turn the server off, and registration fields that do not parse are removed.";
|
||||||
|
const tools = keepsCopilotTools(dialect)
|
||||||
|
? ""
|
||||||
|
: dialect === "toml"
|
||||||
|
? " Invalid Codex tools values are removed; valid per-tool approval maps are kept."
|
||||||
|
: " A tools value is removed.";
|
||||||
|
const replacement =
|
||||||
|
replaced.length === 0
|
||||||
|
? `${removed}${tools}`
|
||||||
|
: `Setup replaces ${replaced.join(" and ")}. ${removed}${tools}`;
|
||||||
|
const legacy =
|
||||||
|
dialect === "opencode_v2"
|
||||||
|
? " A legacy OpenCode entry moved into the native server table keeps only its environment."
|
||||||
|
: "";
|
||||||
|
return `On an existing local command entry, other user fields are kept. ${replacement} When that entry is remote, its other fields are replaced.${legacy}`;
|
||||||
|
};
|
||||||
|
|
||||||
/** Parse one Gemini policy at its source boundary before consolidating scopes. */
|
/** Parse one Gemini policy at its source boundary before consolidating scopes. */
|
||||||
export const parseGeminiMcpPolicy = (parsed: ClientConfigurationDocument) =>
|
export const parseGeminiMcpPolicy = (parsed: ClientConfigurationDocument) =>
|
||||||
z
|
z
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import type { ClientRegistrationDialect } from "./ClientConfigurationDocument.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registration-status fields doctor parses on every client. User fields
|
||||||
|
* outside this schema, such as OMP `timeout`, are not registration-status
|
||||||
|
* fields. Setup accepts a retained value only when this schema does.
|
||||||
|
*/
|
||||||
|
export const registrationSchema = z
|
||||||
|
.object({
|
||||||
|
command: z.string().min(1),
|
||||||
|
args: z.array(z.string()).default([]),
|
||||||
|
startup_timeout_sec: z.number().positive().optional(),
|
||||||
|
type: z.string().optional(),
|
||||||
|
transport: z.string().optional(),
|
||||||
|
serverName: z.string().optional(),
|
||||||
|
tools: z.array(z.string()).optional(),
|
||||||
|
disabled: z.boolean().optional(),
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
const codexRegistrationSchema = registrationSchema.extend({
|
||||||
|
tool_timeout_sec: z.number().optional(),
|
||||||
|
tools: z
|
||||||
|
.record(
|
||||||
|
z.string(),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
approval_mode: z
|
||||||
|
.enum(["auto", "prompt", "writes", "approve"])
|
||||||
|
.optional(),
|
||||||
|
output_token_limit: z.number().int().positive().optional(),
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
required: z.boolean().optional(),
|
||||||
|
cwd: z.string().optional(),
|
||||||
|
env_vars: z.array(z.string()).optional(),
|
||||||
|
enabled_tools: z.array(z.string()).optional(),
|
||||||
|
disabled_tools: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const zcodeRegistrationSchema = registrationSchema.extend({
|
||||||
|
enable: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Client-specific parsing is shared by retention and registration diagnostics. */
|
||||||
|
export const registrationSchemaFor = (dialect: ClientRegistrationDialect) =>
|
||||||
|
dialect === "toml"
|
||||||
|
? codexRegistrationSchema
|
||||||
|
: dialect === "zcode"
|
||||||
|
? zcodeRegistrationSchema
|
||||||
|
: registrationSchema;
|
||||||
|
|
||||||
|
/** Whether a retained field satisfies the selected client's known contract. */
|
||||||
|
export const registrationFieldAccepted = (
|
||||||
|
dialect: ClientRegistrationDialect,
|
||||||
|
key: string,
|
||||||
|
value: unknown,
|
||||||
|
): boolean => {
|
||||||
|
const shape: Readonly<Record<string, z.ZodType>> =
|
||||||
|
registrationSchemaFor(dialect).shape;
|
||||||
|
const field = Object.hasOwn(shape, key) ? shape[key] : undefined;
|
||||||
|
return field === undefined || field.safeParse(value).success;
|
||||||
|
};
|
||||||
@@ -14,6 +14,10 @@ import { resolve } from "node:path";
|
|||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import {
|
||||||
|
registrationSchema,
|
||||||
|
registrationSchemaFor,
|
||||||
|
} from "./ClientRegistrationShape.js";
|
||||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||||
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
|
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
|
||||||
import {
|
import {
|
||||||
@@ -61,20 +65,6 @@ export type UnhealthyClientRegistrationStatus = Exclude<
|
|||||||
{ readonly state: "aligned" }
|
{ readonly state: "aligned" }
|
||||||
>;
|
>;
|
||||||
|
|
||||||
const registrationSchema = z
|
|
||||||
.object({
|
|
||||||
command: z.string().min(1),
|
|
||||||
args: z.array(z.string()).default([]),
|
|
||||||
startup_timeout_sec: z.number().positive().optional(),
|
|
||||||
type: z.string().optional(),
|
|
||||||
transport: z.string().optional(),
|
|
||||||
serverName: z.string().optional(),
|
|
||||||
tools: z.array(z.string()).optional(),
|
|
||||||
disabled: z.boolean().optional(),
|
|
||||||
enabled: z.boolean().optional(),
|
|
||||||
})
|
|
||||||
.passthrough();
|
|
||||||
|
|
||||||
interface ClientRegistrationReadOptions {
|
interface ClientRegistrationReadOptions {
|
||||||
readonly platform?: NodeJS.Platform;
|
readonly platform?: NodeJS.Platform;
|
||||||
readonly environment?: NodeJS.ProcessEnv;
|
readonly environment?: NodeJS.ProcessEnv;
|
||||||
@@ -330,7 +320,9 @@ const parseRegistration = (
|
|||||||
args,
|
args,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const registration = registrationSchema.parse(value);
|
const registration = registrationSchemaFor(client.format ?? "json").parse(
|
||||||
|
value,
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
(client.format === "vscode" || client.format === "copilot_cli") &&
|
(client.format === "vscode" || client.format === "copilot_cli") &&
|
||||||
registration.type !== "stdio"
|
registration.type !== "stdio"
|
||||||
|
|||||||
@@ -404,6 +404,18 @@ export const serializeDshDocument = (
|
|||||||
throw new ConfigurationError("Missing Cordis edit plan");
|
throw new ConfigurationError("Missing Cordis edit plan");
|
||||||
return document.cordis.source.serialize(document.cordis.desired);
|
return document.cordis.source.serialize(document.cordis.desired);
|
||||||
};
|
};
|
||||||
|
/** Approval-plan text for the launcher keys `DshCordisDocument` replaces. */
|
||||||
|
export const describeDshRegistrationRetention = (
|
||||||
|
command: readonly string[],
|
||||||
|
): string => {
|
||||||
|
const entry = dshClientRegistrationEntry(command);
|
||||||
|
const replaced = [...LAUNCHER_KEYS]
|
||||||
|
.filter((key) => Object.hasOwn(entry, key))
|
||||||
|
.sort((left, right) => left.localeCompare(right))
|
||||||
|
.map((key) => `${key} with ${JSON.stringify(entry[key])}`);
|
||||||
|
return `Other fields on the existing Cordis server are kept, except the row-level disabled flag is removed to activate the registration and name is set to ${JSON.stringify(DSH_CLIENT_PACKAGE)}. Setup replaces ${replaced.join(", ")}. Environment overrides are merged.`;
|
||||||
|
};
|
||||||
|
|
||||||
export const dshClientRegistrationEntry = (
|
export const dshClientRegistrationEntry = (
|
||||||
command: readonly string[],
|
command: readonly string[],
|
||||||
environment: Readonly<Record<string, string>> = {},
|
environment: Readonly<Record<string, string>> = {},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
|
||||||
import { runSetup } from "./Setup.js";
|
import { runSetup } from "./Setup.js";
|
||||||
import type { SetupProgressEvent } from "./SetupTypes.js";
|
import type { SetupProgressEvent } from "./SetupTypes.js";
|
||||||
import { FakeSetupHost, options } from "./Setup.fixture.js";
|
import { FakeSetupHost, options } from "./Setup.fixture.js";
|
||||||
@@ -152,6 +153,15 @@ describe("setup workflow", () => {
|
|||||||
expect(result.plannedActions.map(({ id }) => id)).toEqual([
|
expect(result.plannedActions.map(({ id }) => id)).toEqual([
|
||||||
"configure_client:codex",
|
"configure_client:codex",
|
||||||
]);
|
]);
|
||||||
|
expect(result.plannedActions[0]?.detail).toContain(
|
||||||
|
`startup_timeout_sec with ${JSON.stringify(MCP_STARTUP_POLICY.codexStartupTimeoutSeconds)}`,
|
||||||
|
);
|
||||||
|
expect(result.plannedActions[0]?.detail).toContain(
|
||||||
|
"registration fields that do not parse are removed",
|
||||||
|
);
|
||||||
|
expect(result.plannedActions[0]?.detail).toContain(
|
||||||
|
"Invalid Codex tools values are removed; valid per-tool approval maps are kept.",
|
||||||
|
);
|
||||||
expect(host.hopperInstalls).toBe(0);
|
expect(host.hopperInstalls).toBe(0);
|
||||||
expect(host.configurations).toBe(0);
|
expect(host.configurations).toBe(0);
|
||||||
});
|
});
|
||||||
@@ -185,6 +195,116 @@ describe("setup workflow", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("setup approval disclosure", () => {
|
||||||
|
it("keeps Grok Build's entry disabled flag in the approval plan", async () => {
|
||||||
|
const host = new FakeSetupHost();
|
||||||
|
host.hopper = "/Applications/Hopper";
|
||||||
|
host.skill = "unchanged";
|
||||||
|
host.clients = [
|
||||||
|
{
|
||||||
|
name: "grok_build",
|
||||||
|
displayName: "Grok Build",
|
||||||
|
configPath: "/grok.toml",
|
||||||
|
format: "grok",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await runSetup(
|
||||||
|
{ ...options(false), dryRun: true, clientIds: ["grok_build"] },
|
||||||
|
host,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.plannedActions[0]?.detail).toContain(
|
||||||
|
`startup_timeout_sec with ${JSON.stringify(MCP_STARTUP_POLICY.codexStartupTimeoutSeconds)}`,
|
||||||
|
);
|
||||||
|
expect(result.plannedActions[0]?.detail).toContain(
|
||||||
|
"A boolean disabled value is kept.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discloses that Copilot tool selection is replaced", async () => {
|
||||||
|
const host = new FakeSetupHost();
|
||||||
|
host.hopper = "/Applications/Hopper";
|
||||||
|
host.skill = "unchanged";
|
||||||
|
host.clients = [
|
||||||
|
{
|
||||||
|
name: "copilot_cli",
|
||||||
|
displayName: "GitHub Copilot CLI",
|
||||||
|
configPath: "/copilot.json",
|
||||||
|
format: "copilot_cli",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await runSetup(
|
||||||
|
{ ...options(false), dryRun: true, clientIds: ["copilot_cli"] },
|
||||||
|
host,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.plannedActions[0]?.detail).toContain(
|
||||||
|
`tools with ${JSON.stringify(["*"])}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discloses that a legacy OpenCode entry keeps only its environment", async () => {
|
||||||
|
const host = new FakeSetupHost();
|
||||||
|
host.hopper = "/Applications/Hopper";
|
||||||
|
host.skill = "unchanged";
|
||||||
|
host.clients = [
|
||||||
|
{
|
||||||
|
name: "opencode",
|
||||||
|
displayName: "OpenCode",
|
||||||
|
configPath: "/opencode.json",
|
||||||
|
format: "opencode",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
host.clientInspections.set("opencode", {
|
||||||
|
status: "update",
|
||||||
|
dialect: "opencode_v2",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runSetup(
|
||||||
|
{ ...options(false), dryRun: true, clientIds: ["opencode"] },
|
||||||
|
host,
|
||||||
|
);
|
||||||
|
|
||||||
|
const detail = result.plannedActions[0]?.detail ?? "";
|
||||||
|
expect(detail).toContain(
|
||||||
|
"A legacy OpenCode entry moved into the native server table keeps only its environment.",
|
||||||
|
);
|
||||||
|
expect(detail).not.toContain(`enabled with ${JSON.stringify(true)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("describes Cordis fields the Harness writer replaces", async () => {
|
||||||
|
const host = new FakeSetupHost();
|
||||||
|
host.hopper = "/Applications/Hopper";
|
||||||
|
host.skill = "unchanged";
|
||||||
|
host.clients = [
|
||||||
|
{
|
||||||
|
name: "dsh",
|
||||||
|
displayName: "DeepSeek Harness",
|
||||||
|
configPath: "/cordis.patch.yml",
|
||||||
|
format: "dsh",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await runSetup(
|
||||||
|
{ ...options(false), dryRun: true, clientIds: ["dsh"] },
|
||||||
|
host,
|
||||||
|
);
|
||||||
|
const detail = result.plannedActions[0]?.detail ?? "";
|
||||||
|
const [executable = "rea", ...args] = host.registrationCommand;
|
||||||
|
|
||||||
|
expect(detail).toContain(
|
||||||
|
'Other fields on the existing Cordis server are kept, except the row-level disabled flag is removed to activate the registration and name is set to "@deepseek-ai/dsh-mcp-client".',
|
||||||
|
);
|
||||||
|
expect(detail).toContain(`serverName with ${JSON.stringify("rea")}`);
|
||||||
|
expect(detail).toContain(`transport with ${JSON.stringify("stdio")}`);
|
||||||
|
expect(detail).toContain(`command with ${JSON.stringify(executable)}`);
|
||||||
|
expect(detail).toContain(`args with ${JSON.stringify(args)}`);
|
||||||
|
expect(detail).not.toContain("Remote-server keys");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("setup workflow lifecycle and provider planning", () => {
|
describe("setup workflow lifecycle and provider planning", () => {
|
||||||
it("reports append-only lifecycle progress for each selected action", async () => {
|
it("reports append-only lifecycle progress for each selected action", async () => {
|
||||||
const host = new FakeSetupHost();
|
const host = new FakeSetupHost();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from "./ClientConfigurationFiles.js";
|
} from "./ClientConfigurationFiles.js";
|
||||||
import { err, ok, type Result } from "../domain/result.js";
|
import { err, ok, type Result } from "../domain/result.js";
|
||||||
import {
|
import {
|
||||||
|
applyRegistrationStartupTimeout,
|
||||||
clientRegistrationEntry,
|
clientRegistrationEntry,
|
||||||
clientConfigurationValuesEqual,
|
clientConfigurationValuesEqual,
|
||||||
clientServerListedDisabled,
|
clientServerListedDisabled,
|
||||||
@@ -13,9 +14,11 @@ import {
|
|||||||
OMP_DISABLED_SERVERS_KEY,
|
OMP_DISABLED_SERVERS_KEY,
|
||||||
parseClientConfiguration,
|
parseClientConfiguration,
|
||||||
parseZcodeFallbackConfiguration,
|
parseZcodeFallbackConfiguration,
|
||||||
|
retainClientRegistrationFields,
|
||||||
serializeClientConfiguration,
|
serializeClientConfiguration,
|
||||||
withClientServers,
|
withClientServers,
|
||||||
type ClientConfigurationDocument,
|
type ClientConfigurationDocument,
|
||||||
|
type ClientRegistrationDialect,
|
||||||
} from "./ClientConfigurationDocument.js";
|
} from "./ClientConfigurationDocument.js";
|
||||||
import { dshDocument, DshCordisDocument } from "./DshCordisPatch.js";
|
import { dshDocument, DshCordisDocument } from "./DshCordisPatch.js";
|
||||||
import { constants as fsConstants } from "node:fs";
|
import { constants as fsConstants } from "node:fs";
|
||||||
@@ -27,7 +30,6 @@ import { clientRegistrationEnvironment } from "./ClientRegistrationEnvironment.j
|
|||||||
|
|
||||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||||
import { npxRegistrationCommand } from "./ClientRegistrationIdentity.js";
|
import { npxRegistrationCommand } from "./ClientRegistrationIdentity.js";
|
||||||
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
|
|
||||||
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
|
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
|
||||||
import { packageReadTargetChanged } from "./MsixPackageStorage.js";
|
import { packageReadTargetChanged } from "./MsixPackageStorage.js";
|
||||||
import {
|
import {
|
||||||
@@ -444,7 +446,7 @@ const inspectClientDocument = async (
|
|||||||
throwCleanupFailure(fallback.error);
|
throwCleanupFailure(fallback.error);
|
||||||
return { status: "invalid", remediation: fallback.error.detail };
|
return { status: "invalid", remediation: fallback.error.detail };
|
||||||
}
|
}
|
||||||
return { status: "create" };
|
return { status: "create", dialect: clientRegistrationDialect(client) };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
status: "invalid",
|
status: "invalid",
|
||||||
@@ -474,12 +476,26 @@ const inspectClientDocument = async (
|
|||||||
parsed,
|
parsed,
|
||||||
);
|
);
|
||||||
if (registrationCurrent(parsed, desired))
|
if (registrationCurrent(parsed, desired))
|
||||||
return { status: "already_current" };
|
return { status: "already_current", dialect: parsed.dialect };
|
||||||
const fallback = await readZcodeFallbackServers(client, parsed);
|
const fallback = await readZcodeFallbackServers(client, parsed);
|
||||||
if (!fallback.ok) {
|
if (!fallback.ok) {
|
||||||
throwCleanupFailure(fallback.error);
|
throwCleanupFailure(fallback.error);
|
||||||
return { status: "invalid", remediation: fallback.error.detail };
|
return { status: "invalid", remediation: fallback.error.detail };
|
||||||
}
|
}
|
||||||
|
const backupSelection = await selectCurrentRecoveryBackup(
|
||||||
|
`${client.configPath}.rea.backup`,
|
||||||
|
original,
|
||||||
|
);
|
||||||
|
if (!backupSelection.ok)
|
||||||
|
return {
|
||||||
|
status: "invalid",
|
||||||
|
remediation: backupSelection.error.detail,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
status: "update",
|
||||||
|
backupPath: backupSelection.value.backupPath,
|
||||||
|
dialect: parsed.dialect,
|
||||||
|
};
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
if (cause instanceof RegularFileCleanupFailure) throw cause;
|
if (cause instanceof RegularFileCleanupFailure) throw cause;
|
||||||
// Malformed configuration is reported with the invalid remediation.
|
// Malformed configuration is reported with the invalid remediation.
|
||||||
@@ -490,21 +506,15 @@ const inspectClientDocument = async (
|
|||||||
"The existing configuration is malformed. Repair it before setup applies any other changes.",
|
"The existing configuration is malformed. Repair it before setup applies any other changes.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const backupSelection = await selectCurrentRecoveryBackup(
|
|
||||||
`${client.configPath}.rea.backup`,
|
|
||||||
original,
|
|
||||||
);
|
|
||||||
if (!backupSelection.ok)
|
|
||||||
return {
|
|
||||||
status: "invalid",
|
|
||||||
remediation: backupSelection.error.detail,
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
status: "update",
|
|
||||||
backupPath: backupSelection.value.backupPath,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clientRegistrationDialect = (
|
||||||
|
client: SetupClient,
|
||||||
|
): ClientRegistrationDialect =>
|
||||||
|
client.format === undefined || client.format === "unsupported"
|
||||||
|
? "json"
|
||||||
|
: client.format;
|
||||||
|
|
||||||
const isMissing = (cause: unknown): boolean =>
|
const isMissing = (cause: unknown): boolean =>
|
||||||
cause instanceof Error && "code" in cause && cause.code === "ENOENT";
|
cause instanceof Error && "code" in cause && cause.code === "ENOENT";
|
||||||
|
|
||||||
@@ -699,17 +709,17 @@ const clientConfigurationDesired = (
|
|||||||
providerEnvironment,
|
providerEnvironment,
|
||||||
);
|
);
|
||||||
if (!environment.ok) throw environment.error;
|
if (!environment.ok) throw environment.error;
|
||||||
const registration = clientRegistrationEntry(
|
const registration = applyRegistrationStartupTimeout(
|
||||||
format,
|
client.name,
|
||||||
launcher,
|
clientRegistrationEntry(format, launcher, environment.value),
|
||||||
environment.value,
|
);
|
||||||
|
// Keep fields the user added to REA's own entry, such as OMP `timeout`. A
|
||||||
|
// legacy OpenCode V1 entry migrating into a V2 table keeps only its
|
||||||
|
// environment, because its other fields belong to the V1 shape. The approval
|
||||||
|
// plan states that exception through describeClientRegistrationRetention.
|
||||||
|
return retainClientRegistrationFields(
|
||||||
|
format,
|
||||||
|
parsed?.servers[PRODUCT_IDENTITY.mcpServerKey],
|
||||||
|
registration,
|
||||||
);
|
);
|
||||||
return {
|
|
||||||
...registration,
|
|
||||||
...(client.name === "codex" || client.name === "grok_build"
|
|
||||||
? {
|
|
||||||
startup_timeout_sec: MCP_STARTUP_POLICY.codexStartupTimeoutSeconds,
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
describeClientRegistrationRetention,
|
||||||
|
type ClientRegistrationDialect,
|
||||||
|
} from "./ClientConfigurationDocument.js";
|
||||||
|
import { describeDshRegistrationRetention } from "./DshCordisPatch.js";
|
||||||
import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
|
import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
|
||||||
import {
|
import {
|
||||||
linuxHopperInstallDisclosure,
|
linuxHopperInstallDisclosure,
|
||||||
@@ -230,6 +235,9 @@ const plannedClientWrites = (
|
|||||||
inspection.backupPath !== undefined
|
inspection.backupPath !== undefined
|
||||||
? { backupPath: inspection.backupPath }
|
? { backupPath: inspection.backupPath }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(inspection.dialect === undefined
|
||||||
|
? {}
|
||||||
|
: { dialect: inspection.dialect }),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [],
|
: [],
|
||||||
@@ -247,6 +255,7 @@ const setupPlan = (input: {
|
|||||||
readonly client: SetupClient;
|
readonly client: SetupClient;
|
||||||
readonly operation: "create" | "update";
|
readonly operation: "create" | "update";
|
||||||
readonly backupPath?: string;
|
readonly backupPath?: string;
|
||||||
|
readonly dialect?: ClientRegistrationDialect;
|
||||||
}[];
|
}[];
|
||||||
readonly providerEnvironment: SetupProviderEnvironment;
|
readonly providerEnvironment: SetupProviderEnvironment;
|
||||||
readonly linuxPackageFamily?: LinuxPackageFamily;
|
readonly linuxPackageFamily?: LinuxPackageFamily;
|
||||||
@@ -284,13 +293,13 @@ const setupPlan = (input: {
|
|||||||
: []),
|
: []),
|
||||||
...input.clients
|
...input.clients
|
||||||
.filter(({ client }) => client.format !== "unsupported")
|
.filter(({ client }) => client.format !== "unsupported")
|
||||||
.map(({ client, operation, backupPath }): SetupAction => ({
|
.map(({ client, operation, backupPath, dialect }): SetupAction => ({
|
||||||
id: `configure_client:${client.name}`,
|
id: `configure_client:${client.name}`,
|
||||||
kind: "configure_client",
|
kind: "configure_client",
|
||||||
label: client.displayName ?? client.name,
|
label: client.displayName ?? client.name,
|
||||||
target: client.configPath,
|
target: client.configPath,
|
||||||
detail: clientConfigurationDetail(
|
detail: clientConfigurationDetail(
|
||||||
client,
|
{ ...client, ...(dialect === undefined ? {} : { dialect }) },
|
||||||
input.command,
|
input.command,
|
||||||
input.providerEnvironment,
|
input.providerEnvironment,
|
||||||
input.installHopper,
|
input.installHopper,
|
||||||
@@ -334,7 +343,7 @@ const majorVersion = (version: string): number =>
|
|||||||
Number.parseInt(version.split(".")[0] ?? "0", 10);
|
Number.parseInt(version.split(".")[0] ?? "0", 10);
|
||||||
|
|
||||||
const clientConfigurationDetail = (
|
const clientConfigurationDetail = (
|
||||||
client: SetupClient,
|
client: SetupClient & { readonly dialect?: ClientRegistrationDialect },
|
||||||
command: readonly string[],
|
command: readonly string[],
|
||||||
environment: SetupProviderEnvironment,
|
environment: SetupProviderEnvironment,
|
||||||
installHopper: boolean,
|
installHopper: boolean,
|
||||||
@@ -342,10 +351,19 @@ const clientConfigurationDetail = (
|
|||||||
const entries = Object.entries(environment)
|
const entries = Object.entries(environment)
|
||||||
.sort(([left], [right]) => compareUnicodeCodePoints(left, right))
|
.sort(([left], [right]) => compareUnicodeCodePoints(left, right))
|
||||||
.map(([name, value]) => `${name}=${value}`);
|
.map(([name, value]) => `${name}=${value}`);
|
||||||
|
const dialect =
|
||||||
|
client.dialect ??
|
||||||
|
(client.format === undefined || client.format === "unsupported"
|
||||||
|
? "json"
|
||||||
|
: client.format);
|
||||||
|
const retainedFields =
|
||||||
|
dialect === "dsh"
|
||||||
|
? describeDshRegistrationRetention(command)
|
||||||
|
: describeClientRegistrationRetention(client.name, dialect);
|
||||||
const environmentDetail =
|
const environmentDetail =
|
||||||
entries.length === 0
|
entries.length === 0
|
||||||
? " No additional provider environment variables. Existing server environment overrides are retained."
|
? ` No additional provider environment variables. Existing server environment overrides are retained. ${retainedFields}`
|
||||||
: ` Detected provider environment: ${entries.join(", ")}. Existing server environment overrides are retained; detected values replace matching keys.`;
|
: ` Detected provider environment: ${entries.join(", ")}. Existing server environment overrides are retained; detected values replace matching keys. ${retainedFields}`;
|
||||||
const hopperDetail = installHopper
|
const hopperDetail = installHopper
|
||||||
? environment.HOPPER_LAUNCHER_PATH === undefined
|
? environment.HOPPER_LAUNCHER_PATH === undefined
|
||||||
? " If this plan installs Hopper, REA will add HOPPER_LAUNCHER_PATH using the launcher path reported by that installation."
|
? " If this plan installs Hopper, REA will add HOPPER_LAUNCHER_PATH using the launcher path reported by that installation."
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { ClientRegistrationDialect } from "./ClientConfigurationDocument.js";
|
||||||
import type { DoctorReport, DoctorScope, runDoctor } from "./Doctor.js";
|
import type { DoctorReport, DoctorScope, runDoctor } from "./Doctor.js";
|
||||||
import type { LinuxDistribution } from "./LinuxHopper.js";
|
import type { LinuxDistribution } from "./LinuxHopper.js";
|
||||||
import type { SkillDestination, SkillInstallResult } from "./SetupSkill.js";
|
import type { SkillDestination, SkillInstallResult } from "./SetupSkill.js";
|
||||||
@@ -45,10 +46,16 @@ export type ClientConfigurationResult =
|
|||||||
|
|
||||||
/** Read-only state used to block unsafe setup plans before any mutation. */
|
/** Read-only state used to block unsafe setup plans before any mutation. */
|
||||||
export type ClientConfigurationInspection =
|
export type ClientConfigurationInspection =
|
||||||
| { readonly status: "already_current" }
|
| {
|
||||||
|
readonly status: "already_current";
|
||||||
|
/** Dialect the registration writer will use when this file is parsed. */
|
||||||
|
readonly dialect?: ClientRegistrationDialect;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
readonly status: "create" | "update";
|
readonly status: "create" | "update";
|
||||||
readonly backupPath?: string;
|
readonly backupPath?: string;
|
||||||
|
/** Dialect the registration writer will use for this file. */
|
||||||
|
readonly dialect?: ClientRegistrationDialect;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly status: "invalid";
|
readonly status: "invalid";
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ describe.each([
|
|||||||
expect(await inspectClientConfiguration(client, {}, command)).toEqual({
|
expect(await inspectClientConfiguration(client, {}, command)).toEqual({
|
||||||
status: "update",
|
status: "update",
|
||||||
backupPath: `${client.configPath}.rea.backup`,
|
backupPath: `${client.configPath}.rea.backup`,
|
||||||
|
dialect: client.format,
|
||||||
});
|
});
|
||||||
expect(await clientConfigurationAligned(client, {}, command)).toBe(false);
|
expect(await clientConfigurationAligned(client, {}, command)).toBe(false);
|
||||||
expect(await configureClientConfiguration(client, {}, command)).toEqual({
|
expect(await configureClientConfiguration(client, {}, command)).toEqual({
|
||||||
@@ -73,6 +74,7 @@ describe.each([
|
|||||||
expect(await clientConfigurationAligned(client, {}, command)).toBe(true);
|
expect(await clientConfigurationAligned(client, {}, command)).toBe(true);
|
||||||
expect(await inspectClientConfiguration(client, {}, command)).toEqual({
|
expect(await inspectClientConfiguration(client, {}, command)).toEqual({
|
||||||
status: "already_current",
|
status: "already_current",
|
||||||
|
dialect: client.format,
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
await readClientRegistrationStatuses(home, currentCommandPath, {
|
await readClientRegistrationStatuses(home, currentCommandPath, {
|
||||||
@@ -174,6 +176,7 @@ it.each(["", "\n \n", "\uFEFF"])(
|
|||||||
expect(await inspectClientConfiguration(client, {}, command)).toEqual({
|
expect(await inspectClientConfiguration(client, {}, command)).toEqual({
|
||||||
status: "update",
|
status: "update",
|
||||||
backupPath: `${client.configPath}.rea.backup`,
|
backupPath: `${client.configPath}.rea.backup`,
|
||||||
|
dialect: "json",
|
||||||
});
|
});
|
||||||
expect(await configureClientConfiguration(client, {}, command)).toEqual({
|
expect(await configureClientConfiguration(client, {}, command)).toEqual({
|
||||||
status: "configured",
|
status: "configured",
|
||||||
@@ -184,6 +187,7 @@ it.each(["", "\n \n", "\uFEFF"])(
|
|||||||
});
|
});
|
||||||
expect(await inspectClientConfiguration(client, {}, command)).toEqual({
|
expect(await inspectClientConfiguration(client, {}, command)).toEqual({
|
||||||
status: "already_current",
|
status: "already_current",
|
||||||
|
dialect: "json",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -68,7 +68,7 @@ it.each([
|
|||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
await inspectClientConfiguration(client, providers, command),
|
await inspectClientConfiguration(client, providers, command),
|
||||||
).toEqual({ status: "already_current" });
|
).toEqual({ status: "already_current", dialect: format });
|
||||||
expect(
|
expect(
|
||||||
await configureClientConfiguration(client, providers, command),
|
await configureClientConfiguration(client, providers, command),
|
||||||
).toEqual({ status: "unchanged" });
|
).toEqual({ status: "unchanged" });
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ it("previews and creates a current recovery backup while preserving the first ba
|
|||||||
expect(await inspectClientConfiguration(client, {}, ["rea", "mcp"])).toEqual({
|
expect(await inspectClientConfiguration(client, {}, ["rea", "mcp"])).toEqual({
|
||||||
status: "update",
|
status: "update",
|
||||||
backupPath: recoveryPath,
|
backupPath: recoveryPath,
|
||||||
|
dialect: "json",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
import { readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
clientRegistrationEntry,
|
||||||
|
parseClientConfiguration,
|
||||||
|
serializeClientConfiguration,
|
||||||
|
type ClientRegistrationDialect,
|
||||||
|
} from "../../../src/application/ClientConfigurationDocument.js";
|
||||||
|
import {
|
||||||
|
clientConfigurationAligned,
|
||||||
|
configureClientConfiguration,
|
||||||
|
inspectClientConfiguration,
|
||||||
|
} from "../../../src/application/SetupClientConfiguration.js";
|
||||||
|
import type { SetupClient } from "../../../src/application/SupportedClients.js";
|
||||||
|
import { MCP_STARTUP_POLICY } from "../../../src/mcpStartupPolicy.js";
|
||||||
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||||
|
|
||||||
|
const command = ["rea", "mcp"];
|
||||||
|
const environment = { REA_MCP_INPUT_SCHEMA_PROFILE: "compact" };
|
||||||
|
|
||||||
|
/** Wrap an entry in the server table each dialect reads. */
|
||||||
|
const configurationWith = (
|
||||||
|
dialect: ClientRegistrationDialect,
|
||||||
|
entry: unknown,
|
||||||
|
): Record<string, unknown> => {
|
||||||
|
switch (dialect) {
|
||||||
|
case "opencode":
|
||||||
|
return { mcp: { rea: entry } };
|
||||||
|
case "opencode_v2":
|
||||||
|
return { mcp: { servers: { rea: entry } } };
|
||||||
|
case "vscode":
|
||||||
|
return { servers: { rea: entry } };
|
||||||
|
case "toml":
|
||||||
|
case "grok":
|
||||||
|
case "hermes":
|
||||||
|
return { mcp_servers: { rea: entry } };
|
||||||
|
default:
|
||||||
|
return { mcpServers: { rea: entry } };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["cursor", "json", "json", { timeout: 600000, trust: true }],
|
||||||
|
["vscode", "vscode", "vscode", { envFile: ".env" }],
|
||||||
|
[
|
||||||
|
"omp",
|
||||||
|
"omp",
|
||||||
|
"omp",
|
||||||
|
{ timeout: 0, enabled: true, instructions: "Use for binaries." },
|
||||||
|
],
|
||||||
|
["pi", "pi", "pi", { timeout: 0 }],
|
||||||
|
["copilot_cli", "copilot_cli", "copilot_cli", { timeout: 120000 }],
|
||||||
|
["commandcode", "commandcode", "commandcode", { timeout: 300 }],
|
||||||
|
["opencode", "opencode", "opencode", { timeout: 600000 }],
|
||||||
|
["opencode", "opencode", "opencode_v2", { timeout: 600000 }],
|
||||||
|
[
|
||||||
|
"codex",
|
||||||
|
"toml",
|
||||||
|
"toml",
|
||||||
|
{
|
||||||
|
tool_timeout_sec: 900,
|
||||||
|
enabled_tools: ["analyze_function"],
|
||||||
|
tools: { binary_session: { approval_mode: "approve" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
["grok_build", "grok", "grok", { tool_timeout_sec: 900 }],
|
||||||
|
["hermes", "hermes", "hermes", { timeout: 600, connect_timeout: 60 }],
|
||||||
|
] as const)(
|
||||||
|
"keeps %s fields REA does not manage (%s, %s entry)",
|
||||||
|
async (name, format, dialect, extras) => {
|
||||||
|
const root = await createTestTempDirectory("rea-registration-fields-");
|
||||||
|
const configPath = join(root, "config");
|
||||||
|
const client: SetupClient = { name, format, configPath };
|
||||||
|
const existing = {
|
||||||
|
...clientRegistrationEntry(dialect, ["rea-old", "mcp"], environment),
|
||||||
|
...extras,
|
||||||
|
// Remote and disabling keys contradict the active stdio entry REA writes.
|
||||||
|
url: "https://stale.example/mcp",
|
||||||
|
...(Object.hasOwn(extras, "enabled") ? {} : { enabled: false }),
|
||||||
|
};
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
serializeClientConfiguration(
|
||||||
|
configurationWith(dialect, existing),
|
||||||
|
format,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(await inspectClientConfiguration(client, {}, command)).toMatchObject(
|
||||||
|
{ status: "update" },
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await configureClientConfiguration(client, {}, command),
|
||||||
|
).toMatchObject({ status: "configured" });
|
||||||
|
const configured = await readFile(configPath, "utf8");
|
||||||
|
expect(parseClientConfiguration(configured, format).servers.rea).toEqual({
|
||||||
|
...clientRegistrationEntry(dialect, command, environment),
|
||||||
|
...(name === "codex" || name === "grok_build"
|
||||||
|
? {
|
||||||
|
startup_timeout_sec: MCP_STARTUP_POLICY.codexStartupTimeoutSeconds,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...extras,
|
||||||
|
});
|
||||||
|
expect(await clientConfigurationAligned(client, {}, command)).toBe(true);
|
||||||
|
expect(await inspectClientConfiguration(client, {}, command)).toEqual({
|
||||||
|
status: "already_current",
|
||||||
|
dialect,
|
||||||
|
});
|
||||||
|
expect(await configureClientConfiguration(client, {}, command)).toEqual({
|
||||||
|
status: "unchanged",
|
||||||
|
});
|
||||||
|
expect(await readFile(configPath, "utf8")).toBe(configured);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("drops Codex oauth_resource from a local command entry", async () => {
|
||||||
|
const root = await createTestTempDirectory(
|
||||||
|
"rea-registration-oauth-resource-",
|
||||||
|
);
|
||||||
|
const configPath = join(root, "config.toml");
|
||||||
|
const client: SetupClient = { name: "codex", format: "toml", configPath };
|
||||||
|
const existing = {
|
||||||
|
...clientRegistrationEntry("toml", ["rea-old", "mcp"], environment),
|
||||||
|
tool_timeout_sec: 900,
|
||||||
|
oauth_resource: "https://stale.example/resource",
|
||||||
|
};
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
serializeClientConfiguration(configurationWith("toml", existing), "toml"),
|
||||||
|
);
|
||||||
|
expect(await configureClientConfiguration(client, {}, command)).toMatchObject(
|
||||||
|
{ status: "configured" },
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
parseClientConfiguration(await readFile(configPath, "utf8"), "toml").servers
|
||||||
|
.rea,
|
||||||
|
).toEqual({
|
||||||
|
...clientRegistrationEntry("toml", command, environment),
|
||||||
|
startup_timeout_sec: MCP_STARTUP_POLICY.codexStartupTimeoutSeconds,
|
||||||
|
tool_timeout_sec: 900,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a Codex tools array from a local command entry", async () => {
|
||||||
|
const root = await createTestTempDirectory("rea-registration-codex-tools-");
|
||||||
|
const configPath = join(root, "config.toml");
|
||||||
|
const client: SetupClient = { name: "codex", format: "toml", configPath };
|
||||||
|
const existing = {
|
||||||
|
...clientRegistrationEntry("toml", ["rea-old", "mcp"], environment),
|
||||||
|
tool_timeout_sec: 900,
|
||||||
|
tools: ["*"],
|
||||||
|
};
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
serializeClientConfiguration(configurationWith("toml", existing), "toml"),
|
||||||
|
);
|
||||||
|
expect(await configureClientConfiguration(client, {}, command)).toMatchObject(
|
||||||
|
{ status: "configured" },
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
parseClientConfiguration(await readFile(configPath, "utf8"), "toml").servers
|
||||||
|
.rea,
|
||||||
|
).toEqual({
|
||||||
|
...clientRegistrationEntry("toml", command, environment),
|
||||||
|
startup_timeout_sec: MCP_STARTUP_POLICY.codexStartupTimeoutSeconds,
|
||||||
|
tool_timeout_sec: 900,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops malformed registration fields from a local command entry", async () => {
|
||||||
|
const root = await createTestTempDirectory("rea-registration-schema-");
|
||||||
|
const configPath = join(root, "mcp.json");
|
||||||
|
const client: SetupClient = { name: "cursor", format: "json", configPath };
|
||||||
|
const existing = {
|
||||||
|
...clientRegistrationEntry("json", ["rea-old", "mcp"], environment),
|
||||||
|
timeout: 600000,
|
||||||
|
startup_timeout_sec: "30",
|
||||||
|
tools: "*",
|
||||||
|
};
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
serializeClientConfiguration(configurationWith("json", existing), "json"),
|
||||||
|
);
|
||||||
|
expect(await configureClientConfiguration(client, {}, command)).toMatchObject(
|
||||||
|
{ status: "configured" },
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
parseClientConfiguration(await readFile(configPath, "utf8"), "json").servers
|
||||||
|
.rea,
|
||||||
|
).toEqual({
|
||||||
|
...clientRegistrationEntry("json", command, environment),
|
||||||
|
timeout: 600000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops malformed activation switches from a local command entry", async () => {
|
||||||
|
const root = await createTestTempDirectory("rea-registration-switches-");
|
||||||
|
const configPath = join(root, "mcp.json");
|
||||||
|
const client: SetupClient = { name: "cursor", format: "json", configPath };
|
||||||
|
const existing = {
|
||||||
|
...clientRegistrationEntry("json", ["rea-old", "mcp"], environment),
|
||||||
|
timeout: 600000,
|
||||||
|
enabled: "false",
|
||||||
|
disabled: "true",
|
||||||
|
};
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
serializeClientConfiguration(configurationWith("json", existing), "json"),
|
||||||
|
);
|
||||||
|
expect(await configureClientConfiguration(client, {}, command)).toMatchObject(
|
||||||
|
{ status: "configured" },
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
parseClientConfiguration(await readFile(configPath, "utf8"), "json").servers
|
||||||
|
.rea,
|
||||||
|
).toEqual({
|
||||||
|
...clientRegistrationEntry("json", command, environment),
|
||||||
|
timeout: 600000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a Qwen tcp endpoint from a local command entry", async () => {
|
||||||
|
const root = await createTestTempDirectory("rea-registration-tcp-");
|
||||||
|
const configPath = join(root, "settings.json");
|
||||||
|
const client: SetupClient = {
|
||||||
|
name: "qwen_code",
|
||||||
|
format: "json",
|
||||||
|
configPath,
|
||||||
|
};
|
||||||
|
const existing = {
|
||||||
|
...clientRegistrationEntry("json", ["rea-old", "mcp"], environment),
|
||||||
|
timeout: 600000,
|
||||||
|
tcp: "ws://stale.example/mcp",
|
||||||
|
};
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
serializeClientConfiguration(configurationWith("json", existing), "json"),
|
||||||
|
);
|
||||||
|
expect(await configureClientConfiguration(client, {}, command)).toMatchObject(
|
||||||
|
{ status: "configured" },
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
parseClientConfiguration(await readFile(configPath, "utf8"), "json").servers
|
||||||
|
.rea,
|
||||||
|
).toEqual({
|
||||||
|
...clientRegistrationEntry("json", command, environment),
|
||||||
|
timeout: 600000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces a remote server entry instead of merging its fields", async () => {
|
||||||
|
const root = await createTestTempDirectory("rea-registration-remote-");
|
||||||
|
const configPath = join(root, "mcp.json");
|
||||||
|
const client: SetupClient = { name: "cursor", format: "json", configPath };
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
JSON.stringify({
|
||||||
|
mcpServers: {
|
||||||
|
rea: {
|
||||||
|
url: "https://stale.example/mcp",
|
||||||
|
headers: { Authorization: "Bearer stale" },
|
||||||
|
timeout: 5000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(await configureClientConfiguration(client, {}, command)).toMatchObject(
|
||||||
|
{ status: "configured" },
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
parseClientConfiguration(await readFile(configPath, "utf8"), "json").servers
|
||||||
|
.rea,
|
||||||
|
).toEqual(clientRegistrationEntry("json", command, {}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["tool_timeout_sec", "900"],
|
||||||
|
["enabled_tools", "analyze_function"],
|
||||||
|
["disabled_tools", [123]],
|
||||||
|
] as const)(
|
||||||
|
"repairs invalid Codex field %s before reporting alignment",
|
||||||
|
async (field, value) => {
|
||||||
|
const root = await createTestTempDirectory("rea-codex-field-repair-");
|
||||||
|
const configPath = join(root, "config.toml");
|
||||||
|
const client: SetupClient = { name: "codex", format: "toml", configPath };
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
serializeClientConfiguration(
|
||||||
|
{
|
||||||
|
mcp_servers: {
|
||||||
|
rea: {
|
||||||
|
command: "rea",
|
||||||
|
args: ["mcp"],
|
||||||
|
[field]: value,
|
||||||
|
timeout_note: "keep",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"toml",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await configureClientConfiguration(client, {}, command)).status,
|
||||||
|
).toBe("configured");
|
||||||
|
const entry = parseClientConfiguration(
|
||||||
|
await readFile(configPath, "utf8"),
|
||||||
|
"toml",
|
||||||
|
).servers.rea;
|
||||||
|
expect(entry).not.toHaveProperty(field);
|
||||||
|
expect(entry).toHaveProperty("timeout_note", "keep");
|
||||||
|
expect(await clientConfigurationAligned(client, {}, command)).toBe(true);
|
||||||
|
expect(
|
||||||
|
(await configureClientConfiguration(client, {}, command)).status,
|
||||||
|
).toBe("unchanged");
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -168,4 +168,49 @@ describe("OMP disabledServers denylist", () => {
|
|||||||
expect(await status()).toMatchObject({ state: "invalid" });
|
expect(await status()).toMatchObject({ state: "invalid" });
|
||||||
expect(join(home, ".omp", "agent", "mcp.json")).toBe(client.configPath);
|
expect(join(home, ".omp", "agent", "mcp.json")).toBe(client.configPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the entry's timeout through setup and reports it aligned", async () => {
|
||||||
|
const { home, client } = await ompClient();
|
||||||
|
const entry = resolve("scripts/rea.mjs");
|
||||||
|
const current = [process.execPath, entry, "mcp"];
|
||||||
|
const env = { REA_MCP_INPUT_SCHEMA_PROFILE: "compact" };
|
||||||
|
await writeFile(
|
||||||
|
client.configPath,
|
||||||
|
JSON.stringify({
|
||||||
|
mcpServers: {
|
||||||
|
rea: {
|
||||||
|
type: "stdio",
|
||||||
|
command: join(home, "old", "bun"),
|
||||||
|
args: [join(home, "old", "rea.mjs"), "mcp"],
|
||||||
|
env,
|
||||||
|
timeout: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await configureClientConfiguration(client, {}, current)).status,
|
||||||
|
).toBe("configured");
|
||||||
|
const configured = await readFile(client.configPath, "utf8");
|
||||||
|
expect(parseJsonc(configured)).toEqual({
|
||||||
|
mcpServers: {
|
||||||
|
rea: {
|
||||||
|
type: "stdio",
|
||||||
|
command: process.execPath,
|
||||||
|
args: [entry, "mcp"],
|
||||||
|
env,
|
||||||
|
timeout: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(await configureClientConfiguration(client, {}, current)).toEqual({
|
||||||
|
status: "unchanged",
|
||||||
|
});
|
||||||
|
expect(await readFile(client.configPath, "utf8")).toBe(configured);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await readClientRegistrationStatuses(home, entry, { environment: {} })
|
||||||
|
).find(({ client: name }) => name === "omp"),
|
||||||
|
).toMatchObject({ state: "aligned", remediation: null });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -281,7 +281,7 @@ describe("Pi strict JSON parsing", () => {
|
|||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
await inspectClientConfiguration(client, {}, NPX_REGISTRATION_COMMAND),
|
await inspectClientConfiguration(client, {}, NPX_REGISTRATION_COMMAND),
|
||||||
).toEqual({ status: "create" });
|
).toEqual({ status: "create", dialect: "pi" });
|
||||||
expect(
|
expect(
|
||||||
await configureClientConfiguration(client, {}, NPX_REGISTRATION_COMMAND),
|
await configureClientConfiguration(client, {}, NPX_REGISTRATION_COMMAND),
|
||||||
).toMatchObject({ status: "configured" });
|
).toMatchObject({ status: "configured" });
|
||||||
|
|||||||
Reference in new issue
Block a user