Files
reverse-engineering-anything/src/application/ClientRegistrationStatus.ts
T
Christopher Pruijsen 4b4babf1e7 fix(doctor): read each client's own activation key (#6331)
* fix(doctor): read each client's own activation key

Doctor treated both `disabled: true` and `enabled: false` as a disabled
registration for every client except Grok. The installed clients each
read only one of them: Codex and Kimi Code honor `enabled` and still
load a server marked `disabled`, while Devin honors `disabled` and
still loads a server marked `enabled: false`. Doctor reported those
loaded registrations as stale, so `rea doctor --client codex` failed
with config_drift and setup offered to "repair" a working entry.

Skip the key each of these clients ignores. Other clients keep the
existing rule.

* fix(tests): resolve the Devin config root per platform in activation cases

The client activation cases wrote Devin's mcp_config.json under the
XDG root on every platform, but devinDirectory resolves APPDATA (or
~/AppData/Roaming) on Windows. Follow the client's per-platform root
so the cases exercise the file doctor actually reads there.
2026-10-23 02:20:55 +08:00

638 lines
22 KiB
TypeScript

import { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js";
import { readClientPolicyBlock } from "./GeminiClientSettings.js";
import { readEffectiveClientRegistrationConfiguration } from "./ClientConfigurationFiles.js";
import {
clientServerForcedEnabled,
clientServerListedDisabled,
effectiveClientServer,
type ClientConfigurationDocument,
} from "./ClientConfigurationDocument.js";
import { lstat } from "node:fs/promises";
import { err, ok, type Result } from "../domain/result.js";
import { isAbsolute } from "node:path";
import { z } from "zod";
import { valid } from "semver";
import { PRODUCT_IDENTITY } from "../identity.js";
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
import {
MCP_STARTUP_POLICY,
mcpProviderDeadlineAllowanceMs,
recommendedMcpToolTimeoutMs,
recommendedMcpToolTimeoutSeconds,
type McpToolTimeoutInputs,
} from "../mcpStartupPolicy.js";
import {
clientRegistrationPackageReference,
classifyClientRegistrationCommand,
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 { clientRegistrationEnvironmentDetailsWithBudget } from "./ClientRegistrationEnvironment.js";
import type { ProviderRequestBudgetResolver } from "./ClientRegistrationEnvironment.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
interface ClientRegistrationStatusBase {
readonly client: string;
readonly config_path: string;
/** Exact package reference requested by npx/bunx, when registration uses one. */
readonly package_reference?: string;
/** Non-blocking timeout guidance; caller-selected values remain aligned. */
readonly advisory?: 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: RegistrationCommand;
readonly state: "unknown";
readonly remediation: string;
}
| {
readonly command: readonly [];
readonly state: "missing" | "invalid" | "manual";
readonly remediation: string;
}
);
export type UnhealthyClientRegistrationStatus = Exclude<
ClientRegistrationStatus,
{ readonly state: "aligned" }
>;
const tomlTimeoutSchema = z.union([
z.number().positive(),
z.bigint().positive(),
]);
const registrationSchema = z
.object({
command: z.string().min(1),
args: z.array(z.string()).default([]),
// TOML keeps integers beyond the safe Number range exact as bigint.
startup_timeout_sec: tomlTimeoutSchema.optional(),
tool_timeout_sec: tomlTimeoutSchema.optional(),
startupTimeoutMs: z.number().positive().optional(),
toolTimeoutMs: 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;
readonly resources?: ArtifactResourceScope;
readonly resolveProviderRequestBudget?: ProviderRequestBudgetResolver;
}
/** Registration facts shared by diagnostics and update maintenance. */
export interface ClientRegistrationInspection {
readonly status: ClientRegistrationStatus;
readonly activation: "enabled" | "disabled" | "unknown";
}
/** Project diagnostics from the same inspection used to select maintenance. */
export const readClientRegistrationStatuses = async (
home: string,
currentCommandPath: string | undefined = CURRENT_ENTRYPOINT_PATH,
options: ClientRegistrationReadOptions = {},
): Promise<readonly ClientRegistrationStatus[]> =>
(
await readClientRegistrationInspections(home, currentCommandPath, options)
).map(({ status }) => status);
/** Parse effective registrations and their client-specific activation once.
* A supplied scope retains failed cleanup; otherwise transfer its exact failure.
*/
export const readClientRegistrationInspections = async (
home: string,
currentCommandPath: string | undefined = CURRENT_ENTRYPOINT_PATH,
options: ClientRegistrationReadOptions = {},
): Promise<readonly ClientRegistrationInspection[]> => {
const inspections: ClientRegistrationInspection[] = [];
const record = (
status: ClientRegistrationStatus,
activation: ClientRegistrationInspection["activation"] = "unknown",
): void => {
inspections.push({ status, activation });
};
for (const client of supportedClients(
home,
options.platform,
options.environment,
)) {
if (client.configPathError !== undefined) {
record({
client: client.name,
config_path: client.configPath,
command: [],
state: "invalid",
remediation: client.configPathError,
});
continue;
}
const presence = await clientEvidencePresent(client);
if (!presence.ok) {
record({
client: client.name,
config_path: presence.error.path,
command: [],
state: "invalid",
remediation: presence.error.detail,
});
continue;
}
if (!presence.value) continue;
const manualRemediation = manualRegistrationRemediation(client.name);
if (client.format === "unsupported") {
if (manualRemediation !== undefined)
record({
client: client.name,
config_path: client.markerPath ?? client.configPath,
command: [],
state: "manual",
remediation: manualRemediation,
});
continue;
}
let selectedConfigPath = client.configPath;
try {
const configuration = await readEffectiveClientRegistrationConfiguration(
client,
options.resources,
options.platform,
);
if (!configuration.ok) {
if (
options.resources === undefined &&
configuration.error.cleanupFailure !== undefined
)
throw configuration.error.cleanupFailure;
record({
client: client.name,
config_path: configuration.error.path,
command: [],
state: "invalid",
remediation: configuration.error.detail,
});
continue;
}
const { document: parsed, configPath } = configuration.value;
selectedConfigPath = configPath;
if (parsed === undefined) {
record(unavailableStatus(client, "missing", configPath));
continue;
}
const policy = await readClientPolicyBlock(
client,
parsed,
undefined,
options.resources,
);
if (!policy.ok) {
if (
options.resources === undefined &&
policy.error.cleanupFailure !== undefined
)
throw policy.error.cleanupFailure;
record({
client: client.name,
config_path: 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) {
record({
client: client.name,
config_path: configPath,
command: [],
state: "invalid",
remediation: editabilityError.message,
});
continue;
}
record(unavailableStatus(client, "missing", configPath));
continue;
}
const effectiveClient: SetupClient =
parsed.dialect === "zcode_fallback"
? {
...client,
configPath,
configPaths: [configPath],
format: "zcode_fallback",
}
: client;
const registration = parseRegistration(raw, effectiveClient);
const registrationEnvironment =
client.name === "kimi" || client.name === "codex"
? await clientRegistrationEnvironmentDetailsWithBudget(
parsed,
{},
"effective",
options.resolveProviderRequestBudget,
options.platform,
)
: undefined;
if (
registrationEnvironment !== undefined &&
!registrationEnvironment.ok
) {
record({
client: client.name,
config_path: configPath,
command: [],
state: "invalid",
remediation: registrationEnvironment.error.message,
});
continue;
}
const command: RegistrationCommand = [
registration.command,
...registration.args,
];
const packageReference = clientRegistrationPackageReference(command);
const activation = registrationActivation(registration, client, parsed);
const packageVersion = exactRegisteredPackageVersion(packageReference);
const packageVersionDrift =
packageVersion !== undefined &&
packageVersion !== PRODUCT_IDENTITY.packageVersion;
const alignment = registrationAligned({
registration,
client: effectiveClient,
currentCommandPath,
platform: options.platform ?? process.platform,
kimiInputSchemaProfileSelected:
registrationEnvironment?.ok === true &&
snapshotEnvironment(
registrationEnvironment.value.values,
options.platform,
).REA_MCP_INPUT_SCHEMA_PROFILE !== undefined,
});
record(
configuredStatus(
effectiveClient,
command,
alignment === "unknown"
? "unknown"
: activation === "enabled" &&
!packageVersionDrift &&
alignment === "aligned" &&
(parsed.dialect !== "dsh" ||
dshDocument(parsed.document).ready) &&
policyBlock === undefined
? "aligned"
: "stale",
policyBlock ??
(packageVersionDrift
? packageVersionRemediation(packageReference ?? "", client)
: alignment === "unknown"
? unknownEntryPointRemediation()
: undefined),
packageReference,
registrationEnvironment?.ok === true
? timeoutAdvisory(
client,
registration,
registrationEnvironment.value.timeoutInputs,
)
: undefined,
),
activation,
);
} catch (cause: unknown) {
if (
options.resources === undefined &&
cause instanceof RegularFileCleanupFailure
)
throw cause;
record(
unavailableStatus(
client,
isMissing(cause) ? "missing" : "invalid",
selectedConfigPath,
),
);
}
}
return inspections.sort((left, right) =>
compareUnicodeCodePoints(left.status.client, right.status.client),
);
};
// Codex and Kimi Code load a server marked `disabled: true` and honor only
// `enabled`; Devin loads one marked `enabled: false` and honors only `disabled`.
const ignoresDisabledKey = new Set(["codex", "kimi"]);
const ignoresEnabledKey = new Set(["devin"]);
const registrationActivation = (
registration: z.output<typeof registrationSchema>,
client: SetupClient,
parsed: ClientConfigurationDocument,
): "enabled" | "disabled" =>
(client.format !== "grok" &&
!ignoresDisabledKey.has(client.name) &&
registration.disabled === true) ||
(registration.enabled === false &&
!ignoresEnabledKey.has(client.name) &&
!clientServerForcedEnabled(parsed, PRODUCT_IDENTITY.mcpServerKey)) ||
((parsed.dialect === "zcode" || parsed.dialect === "zcode_fallback") &&
registration.enable === false) ||
clientServerListedDisabled(parsed, PRODUCT_IDENTITY.mcpServerKey)
? "disabled"
: "enabled";
const registrationAligned = ({
registration,
client,
currentCommandPath,
platform,
kimiInputSchemaProfileSelected,
}: {
readonly registration: z.output<typeof registrationSchema>;
readonly client: SetupClient;
readonly currentCommandPath: string | undefined;
readonly platform: NodeJS.Platform;
readonly kimiInputSchemaProfileSelected: boolean;
}): "aligned" | "stale" | "unknown" => {
const command = [registration.command, ...registration.args];
const identity = classifyClientRegistrationCommand(
command,
currentCommandPath,
);
if (identity === "unknown") return "unknown";
if (identity === "foreign") return "stale";
if (
client.name === "grok_build" &&
registration.startup_timeout_sec !==
MCP_STARTUP_POLICY.codexStartupTimeoutSeconds
)
return "stale";
if (
client.name === "codex" &&
(registration.startup_timeout_sec === undefined ||
registration.tool_timeout_sec === undefined)
)
return "stale";
if (
client.name === "kimi" &&
(registration.startupTimeoutMs === undefined ||
registration.toolTimeoutMs === undefined)
)
return "stale";
if (client.name === "kimi" && !kimiInputSchemaProfileSelected) return "stale";
if (
(client.format === "kimi" ||
client.format === "omp" ||
client.format === "pi") &&
registration.type !== undefined &&
registration.type !== "stdio"
)
return "stale";
if (client.format === "vscode" && registration.type !== "stdio")
return "stale";
// A Harness registration only connects as the rea stdio server; another
// server name or transport leaves the session without REA tools.
if (
client.format === "dsh" &&
(registration.serverName !== "rea" || registration.transport !== "stdio")
)
return "stale";
if (
client.format === "copilot_cli" &&
(registration.type !== "stdio" ||
JSON.stringify(registration.tools) !== JSON.stringify(["*"]))
)
return "stale";
if (client.format === "commandcode" && registration.transport !== "stdio")
return "stale";
if (
(["npx", "bunx"] as const).some((runner) => {
const expected = packageRunnerRegistrationCommand(runner, platform);
return (
command.length === expected.length &&
command.every((argument, index) => argument === expected[index])
);
})
)
return "aligned";
// Other owned package references remain stale until pinned to this version.
if (clientRegistrationPackageReference(command) !== undefined) return "stale";
// The identity owner already established an exact direct launcher match.
// A bare command still depends on the client's PATH, so cannot prove alignment.
return isAbsolute(command[0] ?? "") ? "aligned" : "stale";
};
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 === "kimi" &&
registration.transport !== undefined &&
registration.transport !== "stdio"
)
throw new TypeError("Expected an stdio registration");
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 === "kimi" ||
client.format === "omp" ||
client.format === "pi") &&
registration.type !== undefined &&
registration.type !== "stdio"
)
throw new TypeError("Expected an stdio registration");
return registration;
};
/** A client that rewrites its file from memory must be quit before setup. */
const remediation = (client: SetupClient): string => {
if (client.quitBeforeSetup !== true)
return "Run rea setup to refresh this registration, then restart the client.";
const name = client.displayName ?? client.name;
return `Quit ${name} fully, run rea setup to refresh this registration, then start ${name} again. A running ${name} can rewrite its configuration from memory and drop the registration.`;
};
const unknownEntryPointRemediation = (): string =>
"The existing registration was retained because its executable identity cannot be verified. Check the registered launcher path, symlink targets and access permissions. Run the installed REA CLI or a supported package runner, then rerun rea doctor; no registration was changed.";
const configuredStatus = (
client: SetupClient,
command: RegistrationCommand,
state: "aligned" | "stale" | "unknown",
policyRemediation?: string,
packageReference?: string,
advisory?: string,
): ClientRegistrationStatus =>
state === "aligned"
? {
client: client.name,
config_path: client.configPath,
command,
...(packageReference === undefined
? {}
: { package_reference: packageReference }),
state,
remediation: null,
...(advisory === undefined ? {} : { advisory }),
}
: {
client: client.name,
config_path: client.configPath,
command,
...(packageReference === undefined
? {}
: { package_reference: packageReference }),
state,
remediation: policyRemediation ?? remediation(client),
};
const timeoutAdvisory = (
client: SetupClient,
registration: z.output<typeof registrationSchema>,
timeoutInputs: McpToolTimeoutInputs,
): string | undefined => {
const toolTimeout =
client.name === "kimi"
? registration.toolTimeoutMs
: client.name === "codex"
? registration.tool_timeout_sec
: undefined;
if (toolTimeout === undefined) return undefined;
const providerAllowance = mcpProviderDeadlineAllowanceMs(timeoutInputs);
const isKimi = client.name === "kimi";
// A bigint is beyond the safe Number range, so rounding cannot cross the allowance.
const toolTimeoutMs = Number(toolTimeout) * (isKimi ? 1 : 1000);
if (toolTimeoutMs >= providerAllowance) return undefined;
const setting = isKimi ? "toolTimeoutMs" : "tool_timeout_sec";
const currentValue = isKimi
? `${String(toolTimeout)} ms`
: `${String(toolTimeout)} seconds`;
const recommendedValue = isKimi
? `${String(recommendedMcpToolTimeoutMs(timeoutInputs))} ms`
: `${String(recommendedMcpToolTimeoutSeconds(timeoutInputs))} seconds`;
const headroom = isKimi
? `${String(MCP_STARTUP_POLICY.toolOperationHeadroomMs)} ms`
: `${String(MCP_STARTUP_POLICY.toolOperationHeadroomMs / 1000)} seconds`;
return `The configured ${setting} (${currentValue}) is shorter than REA's known provider allowance (${String(providerAllowance)} ms), so provider startup or an upstream request may exceed this client deadline. REA recommends ${setting} = ${recommendedValue} to cover that allowance plus ${headroom} of operation headroom; this additional headroom is a recommendation, not a guarantee for every analysis.`;
};
const exactRegisteredPackageVersion = (
packageReference: string | undefined,
): string | undefined => {
if (packageReference === undefined) return undefined;
const prefix = `${PRODUCT_IDENTITY.packageName}@`;
if (!packageReference.startsWith(prefix)) return undefined;
const version = packageReference.slice(prefix.length);
return valid(version) === version ? version : undefined;
};
const packageVersionRemediation = (
packageReference: string,
client: SetupClient,
): string =>
`This registration requests ${packageReference}, while the current REA CLI package is ${PRODUCT_IDENTITY.packageVersion}. REA does not resolve package-runner requests during doctor; update the registration with rea setup --client ${client.name}, then restart the client.`;
const unavailableStatus = (
client: SetupClient,
state: "missing" | "invalid",
configPath: string = client.configPath,
): ClientRegistrationStatus => ({
client: client.name,
config_path: configPath,
command: [],
state,
remediation: remediation(client),
});
/** Entry presence is independent of whether its target can currently be read. */
const clientEvidencePresent = async (
client: SetupClient,
): Promise<
Result<boolean, { readonly path: string; readonly detail: string }>
> => {
let unavailable:
| { readonly path: string; readonly detail: string }
| undefined;
for (const path of clientEvidencePaths(client)) {
try {
await lstat(path);
return ok(true);
} catch (cause: unknown) {
if (!isMissing(cause))
unavailable ??= {
path,
detail: `Client configuration evidence at ${path} could not be inspected: ${cause instanceof Error ? cause.message : String(cause)}. Check file permissions and rerun rea doctor.`,
};
}
}
return unavailable === undefined ? ok(false) : err(unavailable);
};
const isMissing = (cause: unknown): boolean =>
cause instanceof Error && "code" in cause && cause.code === "ENOENT";