mirror of
https://github.com/morluto/rea.git
synced 2026-11-11 01:13:39 +00:00
* fix(doctor): probe providers with the selected client's registration environment rea doctor read provider settings only from the terminal running it. A client registration that carries GHIDRA_INSTALL_DIR or REA_JADX_MCP_CONFIG under its own env block therefore looked unconfigured: doctor answered not_configured while the server that client starts inherited the settings and worked. This is the recovery command the docs point agents at, so the false negative sent them back to re-apply fixes that were already in place. When the caller names exactly one client, read that registration's effective environment and hand it to the provider checks. Terminal variables that providers resolve outside server config (PATH, SYSTEMROOT) stay inherited, and running doctor without --client still reports this terminal. A registration the loader cannot read keeps its own registration check as the answer instead of being re-reported as four missing providers. Fixes #9102 * fix(doctor): keep setup's terminal settings and a failed close on the host Greptile found two regressions in the selected-client environment read. Provider probes now start from the terminal and overlay the selected registration, instead of replacing the terminal with the registration alone. `rea setup --client` passes its own client selection through the same doctor call, so on a first run with GHIDRA_INSTALL_DIR exported and no registration yet, the replacements made setup report providerConfigured: false and offer Hopper over the selected BYO provider. The registration still wins for every value it records. The registration read now runs inside the caller's resource scope and, when the close of the configuration file fails, retains the returned owner there so host.close() retries it. Dropping that owner left an unretired handle with no way to report the unfinished cleanup. * fix(doctor): leave a registration's expression settings unknown Greptile found the previous fallback filling a name the selected client records as a Cordis expression with the terminal's value. `dsh` keeps such a value as `!!js ...`, which is not a literal; the parser reports the name in `unresolvedSettings`, and merging the terminal into it then made probes certify a setting the client owns and this process cannot read. Unresolved names are now removed after the overlay, and an unreadable, malformed, or unresolvable registration returns no environment at all, so the caller keeps the terminal instead of a partly-known one. Provider checks report those settings as unconfigured, which is the honest answer for a chain that resolves outside REA. dsh's missing registration still inherits the terminal, which is the case `rea setup` depends on. * fix(doctor): fold registration settings by platform identity `selectedProviderSettings` overlaid a selected client's registration on the terminal with an object spread. On Windows a registration can record `ghidra_install_dir` while the terminal exports `GHIDRA_INSTALL_DIR`; the spread kept both spellings, and since provider checks read the canonical uppercase names, doctor either certified the terminal's install or reported the setting as unconfigured. An expression the registration marks unresolved was skipped under one spelling while its terminal value survived under the other. Merge the two layers through `mergeEnvironmentOverrides` and fold the result with `snapshotEnvironment`, so the registration's value wins regardless of how it spells the name and the provider sees the names it reads. Fold the unresolved names the same way before deleting them, and export the helper so the two platform identities can be covered directly. * style(doctor): wrap the long import in the runtime test oxfmt wants the three imported names on their own lines; the previous commit left the single-line form, which fails `npm run format:check`. * test(codebuddy): construct the registration command like its sibling clients `npxRegistrationCommand` was removed inc77735fa("fix(setup)!: bind managed registrations to selected installation"). The CodeBuddy boundary test landed after that refactor and still imported it, so `npm run typecheck` failed on every branch that descends from mergefb841a95— main included, where the main push CI is still queued. Use `currentClientRegistrationCommand()` the way the kimi, lmStudio and other client configuration tests already do. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(doctor): retain failed registration file cleanup * fix(doctor): preserve unknown client provider readiness * fix(doctor): scope Kuna readiness to its environment * test(doctor): respect Kuna host support * fix(doctor): read a registration's unresolved settings only where the probe needs them Every setting an expression made unresolved blocked readiness, even one the provider never consults for the configuration the client supplied. With a literal JAVA_HOME and jar path, JADX resolves Java by absolute path and does not read PATH, yet a dsh registration with an expression-backed PATH skipped the probe and reported the provider unknown. Ghidra is the same when both GHIDRA_INSTALL_DIR and JAVA_HOME are explicit. Decide per provider which unresolved setting the probe actually reads, and skip only those. Kuna's readiness list also omitted HOME and XDG_CACHE_HOME, which its cache root falls back to, so an unresolved cache input could let the probe check a fallback directory and report available without knowing the client's real cache. * fix(doctor): stop probing a registration expression as a literal Two findings from the review, both about handing a probe a value REA cannot interpret. REA_KUNA_CACHE_DIR made settingAffectsProbe ignore every unresolved Kuna setting, so an expression-backed REA_KUNA_COMMAND or REA_KUNA_JOBS silently fell back to a default and the provider could report ready. Limit the exception to HOME and XDG_CACHE_HOME, the two cache-location defaults. HOME and XDG_CACHE_HOME were also added to KUNA_DOCTOR_SETTINGS, which registrationEnvironment copies wholesale, so setup persisted ambient values a child already inherits. Split the readiness inputs from the settings setup writes. Qwen expands $VAR and ${VAR} in a recorded env value from the environment it runs with. REA cannot predict that, but it was handing the literal to the probes, so a registration appending to PATH made JADX search for a literal ${PATH} entry. Treat such values as unresolved for that client so the affected provider reports unknown instead of probing the literal text. * fix(registration): recognize a client's env expression at the shared reader Doctor decided which Qwen settings were expressions after the shared registration reader had already parsed them as literals, so doctor and setup/status held two interpretations of the same file. AGENTS.md requires normalizing once at the owning boundary, so move the recognition into ClientRegistrationEnvironment and let every consumer read the facts it returns. The reader now takes the client name, marks a variable-expanding client's $VAR and ${VAR} values as unresolved, and reports those names through unresolvedSettings. Doctor drops its own parser; setup and status pick up the same facts, which also keeps an expression-valued timeout out of the resolved budget instead of trusting the literal. * fix(doctor): preserve unresolved typed settings * style(cli): wrap the native data type path description The `inspect-native-data-type` argument description line is 82 columns, over the repository's 80-column `.oxfmtrc.json` limit, so `npm run format:check` fails on main and on every branch merged from it. It came in with #9465 and the following commits to this file did not touch it. Reformat the one call the formatter objects to; no behaviour change. --------- Co-authored-by: 2SinceJuly <202121119+2SinceJuly@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
403 lines
14 KiB
TypeScript
403 lines
14 KiB
TypeScript
import {
|
|
currentClientRegistrationCommand,
|
|
invokingPackageRunner,
|
|
withoutPackageRunnerIdentity,
|
|
} from "./application/ClientRegistrationIdentity.js";
|
|
import { UpdateCommandRunner } from "./application/UpdateCommandRunner.js";
|
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
|
|
import { createDoctorHostFixture } from "./application/Doctor.fixture.js";
|
|
import * as macHopperInstaller from "./application/MacHopper.js";
|
|
import { runSetup } from "./application/Setup.js";
|
|
import { discoverSetupState } from "./application/SetupPlan.js";
|
|
import { options } from "./application/Setup.fixture.js";
|
|
import { systemSetupHost } from "./application/SetupHost.js";
|
|
import { systemUninstallHost } from "./application/Uninstall.js";
|
|
import { createSystemDoctorHost } from "./doctorRuntime.js";
|
|
import { PRODUCT_IDENTITY } from "./identity.js";
|
|
import { snapshotEnvironment } from "./process/snapshotEnvironment.js";
|
|
|
|
afterEach(() => vi.unstubAllEnvs());
|
|
|
|
it.each([undefined, "/caller/tools with spaces/ilspycmd"])(
|
|
"preserves the explicit ILSpy path %s without inventing an unset value",
|
|
async (selected) => {
|
|
const host = systemSetupHost(
|
|
createDoctorHostFixture({
|
|
...(selected === undefined ? {} : { configuredIlspyCmdPath: selected }),
|
|
ilspyCmdVersion: async () => "ilspycmd 9.1.0",
|
|
}),
|
|
selected === undefined ? {} : { REA_ILSPY_CMD_PATH: selected },
|
|
);
|
|
try {
|
|
const state = await host.initialSetupState();
|
|
expect(state.providerEnvironment.REA_ILSPY_CMD_PATH).toBe(selected);
|
|
if (selected !== undefined)
|
|
expect(state.doctor.checks).toContainEqual(
|
|
expect.objectContaining({ name: "ilspycmd", ok: true }),
|
|
);
|
|
} finally {
|
|
await host.close?.();
|
|
}
|
|
},
|
|
);
|
|
|
|
it("plans setup and uninstall against selected home and client overrides", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "rea-selected-setup-"));
|
|
const environment = {
|
|
HOME: join(root, "selected-home"),
|
|
USERPROFILE: join(root, "selected-home"),
|
|
CODEX_HOME: join(root, "selected-codex"),
|
|
npm_command: "exec",
|
|
};
|
|
vi.stubEnv("HOME", join(root, "ambient-home"));
|
|
vi.stubEnv("CODEX_HOME", join(root, "ambient-codex"));
|
|
vi.stubEnv("npm_command", "other");
|
|
try {
|
|
const doctor = createDoctorHostFixture({ homeDirectory: environment.HOME });
|
|
const host = systemSetupHost(doctor, environment);
|
|
const result = await runSetup(
|
|
{
|
|
...options(false),
|
|
dryRun: true,
|
|
clientIds: ["codex"],
|
|
installSkill: true,
|
|
},
|
|
host,
|
|
);
|
|
expect(result.status).toBe("planned");
|
|
expect(result.plannedActions).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: "configure_client:codex",
|
|
target: join(environment.CODEX_HOME, "config.toml"),
|
|
}),
|
|
expect.objectContaining({
|
|
id: "install_skill",
|
|
target: join(
|
|
environment.HOME,
|
|
".agents/skills",
|
|
PRODUCT_IDENTITY.skillName,
|
|
),
|
|
}),
|
|
]),
|
|
);
|
|
expect(host.registrationCommand).toEqual(
|
|
currentClientRegistrationCommand({ platform: "win32", environment }),
|
|
);
|
|
const clients = await systemUninstallHost(
|
|
environment.HOME,
|
|
undefined,
|
|
environment,
|
|
).clients();
|
|
expect(clients.find(({ name }) => name === "codex")?.configPath).toBe(
|
|
join(environment.CODEX_HOME, "config.toml"),
|
|
);
|
|
expect(JSON.stringify(result)).not.toContain("ambient-");
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("composes provider diagnostics from selected configuration rather than ambient configuration", async () => {
|
|
vi.stubEnv("GHIDRA_INSTALL_DIR", "/ambient/ghidra");
|
|
vi.stubEnv("REA_IDA_MCP_CONFIG", "/ambient/ida.json");
|
|
const host = createSystemDoctorHost({
|
|
GHIDRA_INSTALL_DIR: "/selected/ghidra",
|
|
REA_IDA_MCP_CONFIG: "/selected/ida.json",
|
|
});
|
|
const inspections = await host.providerInspections?.([]);
|
|
expect(inspections?.find(({ id }) => id === "ghidra")?.configured).toBe(true);
|
|
expect(JSON.stringify(inspections)).toContain("/selected/ghidra");
|
|
expect(JSON.stringify(inspections)).toContain("/selected/ida.json");
|
|
expect(JSON.stringify(inspections)).not.toContain("/ambient/");
|
|
});
|
|
|
|
it("includes the macOS Hopper installer diagnostic in setup remediation", async () => {
|
|
const install = vi
|
|
.spyOn(macHopperInstaller, "installMacHopper")
|
|
.mockResolvedValue({
|
|
status: "failed",
|
|
reason: "mount",
|
|
diagnostic: "hdiutil attach exited 1:\n\u001b[31mresource busy\u001b[0m",
|
|
});
|
|
const host = systemSetupHost(
|
|
createDoctorHostFixture({ platform: "darwin" }),
|
|
{ HOME: "/fixture/home", USERPROFILE: "/fixture/home" },
|
|
);
|
|
try {
|
|
const result = await host.installHopper(false);
|
|
expect(result).toMatchObject({
|
|
status: "failed",
|
|
code: "mount_failed",
|
|
remediation: expect.stringContaining(
|
|
"Installer detail: hdiutil attach exited 1: resource busy",
|
|
),
|
|
});
|
|
if (result.status !== "failed") throw new Error("expected setup failure");
|
|
expect(result.remediation).not.toContain("\u001b");
|
|
} finally {
|
|
await host.close?.();
|
|
install.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("preserves a validated selected JEB endpoint in setup registrations without claiming live readiness", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "rea-jeb-setup-"));
|
|
const endpoint = "http://127.0.0.1:8425/mcp/";
|
|
const ambientEndpoint = "http://127.0.0.1:8426/mcp";
|
|
vi.stubEnv("REA_JEB_MCP_URL", ambientEndpoint);
|
|
try {
|
|
const environment = {
|
|
HOME: root,
|
|
USERPROFILE: root,
|
|
CODEX_HOME: join(root, "codex"),
|
|
REA_JEB_MCP_URL: endpoint,
|
|
};
|
|
const doctorHost = createSystemDoctorHost(environment);
|
|
const setupHost = systemSetupHost(doctorHost, environment);
|
|
const state = await setupHost.initialSetupState();
|
|
|
|
expect(state.providerEnvironment).toHaveProperty(
|
|
"REA_JEB_MCP_URL",
|
|
endpoint,
|
|
);
|
|
const planned = await runSetup(
|
|
{
|
|
...options(false),
|
|
dryRun: true,
|
|
clientIds: ["codex"],
|
|
installSkill: false,
|
|
},
|
|
setupHost,
|
|
);
|
|
expect(planned.status).toBe("planned");
|
|
expect(JSON.stringify(planned.plannedActions)).toContain(endpoint);
|
|
expect(JSON.stringify(planned.plannedActions)).not.toContain(
|
|
ambientEndpoint,
|
|
);
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "registration-only settings",
|
|
environment: {
|
|
REA_ANALYSIS_PROVIDER: "auto",
|
|
REA_BRIDGE_MAX_REQUEST_BYTES: "33554432",
|
|
REA_GHIDRA_STARTUP_TIMEOUT_MS: "2700000",
|
|
REA_HOPPER_STARTUP_TIMEOUT_MS: "120000",
|
|
REA_MCP_INPUT_SCHEMA_PROFILE: "full",
|
|
REA_MCP_TOOL_CATALOG_PROFILE: "hot-cold",
|
|
REA_CAPTURE_DECODER_OLD_GENERATION_MIB: "256",
|
|
REA_REGEX_MATCHING_DEADLINE_MS: "10000",
|
|
REA_REGEX_WORKER_HEAP_REQUEST_MIB: "256",
|
|
REA_MCP_INITIALIZE_BUDGET_MS: "16000",
|
|
REA_MCP_TOOLS_LIST_BUDGET_MS: "6000",
|
|
REA_MCP_FIRST_CATALOG_BUDGET_MS: "21000",
|
|
REA_MCP_DOCTOR_DEADLINE_MS: "31000",
|
|
REA_WINDOWS_CHANGE_TIME_DRIFT_MS: "4000",
|
|
REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS: "50",
|
|
REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS: "80",
|
|
},
|
|
providerConfigured: false,
|
|
installHopper: true,
|
|
},
|
|
{
|
|
name: "an explicit unready provider selection",
|
|
environment: { REA_ANALYSIS_PROVIDER: "ghidra" },
|
|
providerConfigured: true,
|
|
installHopper: false,
|
|
},
|
|
])(
|
|
"derives Hopper proposal behavior from setup-host facts for $name",
|
|
async ({ environment, providerConfigured, installHopper }) => {
|
|
const doctorHost = createDoctorHostFixture({
|
|
platform: "darwin",
|
|
validTarget: () => Promise.resolve(false),
|
|
executable: () => Promise.resolve(false),
|
|
});
|
|
const setupHost = systemSetupHost(doctorHost, {
|
|
HOME: "/fixture/home",
|
|
USERPROFILE: "/fixture/home",
|
|
...environment,
|
|
});
|
|
try {
|
|
const initial = await setupHost.initialSetupState();
|
|
expect(initial.providerConfigured).toBe(providerConfigured);
|
|
if ("REA_MCP_INPUT_SCHEMA_PROFILE" in environment) {
|
|
expect(initial.providerEnvironment).toMatchObject({
|
|
REA_ANALYSIS_PROVIDER: "auto",
|
|
REA_BRIDGE_MAX_REQUEST_BYTES: "33554432",
|
|
REA_GHIDRA_STARTUP_TIMEOUT_MS: "2700000",
|
|
REA_HOPPER_STARTUP_TIMEOUT_MS: "120000",
|
|
REA_MCP_INPUT_SCHEMA_PROFILE: "full",
|
|
REA_MCP_TOOL_CATALOG_PROFILE: "hot-cold",
|
|
REA_CAPTURE_DECODER_OLD_GENERATION_MIB: "256",
|
|
REA_REGEX_MATCHING_DEADLINE_MS: "10000",
|
|
REA_REGEX_WORKER_HEAP_REQUEST_MIB: "256",
|
|
REA_MCP_INITIALIZE_BUDGET_MS: "16000",
|
|
REA_MCP_TOOLS_LIST_BUDGET_MS: "6000",
|
|
REA_MCP_FIRST_CATALOG_BUDGET_MS: "21000",
|
|
REA_MCP_DOCTOR_DEADLINE_MS: "31000",
|
|
REA_WINDOWS_CHANGE_TIME_DRIFT_MS: "4000",
|
|
REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS: "50",
|
|
REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS: "80",
|
|
});
|
|
} else {
|
|
expect(initial.providerEnvironment).not.toHaveProperty(
|
|
"REA_BRIDGE_MAX_REQUEST_BYTES",
|
|
);
|
|
expect(initial.providerEnvironment).not.toHaveProperty(
|
|
"REA_REGEX_MATCHING_DEADLINE_MS",
|
|
);
|
|
expect(initial.providerEnvironment).not.toHaveProperty(
|
|
"REA_REGEX_WORKER_HEAP_REQUEST_MIB",
|
|
);
|
|
expect(initial.providerEnvironment).not.toHaveProperty(
|
|
"REA_MCP_INITIALIZE_BUDGET_MS",
|
|
);
|
|
}
|
|
const discovery = await discoverSetupState({
|
|
host: setupHost,
|
|
providerConfigured: initial.providerConfigured,
|
|
forceHopperInstall: false,
|
|
proposeHopper: true,
|
|
initialDoctor: initial.doctor,
|
|
});
|
|
expect(discovery.installHopper).toBe(installHopper);
|
|
} finally {
|
|
await setupHost.close?.();
|
|
}
|
|
},
|
|
);
|
|
|
|
it("passes only selected environment to updater subprocesses and drops npm invocation identity", async () => {
|
|
vi.stubEnv("REA_UPDATE_MARKER", "ambient");
|
|
const result = await updateCommands.runProbe(
|
|
[
|
|
process.execPath,
|
|
"-e",
|
|
"process.stdout.write(JSON.stringify({marker:process.env.REA_UPDATE_MARKER,npm:process.env.npm_command,ambient:process.env.HOME}))",
|
|
],
|
|
{ REA_UPDATE_MARKER: "selected", npm_command: "exec" },
|
|
);
|
|
expect(result).toEqual({ ok: true, value: '{"marker":"selected"}' });
|
|
});
|
|
|
|
it.each([
|
|
{ userAgent: "npm/10.9.0 node/v24.11.0 win32 x64", runner: "npx" },
|
|
{ userAgent: "bun/1.4.2 npm/? node/v26.3.0 win32 x64", runner: "bunx" },
|
|
] as const)(
|
|
"registers the $runner launcher from a Windows CLI environment snapshot",
|
|
({ userAgent, runner }) => {
|
|
// The CLI snapshot canonicalizes Windows variable names to upper case.
|
|
const environment = snapshotEnvironment(
|
|
{ npm_command: "exec", npm_config_user_agent: userAgent },
|
|
"win32",
|
|
);
|
|
expect(invokingPackageRunner(environment, "win32")).toBe(runner);
|
|
expect(
|
|
systemSetupHost(
|
|
createDoctorHostFixture({ platform: "win32" }),
|
|
environment,
|
|
).registrationCommand,
|
|
).toEqual(currentClientRegistrationCommand({ platform: "win32" }));
|
|
expect(withoutPackageRunnerIdentity(environment, "win32")).toEqual({
|
|
NPM_CONFIG_USER_AGENT: userAgent,
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{
|
|
architecture: "x64",
|
|
hopperSetupSupported: true,
|
|
code: "download_failed",
|
|
downloads: 1,
|
|
},
|
|
{
|
|
architecture: "arm64",
|
|
hopperSetupSupported: false,
|
|
code: "unsupported_host",
|
|
downloads: 0,
|
|
},
|
|
] as const)(
|
|
"installs Linux Hopper from the selected doctor host's $architecture classification",
|
|
async ({ architecture, hopperSetupSupported, code, downloads }) => {
|
|
// Installation must follow the doctor host's facts, never the ambient
|
|
// os-release and process.arch. A refused download keeps the supported case
|
|
// from writing or invoking a package manager.
|
|
const fetch = vi
|
|
.spyOn(globalThis, "fetch")
|
|
.mockResolvedValue(new Response(null, { status: 403 }));
|
|
const host = systemSetupHost(
|
|
createDoctorHostFixture({
|
|
platform: "linux",
|
|
architecture,
|
|
linuxDistribution: () =>
|
|
Promise.resolve({
|
|
id: "ubuntu",
|
|
versionId: "24.04",
|
|
packageFamily: "deb",
|
|
hopperSetupSupported,
|
|
}),
|
|
}),
|
|
{ HOME: "/fixture/home" },
|
|
);
|
|
try {
|
|
expect(await host.installHopper(false)).toMatchObject({
|
|
status: "failed",
|
|
code,
|
|
});
|
|
expect(fetch).toHaveBeenCalledTimes(downloads);
|
|
} finally {
|
|
fetch.mockRestore();
|
|
await host.close?.();
|
|
}
|
|
},
|
|
);
|
|
|
|
it("registers the selected Windows package runner after canonicalizing environment aliases", async () => {
|
|
const environment = snapshotEnvironment(
|
|
{
|
|
NPM_COMMAND: "run",
|
|
npm_command: "exec",
|
|
NPM_CONFIG_USER_AGENT: "npm/10.9.0 node/v24.11.0 win32 x64",
|
|
},
|
|
"win32",
|
|
);
|
|
expect(environment).toEqual({
|
|
NPM_COMMAND: "exec",
|
|
NPM_CONFIG_USER_AGENT: "npm/10.9.0 node/v24.11.0 win32 x64",
|
|
});
|
|
expect(invokingPackageRunner(environment, "win32")).toBe("npx");
|
|
|
|
const host = systemSetupHost(
|
|
createDoctorHostFixture({ platform: "win32" }),
|
|
environment,
|
|
);
|
|
try {
|
|
expect(host.registrationCommand).toEqual(
|
|
currentClientRegistrationCommand({ platform: "win32" }),
|
|
);
|
|
} finally {
|
|
await host.close?.();
|
|
}
|
|
});
|
|
|
|
let updateCommands: UpdateCommandRunner;
|
|
beforeEach(() => {
|
|
updateCommands = new UpdateCommandRunner();
|
|
});
|
|
afterEach(async () => {
|
|
await updateCommands.close();
|
|
});
|