Files
rea/src/application/ClientRegistrationStatus.ts
T
Iven Kooandmorluto 51d6c87cdb feat(clients): register ZCode during setup (#1564)
* feat(clients): register ZCode during setup

- SupportedClients: zcode definition (~/.zcode/cli/config.json, format
  zcode, personal skills at ~/.zcode/skills)
- ClientConfigurationDocument: zcode dialect reads/writes the nested
  mcp.servers table; withClientServers now tolerates a missing outer
  table for fresh documents
- docs: add ZCode to the supported-clients table with configuration notes
- tests: zcodeClientConfiguration.test.ts (path resolution, nested
  upsert, fresh/empty configs, uninstall)

Verified: tsc --noEmit clean; vitest run
tests/boundary/filesystem/zcodeClientConfiguration.test.ts 6/6.

* fix(clients): preserve ZCode fallback servers and honor enable flag

Review follow-up (#1564), two integration gaps:

- ZCode reads the user-scope ~/.agents/mcp.json fallback only while the
  native .zcode config defines no servers at all, with no merging. Setup
  now copies the fallback's mcpServers entries into the native table when
  writing the first native server, so registrations that only live in
  the fallback keep loading; the fallback file is left untouched. A
  malformed fallback fails setup closed (configure and preflight)
  instead of being silently suppressed.
- ZCode disables a server with "enable": false on its entry, which the
  generic status parsing (disabled/enabled) did not recognize: doctor
  reported such a registration as aligned although ZCode never starts
  it. The ZCode dialect now treats enable: false as disabled (state
  stale) and setup rewrites the entry on rerun.

Docs describe both behaviors; the bundled REA skill's supported client
list includes zcode.

Verified: tsc --noEmit clean; vitest run
tests/boundary/filesystem/zcodeClientConfiguration.test.ts 10/10;
npm run check:fast green; npm run verify:test-discovery 733 files.

* fix(clients): reject non-regular ZCode fallback files before reading

Resolve the fallback through the client-config transaction path and the
regular-file admission before parsing it, so a FIFO or other non-regular
file at ~/.agents/mcp.json fails setup closed instead of blocking the
read indefinitely. Directory and FIFO fallback cases are covered in
tests, and the preflight remediation names unreadable fallbacks too.

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-12 22:51:40 +08:00

383 lines
11 KiB
TypeScript

import { readClientPolicyBlock } from "./GeminiClientSettings.js";
import {
effectiveClientConfiguration,
readClientConfigurationFiles,
} from "./ClientConfigurationFiles.js";
import {
clientServerForcedEnabled,
clientServerListedDisabled,
effectiveClientServer,
} from "./ClientConfigurationDocument.js";
import { access } from "node:fs/promises";
import { resolve } from "node:path";
import { z } from "zod";
import { PRODUCT_IDENTITY } from "../identity.js";
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
import {
isOwnedClientRegistrationCommand,
packageRunnerRegistrationCommand,
} from "./ClientRegistrationIdentity.js";
import {
clientEvidencePaths,
manualRegistrationRemediation,
supportedClients,
} from "./SupportedClients.js";
import type { SetupClient } from "./SupportedClients.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import { dshDocument } from "./DshCordisPatch.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
interface ClientRegistrationStatusBase {
readonly client: string;
readonly config_path: string;
}
type RegistrationCommand = readonly [string, ...string[]];
export type ClientRegistrationStatus = ClientRegistrationStatusBase &
(
| {
readonly command: RegistrationCommand;
readonly state: "aligned";
readonly remediation: null;
}
| {
readonly command: RegistrationCommand;
readonly state: "stale";
readonly remediation: string;
}
| {
readonly command: readonly [];
readonly state: "missing" | "invalid" | "manual";
readonly remediation: string;
}
);
export type UnhealthyClientRegistrationStatus = Exclude<
ClientRegistrationStatus,
{ 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();
/** Inspect supported client registrations without reading their environment. */
export const readClientRegistrationStatuses = async (
home: string,
currentCommandPath: string = resolve(process.argv[1] ?? "unknown"),
options: {
readonly platform?: NodeJS.Platform;
readonly environment?: NodeJS.ProcessEnv;
readonly resources?: ArtifactResourceScope;
} = {},
): Promise<readonly ClientRegistrationStatus[]> => {
const statuses: ClientRegistrationStatus[] = [];
for (const client of supportedClients(
home,
options.platform,
options.environment,
)) {
if (client.configPathError !== undefined) {
statuses.push({
client: client.name,
config_path: client.configPath,
command: [],
state: "invalid",
remediation: client.configPathError,
});
continue;
}
if (!(await clientEvidencePresent(client))) continue;
const manualRemediation = manualRegistrationRemediation(client.name);
if (client.format === "unsupported") {
if (manualRemediation !== undefined)
statuses.push({
client: client.name,
config_path: client.markerPath ?? client.configPath,
command: [],
state: "manual",
remediation: manualRemediation,
});
continue;
}
try {
const files = await readClientConfigurationFiles(
client,
options.resources,
);
if (!files.ok) {
statuses.push({
client: client.name,
config_path: client.configPath,
command: [],
state: "invalid",
remediation: files.error.detail,
});
continue;
}
const parsed = effectiveClientConfiguration(files.value);
if (parsed === undefined) {
statuses.push(
unavailableStatus(client.name, client.configPath, "missing"),
);
continue;
}
const policy = await readClientPolicyBlock(
client,
parsed,
undefined,
options.resources,
);
if (!policy.ok) {
statuses.push({
client: client.name,
config_path: client.configPath,
command: [],
state: "invalid",
remediation: policy.error.detail,
});
continue;
}
const policyBlock = policy.value;
const raw = effectiveClientServer(parsed, PRODUCT_IDENTITY.mcpServerKey);
if (raw === undefined) {
const editabilityError =
parsed.dialect === "dsh"
? dshDocument(parsed.document).editabilityError
: undefined;
if (editabilityError !== undefined) {
statuses.push({
client: client.name,
config_path: client.configPath,
command: [],
state: "invalid",
remediation: editabilityError.message,
});
continue;
}
statuses.push(
unavailableStatus(client.name, client.configPath, "missing"),
);
continue;
}
const registration = parseRegistration(raw, client);
const command: RegistrationCommand = [
registration.command,
...registration.args,
];
statuses.push(
configuredStatus(
client.name,
client.configPath,
command,
registrationAligned(
registration,
client,
currentCommandPath,
options.platform ?? process.platform,
clientServerForcedEnabled(parsed, PRODUCT_IDENTITY.mcpServerKey),
) &&
!clientServerListedDisabled(
parsed,
PRODUCT_IDENTITY.mcpServerKey,
) &&
(parsed.dialect !== "dsh" || dshDocument(parsed.document).ready) &&
policyBlock === undefined
? "aligned"
: "stale",
policyBlock,
),
);
} catch (cause: unknown) {
statuses.push(
unavailableStatus(
client.name,
client.configPath,
isMissing(cause) ? "missing" : "invalid",
),
);
}
}
return statuses.sort((left, right) =>
compareUnicodeCodePoints(left.client, right.client),
);
};
const registrationAligned = (
registration: z.output<typeof registrationSchema>,
client: SetupClient,
currentCommandPath: string,
platform: NodeJS.Platform,
forcedEnabled = false,
): boolean => {
const command = [registration.command, ...registration.args];
if (
(client.format !== "grok" && registration.disabled === true) ||
(registration.enabled === false && !forcedEnabled) ||
// ZCode's native flag is `enable`; other dialects leave that field opaque.
(client.format === "zcode" && registration.enable === false)
)
return false;
if (!isOwnedClientRegistrationCommand(command, currentCommandPath))
return false;
if (
(client.name === "codex" || client.name === "grok_build") &&
registration.startup_timeout_sec !==
MCP_STARTUP_POLICY.codexStartupTimeoutSeconds
)
return false;
if (client.format === "vscode" && registration.type !== "stdio") return false;
// A Harness registration only connects as the rea stdio server; another
// server name or transport leaves the session without REA tools.
if (
client.format === "dsh" &&
(registration.serverName !== "rea" || registration.transport !== "stdio")
)
return false;
// OMP and Pi infer stdio for a command entry without an explicit type.
if (
(client.format === "omp" || client.format === "pi") &&
registration.type !== undefined &&
registration.type !== "stdio"
)
return false;
if (
client.format === "copilot_cli" &&
(registration.type !== "stdio" ||
JSON.stringify(registration.tools) !== JSON.stringify(["*"]))
)
return false;
if (client.format === "commandcode" && registration.transport !== "stdio")
return false;
if (
command.length === 3 &&
command[2] === "mcp" &&
resolve(command[0] ?? "") === resolve(process.execPath) &&
resolve(command[1] ?? "") === currentCommandPath
)
return true;
if (
(["npx", "bunx"] as const).some((runner) => {
const runnerCommand = packageRunnerRegistrationCommand(runner, platform);
return (
command.length === runnerCommand.length &&
command.every((argument, index) => argument === runnerCommand[index])
);
})
)
return true;
return (
command.length === 2 &&
command[1] === "mcp" &&
resolve(command[0] ?? "") === currentCommandPath
);
};
const parseRegistration = (
value: unknown,
client: SetupClient,
): z.output<typeof registrationSchema> => {
if (client.format === "dsh")
return registrationSchema
.pick({ command: true, args: true, serverName: true, transport: true })
.strip()
.parse(value);
if (client.format === "opencode") {
const entry = z
.object({
type: z.literal("local"),
command: z.array(z.string()).min(1),
environment: z.record(z.string(), z.string()).optional(),
})
.passthrough()
.parse(value);
const [command = "", ...args] = entry.command;
return registrationSchema.parse({
...entry,
command,
args,
});
}
const registration = registrationSchema.parse(value);
if (
(client.format === "vscode" || client.format === "copilot_cli") &&
registration.type !== "stdio"
)
throw new TypeError("Expected an stdio registration");
if (client.format === "commandcode" && registration.transport !== "stdio")
throw new TypeError("Expected an stdio registration");
if (
(client.format === "omp" || client.format === "pi") &&
registration.type !== undefined &&
registration.type !== "stdio"
)
throw new TypeError("Expected an stdio registration");
return registration;
};
const remediation =
"Run rea setup to refresh this registration, then restart the client.";
const configuredStatus = (
client: string,
configPath: string,
command: RegistrationCommand,
state: "aligned" | "stale",
policyRemediation?: string,
): ClientRegistrationStatus =>
state === "aligned"
? { client, config_path: configPath, command, state, remediation: null }
: {
client,
config_path: configPath,
command,
state,
remediation: policyRemediation ?? remediation,
};
const unavailableStatus = (
client: string,
configPath: string,
state: "missing" | "invalid",
): ClientRegistrationStatus => ({
client,
config_path: configPath,
command: [],
state,
remediation,
});
const clientEvidencePresent = async (client: SetupClient): Promise<boolean> => {
for (const path of clientEvidencePaths(client))
if (await exists(path)) return true;
return false;
};
const exists = async (path: string | undefined): Promise<boolean> => {
if (path === undefined) return false;
try {
await access(path);
return true;
} catch (cause: unknown) {
// best-effort cleanup: optional registration probing; absence means unregistered.
void cause;
return false;
}
};
const isMissing = (cause: unknown): boolean =>
cause instanceof Error && "code" in cause && cause.code === "ENOENT";