Files
rea/src/application/SetupPlan.ts
T
morluto 08561e0eb7 feat(clients): register Grok Build and Grok Bot during setup (#1134)
* feat(clients): register Grok Build and Grok Bot during setup

Detect both clients and install the local REA MCP registration the same
way setup already handles the other agents. Grok Build edits only the
REA tables in its TOML config and sets startup_timeout_sec to 30. Grok
Bot writes the mcpServers.rea entry into mcp.json. That file is the
registration to add to the signed-in account; setup does not call the
account API.

Filed by Grok.

* fix(clients): clear a disabled Grok Build registration during setup

Grok Build keeps a server suppressed when its name stays in
disabled_mcp_servers, and setup was treating that file as already aligned.
Setup now removes only that name, doctor reports the registration stale until
it is gone, and uninstall keeps comments in an inline table whose only server
was REA. Split the lifecycle suite and reflow the get-started page so static
checks pass.

* fix(verify): check packed Hopper search as literal-only

The package verifier still called a regex probe that main deleted. The
native bridge refuses regex and joins string hits with typed records, so
the restored probe stubs that inventory and the check asserts the literal
matches and the regex refusal. The new README translations also name
Grok Build and Grok Bot.

* fix(clients): stop treating a Grok Bot data directory as registered

Grok Bot stores connectors in the signed-in account and runs them on its
hosted computer. Setup no longer writes mcp.json or reports that file as
aligned. Doctor and setup tell the caller to add the server from the
Grok Bot chat.

* fix(clients): drop multiline Grok disable-list entries

Decode disabled_mcp_servers strings with the document parser so a
multiline basic or literal string that loads as rea is removed.

---------

Co-authored-by: Grok <eugene.ray@gmail.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: morluto <morluto@users.noreply.github.com>
2026-10-09 06:08:29 +08:00

319 lines
12 KiB
TypeScript

import { homedir } from "node:os";
import { join } from "node:path";
import { PRODUCT_IDENTITY } from "../identity.js";
import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
import {
linuxHopperInstallDisclosure,
type LinuxPackageFamily,
} from "./LinuxHopper.js";
import { macHopperInstallDisclosure } from "./MacHopper.js";
import type {
SetupAction,
SetupClientState,
SetupHost,
SetupProviderEnvironment,
} from "./SetupTypes.js";
import type { SetupClient } from "./SupportedClients.js";
import type { DoctorScope } from "./Doctor.js";
import type { DoctorReport } from "./Doctor.js";
/** Read-only setup facts shared by client selection and preflight. */
export interface SetupDiscovery {
readonly initialDoctor: Awaited<ReturnType<SetupHost["doctor"]>>;
readonly installHopper: boolean;
readonly skillNeedsInstall: boolean;
readonly clients: readonly SetupClient[];
readonly detectedClientIds: readonly string[];
readonly defaultClientIds: readonly string[];
readonly clientStates: readonly SetupClientState[];
readonly linuxPackageFamily?: LinuxPackageFamily;
}
/** Discover supported clients and current registrations without mutation. */
export const discoverSetupState = async (input: {
readonly host: SetupHost;
readonly providerEnvironment: SetupProviderEnvironment;
readonly forceHopperInstall: boolean;
readonly proposeHopper: boolean;
readonly doctorScope: DoctorScope | undefined;
readonly initialDoctor?: DoctorReport;
}): Promise<SetupDiscovery> => {
const initialDoctor =
input.initialDoctor ?? (await input.host.doctor(input.doctorScope));
const linuxHopperRepairNeeded = initialDoctor.checks.some(
({ name, ok, detail }) =>
!ok &&
(name === "hopper-demo-runtime" ||
(name === "hopper-version" && detail === "/opt/hopper/bin/Hopper")),
);
const linuxDistribution =
input.host.platform === "linux"
? await input.host.linuxDistribution()
: undefined;
const hopperInstallSupported =
(input.host.platform === "darwin" &&
majorVersion((await input.host.macosVersion()) ?? "0") >= 12) ||
(input.host.platform === "linux" && linuxDistribution?.supported === true);
const installHopper =
input.forceHopperInstall ||
(hopperInstallSupported &&
input.proposeHopper &&
(Object.keys(input.providerEnvironment).length === 0 ||
linuxHopperRepairNeeded));
const [clients, detectedClients, skillNeedsInstall] = await Promise.all([
input.host.supportedClients?.() ?? input.host.detectedClients(),
input.host.detectedClients(),
input.host.skillNeedsInstall(),
]);
const detectedIds = new Set(detectedClients.map(({ name }) => name));
const registrations = initialDoctor.identity?.registrations ?? [];
const registrationByClient = new Map(
registrations.map((registration) => [registration.client, registration]),
);
const clientStates = clients.map((client): SetupClientState => {
const registration = registrationByClient.get(client.name);
const ownedRegistration =
(registration?.state === "aligned" || registration?.state === "stale") &&
isOwnedClientRegistrationCommand(registration.command);
const configured = ownedRegistration;
const status: SetupClientState["status"] =
registration?.state === "invalid"
? "invalid"
: ownedRegistration && registration.state === "aligned"
? "configured"
: ownedRegistration && registration.state === "stale"
? "needs_configuration"
: detectedIds.has(client.name)
? "needs_configuration"
: "missing";
return {
client,
detected: detectedIds.has(client.name),
configured,
status,
};
});
const defaultClientIds = registrations
.filter(
(registration) =>
(registration.state === "aligned" || registration.state === "stale") &&
isOwnedClientRegistrationCommand(registration.command),
)
.map(({ client }) => client);
return {
initialDoctor,
installHopper,
skillNeedsInstall,
clients,
detectedClientIds: [...detectedIds],
defaultClientIds,
clientStates,
...(linuxDistribution?.packageFamily === undefined
? {}
: { linuxPackageFamily: linuxDistribution.packageFamily }),
};
};
/** Preflight exactly the selected clients and produce their concrete actions. */
export const planSetupActions = async (input: {
readonly discovery: SetupDiscovery;
readonly host: SetupHost;
readonly providerEnvironment: SetupProviderEnvironment;
readonly command: readonly string[];
readonly clientIds: readonly string[];
readonly installSkill: boolean;
}): Promise<{
readonly plannedActions: readonly SetupAction[];
readonly selectedClients: readonly SetupClient[];
readonly clientsToConfigure: readonly SetupClient[];
readonly blocker?: string;
}> => {
const byId = new Map(
input.discovery.clients.map((client) => [client.name, client]),
);
const unknownClient = input.clientIds.find((id) => !byId.has(id));
if (unknownClient !== undefined) {
return {
plannedActions: [],
selectedClients: [],
clientsToConfigure: [],
blocker: `Unsupported setup client: ${unknownClient}.`,
};
}
const selectedClients = input.clientIds.flatMap((id) => {
const client = byId.get(id);
return client === undefined ? [] : [client];
});
const inspections = await Promise.all(
selectedClients.map(async (client) => ({
client,
inspection:
(await input.host.inspectClientConfiguration?.(
client,
input.providerEnvironment,
input.command,
)) ?? ({ status: "update" } as const),
})),
);
const invalid = inspections.find(
({ inspection }) => inspection.status === "invalid",
);
if (invalid?.inspection.status === "invalid") {
return {
plannedActions: [],
selectedClients,
clientsToConfigure: [],
blocker: `${invalid.client.displayName ?? invalid.client.name}: ${invalid.inspection.remediation}`,
};
}
const clientPlans = inspections.flatMap(({ client, inspection }) =>
client.format !== "unsupported" &&
(inspection.status === "create" ||
inspection.status === "update" ||
(input.discovery.installHopper &&
inspection.status === "already_current"))
? [
{
client,
operation:
inspection.status === "already_current"
? ("update" as const)
: inspection.status,
...(inspection.status !== "already_current" &&
inspection.backupPath !== undefined
? { backupPath: inspection.backupPath }
: {}),
},
]
: [],
);
return {
plannedActions: setupPlan({
platform: input.host.platform,
installHopper: input.discovery.installHopper,
installSkill: input.installSkill,
clients: clientPlans,
providerEnvironment: input.providerEnvironment,
...(input.discovery.linuxPackageFamily === undefined
? {}
: { linuxPackageFamily: input.discovery.linuxPackageFamily }),
command: input.command,
}),
selectedClients,
clientsToConfigure: selectedClients.filter((client) =>
clientPlans.some(({ client: planned }) => planned.name === client.name),
),
};
};
/** Describe every selected setup mutation before user approval. */
const setupPlan = (input: {
readonly platform: NodeJS.Platform;
readonly installHopper: boolean;
readonly installSkill: boolean;
readonly clients: readonly {
readonly client: SetupClient;
readonly operation: "create" | "update";
readonly backupPath?: string;
}[];
readonly providerEnvironment: SetupProviderEnvironment;
readonly linuxPackageFamily?: LinuxPackageFamily;
readonly command: readonly string[];
}): readonly SetupAction[] => [
...(input.installHopper
? [
{
id: "install_hopper",
kind: "install_hopper" as const,
label: "Hopper deep-analysis provider",
target:
input.platform === "darwin"
? join(homedir(), "Applications/Hopper Disassembler.app")
: "system package manager",
detail:
input.platform === "linux"
? "Download, verify, and install Hopper plus its Xvfb demo-session dependencies. For the supported demo build, REA uses a private display and selects Hopper's offered demo mode for each analysis session."
: "Download the official Hopper package, verify it, and install it. Hopper may show its demo or license prompt when first opened.",
external: true,
operation: "install" as const,
...(input.platform === "linux"
? input.linuxPackageFamily === undefined
? {}
: linuxDisclosure(input.linuxPackageFamily)
: {
networkOrigins: macHopperInstallDisclosure.networkOrigins,
commands: macHopperInstallDisclosure.commands,
}),
},
]
: []),
...input.clients
.filter(({ client }) => client.format !== "unsupported")
.map(({ client, operation, backupPath }): SetupAction => ({
id: `configure_client:${client.name}`,
kind: "configure_client",
label: client.displayName ?? client.name,
target: client.configPath,
detail: clientConfigurationDetail(
client.displayName ?? client.name,
input.command,
input.providerEnvironment,
input.installHopper,
),
external: false,
operation,
...(backupPath === undefined ? {} : { backupPath }),
})),
...(input.installSkill
? [
{
id: "install_skill",
kind: "install_skill" as const,
label: "REA reverse-engineering skill",
target: join(homedir(), ".agents/skills", PRODUCT_IDENTITY.skillName),
detail:
"Install or update the bundled REA reverse-engineering skill and on-demand references.",
external: false,
operation: "install" as const,
},
]
: []),
];
const linuxDisclosure = (family: LinuxPackageFamily) => {
const disclosure = linuxHopperInstallDisclosure(
family,
process.getuid?.() === 0,
);
return {
networkOrigins: [disclosure.downloadUrl],
commands: disclosure.commands,
integrity: `${String(disclosure.expectedBytes)} bytes · SHA-1 ${disclosure.expectedSha1}`,
};
};
const majorVersion = (version: string): number =>
Number.parseInt(version.split(".")[0] ?? "0", 10);
const clientConfigurationDetail = (
client: string,
command: readonly string[],
environment: SetupProviderEnvironment,
installHopper: boolean,
): string => {
const entries = Object.entries(environment)
.sort(([left], [right]) => left.localeCompare(right))
.map(([name, value]) => `${name}=${value}`);
const environmentDetail =
entries.length === 0
? " No additional environment variables."
: ` Environment: ${entries.join(", ")}.`;
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."
: " The HOPPER_LAUNCHER_PATH value shown above is the current fallback; if this plan installs Hopper, REA will replace it with the launcher path reported by that installation."
: "";
return `Add the REA MCP registration for ${client}; preserve unrelated configuration. Command: ${command.map((part) => JSON.stringify(part)).join(" ")}.${environmentDetail}${hopperDetail}`;
};