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 Agent 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 Agent 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 Agent 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 Agent 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 Agent 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 Agent 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 Agent 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 Agent 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 801 additions and 51 deletions

No files matched your search

+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, 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;
};
+7 -15
View File
@@ -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"
+12
View File
@@ -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>> = {},
+120
View File
@@ -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();
+38 -28
View File
@@ -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,
}
: {}),
};
}; };
+23 -5
View File
@@ -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."
+8 -1
View File
@@ -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" });