Compare commits

...
Author SHA1 Message Date
morluto a8adc2c6b1 fix(setup): retain Codex per-tool approval maps 2026-10-14 01:10:13 +08:00
REA Maintainers 7b90bc5a67 fix(setup): reconcile client plans and native registration fields 2026-10-13 22:55:53 +08:00
REA Maintainers 1f1bb95d53 fix(setup): align activation schemas and approval disclosure 2026-10-13 21:32:35 +08:00
morluto 3370bab50b style(setup): format retained field validation 2026-10-13 20:20:42 +08:00
morluto 69582df9c3 fix(setup): validate retained Codex registration fields 2026-10-13 20:10:19 +08:00
xeropresence 303008f35d fix(setup): drop ZCode's enable: false from retained entries
Main's ZCode registration treats a native enable: false as stale and expects setup to repair it. Entry retention kept that switch, so setup reported unchanged while doctor still reported stale.
2026-10-12 21:49:21 -04:00
Cursor Agentandxeropresence 55246a6425 fix(setup): match retained fields to the client that writes them
Codex drops a tools array that its stdio config rejects, and the Harness approval plan lists the Cordis launcher keys the writer replaces.
2026-10-12 21:37:33 -04:00
Cursor Agentandxeropresence 4eb3b5335a fix(setup): describe the inspected OpenCode registration dialect
The approval plan uses the dialect configuration inspection parsed, so a native OpenCode server table is not described as rewriting enabled.
2026-10-12 21:37:33 -04:00
Cursor Agentandxeropresence fb3fa1dc5f refactor(setup): accept retained fields from the registration schema
Setup keeps a registration field only when the schema doctor parses accepts it, and the OpenCode legacy disclosure follows that client's dialect.
2026-10-12 21:37:33 -04:00
Cursor Agentandxeropresence ecd92970a4 fix(setup): retain only registration fields doctor can parse
Setup kept malformed startup_timeout_sec and tools values that doctor
rejects, and the approval plan restated retention rules by client name.
Both now use the registration schema and the entry the writer builds.
2026-10-12 21:37:33 -04:00
Cursor Agentandxeropresence ff34e7aeff fix(setup): drop non-boolean activation switches from retained entries
A string such as enabled: "false" was kept and then accepted as the desired
entry, so setup could report success while doctor rejected the registration.
Those values are removed with the other managed switches.
2026-10-12 21:35:28 -04:00
Cursor Agentandxeropresence 53ba7e7b31 fix(setup): drop stale Qwen tcp endpoints and name rewritten entry keys
Qwen prefers a tcp websocket endpoint over command, so a command entry that
still has tcp stays remote. Setup now removes that key. The approval plan
names startup_timeout_sec and Copilot tools when setup replaces them.
2026-10-12 21:35:28 -04:00
Cursor Agentandxeropresence 0cd9f47ff1 fix(setup): disclose remote-key removal in the approval plan
A local command entry still drops stale remote-server keys and off switches.
The plan now says those fields are removed, and that Grok Build keeps its
entry disabled flag.
2026-10-12 21:35:28 -04:00
Cursor Agentandxeropresence 502e665212 fix(setup): drop stale Codex oauth_resource and disclose remote replacement
A command entry that still carries oauth_resource is rejected by Codex for
stdio. Setup now treats that key as remote and removes it. The approval plan
keeps other fields only for a local command entry and says a remote entry's
other fields are replaced.
2026-10-12 21:35:28 -04:00
xeropresence b478a8b011 fix(setup): preserve user fields on an existing REA client entry 2026-10-12 21:35:28 -04:00
15 changed files with 800 additions and 50 deletions
+15
View File
@@ -66,6 +66,21 @@ copy's path and the restore failure.
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`.
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
setting before setup writes files. Repair the named setting in the client
configuration; setup does not silently discard the rest of the map.
@@ -23,7 +23,9 @@ import {
} from "jsonc-parser";
import { z } from "zod";
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
import { PRODUCT_IDENTITY } from "../identity.js";
import { registrationFieldAccepted } from "./ClientRegistrationShape.js";
import type { SetupClient } from "./SupportedClients.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. */
export const parseGeminiMcpPolicy = (parsed: ClientConfigurationDocument) =>
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;
};
+7 -15
View File
@@ -14,6 +14,10 @@ import { resolve } from "node:path";
import { z } from "zod";
import {
registrationSchema,
registrationSchemaFor,
} from "./ClientRegistrationShape.js";
import { PRODUCT_IDENTITY } from "../identity.js";
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
import {
@@ -61,20 +65,6 @@ export type UnhealthyClientRegistrationStatus = Exclude<
{ 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 {
readonly platform?: NodeJS.Platform;
readonly environment?: NodeJS.ProcessEnv;
@@ -330,7 +320,9 @@ const parseRegistration = (
args,
});
}
const registration = registrationSchema.parse(value);
const registration = registrationSchemaFor(client.format ?? "json").parse(
value,
);
if (
(client.format === "vscode" || client.format === "copilot_cli") &&
registration.type !== "stdio"
+12
View File
@@ -404,6 +404,18 @@ export const serializeDshDocument = (
throw new ConfigurationError("Missing Cordis edit plan");
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 = (
command: readonly string[],
environment: Readonly<Record<string, string>> = {},
+120
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
import { runSetup } from "./Setup.js";
import type { SetupProgressEvent } from "./SetupTypes.js";
import { FakeSetupHost, options } from "./Setup.fixture.js";
@@ -152,6 +153,15 @@ describe("setup workflow", () => {
expect(result.plannedActions.map(({ id }) => id)).toEqual([
"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.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", () => {
it("reports append-only lifecycle progress for each selected action", async () => {
const host = new FakeSetupHost();
+37 -27
View File
@@ -5,6 +5,7 @@ import {
} from "./ClientConfigurationFiles.js";
import { err, ok, type Result } from "../domain/result.js";
import {
applyRegistrationStartupTimeout,
clientRegistrationEntry,
clientConfigurationValuesEqual,
clientServerListedDisabled,
@@ -13,9 +14,11 @@ import {
OMP_DISABLED_SERVERS_KEY,
parseClientConfiguration,
parseZcodeFallbackConfiguration,
retainClientRegistrationFields,
serializeClientConfiguration,
withClientServers,
type ClientConfigurationDocument,
type ClientRegistrationDialect,
} from "./ClientConfigurationDocument.js";
import { dshDocument, DshCordisDocument } from "./DshCordisPatch.js";
import { constants as fsConstants } from "node:fs";
@@ -27,7 +30,6 @@ import { clientRegistrationEnvironment } from "./ClientRegistrationEnvironment.j
import { PRODUCT_IDENTITY } from "../identity.js";
import { npxRegistrationCommand } from "./ClientRegistrationIdentity.js";
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
import { packageReadTargetChanged } from "./MsixPackageStorage.js";
import {
@@ -444,7 +446,7 @@ const inspectClientDocument = async (
throwCleanupFailure(fallback.error);
return { status: "invalid", remediation: fallback.error.detail };
}
return { status: "create" };
return { status: "create", dialect: clientRegistrationDialect(client) };
}
return {
status: "invalid",
@@ -474,12 +476,26 @@ const inspectClientDocument = async (
parsed,
);
if (registrationCurrent(parsed, desired))
return { status: "already_current" };
return { status: "already_current", dialect: parsed.dialect };
const fallback = await readZcodeFallbackServers(client, parsed);
if (!fallback.ok) {
throwCleanupFailure(fallback.error);
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) {
if (cause instanceof RegularFileCleanupFailure) throw cause;
// 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.",
};
}
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 =>
cause instanceof Error && "code" in cause && cause.code === "ENOENT";
@@ -699,17 +709,17 @@ const clientConfigurationDesired = (
providerEnvironment,
);
if (!environment.ok) throw environment.error;
const registration = clientRegistrationEntry(
const registration = applyRegistrationStartupTimeout(
client.name,
clientRegistrationEntry(format, launcher, 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,
launcher,
environment.value,
parsed?.servers[PRODUCT_IDENTITY.mcpServerKey],
registration,
);
return {
...registration,
...(client.name === "codex" || client.name === "grok_build"
? {
startup_timeout_sec: MCP_STARTUP_POLICY.codexStartupTimeoutSeconds,
}
: {}),
};
};
+23 -5
View File
@@ -1,5 +1,10 @@
import { join } from "node:path";
import {
describeClientRegistrationRetention,
type ClientRegistrationDialect,
} from "./ClientConfigurationDocument.js";
import { describeDshRegistrationRetention } from "./DshCordisPatch.js";
import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
import {
linuxHopperInstallDisclosure,
@@ -230,6 +235,9 @@ const plannedClientWrites = (
inspection.backupPath !== undefined
? { backupPath: inspection.backupPath }
: {}),
...(inspection.dialect === undefined
? {}
: { dialect: inspection.dialect }),
},
]
: [],
@@ -247,6 +255,7 @@ const setupPlan = (input: {
readonly client: SetupClient;
readonly operation: "create" | "update";
readonly backupPath?: string;
readonly dialect?: ClientRegistrationDialect;
}[];
readonly providerEnvironment: SetupProviderEnvironment;
readonly linuxPackageFamily?: LinuxPackageFamily;
@@ -284,13 +293,13 @@ const setupPlan = (input: {
: []),
...input.clients
.filter(({ client }) => client.format !== "unsupported")
.map(({ client, operation, backupPath }): SetupAction => ({
.map(({ client, operation, backupPath, dialect }): SetupAction => ({
id: `configure_client:${client.name}`,
kind: "configure_client",
label: client.displayName ?? client.name,
target: client.configPath,
detail: clientConfigurationDetail(
client,
{ ...client, ...(dialect === undefined ? {} : { dialect }) },
input.command,
input.providerEnvironment,
input.installHopper,
@@ -334,7 +343,7 @@ const majorVersion = (version: string): number =>
Number.parseInt(version.split(".")[0] ?? "0", 10);
const clientConfigurationDetail = (
client: SetupClient,
client: SetupClient & { readonly dialect?: ClientRegistrationDialect },
command: readonly string[],
environment: SetupProviderEnvironment,
installHopper: boolean,
@@ -342,10 +351,19 @@ const clientConfigurationDetail = (
const entries = Object.entries(environment)
.sort(([left], [right]) => compareUnicodeCodePoints(left, right))
.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 =
entries.length === 0
? " No additional provider environment variables. Existing server environment overrides are retained."
: ` Detected provider environment: ${entries.join(", ")}. Existing server environment overrides are retained; detected values replace matching keys.`;
? ` 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. ${retainedFields}`;
const hopperDetail = installHopper
? environment.HOPPER_LAUNCHER_PATH === undefined
? " If this plan installs Hopper, REA will add HOPPER_LAUNCHER_PATH using the launcher path reported by that installation."
+8 -1
View File
@@ -1,3 +1,4 @@
import type { ClientRegistrationDialect } from "./ClientConfigurationDocument.js";
import type { DoctorReport, DoctorScope, runDoctor } from "./Doctor.js";
import type { LinuxDistribution } from "./LinuxHopper.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. */
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 backupPath?: string;
/** Dialect the registration writer will use for this file. */
readonly dialect?: ClientRegistrationDialect;
}
| {
readonly status: "invalid";
@@ -50,6 +50,7 @@ describe.each([
expect(await inspectClientConfiguration(client, {}, command)).toEqual({
status: "update",
backupPath: `${client.configPath}.rea.backup`,
dialect: client.format,
});
expect(await clientConfigurationAligned(client, {}, command)).toBe(false);
expect(await configureClientConfiguration(client, {}, command)).toEqual({
@@ -73,6 +74,7 @@ describe.each([
expect(await clientConfigurationAligned(client, {}, command)).toBe(true);
expect(await inspectClientConfiguration(client, {}, command)).toEqual({
status: "already_current",
dialect: client.format,
});
expect(
await readClientRegistrationStatuses(home, currentCommandPath, {
@@ -174,6 +176,7 @@ it.each(["", "\n \n", "\uFEFF"])(
expect(await inspectClientConfiguration(client, {}, command)).toEqual({
status: "update",
backupPath: `${client.configPath}.rea.backup`,
dialect: "json",
});
expect(await configureClientConfiguration(client, {}, command)).toEqual({
status: "configured",
@@ -184,6 +187,7 @@ it.each(["", "\n \n", "\uFEFF"])(
});
expect(await inspectClientConfiguration(client, {}, command)).toEqual({
status: "already_current",
dialect: "json",
});
},
);
@@ -68,7 +68,7 @@ it.each([
);
expect(
await inspectClientConfiguration(client, providers, command),
).toEqual({ status: "already_current" });
).toEqual({ status: "already_current", dialect: format });
expect(
await configureClientConfiguration(client, providers, command),
).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({
status: "update",
backupPath: recoveryPath,
dialect: "json",
});
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(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(
await inspectClientConfiguration(client, {}, NPX_REGISTRATION_COMMAND),
).toEqual({ status: "create" });
).toEqual({ status: "create", dialect: "pi" });
expect(
await configureClientConfiguration(client, {}, NPX_REGISTRATION_COMMAND),
).toMatchObject({ status: "configured" });