* fix(kuna): reject dangling cache roots in doctor Treat a cache path as creatable only when no directory entry occupies it. A dangling symbolic link, including one hidden by a trailing separator, fails the cache check and clears provider availability while the installation check and caller-selected registration settings remain. Fixes #5554 Fixes #5105 Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(doctor): admit the same settings native analysis rejects Project parseConfig failures into scoped engine readiness. Invalid provider preferences, startup timeouts, and other shared settings now fail the selected provider and the full audit, while a JEB-only timeout stays outside that admission. Fixes #5454 Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(doctor): report an invalid log level from doctor The dispatcher rejected REA_LOG_LEVEL before every command, so doctor never emitted its readiness check. Doctor now runs and names that setting; other commands still stop before execution. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(doctor): require log-level failures in every scope An invalid REA_LOG_LEVEL stops the executable and MCP server before a client or skill can run, so those scopes now fail readiness too. Other native admission failures stay limited to a selected provider. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(kuna): strip only the platform cache separator On Darwin and Linux a trailing backslash is a filename character. Doctor now removes only the selected platform separator before inspecting the cache path. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(doctor): fail every provider on an invalid Hopper timeout REA_HOPPER_STARTUP_TIMEOUT_MS is rejected before any native engine starts. A selected non-Hopper provider now includes the existing timeout check. Co-authored-by: morluto <morluto@users.noreply.github.com> * style(doctor): format the Hopper timeout regression Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(doctor): fail client scopes on MCP startup errors A registered client starts the MCP server, and parseConfig rejects every native admission failure before that server starts. Client readiness now includes those failures, not only REA_LOG_LEVEL. Co-authored-by: morluto <morluto@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: morluto <morluto@users.noreply.github.com>
217 lines
7.5 KiB
TypeScript
217 lines
7.5 KiB
TypeScript
import { access, chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import { delimiter, join } from "node:path";
|
|
|
|
import { expect } from "vitest";
|
|
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
import { cliTest } from "../../support/cli/cliFixture.js";
|
|
|
|
cliTest.skipIf(!["darwin", "linux"].includes(process.platform))(
|
|
"reports invalid Hopper startup timeouts without launching the discovered engine",
|
|
async ({ cli }) => {
|
|
const home = await createTestTempDirectory("rea-doctor-hopper-timeout-");
|
|
const launcher = join(home, "hopper");
|
|
const marker = join(home, "launched");
|
|
await writeFile(launcher, `#!/bin/sh\nprintf launched > '${marker}'\n`);
|
|
await chmod(launcher, 0o700);
|
|
|
|
for (const [timeout, expectedExit] of [
|
|
["0", 1],
|
|
["120000", 0],
|
|
] as const) {
|
|
const environment = {
|
|
...(process.platform === "linux"
|
|
? {
|
|
// Keep the foreign launcher unsupported without reaching the
|
|
// release network. This qualifies timeout diagnostics only.
|
|
NODE_OPTIONS: `--import=${new URL("../../fixtures/hopperRejectionFeed.mjs", import.meta.url).href}`,
|
|
}
|
|
: {}),
|
|
HOME: home,
|
|
HOPPER_LAUNCHER_PATH: launcher,
|
|
REA_HOPPER_STARTUP_TIMEOUT_MS: timeout,
|
|
GHIDRA_INSTALL_DIR: join(home, "missing-ghidra"),
|
|
};
|
|
const providers = await cli.run({
|
|
arguments: ["providers", "--json"],
|
|
environment,
|
|
});
|
|
expect(providers.exitCode).toBe(expectedExit);
|
|
const doctor = await cli.run({
|
|
arguments: ["doctor", "--provider", "hopper", "--json"],
|
|
environment,
|
|
});
|
|
// Linux independently rejects this shell fixture as a Hopper build.
|
|
const healthy = process.platform === "darwin" && expectedExit === 0;
|
|
expect(doctor.exitCode).toBe(healthy ? 0 : 1);
|
|
expect(doctor.json).toMatchObject({
|
|
healthy,
|
|
scope_checks: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
name: "hopper-startup-timeout",
|
|
ok: expectedExit === 0,
|
|
classification: expectedExit === 0 ? "healthy" : "config_drift",
|
|
detail: expect.stringContaining("REA_HOPPER_STARTUP_TIMEOUT_MS"),
|
|
}),
|
|
]),
|
|
});
|
|
}
|
|
await expect(access(marker)).rejects.toMatchObject({ code: "ENOENT" });
|
|
},
|
|
);
|
|
|
|
cliTest.skipIf(!["darwin", "linux"].includes(process.platform))(
|
|
"reports native configuration that blocks the selected engine",
|
|
async ({ cli }) => {
|
|
const home = await createTestTempDirectory("rea-doctor-native-config-");
|
|
const shared = {
|
|
HOME: home,
|
|
USERPROFILE: home,
|
|
GHIDRA_INSTALL_DIR: join(home, "missing-ghidra"),
|
|
HOPPER_LAUNCHER_PATH: join(home, "missing-hopper"),
|
|
};
|
|
const invalid = await cli.run({
|
|
arguments: ["doctor", "--provider", "hopper", "--json"],
|
|
environment: {
|
|
...shared,
|
|
REA_ANALYSIS_PROVIDER: "Hopper",
|
|
REA_GHIDRA_STARTUP_TIMEOUT_MS: "abc",
|
|
},
|
|
});
|
|
expect(invalid.exitCode, invalid.stderr).toBe(1);
|
|
expect(invalid.json).toMatchObject({
|
|
healthy: false,
|
|
scope_checks: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
name: "runtime-configuration:REA_ANALYSIS_PROVIDER",
|
|
ok: false,
|
|
classification: "config_drift",
|
|
}),
|
|
expect.objectContaining({
|
|
name: "runtime-configuration:REA_GHIDRA_STARTUP_TIMEOUT_MS",
|
|
ok: false,
|
|
classification: "config_drift",
|
|
}),
|
|
]),
|
|
});
|
|
|
|
const jebOnly = await cli.run({
|
|
arguments: ["doctor", "--provider", "hopper", "--json"],
|
|
environment: { ...shared, REA_JEB_MCP_TIMEOUT_MS: "abc" },
|
|
});
|
|
const checks = (jebOnly.json as { checks?: { name: string }[] } | undefined)
|
|
?.checks;
|
|
expect(
|
|
checks?.some((check) => check.name.startsWith("runtime-configuration:")),
|
|
).toBe(false);
|
|
},
|
|
);
|
|
|
|
cliTest(
|
|
"reports MCP startup failures for a selected client",
|
|
async ({ cli }) => {
|
|
const doctor = await cli.run({
|
|
arguments: ["--json", "doctor", "--client", "codex"],
|
|
environment: { REA_GHIDRA_STARTUP_TIMEOUT_MS: "abc" },
|
|
});
|
|
expect(doctor.exitCode, doctor.stderr).toBe(1);
|
|
expect(doctor.stderr).toBe("");
|
|
expect(doctor.json).toMatchObject({
|
|
healthy: false,
|
|
scope: { mode: "explicit", clients: ["codex"], providers: [] },
|
|
scope_checks: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
name: "runtime-configuration:REA_GHIDRA_STARTUP_TIMEOUT_MS",
|
|
ok: false,
|
|
}),
|
|
]),
|
|
});
|
|
},
|
|
);
|
|
|
|
cliTest.skipIf(!["darwin", "linux"].includes(process.platform))(
|
|
"retains a configured Hopper path in host diagnostics without launching it",
|
|
async ({ cli }) => {
|
|
const home = await createTestTempDirectory("rea-doctor-hopper-home-");
|
|
const launcher = `${home}/bin/../bin/hopper`;
|
|
const launchMarker = join(home, "hopper-was-launched");
|
|
await mkdir(join(home, "bin"), { recursive: true });
|
|
await writeFile(
|
|
launcher,
|
|
'#!/bin/sh\nprintf launched > "$REA_HOPPER_TEST_MARKER"\nexit 99\n',
|
|
);
|
|
await chmod(launcher, 0o700);
|
|
const loaderMarker = join(home, "loader-checked-launcher");
|
|
if (process.platform === "linux") {
|
|
// A shell fixture needs controlled loader diagnostics on Linux. This
|
|
// checks discovery and path preservation, not actual Hopper compatibility.
|
|
const loader = join(home, "bin", "ldd");
|
|
await writeFile(
|
|
loader,
|
|
[
|
|
"#!/bin/sh",
|
|
'test "$#" -eq 1 || exit 87',
|
|
'test "$1" = "$HOPPER_LAUNCHER_PATH" || exit 88',
|
|
'printf "%s" "$1" > "$REA_HOPPER_LDD_TEST_MARKER"',
|
|
'printf "%s\\n" "libc.so.6 => /fixture/libc.so.6 (0x1)"',
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
await chmod(loader, 0o700);
|
|
}
|
|
|
|
const result = await cli.run({
|
|
arguments: ["doctor", "--provider", "hopper", "--json"],
|
|
cwd: home,
|
|
environment: {
|
|
...(process.platform === "linux"
|
|
? {
|
|
PATH: [join(home, "bin"), process.env.PATH ?? ""].join(delimiter),
|
|
REA_HOPPER_LDD_TEST_MARKER: loaderMarker,
|
|
NODE_OPTIONS: `--import=${new URL("../../fixtures/hopperRejectionFeed.mjs", import.meta.url).href}`,
|
|
}
|
|
: {}),
|
|
HOME: home,
|
|
USERPROFILE: home,
|
|
HOPPER_LAUNCHER_PATH: launcher,
|
|
REA_HOPPER_TEST_MARKER: launchMarker,
|
|
GHIDRA_INSTALL_DIR: join(home, "missing-ghidra"),
|
|
},
|
|
timeoutMs: 20_000,
|
|
});
|
|
|
|
const hopperReady = process.platform === "darwin";
|
|
expect(result.signal).toBeNull();
|
|
expect(result.exitCode).toBe(hopperReady ? 0 : 1);
|
|
expect(result.json).toMatchObject({
|
|
healthy: hopperReady,
|
|
environment_healthy: false,
|
|
hopperPath: launcher,
|
|
checks: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
name: "hopper",
|
|
ok: true,
|
|
detail: launcher,
|
|
classification: "healthy",
|
|
}),
|
|
...(process.platform === "linux"
|
|
? [
|
|
expect.objectContaining({
|
|
name: "hopper-version",
|
|
ok: false,
|
|
detail: launcher,
|
|
classification: "config_drift",
|
|
remediation: expect.stringContaining("HOPPER_LAUNCHER_PATH"),
|
|
}),
|
|
]
|
|
: []),
|
|
]),
|
|
});
|
|
await expect(access(launchMarker)).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
if (process.platform === "linux")
|
|
expect(await readFile(loaderMarker, "utf8")).toBe(launcher);
|
|
},
|
|
);
|