Files
rea/tests/boundary/cli/doctorRegistrationEnvironment.test.ts
Kaoru0822andClaude Opus 5.5 6a0ccf7eeb fix(doctor): report provider settings a client registration does not record (#8645)
* fix(doctor): report provider settings a client registration does not record

Provider checks read the terminal running doctor, so a registration with no
env block looked ready while its server reported not_configured. Compare what
an available provider relied on with the registration's own file and report the
setting names in a non-blocking registration_environment check. The client's
effective environment stays unknown, so health and the exit status are unchanged.

Fixes #8202

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(doctor): write the Cordis fixture at its literal path

The test resolved the path through supportedClients, whose default environment
argument CodeQL traces to the fixture's writes even though the test passes {}.
It only needed the path, so spell it like the other fixtures in this file and
drop a leftover directory it never used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 05:14:50 -08:00

290 lines
9.9 KiB
TypeScript

import { chmod, mkdir, readFile, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { z } from "zod";
import { elf, thinMach } from "../../../src/domain/binaryTarget.fixture.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest, type TestCli } from "../../support/cli/cliFixture.js";
/** An installation doctor admits; nothing here starts an analysis JVM. */
const writeAdmittedGhidra = async (root: string) => {
const jdk = join(root, "jdk");
const install = join(root, "ghidra");
const native = join(
install,
"Ghidra/Features/Decompiler/os",
`${process.platform === "darwin" ? "mac" : "linux"}_${process.arch === "arm64" ? "arm_64" : "x86_64"}`,
"decompile",
);
for (const path of [
join(jdk, "bin"),
join(install, "support"),
join(native, ".."),
])
await mkdir(path, { recursive: true });
await writeFile(
join(install, "Ghidra/application.properties"),
"application.version=12.1.4\napplication.java.min=21\n",
);
await writeFile(
join(install, "support/analyzeHeadless"),
"#!/bin/sh\nexit 99\n",
{ mode: 0o700 },
);
await writeFile(
native,
process.platform === "darwin"
? thinMach(0xfeedfacf, process.arch === "arm64" ? 0x0100000c : 0x01000007)
: elf(2, 1, process.arch === "arm64" ? 183 : 62),
);
await chmod(native, 0o700);
const properties = [
"java.version = 21.0.11",
`java.home = ${jdk}`,
"sun.arch.data.model = 64",
`os.arch = ${process.arch === "arm64" ? "aarch64" : "amd64"}`,
`os.name = ${process.platform === "darwin" ? "Mac OS X" : "Linux"}`,
"",
].join("\n");
await writeFile(
join(jdk, "bin/java"),
`#!${process.execPath}\nprocess.stderr.write(${JSON.stringify(properties)});\n`,
{ mode: 0o700 },
);
await writeFile(join(jdk, "bin/javac"), "#!/bin/sh\nexit 0\n", {
mode: 0o700,
});
return { install, jdk };
};
const reportSchema = z.object({
healthy: z.boolean(),
scope_checks: z.array(z.object({ name: z.string() }).loose()),
informational_checks: z.array(z.object({ name: z.string() }).loose()),
identity: z.object({
registrations: z.array(z.object({ client: z.string() }).loose()),
}),
});
const run = async (
cli: TestCli,
arguments_: readonly string[],
environment: NodeJS.ProcessEnv,
) => {
const result = await cli.run({
arguments: [...arguments_, "--json"],
environment,
timeoutMs: 30_000,
});
return { result, report: reportSchema.safeParse(result.json) };
};
const doctor = async (
cli: TestCli,
scope: readonly string[],
environment: NodeJS.ProcessEnv,
) => {
const { result, report } = await run(cli, ["doctor", ...scope], environment);
if (!report.success) throw new Error(result.stdout + result.stderr);
return { exitCode: result.exitCode, report: report.data };
};
const names = (checks: readonly { readonly name: string }[]) =>
checks.map(({ name }) => name);
const CHECK = "registration_environment:claude_code";
cliTest.skipIf(!["darwin", "linux"].includes(process.platform))(
"doctor does not let a configured terminal stand in for an unconfigured registration",
{ timeout: 120_000 },
async ({ cli }) => {
const root = await createTestTempDirectory("rea-doctor-registration-env-");
const home = join(root, "home");
await mkdir(home);
const { install, jdk } = await writeAdmittedGhidra(root);
const clientEnvironment = {
HOME: home,
USERPROFILE: home,
// The host's own Ghidra or Java must not satisfy this fixture.
PATH: "/usr/bin:/bin",
HOPPER_LAUNCHER_PATH: join(root, "missing-hopper"),
REA_LOG_LEVEL: "silent",
};
const terminal = {
...clientEnvironment,
GHIDRA_INSTALL_DIR: install,
JAVA_HOME: jdk,
};
const configPath = join(home, ".claude.json");
const saved = async () =>
z
.object({
mcpServers: z.object({
rea: z.object({ env: z.record(z.string(), z.string()) }),
}),
})
.parse(JSON.parse(await readFile(configPath, "utf8"))).mcpServers.rea
.env;
// Setup ran where the provider settings were not visible.
const unconfigured = await run(
cli,
["setup", "--client", "claude_code", "--skill=false", "--yes"],
clientEnvironment,
);
expect(unconfigured.result.exitCode, unconfigured.result.stdout).toBe(0);
expect(await saved()).not.toHaveProperty("GHIDRA_INSTALL_DIR");
// The registration's own environment is what a client without these
// settings gives its server; it does not reach the terminal's result.
const served = await doctor(cli, ["--provider", "ghidra"], {
...(await saved()),
HOME: home,
USERPROFILE: home,
PATH: clientEnvironment.PATH,
});
expect(served.exitCode).toBe(1);
expect(served.report.scope_checks).toContainEqual(
expect.objectContaining({
name: "ghidra",
ok: false,
classification: "missing_analysis_engine",
}),
);
expect(names(served.report.scope_checks)).not.toContain(CHECK);
for (const scope of [
["--provider", "ghidra"],
["--client", "claude_code"],
["--provider", "ghidra", "--client", "claude_code"],
]) {
const scoped = await doctor(cli, scope, terminal);
expect(scoped.report.scope_checks, scope.join(" ")).toContainEqual(
expect.objectContaining({
name: CHECK,
ok: true,
classification: "unknown",
detail: expect.stringContaining(
"does not record GHIDRA_INSTALL_DIR, JAVA_HOME",
),
details: {
effective_environment: "unknown",
providers: ["ghidra"],
},
remediation: expect.stringContaining(
"rea setup --client claude_code --skill=false --dry-run",
),
}),
);
// An unobserved environment is neither a failure nor a healthy match.
expect(scoped.report.healthy, scope.join(" ")).toBe(true);
expect(scoped.exitCode, scope.join(" ")).toBe(0);
expect(scoped.report.identity.registrations).toEqual([
expect.objectContaining({
client: "claude_code",
state: "aligned",
provider_settings: {
unrecorded: ["GHIDRA_INSTALL_DIR", "JAVA_HOME"],
differing: [],
unresolved: [],
},
}),
]);
}
const unrelated = await doctor(cli, ["--skill"], terminal);
expect(names(unrelated.report.scope_checks)).not.toContain(CHECK);
expect(names(unrelated.report.informational_checks)).toContain(CHECK);
// Doctor reads; the configuration is unchanged until setup is approved.
const before = await readFile(configPath, "utf8");
await doctor(cli, [], terminal);
expect(await readFile(configPath, "utf8")).toBe(before);
// The named remediation records exactly these settings and then clears it.
const planned = await run(
cli,
["setup", "--client", "claude_code", "--skill=false", "--dry-run"],
terminal,
);
expect(planned.result.stdout).toContain(`GHIDRA_INSTALL_DIR=${install}`);
expect(planned.result.stdout).toContain(`JAVA_HOME=${jdk}`);
expect(await readFile(configPath, "utf8")).toBe(before);
const applied = await run(
cli,
["setup", "--client", "claude_code", "--skill=false", "--yes"],
terminal,
);
expect(applied.result.exitCode, applied.result.stdout).toBe(0);
expect(await saved()).toMatchObject({
GHIDRA_INSTALL_DIR: install,
JAVA_HOME: jdk,
});
expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(before);
const recorded = await doctor(cli, ["--provider", "ghidra"], terminal);
expect(recorded.exitCode).toBe(0);
expect(names(recorded.report.scope_checks)).not.toContain(CHECK);
expect(recorded.report.identity.registrations[0]).not.toHaveProperty(
"provider_settings",
);
// Another terminal selecting a different JDK spelling is reported as that.
const alias = join(root, "jdk-alias");
await symlink(jdk, alias);
const otherTerminal = { ...terminal, JAVA_HOME: alias };
const other = await doctor(cli, ["--provider", "ghidra"], otherTerminal);
expect(other.report.identity.registrations[0]).toMatchObject({
provider_settings: {
unrecorded: [],
differing: ["JAVA_HOME"],
unresolved: [],
},
});
// Setup's own spelling of a caller selection compares as recorded.
await run(
cli,
["setup", "--client", "claude_code", "--skill=false", "--yes"],
otherTerminal,
);
expect(await saved()).toMatchObject({ JAVA_HOME: alias });
const aliased = await doctor(cli, ["--provider", "ghidra"], otherTerminal);
expect(names(aliased.report.scope_checks)).not.toContain(CHECK);
},
);
cliTest.skipIf(!["darwin", "linux"].includes(process.platform))(
"doctor adds nothing when the terminal's provider is not available",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-doctor-registration-off-");
const home = join(root, "home");
await mkdir(home);
const environment = {
HOME: home,
USERPROFILE: home,
PATH: "/usr/bin:/bin",
HOPPER_LAUNCHER_PATH: join(root, "missing-hopper"),
REA_LOG_LEVEL: "silent",
};
await run(
cli,
["setup", "--client", "claude_code", "--skill=false", "--yes"],
environment,
);
// A provider setting that fails its own check has no green result to
// misread; that failure is the report.
const { report } = await doctor(cli, ["--provider", "ghidra"], {
...environment,
GHIDRA_INSTALL_DIR: join(root, "missing-ghidra"),
});
expect(report.healthy).toBe(false);
expect(names(report.scope_checks)).not.toContain(CHECK);
expect(names(report.informational_checks)).not.toContain(CHECK);
expect(report.identity.registrations[0]).not.toHaveProperty(
"provider_settings",
);
},
);