Compare commits

...
Author SHA1 Message Date
gensokyo-devandClaude Opus 5.5 5b158b2d1d fix(setup): stop treating Antigravity's .gemini storage as Gemini CLI
Antigravity keeps its MCP configuration and skills under
~/.gemini/config, and Gemini CLI was detected by the bare ~/.gemini
directory. Setting up Antigravity therefore made broad setup select
Gemini CLI and write a registration nobody asked for.

Gemini CLI's applicability now follows its own evidence: settings.json,
its trusted-folder records (honoring GEMINI_CLI_TRUSTED_FOLDERS_PATH) and
its .gemini/tmp session state, through the existing discoveryPaths
owner that detection, status and uninstall share. Selecting
--client gemini_cli explicitly is unchanged.

Fixes #7567

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 05:36:32 +01:00
gensokyo-devandClaude Opus 5.5 899f77d840 fix(setup): say when an authentic Hopper package mismatches its feed hash
Linux Hopper setup verifies the package signature, then compares the
launcher it contains with the release feed's main_exe_hash. When only
that comparison failed, as with the Fedora 6.6.1 RPM, setup reported the
generic integrity failure and advised retrying or updating REA, which
cannot change a stale vendor feed.

The publisher now reports that case as launcher_digest_mismatch. Setup
keeps the public integrity_mismatch code and the refusal, and its
recovery says the download is authentic, the feed describes another
launcher build, and the mismatch needs a corrected feed. The two digests
stay in the detail. Every other integrity failure keeps its recovery.

Fixes #8138

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 05:36:32 +01:00
gensokyo-devandClaude Opus 5.5 aa848cf1d2 fix(android): name the JADX JAR download and checksum in recovery
Doctor and configuration failures for a missing or rejected
REA_JADX_MCP_JAR asked for a "caller-supplied" jadx-headless-mcp 0.7.1
JAR without saying where it comes from. The upstream project has since
renamed its packages, so users concluded the JAR could not be obtained.

The shared JADX JAR remediation now names the audited release URL and
SHA-256 that REA already pins. The two capability-availability texts
point to rea doctor --provider jadx for them instead of repeating the
release data outside its owner.

Fixes #7768

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 05:36:32 +01:00
gensokyo-devandClaude Opus 5.5 82f3bfb6e8 fix(doctor): refuse an empty --target like other target inputs
`rea doctor --target ""` passed an empty string to target readiness,
which checked the working directory instead of reporting that no app
was named. Use the shared local path schema, so an empty target fails
validation before any readiness check, as every other target does.

Fixes #7561

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 05:36:32 +01:00
gensokyo-devandClaude Opus 5.5 5557d9b3eb fix(doctor): count installations by resolved launcher directory
On Windows `where rea` lists the npm shims rea, rea.cmd and rea.ps1 that
one global install writes side by side, and doctor counted each listed
path as an installation, so a single install reported "multiple".

The system host now pairs each PATH launcher with the installation it
resolves to: its directory after symlink resolution, compared
case-insensitively on Windows. Doctor counts distinct installations and
still lists every launcher path. Two POSIX launchers that resolve to one
package also count once; an unresolvable launcher keeps its own path.

Fixes #8122

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 05:36:32 +01:00
3a13b088e6 fix(mcp): explain skipped doctor identity probes
Report identity as unobserved when the selected tool families omit
binary_session, preserving the current health decision and exit status.
Cover scoped production sessions and retain validation of successful
and schema-invalid probe responses.

Fixes #7438

[gensokyo-dev: rebased onto main; report "identity not observed" for every
request-flow reply that was not received (skipped scope, transport failure or
rejected output schema), not only skipped scopes; corrected the replayed
inventory-mode expectation, whose identity is observed and matches.]

Fixes #7438

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 05:36:32 +01:00
gensokyo-devandClaude Opus 5.5 d412569293 fix(mcp-doctor): close only through the owned transport
The owned transport is the doctor's only cleanup owner, and Client.close
only delegates to it. Close it once and report its typed failure instead
of aggregating a duplicate SDK close and clearing that failure after a
retry. Pass the filtered caller environment as the launcher's base only,
and name the doctor operation in transport cleanup errors.

Cover a descendant in its own session, which only the token sweep can
reach, and a late cleanup denial that keeps the startup diagnostic and
reports the retained run. Bound the fixture descendant's lifetime so a
failing run cannot leak it. Document the doctor's process-family
ownership and output bound.

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 05:36:23 +01:00
2SinceJuly 1b019a73d8 fix(mcp-doctor): keep the diagnostic child target-free and its stderr visible
The owned launcher merges its host base back under the explicit overrides, so
filtering only the override object let an ambient HOPPER_TARGET_* variable
through and the diagnostic child could open the target the doctor promises to
skip. Pass the filtered environment as the host base too, and opt into the
token sweep so a detached descendant that outlives the launcher is still
reached by cleanup.

Also keep the stderr observer attached until bounded cleanup and draining have
settled, report the typed cleanup resources instead of a generic close_binary
message, and drop a first-attempt cleanup failure once the owned retry proves
release.
2026-11-14 05:36:23 +01:00
2SinceJuly d427fc05c3 fix(mcp-doctor): stop the owned process family on every exit path
runProductionMcpDoctor wrapped the SDK StdioClientTransport and only joined its
close calls. That transport clears its direct-child reference, ends stdin and
signals only that child, so a server whose launcher exited after forking an
inherited-stdio descendant left the descendant running while the doctor
reported nothing but the transport deadline.

Run the doctor on OwnedMcpStdioTransport, which spawns the child in a dedicated
POSIX process group or Windows job and verifies group and token-owned
descendant cleanup before close returns. Feed stderr through a sink on
acquisition so diagnostics survive an early child exit and still honor the
64 KiB retained prefix and full byte count.

Covered by a fixture that forks a descendant, exits, and is timed out: the
regression asserts the descendant is gone after the doctor returns.
2026-11-14 05:36:23 +01:00
gensokyo-devandClaude Opus 5.5 3f7d0d2d21 test(process): drive kernel thread exclusion through Linux procfs
Replace the simulated-host sweep test with one that parses PF_KTHREAD
from a controlled /proc/<pid>/stat record through the system ownership
host, with root-matching credentials and an ESRCH environ read. The
user-process control with identical facts stays fail-closed. Name the
task flag constants and correct the comments: the reported kernel
threads were live, not exiting.

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 05:36:23 +01:00
2SinceJuly 5bb6591d58 fix(process): exonerate kernel threads from ownership token checks
An unrelated kernel thread that appeared during a provider run has no
readable /proc/<pid>/environ, so the ownership sweep recorded it as a live
candidate with an unreadable token and failed cleanup with
cleanup_incomplete. Kernel threads exit quickly, so the read usually
reports ESRCH and is not attributable to environment withholding, which
meant a cleanly finished provider run discarded its result.

Kernel threads are forked by the kernel, never by a provider, so they can
never carry that run's token. Read PF_KTHREAD from the same /proc/<pid>/stat
identity observation and treat a kernel thread with no readable token as
unverified evidence, like an unrelated process whose environment the OS
withholds. The candidate still has to be otherwise unrelated and holds no
signal authority.
2026-11-14 05:36:23 +01:00
gensokyo-devandClaude Opus 5.5 7db1b61790 fix(ghidra): qualify inferred no-return cuts by the excluded fall-through
A terminal call on one branch does not end the whole function: other
branches can still reach the following code, which stays in the body and
pseudocode. The function-level limitation now says Ghidra excluded that
call's fall-through and that code reachable only through it is missing
if the callee returns. It is listed before the call-specific facts so a
bounded composed summary that admits limitations in order keeps it.

The body-coverage tests now check the counted records and their reasons
instead of freezing the explanatory sentence.

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 05:34:39 +01:00
gensokyo-devandClaude Opus 5.5 ad2111fac6 fix(ghidra): say when an inferred no-return call cuts a function
Ghidra's Non-Returning Functions - Discovered analyzer can mark a
returning callee, such as an imported operator new thunk, as
non-returning. Ghidra then clears the fallthrough at each call site and
refits the callers, so a constructor decompiles to a few plausible
lines. REA reported the terminal call with the FunctionManager flag but
did not say the result omits the code after it or how to compare.

That analyzer bookmarks each entry it marks with the Analysis comment
"Non-Returning Function Found" (Ghidra 11.0 through 12.1.4 source); the
name-list Known analyzer writes "Non-Returning Function Identified".
When a terminal call's callee, or the function a thunk forwards to,
carries the Discovered bookmark, the call's limitation now says the
flag was inferred, and the function adds one address-free limitation
that its body and pseudocode end at that call and omit the following
code if the callee returns, with the REA_GHIDRA_DISABLED_ANALYZERS
comparison. A flag without the bookmark keeps an unknown origin.

The function-boundary observation contract moves to v5 so snapshots
from earlier bridges are not replayed without the qualification.

Refs #10365

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 05:34:39 +01:00
gensokyo-devandClaude Opus 5.5 9d082b144a fix(decompile): derive the body-coverage caveat from retained records
decompile_all set its top-level "procedure_info is unavailable" caveat
from a per-call flag that only a newly attempted function could raise.
A resume that skipped every verified file, or selected nothing, rewrote
the manifest and decompile-index.json with an empty limitations list
while every retained record still reported an unavailable body.

The export owner now answers from the records it retains: the summary
counts every non-external record whose body is unavailable, including
records an earlier run wrote, and repeats the reason when all share one.
The per-call flag is removed. A fresh export whose procedure_info works
but leaves some bodies unavailable now reports that caveat too; a later
attempt that records a known body clears it.

Fixes #8351

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 05:34:39 +01:00
gensokyo-devandClaude Opus 5.5 e8c838e8bd test(mcp): keep the surrogate path handler check within its deadline
The handler regression added in #10390 listed tools under the full input
profile and matched every advertised schema before calling each tool. It
took about 14 s locally and passed the 30 s deadline on loaded CI runners.
advertisedPatternsVMode.test.ts already proves that no input or output
profile advertises the surrogate escape, so this case now checks only the
runtime refusal through the default-profile handlers, as the
relative-path case does.

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 05:06:09 +01:00
gensokyo-devandClaude Opus 5.5 809f0d69db fix(mcp): report HTTP listener recovery from its parsed failure
rea mcp --http printed "Choose another --port or stop the process using
it" for every listener failure except a missing host allowlist, so a host
lookup failure, an unassigned address or a permission denial got port
advice. StreamableHttpListenError already classifies the native failure
and owns a recovery for each kind.

Startup now always prints that owned recovery behind one shared lead-in,
and the fixed message is removed. The native cause text stays out of
stderr as before.

Fixes #7346

Co-Authored-By: gensokyo-dev <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 04:57:03 +01:00
47 changed files with 972 additions and 136 deletions

No files matched your search

+49 -6
View File
@@ -77,6 +77,8 @@ import ghidra.program.model.data.StringDataInstance;
import ghidra.program.model.data.DataType;
import ghidra.program.model.lang.Register;
import ghidra.program.model.scalar.Scalar;
import ghidra.program.model.listing.Bookmark;
import ghidra.program.model.listing.BookmarkType;
import ghidra.program.model.listing.CommentType;
import ghidra.program.model.listing.Data;
import ghidra.program.model.listing.DataIterator;
@@ -3987,6 +3989,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
private final class FunctionBoundaryLimitations {
private final Function function;
private final List<String> limitations = new ArrayList<>();
private boolean inferredNoReturnCall;
private Instruction beforePrevious;
private Instruction previous;
@@ -3996,8 +3999,11 @@ public final class ReaGhidraBridge extends HeadlessScript {
private void observe(Instruction instruction) throws Exception {
monitor.checkCancelled();
String limitation = terminalCallLimitation(instruction);
if (limitation != null) limitations.add(limitation);
TerminalCall terminal = terminalCall(instruction);
if (terminal != null) {
limitations.add(terminal.limitation());
if (terminal.inferredNoReturn()) inferredNoReturnCall = true;
}
if (beforePrevious != null && previous != null) {
String seh = borlandSehHandlerLimitation(
function, beforePrevious, previous, instruction);
@@ -4008,10 +4014,42 @@ public final class ReaGhidraBridge extends HeadlessScript {
}
private List<String> values() {
return List.copyOf(limitations);
if (!inferredNoReturnCall) return List.copyOf(limitations);
List<String> result = new ArrayList<>();
// One consequence and recovery per function, ahead of the call-specific facts so a
// bounded summary that admits limitations in order keeps it. The text names no
// address, so those summaries also deduplicate it across functions.
result.add("A terminal call in this function targets a callee that " +
"Ghidra's Non-Returning Functions - Discovered analyzer inferred cannot return, so Ghidra " +
"excluded that call's fall-through from the analyzed body and from the pseudocode and facts " +
"derived from it. If the callee returns, code reachable only through that fall-through is " +
"missing from this result. To compare, analyze a fresh import with " +
"REA_GHIDRA_DISABLED_ANALYZERS='Non-Returning Functions - Discovered'.");
result.addAll(limitations);
return result;
}
}
/** A terminal call's limitation and whether its no-return flag was analyzer-inferred. */
private record TerminalCall(String limitation, boolean inferredNoReturn) {}
/**
* Ghidra's Non-Returning Functions - Discovered analyzer bookmarks each entry it marks with
* this Analysis comment; the name-list Known analyzer uses "Non-Returning Function Identified".
* A missing bookmark leaves the flag's origin unknown, never declared.
*/
private boolean discoveredNoReturn(Function target) {
Function thunked = target.isThunk() ? target.getThunkedFunction(true) : null;
return hasDiscoveredNoReturnBookmark(target) ||
(thunked != null && hasDiscoveredNoReturnBookmark(thunked));
}
private boolean hasDiscoveredNoReturnBookmark(Function function) {
Bookmark bookmark = currentProgram.getBookmarkManager().getBookmark(
function.getEntryPoint(), BookmarkType.ANALYSIS, "Non-Returning Function");
return bookmark != null && "Non-Returning Function Found".equals(bookmark.getComment());
}
private String borlandSehHandlerLimitation(
Function function, Instruction handlerPush, Instruction framePush,
Instruction frameLink) {
@@ -4104,9 +4142,10 @@ public final class ReaGhidraBridge extends HeadlessScript {
}
}
private String terminalCallLimitation(Instruction instruction) {
private TerminalCall terminalCall(Instruction instruction) {
if (!instruction.getFlowType().isCall() || !instruction.getFlowType().isTerminal())
return null;
boolean inferred = false;
Address[] destinations = instruction.getFlows();
StringBuilder limitation = new StringBuilder();
limitation.append("Ghidra Listing reports a terminal call at ")
@@ -4135,14 +4174,18 @@ public final class ReaGhidraBridge extends HeadlessScript {
Function target = destinations.length == 1
? currentProgram.getFunctionManager().getFunctionAt(destinations[0])
: null;
if (target != null && target.hasNoReturn()) {
if (target != null && target.hasNoReturn() && discoveredNoReturn(target)) {
inferred = true;
limitation.append(" Ghidra's Non-Returning Functions - Discovered analyzer bookmarked this callee as a non-returning function it found; the flag is an analysis inference, not an independent verification that the callee cannot return.");
}
else if (target != null && target.hasNoReturn()) {
limitation.append(" This records Ghidra's FunctionManager flag, not an independent verification that the callee cannot return.");
}
else {
limitation.append(" The reason for the terminal flow is not established by this observation.");
}
}
return limitation.toString();
return new TerminalCall(limitation.toString(), inferred);
}
private static String renderAssembly(List<Instruction> instructions) {
+9 -1
View File
@@ -1339,7 +1339,15 @@ bytes as text, omitting a final UTF-8 code point if it crosses that byte limit.
`stderr_bytes` counts all observed bytes, including omitted output;
`stderr_truncated` indicates that output exceeded the retention limit. The same
diagnostics appear inside the report when `--full-output` is selected. Log
truncation alone does not change the diagnostic health or exit status.
truncation alone does not change the diagnostic health or exit status. More than
32 MiB of combined child stdout and stderr stops the child and fails the check
in progress.
Doctor owns the server's process group on POSIX hosts, or its Job Object on
Windows. Before reporting, it stops that process family, including POSIX
descendants in other process groups that inherited its run token. If that
cleanup cannot be confirmed, the report keeps its earlier checks and adds a
failed `cleanup` check with the reason and retained run identifier.
The default terminal format is TOON. Use `--json` when saving results for a
JSON consumer. Valued output flags (`--format`, `--filter-output`,
+5 -1
View File
@@ -515,7 +515,11 @@ Setup installs its selected skill copy there.
Gemini CLI uses `$GEMINI_CLI_HOME/.gemini/settings.json` when that home override
is nonempty, including its shared personal skills at
`$GEMINI_CLI_HOME/.agents/skills`. Setup and doctor check MCP allow/exclude
`$GEMINI_CLI_HOME/.agents/skills`. Automatic detection uses Gemini CLI's own
settings, trusted-folder records or `.gemini/tmp` session state; the `.gemini`
directory alone does not identify it, because Antigravity keeps its
configuration there too. Select `--client gemini_cli` explicitly before those
files exist. Setup and doctor check MCP allow/exclude
policies from system defaults, user settings, trusted workspace settings, and
system overrides. Workspace trust follows `GEMINI_CLI_TRUST_WORKSPACE`,
`GEMINI_RESTRICTED_MODE`, the effective `security.folderTrust.enabled` setting,
+6 -1
View File
@@ -321,7 +321,12 @@ the others. A function that `procedure_info` classifies as external, such as an
import, is counted as `external` and recorded with that status and the body
fact as observed. It gets no file, no pseudocode is requested, and a resume
skips it. A function whose classification is unknown is decompiled as usual;
a provider that returns null pseudocode for it is reported as `no_pseudocode`. Inventory rows without an address are counted in the limitations. `--layout dwarf`
a provider that returns null pseudocode for it is reported as `no_pseudocode`. Inventory rows without an address are counted in the limitations.
The top-level `limitations` of the manifest and index count every retained
non-external function record whose body is unavailable, including records an
earlier run wrote, and repeat the reason when all of them share one. A resume
that skips every file therefore keeps the caveat until a later attempt records
a known body. `--layout dwarf`
reports that compilation-unit paths are unavailable and still names files by
address. Filters are a case-sensitive name substring, a case-sensitive name
prefix, an inclusive address range (including uppercase hexadecimal input), and a positive safe-integer attempt limit. The limit does not allocate space in proportion to its value. Cancellation leaves
+12 -1
View File
@@ -338,7 +338,18 @@ handler execution or change the body. Instruction windows and dossiers also
retain these warnings in their result limitations. An unresolved callee or
unestablished terminal-flow basis remains explicit. These observations qualify
the analysis; they do not prove that a no-return flag is incorrect or recover
excluded code. Analysis profiles version this observation contract so older
excluded code. When the callee's entry, or the function a thunk forwards to,
carries the `Non-Returning Function Found` analysis bookmark that Ghidra's
`Non-Returning Functions - Discovered` analyzer writes, the call's limitation
says the flag was inferred, and the function's limitations add one statement
that Ghidra excluded that call's fall-through from the body and pseudocode, so
code reachable only through it is missing if the callee returns. That analyzer can mark returning callees, such as an
imported `operator new` thunk, so a constructor may decompile to a few lines. To
compare, open a fresh import with
`REA_GHIDRA_DISABLED_ANALYZERS='Non-Returning Functions - Discovered'`; an
existing project (`REA_GHIDRA_PROJECT_PATH`) keeps the flags its analysis
already stored. A flag without that bookmark keeps an unknown origin; REA does
not treat it as declared. Analysis profiles version this observation contract so older
snapshots cannot replay unqualified results.
On non-Windows hosts, the ephemeral import repair clears no-return flags on the
+1 -1
View File
@@ -14,7 +14,7 @@ export const JADX_BRIDGE_IDENTITY = Object.freeze({
});
/** Remediation when `REA_JADX_MCP_JAR` is missing or not an absolute path. */
export const JADX_JAR_CONFIGURATION_REMEDIATION = `Set REA_JADX_MCP_JAR to the absolute path of a caller-supplied jadx-headless-mcp ${JADX_RELEASE.version} JAR. REA does not download or install it.`;
export const JADX_JAR_CONFIGURATION_REMEDIATION = `Download the jadx-headless-mcp ${JADX_RELEASE.version} release JAR from ${JADX_RELEASE.url}, verify SHA-256 ${JADX_RELEASE.sha256}, and set REA_JADX_MCP_JAR to its absolute path. REA does not download or install it.`;
/** Remediation when the selected Java runtime cannot satisfy the JADX bridge. */
export const JADX_JAVA_RUNTIME_REMEDIATION =
+1 -1
View File
@@ -748,7 +748,7 @@ const workflowAvailabilityFor = (
: "provider_missing",
remediation:
policy.androidAnalysisAvailability?.remediation ??
"Set REA_JADX_MCP_JAR to a caller-supplied jadx-headless-mcp 0.7.1 JAR and provide a full JDK on Linux, macOS, or Windows x64 with its matching bundled native controls.",
"Set REA_JADX_MCP_JAR to the jadx-headless-mcp 0.7.1 release JAR (rea doctor --provider jadx names its download URL and SHA-256) and provide a full JDK on Linux, macOS, or Windows x64 with its matching bundled native controls.",
};
if (kind === "adb-provider")
return ownerProbeAvailability(
+1 -1
View File
@@ -36,7 +36,7 @@ const buildHost = (
createDoctorHostFixture({
platform: "linux",
architecture: "x64",
installationPaths: () => Promise.resolve([]),
installationLaunchers: () => Promise.resolve([]),
clientRegistrations: () => Promise.resolve([]),
desktopBuildFacts: () => Promise.resolve({ ...ready, ...facts }),
...overrides,
+30 -4
View File
@@ -17,7 +17,7 @@ const configurationHost = (
): DoctorHost => ({
...systemDoctorHost({ environment: { ...environment } }),
...host({
installationPaths: () => Promise.resolve([]),
installationLaunchers: () => Promise.resolve([]),
clientRegistrations: () => Promise.resolve([]),
}),
});
@@ -32,7 +32,7 @@ describe("doctor Hopper startup timeout", () => {
value === undefined ? {} : { REA_HOPPER_STARTUP_TIMEOUT_MS: value },
}),
...host({
installationPaths: () => Promise.resolve([]),
installationLaunchers: () => Promise.resolve([]),
clientRegistrations: () => Promise.resolve([]),
...overrides,
}),
@@ -725,8 +725,13 @@ describe("doctor installation identity", () => {
const result = await runDoctor(
undefined,
host({
installationPaths: () =>
Promise.resolve(["/usr/local/bin/rea", "/home/user/bin/rea"]),
installationLaunchers: () =>
Promise.resolve(
["/usr/local/bin", "/home/user/bin"].map((installation) => ({
path: `${installation}/rea`,
installation,
})),
),
installedSkillIdentity: () =>
Promise.resolve({
version: "10",
@@ -1390,3 +1395,24 @@ describe("doctor target admission diagnostics", () => {
expect(result.checks).toContainEqual(targetCheck);
});
});
describe("doctor installation count", () => {
it("reports one installation when several launchers resolve to it", async () => {
const result = await runDoctor(
undefined,
host({
installationLaunchers: () =>
Promise.resolve(
["rea", "rea.cmd"].map((name) => ({
path: `C:\\npm\\${name}`,
installation: "c:\\npm",
})),
),
}),
);
expect(result.identity?.installations).toEqual({
paths: ["C:\\npm\\rea", "C:\\npm\\rea.cmd"],
state: "single",
});
});
});
+49 -10
View File
@@ -8,7 +8,8 @@ import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
import { STARTUP_OWNER_CLOSED } from "../process/SharedStartupAttempt.js";
import { constants } from "node:fs";
import { access, stat } from "node:fs/promises";
import { access, realpath, stat } from "node:fs/promises";
import { posix, win32 } from "node:path";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import type { RetainedResourceScope } from "./AnalysisProvider.js";
import {
@@ -151,7 +152,8 @@ export interface DoctorHost {
): Promise<readonly DoctorProviderInspection[]>;
/** Resolve a parsed upstream request budget from the effective client environment. */
resolveProviderRequestBudget?: ProviderRequestBudgetResolver;
installationPaths?(): Promise<readonly string[]>;
/** `rea` launchers in PATH order, each with the installation it resolves to. */
installationLaunchers?(): Promise<readonly DoctorInstallationLauncher[]>;
/** Undefined checks installed skill roots; explicit selections stay scoped. */
installedSkillIdentity?(
clientIds: readonly string[] | undefined,
@@ -486,7 +488,7 @@ const collectDoctorIdentity = async (
readonly checks: readonly DoctorCheck[];
readonly value: DoctorIdentity;
}> => {
const installationPaths = (await host.installationPaths?.()) ?? [];
const launchers = (await host.installationLaunchers?.()) ?? [];
const registrations =
(await host.clientRegistrations?.(
scope.clients,
@@ -528,8 +530,8 @@ const collectDoctorIdentity = async (
"Call binary_session in the active client to inspect the running server identity; a registration whose saved scope omits the session family cannot report it, so include session through rea setup --tool-families and restart first.",
},
installations: {
paths: installationPaths,
state: installationState(installationPaths),
paths: launchers.map(({ path }) => path),
state: installationState(launchers),
},
skill: {
installed_version: installedSkillIdentity?.version ?? null,
@@ -626,10 +628,41 @@ const registrationCheck = (
remediation: registration.remediation,
});
/** A PATH launcher and the installation identity its host resolved it to. */
export interface DoctorInstallationLauncher {
readonly path: string;
readonly installation: string;
}
const installationState = (
paths: readonly string[],
): DoctorIdentity["installations"]["state"] =>
paths.length === 0 ? "unknown" : paths.length === 1 ? "single" : "multiple";
launchers: readonly DoctorInstallationLauncher[],
): DoctorIdentity["installations"]["state"] => {
const installations = new Set(
launchers.map(({ installation }) => installation),
);
return installations.size === 0
? "unknown"
: installations.size === 1
? "single"
: "multiple";
};
/**
* One installation is one launcher directory after symlink resolution. npm
* writes `rea`, `rea.cmd` and `rea.ps1` side by side on Windows, where `where`
* lists each and directory spelling is case-insensitive; PATH can also reach
* one launcher through a symlinked directory. An unresolvable launcher keeps
* its listed path rather than joining another installation.
*/
const launcherInstallation = async (
path: string,
platform: NodeJS.Platform,
): Promise<string> => {
const resolved = await realpath(path).catch(() => path);
return platform === "win32"
? win32.dirname(resolved).toLowerCase()
: posix.dirname(resolved);
};
/** Optional outer-adapter diagnostics composed without reversing dependencies. */
export interface SystemDoctorHostOptions {
@@ -870,14 +903,20 @@ export const systemDoctorHost = (
return undefined;
}
},
async installationPaths() {
async installationLaunchers() {
try {
const command = platform === "win32" ? "where" : "which";
const arguments_ = platform === "win32" ? ["rea"] : ["-a", "rea"];
return uniqueLines(
const paths = uniqueLines(
(await hostExecFileOutput(command, arguments_, commandEnvironment))
.stdout,
);
return await Promise.all(
paths.map(async (path) => ({
path,
installation: await launcherInstallation(path, platform),
})),
);
} catch (cause: unknown) {
if (cause instanceof AnalysisCancelledError) throw cause;
// best-effort cleanup: optional path probing; failure means none found.
+2
View File
@@ -123,6 +123,8 @@ export class HopperReleaseError extends Error {
| "release_metadata"
| "download"
| "integrity"
/** The authenticated package's launcher differs from the feed's main_exe_hash. */
| "launcher_digest_mismatch"
| "publisher_verification_failed"
| "verification_unavailable"
| "unsupported_hopper_build"
+1
View File
@@ -226,6 +226,7 @@ export type LinuxHopperInstallResult =
| "archive_write"
| "launcher_inspection"
| "integrity"
| "launcher_digest_mismatch"
| "authorization_or_package_manager"
| "launcher_missing"
| "runtime_dependencies"
+1 -1
View File
@@ -261,7 +261,7 @@ export class LinuxHopperPublisher {
if (mainExeHash !== release.main_exe_hash)
return err(
new HopperReleaseError(
"integrity",
"launcher_digest_mismatch",
`The signed ${family} package launcher SHA-256 ${mainExeHash} differs from the release main_exe_hash ${release.main_exe_hash}.`,
),
);
@@ -41,6 +41,20 @@ describe("macOS Hopper setup failure projection", () => {
});
});
describe("Linux Hopper launcher digest projection", () => {
it("keeps the public integrity code without advising a retry", () => {
const failure = setupInstallFailure("launcher_digest_mismatch", {
platform: "linux",
});
expect(failure.code).toBe("integrity_mismatch");
expect(failure.remediation).toMatch(/signature verified/u);
expect(failure.remediation).not.toMatch(/^Retry setup/u);
expect(failure.remediation).not.toBe(
setupInstallFailure("integrity", { platform: "linux" }).remediation,
);
});
});
describe("Linux package-manager setup failure projection", () => {
it("quotes the failing command and a sanitized bounded diagnostic", () => {
const result = setupInstallFailure("authorization_or_package_manager", {
+8
View File
@@ -54,6 +54,7 @@ export type HopperInstallFailureReason =
| "release_metadata"
| "download"
| "integrity"
| "launcher_digest_mismatch"
| "authorization_or_package_manager"
| "launcher_missing"
| "runtime_dependencies"
@@ -166,6 +167,13 @@ const stageFailure = (
remediation:
"The Hopper download failed integrity verification. Retry setup; if it repeats, update REA before installing.",
};
case "launcher_digest_mismatch":
return {
status: "failed",
code: "integrity_mismatch",
remediation:
"The package signature verified, so the download is authentic, but the vendor release feed describes a different launcher build, so REA cannot identify this Hopper build and did not install it. Retrying setup or updating REA does not change the feed; report the mismatch to the Hopper vendor and rerun setup after the feed is corrected.",
};
case "authorization_or_package_manager":
return {
status: "failed",
+11
View File
@@ -689,6 +689,17 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
join(env.GEMINI_CLI_HOME || home, ".gemini", "settings.json"),
markerPath: ({ home, env }: ClientPathContext) =>
join(env.GEMINI_CLI_HOME || home, ".gemini"),
// Antigravity keeps its configuration under the same .gemini directory,
// so the directory alone does not show Gemini CLI. Its own settings,
// trusted-folder records and session state do.
discoveryPaths: (context: ClientPathContext) => {
const root = join(context.env.GEMINI_CLI_HOME || context.home, ".gemini");
return [
join(root, "settings.json"),
geminiSettings(context).trustedFoldersPath,
join(root, "tmp"),
];
},
skillPath: ({ home, env }: ClientPathContext) =>
join(env.GEMINI_CLI_HOME || home, ".agents", "skills"),
format: "json",
@@ -735,6 +735,97 @@ describe("decompile_all resume", () => {
});
});
describe("decompile_all retained body coverage", () => {
const directory = useOutputDirectory();
const limitationsOf = (value: JsonValue): readonly string[] =>
(value as { limitations: string[] }).limitations;
const unavailableReasons = (index: {
readonly functions: readonly {
readonly body: { readonly available: boolean; readonly reason?: string };
}[];
}): string[] =>
index.functions.flatMap(({ body }) =>
!body.available && body.reason !== undefined ? [body.reason] : [],
);
const indexOf = async () =>
decompileDirectoryIndexSchema.parse(
JSON.parse(
await readFile(
join(directory(), DECOMPILE_DIRECTORY_INDEX_NAME),
"utf8",
),
),
);
it("keeps the caveat of retained records through no-op and empty resumes", async () => {
const created = await run(
harness({ info: "unavailable" }).tools,
directory(),
);
expect(created.ok).toBe(true);
if (!created.ok) return;
const createdLimitations = limitationsOf(created.value);
// One caveat counts the retained records and repeats their shared reason.
expect(createdLimitations).toHaveLength(1);
const createdReason = unavailableReasons(await indexOf())[0];
expect(createdReason).toBeDefined();
expect(createdLimitations[0]).toMatch(/^3 exported function record/u);
expect(createdLimitations[0]).toContain(createdReason);
const file = join(directory(), "functions/0x1000.c");
const before = await readFile(file, "utf8");
for (const filter of [{}, { name: "NoSuchFunction" }]) {
const resumed = harness();
const result = await run(resumed.tools, directory(), {
resume: true,
...filter,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value).toMatchObject({ counts: { requested: 0 } });
expect(limitationsOf(result.value)).toEqual(createdLimitations);
expect((await indexOf()).limitations).toEqual(createdLimitations);
expect(of(resumed.calls, "procedure_info")).toHaveLength(0);
}
expect(await readFile(file, "utf8")).toBe(before);
expect(
(await indexOf()).functions.map((record) => record.body.available),
).toEqual([false, false, false]);
});
it("counts retained and new unknown bodies and drops the caveat once every body is known", async () => {
expect(
(await run(harness({ info: "unavailable" }).tools, directory())).ok,
).toBe(true);
await rm(join(directory(), "functions/0x1000.c"));
const mixed = await run(harness({ info: "error" }).tools, directory(), {
resume: true,
});
expect(mixed.ok).toBe(true);
if (!mixed.ok) return;
expect(mixed.value).toMatchObject({
counts: { written: 1, skipped: 2 },
});
// Differing reasons are counted together without naming either one.
const reasons = new Set(unavailableReasons(await indexOf()));
expect(reasons.size).toBe(2);
const mixedCaveats = limitationsOf(mixed.value).filter((limitation) =>
limitation.startsWith("3 exported function record"),
);
expect(mixedCaveats).toHaveLength(1);
for (const reason of reasons) expect(mixedCaveats[0]).not.toContain(reason);
for (const address of ["0x1000", "0x2000", "0x3000"])
await rm(join(directory(), `functions/${address}.c`));
const known = await run(harness().tools, directory(), { resume: true });
expect(known.ok).toBe(true);
if (!known.ok) return;
expect(known.value).toMatchObject({ counts: { written: 3 } });
expect(limitationsOf(known.value).join("\n")).not.toContain("body");
expect((await indexOf()).limitations).toEqual([]);
});
});
describe("decompile_all filters and layout", () => {
const directory = useOutputDirectory();
@@ -38,6 +38,7 @@ import {
DecompileExport,
exportOutputFailure,
type ExportIdentity,
type UnknownBodies,
} from "./exportTree.js";
import {
bodyRecord,
@@ -64,6 +65,13 @@ const DUPLICATE_ADDRESSES =
export const OUTPUT_IO_STREAK_LIMIT = 5;
const unaddressedRows = (count: number): string =>
`${count.toString()} inventory row(s) had no address and were not exported.`;
/** Summarizes every retained record, including those earlier runs wrote. */
const unknownBodyRecords = (unknown: UnknownBodies): string =>
`${unknown.count.toString()} exported function record(s) report an unavailable body${
unknown.sharedReason === undefined
? ` for differing reasons; see each record's body.reason in ${DECOMPILE_DIRECTORY_INDEX_NAME}.`
: `: ${unknown.sharedReason}`
}`;
interface FailureReason {
readonly code: string;
@@ -84,7 +92,6 @@ interface PublishState {
readonly failures: InlineFailure[];
inlineFailureBytes: number;
readonly recentFailures: PublishState["failures"];
bodyUnavailable: boolean;
/** Failure code of the function being attempted, if it failed. */
lastFailureCode: string | undefined;
/**
@@ -137,7 +144,6 @@ export const decompileAll = async (
failures: [],
inlineFailureBytes: 2,
recentFailures: [],
bodyUnavailable: false,
lastFailureCode: undefined,
outputIoStreak: 0,
};
@@ -161,7 +167,7 @@ export const decompileAll = async (
input.layout,
loaded.value.identity,
selection.collapsedDuplicates,
state.bodyUnavailable,
session.unknownBodies(),
loaded.value.unaddressed,
);
indexed = await session.writeIndex({
@@ -520,7 +526,6 @@ const publishOne = async (input: {
);
if (body.kind === "error")
return { result: err(body.error), bodySupport: input.bodySupport };
if (body.kind === "unavailable") input.state.bodyUnavailable = true;
// Caveats of the observations this attempt used. The journal record keeps
// the complete list, written or failed, so the bounded Evidence summary and
// the length-capped file header never hold the only copy.
@@ -1084,7 +1089,7 @@ const exportLimitations = (
layout: DecompileDirectoryInput["layout"],
identity: ExportIdentity,
collapsedDuplicates: boolean,
bodyUnavailable: boolean,
unknownBodies: UnknownBodies,
unaddressed: number,
): readonly string[] => [
...(layout === "dwarf" ? [DWARF_LAYOUT_UNAVAILABLE] : []),
@@ -1092,7 +1097,7 @@ const exportLimitations = (
...(identity.analysisProfileDigest === null ? [PROFILE_UNAVAILABLE] : []),
...(collapsedDuplicates ? [DUPLICATE_ADDRESSES] : []),
...(unaddressed > 0 ? [unaddressedRows(unaddressed)] : []),
...(bodyUnavailable ? [BODY_UNAVAILABLE] : []),
...(unknownBodies.count > 0 ? [unknownBodyRecords(unknownBodies)] : []),
];
const layoutStatus = (
@@ -105,6 +105,13 @@ export const classifyOutputPath = async (
);
};
/** Retained records whose body ranges are unknown. */
export interface UnknownBodies {
readonly count: number;
/** The reason every such record gives, or undefined when they differ. */
readonly sharedReason: string | undefined;
}
/** Owns one export directory and its append-only journal. */
export class DecompileExport {
private constructor(
@@ -148,6 +155,27 @@ export class DecompileExport {
return this.written.get(address);
}
/**
* Retained non-external records whose body ranges are unknown, with their
* reason when every such record shares one. The index lists every retained
* record, so its summary must come from these records rather than from the
* functions the latest call attempted. External records have no file for a
* body caveat to qualify.
*/
unknownBodies(): UnknownBodies {
let count = 0;
let sharedReason: string | undefined;
for (const record of this.records.values()) {
if (record.status === "external" || record.body.available) continue;
sharedReason =
count === 0 || sharedReason === record.body.reason
? record.body.reason
: undefined;
count += 1;
}
return { count, sharedReason };
}
/**
* Stable relative path for an address. The first path the journal gave this
* address wins, even when a later failure record names no path.
+4 -1
View File
@@ -12,6 +12,7 @@ import {
} from "../application/SetupTypes.js";
import { isUninstallFailure } from "../application/UninstallFailure.js";
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
import { localPathStringSchema } from "../domain/localPath.js";
import {
mcpToolFamiliesSchema,
toolFamiliesConstraint,
@@ -144,7 +145,9 @@ const registerDoctorCommand = (
cli.command(CLI_COMMANDS.doctor, {
description: "Check whether REA is ready",
options: z.object({
target: z.string().optional().describe("Optional app path to check"),
target: localPathStringSchema
.optional()
.describe("Optional app path to check"),
client: z
.array(supportedClientSchema)
.default([])
+8 -2
View File
@@ -215,7 +215,9 @@ export const decompileDirectoryManifestSchema = z
),
limitations: z
.array(z.string())
.describe("Limits of this export. Pseudocode is not included."),
.describe(
"Limits of this export directory. The body-coverage caveat counts every retained function record, including records earlier runs wrote. Pseudocode is not included.",
),
resume: z.boolean().describe("Whether this call continued a journal."),
})
.superRefine((manifest, context) => {
@@ -366,7 +368,11 @@ export const decompileDirectoryIndexSchema = z.strictObject({
.describe(
"Selection and inventory limitations owned by the run that last rewrote this index. Absent in indexes written before this field existed.",
),
limitations: z.array(z.string()),
limitations: z
.array(z.string())
.describe(
"Limits of this export directory. The body-coverage caveat counts every retained function record, including records earlier runs wrote.",
),
functions: z.array(decompileDirectoryFunctionSchema),
});
+1 -1
View File
@@ -74,7 +74,7 @@ const sharedAnalysisSemantics = {
instruction_status: "unlisted-and-containing-code-data-v3",
instruction_references: "exact-address-incoming-without-synthetic-entry-v3",
byte_search_coverage: "separate-loaded-overlays-v1",
function_boundary_observations: "ghidra-function-boundary-limitations-v4",
function_boundary_observations: "ghidra-function-boundary-limitations-v5",
} as const;
/**
+3 -3
View File
@@ -8,12 +8,12 @@ import { GhidraProvider } from "./GhidraProvider.js";
// Digests commit to the current shared policy, including the instruction
// status, load-image relocation-symbol, procedure search, byte-search and
// bit-field base-type, runtime value-flow budgets and inventory-search revisions that prevent reuse of snapshots with earlier
// bit-field base-type, runtime value-flow budgets, function-boundary and inventory-search revisions that prevent reuse of snapshots with earlier
// results.
const RAW_DIGESTS = {
"6502": "51160ff6e94cb32eaeb87ac248915afb3bc17e33b11a1a101222344dd3907c6f",
"6502": "6c4e88435526e8f0907b072600e3b121577962a36554e023dde4cf85113dd9f6",
"x86-disabled-analyzer":
"94e93961a699cf9250844d53fe95802c72063c08656fc308b3fed523dd86ea1c",
"b2dc33d1932cbe71d72819428634927f3a2d8a2bbd54b6893f823ccb6b795d0b",
} as const;
const configFor = (environment: NodeJS.ProcessEnv) => {
+2 -2
View File
@@ -351,8 +351,8 @@ describe("Ghidra snapshot profile admission", () => {
},
{
parameter: "function_boundary_observations",
current: "ghidra-function-boundary-limitations-v4",
legacy: "ghidra-function-boundary-limitations-v3",
current: "ghidra-function-boundary-limitations-v5",
legacy: "ghidra-function-boundary-limitations-v4",
},
{
parameter: "instruction_references",
+3 -2
View File
@@ -10,5 +10,6 @@ export const MCP_SHUTDOWN_FAILED =
"REA could not close cleanly. End the REA process before starting it again.";
export const SERVER_START_FAILED =
"Do not repeat this startup. Tell the user REA could not start and ask them to restart the MCP client, then run `rea doctor`.";
export const MCP_HTTP_LISTEN_FAILED =
"REA could not listen for MCP HTTP connections on the selected host and port. Choose another --port or stop the process using it.";
/** The listener error owns recovery for its parsed failure; never its native cause text. */
export const mcpHttpListenFailed = (remediation: string): string =>
`REA could not listen for MCP HTTP connections. ${remediation}`;
+2 -5
View File
@@ -12,7 +12,7 @@ import type { OptionalProviderLoadResult } from "../application/OptionalObservat
import { OwnedMcpServers } from "./OwnedMcpServers.js";
import {
MCP_CONNECTION_START_FAILED,
MCP_HTTP_LISTEN_FAILED,
mcpHttpListenFailed,
} from "./messages.js";
import { reportMcpTransportError } from "./reportMcpTransportError.js";
@@ -181,10 +181,7 @@ export const startMcpTransport = async (
if (!cancelled) {
const startFailure =
cause instanceof StreamableHttpListenError
? cause.classification.ok &&
cause.classification.value.kind === "host_allowlist"
? cause.remediationAction
: MCP_HTTP_LISTEN_FAILED
? mcpHttpListenFailed(cause.remediationAction)
: MCP_CONNECTION_START_FAILED;
serverLogger.error(
{
+59
View File
@@ -0,0 +1,59 @@
import { resolve } from "node:path";
import { expect, it, vi } from "vitest";
const cleanupDenial = vi.hoisted(() => ({
reason: undefined as string | undefined,
}));
// Keep real process-group cleanup, then report the selected denial so the
// doctor's late cleanup failure is observed without leaking the fixture.
vi.mock("./process/ProcessOwnership.js", async (importOriginal) => {
const original =
await importOriginal<typeof import("./process/ProcessOwnership.js")>();
return {
...original,
cleanupOwnedProcessGroup: async (
...parameters: Parameters<typeof original.cleanupOwnedProcessGroup>
) => {
const released = await original.cleanupOwnedProcessGroup(...parameters);
return cleanupDenial.reason === undefined
? released
: { cleaned: false as const, reason: cleanupDenial.reason };
},
};
});
import { runProductionMcpDoctor } from "./mcpDoctor.js";
it("keeps the startup diagnostic and reports retained resources when cleanup fails", async () => {
cleanupDenial.reason = "fixture process group remained alive";
try {
const result = await runProductionMcpDoctor({
command: process.execPath,
args: [resolve("tests/fixtures/mcpDoctorExit.mjs")],
cwd: process.cwd(),
environment: { ...process.env, REA_MCP_DOCTOR_DEADLINE_MS: "5000" },
});
expect(result).toMatchObject({
healthy: false,
checks: [
{ name: "transport", ok: false },
{
name: "cleanup",
ok: false,
detail: expect.stringMatching(
/^MCP stdio cleanup failed: Provider mcp-doctor adapter failed during mcp_doctor \(fixture process group remained alive\); retained resources: [0-9a-f-]{36}$/u,
),
},
],
diagnostics: {
stderr: "fixture MCP startup exit\n",
stderr_truncated: false,
},
});
} finally {
cleanupDenial.reason = undefined;
}
});
+60 -34
View File
@@ -1,5 +1,4 @@
import { Client, SdkError, SdkErrorCode } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { Help } from "incur";
import { performance } from "node:perf_hooks";
import { resolve } from "node:path";
@@ -27,6 +26,7 @@ import {
import { contractsForToolFamilies } from "./contracts/toolFamilies.js";
import { promptsForToolFamilies } from "./contracts/promptToolFamilyScope.js";
import { err, ok } from "./domain/result.js";
import { AnalysisError } from "./domain/analysisErrorBase.js";
import { PRODUCT_IDENTITY } from "./identity.js";
import {
parseMcpStartupBudgets,
@@ -37,6 +37,7 @@ import {
mcpDoctorDeadlineRecovery,
} from "./mcpStartupPolicy.js";
import { MCP_DOCTOR_ALIAS, MCP_DOCTOR_OPTIONS } from "./mcpDoctorOptions.js";
import { OwnedMcpStdioTransport } from "./process/OwnedMcpStdioTransport.js";
const OUTPUT_FORMATS = CLI_DOCUMENT_OUTPUT_FORMATS;
type OutputFormat = (typeof OUTPUT_FORMATS)[number];
@@ -120,14 +121,20 @@ export const runProductionMcpDoctor = async (options: McpDoctorOptions) => {
Math.max(1, Math.ceil(deadline - performance.now())),
);
const stderr = captureStderr();
const transport = new OwnedStdioClientTransport({
command: options.command,
args: [...options.args],
cwd: options.cwd,
env: targetFreeEnvironment(options.environment),
stderr: "pipe",
});
transport.stderr?.on("data", stderr.append);
const transport = new DoctorStdioTransport(
{
command: options.command,
arguments: [...options.args],
...(options.cwd === undefined ? {} : { cwd: options.cwd }),
// The caller's environment is the child's complete base. The launcher
// would otherwise merge the ambient process environment, including the
// target selection the doctor removes.
hostEnvironment: targetFreeEnvironment(options.environment),
// A server can start its own providers in separate process groups.
sweepTokenOwnedProcesses: true,
},
stderr.append,
);
const client = new Client({ name: "rea-mcp-doctor", version: "1" });
let result: ProductionMcpDoctorResult;
let cleanupFailure: { readonly cause: unknown } | undefined;
@@ -161,25 +168,13 @@ export const runProductionMcpDoctor = async (options: McpDoctorOptions) => {
};
} finally {
clearTimeout(timer);
// The owned transport is the only cleanup owner; Client.close would only
// delegate to it. An interrupted connect can also clear the client's
// transport reference before its startup child is closed.
try {
await client.close();
} catch (cause: unknown) {
cleanupFailure = { cause };
}
try {
// An interrupted connect can clear the client's transport reference
// before its startup child is closed. The transport retains that child.
await transport.close();
} catch (cause: unknown) {
cleanupFailure = {
cause:
cleanupFailure === undefined || cleanupFailure.cause === cause
? cause
: new AggregateError(
[cleanupFailure.cause, cause],
`MCP client and transport cleanup failed: ${errorMessage(cleanupFailure.cause)}; ${errorMessage(cause)}`,
),
};
cleanupFailure = { cause };
}
}
if (cleanupFailure !== undefined)
@@ -191,10 +186,12 @@ export const runProductionMcpDoctor = async (options: McpDoctorOptions) => {
{
name: "cleanup",
ok: false,
detail: `MCP stdio cleanup failed: ${errorMessage(cleanupFailure.cause)}`,
detail: cleanupFailureDetail(cleanupFailure.cause),
},
],
};
// This reporter keeps the documented stderr prefix, including a withheld
// partial character; the transport's own retention serves cleanup errors.
return { ...result, diagnostics: stderr.value() };
};
@@ -213,13 +210,25 @@ type ProductionMcpDoctorResult =
readonly checks: readonly McpDoctorCheck[];
};
/** Join SDK-triggered and caller-triggered close calls for the same child. */
class OwnedStdioClientTransport extends StdioClientTransport {
#closePromise: Promise<void> | undefined;
override close(): Promise<void> {
this.#closePromise ??= super.close();
return this.#closePromise;
/** The doctor's bounded stdio session, owning the process family it starts. */
class DoctorStdioTransport extends OwnedMcpStdioTransport {
constructor(
options: ConstructorParameters<typeof OwnedMcpStdioTransport>[0],
onStderr: (chunk: unknown) => void,
) {
super(options, {
provider: "mcp-doctor",
operation: "mcp_doctor",
label: "MCP doctor",
maxFrameBytes: 8 * 1024 * 1024,
maxOperationBytes: 32 * 1024 * 1024,
// Retain only stderr, with the byte budget the report promises.
maxDiagnosticBytes: {
stdout: 0,
stderr: MCP_STARTUP_POLICY.doctorStderrBytes,
},
onStderr,
});
}
}
@@ -315,7 +324,10 @@ const inspectProductionMcpSession = async (
ok:
observedIdentity.packageVersion === PRODUCT_IDENTITY.packageVersion &&
observedIdentity.catalogDigest === catalog.digests.combined_sha256,
detail: `package=${observedIdentity.packageVersion ?? "missing"}; catalog=${observedIdentity.catalogDigest ?? "missing"}`,
// Identity is carried by the request-flow reply; without one it is unobserved, not missing.
detail: requestFlow.ok
? `package=${observedIdentity.packageVersion ?? "missing"}; catalog=${observedIdentity.catalogDigest ?? "missing"}`
: `identity not observed: ${requestFlow.error.detail}`,
},
{
name: "request-flow",
@@ -665,6 +677,20 @@ const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
const errorMessage = (cause: unknown): string =>
cause instanceof Error ? cause.message : String(cause);
/** Report the transport's typed cleanup reason and the resources it retains. */
const cleanupFailureDetail = (cause: unknown): string => {
const detail = `MCP stdio cleanup failed: ${errorMessage(cause)}`;
if (!(cause instanceof AnalysisError) || !cause.cleanupIncomplete)
return detail;
const reason = cause.diagnostics?.reason;
return [
typeof reason === "string" ? `${detail} (${reason})` : detail,
...(cause.cleanupResources.length === 0
? []
: [`retained resources: ${cause.cleanupResources.join(", ")}`]),
].join("; ");
};
/**
* Name the applied doctor deadline when an owned abort or SDK request timeout
* fires. Remote failure text is not a deadline. Codex's 30 second startup
+23
View File
@@ -37,6 +37,7 @@ it("parses command names containing parentheses and retains native relation fact
parentPid: 17,
processGroupId: 18,
sessionId: 19,
kernelThread: false,
});
});
@@ -69,6 +70,7 @@ it("retains identity when Linux teardown reports unavailable group and session I
parentPid: 0,
processGroupId: -1,
sessionId: -1,
kernelThread: false,
});
expect(
parseLinuxProcessStat(statRecord({ processGroupId: "-2" })),
@@ -122,6 +124,27 @@ it.each(["invalid", "-1", "4.1", "9007199254740992"])(
parentPid: 17,
processGroupId: 18,
sessionId: 19,
kernelThread: undefined,
});
},
);
it("distinguishes a kernel thread from a process sharing its flags", () => {
// Field 9 task_flags: PF_KTHREAD is 0x00200000; PF_EXITING stays orthogonal.
expect(parseLinuxProcessStat(statRecord({ flags: "2097408" }))).toMatchObject(
{
kernelThread: true,
lifecycle: "non-zombie",
},
);
expect(parseLinuxProcessStat(statRecord({ flags: "2097156" }))).toMatchObject(
{
kernelThread: true,
lifecycle: "exiting",
},
);
expect(parseLinuxProcessStat(statRecord({ flags: "4" }))).toMatchObject({
kernelThread: false,
lifecycle: "exiting",
});
});
+15 -1
View File
@@ -1,5 +1,9 @@
import type { ProcessLifecycle } from "./ProcessLifecycle.js";
/** Linux `include/linux/sched.h` task flags reported by `/proc/<pid>/stat`. */
const PF_EXITING = 0x00000004;
const PF_KTHREAD = 0x00200000;
/** Native process facts parsed from one Linux `/proc/<pid>/stat` record. */
export interface LinuxProcessStat {
readonly pid: number;
@@ -10,6 +14,12 @@ export interface LinuxProcessStat {
readonly parentPid: number;
readonly processGroupId: number;
readonly sessionId: number;
/**
* PF_KTHREAD from the task flags. A kernel thread has no user address space
* or environment, so it cannot carry a run token. Undefined when the flags
* field is malformed.
*/
readonly kernelThread: boolean | undefined;
}
/**
@@ -70,6 +80,9 @@ export const parseLinuxProcessStat = (
flagsText !== undefined && /^\d+$/u.test(flagsText)
? Number(flagsText)
: Number.NaN;
const kernelThread = Number.isSafeInteger(flags)
? (flags & PF_KTHREAD) !== 0
: undefined;
const lifecycle: ProcessLifecycle | undefined =
state === "Z"
? "zombie"
@@ -77,7 +90,7 @@ export const parseLinuxProcessStat = (
? "dead"
: !Number.isSafeInteger(flags)
? undefined
: (flags & 0x00000004) !== 0
: (flags & PF_EXITING) !== 0
? "exiting"
: "non-zombie";
@@ -89,5 +102,6 @@ export const parseLinuxProcessStat = (
parentPid,
processGroupId,
sessionId,
kernelThread,
};
};
+15 -1
View File
@@ -8,6 +8,7 @@ import { randomUUID } from "node:crypto";
import { finished } from "node:stream/promises";
import type {
OwnedProviderProcessSpawnOptions,
ProcessDiagnosticBudget,
SpawnedOwnedProviderProcess,
} from "./ProviderProcess.js";
import type { AnalysisCapturedOutput } from "../domain/analysisErrorBase.js";
@@ -58,11 +59,15 @@ export class OwnedMcpStdioTransport implements Transport {
readonly options: Omit<OwnedProviderProcessSpawnOptions, "runId" | "stdin">,
readonly settings: {
readonly provider: string;
/** Operation named by cleanup failures; provider sessions keep close_binary. */
readonly operation?: string;
readonly label: string;
readonly maxFrameBytes: number;
readonly maxOperationBytes: number;
readonly gracefulInputCloseMs?: number;
readonly maxDiagnosticBytes?: number;
readonly maxDiagnosticBytes?: ProcessDiagnosticBudget;
/** Observe the owned child's stderr from acquisition onward, including when it closes early. */
readonly onStderr?: (chunk: unknown) => void;
readonly notifyOnExit?: boolean;
},
readonly launcher: OwnedMcpLauncher = spawnOwnedProviderProcess,
@@ -113,6 +118,8 @@ export class OwnedMcpStdioTransport implements Transport {
spawned.process.stdout.on("data", this.#read);
spawned.process.stdout.on("error", this.#error);
spawned.process.stderr?.on("data", this.#countStderr);
if (this.settings.onStderr !== undefined)
spawned.process.stderr?.on("data", this.settings.onStderr);
spawned.process.on("error", this.#error);
spawned.process.once("exit", this.#exit);
spawned.process.once("close", this.#outputClosed);
@@ -215,6 +222,10 @@ export class OwnedMcpStdioTransport implements Transport {
input?.destroy();
await inputClosed;
input?.off("error", this.#error);
// Detach the stderr observer only after bounded cleanup and draining have
// settled, so shutdown diagnostics still reach the caller's report.
if (this.settings.onStderr !== undefined)
process?.stderr?.off("data", this.settings.onStderr);
this.onclose?.();
if (stopped?.status === "incomplete") {
const failure = new ProviderCleanupError(
@@ -224,6 +235,9 @@ export class OwnedMcpStdioTransport implements Transport {
reason: stopped.reason,
stderr: this.diagnostics()?.stderr ?? "",
},
this.settings.operation === undefined
? undefined
: { operation: this.settings.operation },
);
new AnalysisCleanupLeaseGroup(failure, [this.#cleanup]);
throw failure;
@@ -606,3 +606,71 @@ it("preserves cancellation before and after the signal-zero probe", async () =>
probeAbort.mockRestore();
}
});
it.each([
// PF_KTHREAD | PF_NOFREEZE | PF_FORKNOEXEC, as procfs reports a kworker.
["a kernel thread", 0x00208040, true],
["a user process", 0x00000040, false],
] as const)(
"classifies a live unrelated %s whose environment read reports ESRCH",
async (_case, flags, cleaned) => {
// Reported on a root caller: a live kernel thread's environ read failed
// with ESRCH. Its credentials match the caller, so only the PF_KTHREAD
// task flag from the same stat record distinguishes it.
const callerUid = process.getuid?.() ?? 0;
const kernelThread: ProcessTableEntry = {
pid: 200,
parentPid: 2,
processGroupId: 0,
state: "I",
command: "kworker/0:1",
};
filesystem.readFile.mockImplementation(async (path) => {
if (String(path) === "/proc/200/stat")
return procStat(100, 2, 0, 200, flags);
if (String(path) === "/proc/200/status")
return `Uid:\t${callerUid}\t${callerUid}\t${callerUid}\t${callerUid}\nGid:\t0\t0\t0\t0\n`;
if (String(path) === "/proc/200/environ")
throw Object.assign(new Error("ESRCH: no such process, read"), {
code: "ESRCH",
});
throw new Error(`unexpected path: ${String(path)}`);
});
filesystem.readlink.mockImplementation(async () => "user:[1]");
filesystem.stat.mockResolvedValue({ uid: callerUid } as never);
const signalGroup = vi.fn();
const host: ProcessOwnershipHost = {
...createSystemProcessOwnershipHost("linux", {}),
listProcesses: async () => [kernelThread],
signalGroup,
};
try {
const result = await withCaller(callerUid, 0, () =>
verifyNoTokenOwnedProcesses("owned-run", host, undefined, {
leaderPid: 100,
processGroupId: 100,
evidenceOwner: new ProcessOwnershipEvidenceOwner(),
}),
);
if (cleaned)
expect(result).toEqual({
cleaned: true,
signaled: false,
unverified: [
{ pid: 200, diagnostic: "ESRCH: no such process, read" },
],
});
else
expect(result).toMatchObject({
cleaned: false,
failures: [{ pid: 200, reason: "environment-unreadable" }],
});
expect(signalGroup).not.toHaveBeenCalled();
} finally {
filesystem.stat.mockReset();
filesystem.stat.mockRejectedValue(filesystemError("ENOENT"));
}
},
);
+14 -5
View File
@@ -110,6 +110,11 @@ export type ProcessIdentityObservation =
* truncated. It names a process in diagnostics; never identity or lookup.
*/
readonly name?: string;
/**
* Linux PF_KTHREAD from the same `/proc/<pid>/stat` record as `identity`.
* A kernel thread cannot carry a run token. Only a Linux stat read sets it.
*/
readonly kernelThread?: true;
}
| {
readonly state: "absent";
@@ -1911,13 +1916,17 @@ const scanWithOwnershipEvidence = async (
fact.identityAfter.state === "readable"
? fact.identityAfter
: undefined;
// A kernel thread has no user environment, so it cannot carry this run's
// token. Procfs can fail its environment read (for example with ESRCH)
// while it stays live, and root callers see no credential restriction.
if (
knownUnrelated &&
environmentWithheldFromInspection(
fact.token.reason,
observed?.effectiveUid,
observed?.environmentRestriction,
)
(observed?.kernelThread === true ||
environmentWithheldFromInspection(
fact.token.reason,
observed?.effectiveUid,
observed?.environmentRestriction,
))
) {
unverified.push({
pid: fact.process.pid,
@@ -1304,6 +1304,9 @@ export const createSystemProcessOwnershipHost = (
state: "readable" as const,
identity: `linux-starttime:${facts.startTime}`,
...(facts.name === "" ? {} : { name: facts.name }),
...(facts.kernelThread === true
? { kernelThread: true as const }
: {}),
...(effectiveUid === undefined ? {} : { effectiveUid }),
...(environmentRestriction === undefined
? {}
+1 -1
View File
@@ -520,6 +520,6 @@ const missingAndroidAvailability = async (): Promise<ProviderAvailability> => ({
code: "not_configured",
reason: "No Android JADX analysis provider is configured.",
remediation:
"Set REA_JADX_MCP_JAR to a caller-supplied jadx-headless-mcp 0.7.1 JAR and provide a full JDK on Linux, macOS, or Windows x64 with its matching bundled native controls.",
"Set REA_JADX_MCP_JAR to the jadx-headless-mcp 0.7.1 release JAR (rea doctor --provider jadx names its download URL and SHA-256) and provide a full JDK on Linux, macOS, or Windows x64 with its matching bundled native controls.",
diagnostics: { configured: false },
});
@@ -5,6 +5,7 @@ import { join } from "node:path";
import { expect, it as test, onTestFinished } from "vitest";
import { inspectJadxProvider as inspectProvider } from "../../../src/android/JadxDoctor.js";
import { JADX_RELEASE } from "../../../src/android/JadxRelease.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { writeJadxJarInventory } from "../../fixtures/android/jadxJar.js";
import { writeOrderedZip } from "../../fixtures/artifactEntryOrder.js";
@@ -131,6 +132,9 @@ it("publishes no registration environment when REA_JADX_MCP_JAR is unset", async
},
],
});
// The recovery names where to obtain the exact audited JAR.
expect(inspection.checks[0]?.remediation).toContain(JADX_RELEASE.url);
expect(inspection.checks[0]?.remediation).toContain(JADX_RELEASE.sha256);
});
it("reports a relative REA_JADX_MCP_JAR as a rejected selection, not an unset one", async () => {
@@ -2,6 +2,7 @@ import { readdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect } from "vitest";
import { decompileDirectoryIndexSchema } from "../../../src/contracts/decompileDirectory.js";
import { elf } from "../../../src/domain/binaryTarget.fixture.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
@@ -97,6 +98,50 @@ describe("decompile-all CLI", () => {
counts: { selected: 2, written: 1, skipped: 1, remaining: 0 },
},
});
// Kuna lacks procedure_info, so every retained record keeps an
// unavailable body. A resume that writes nothing still summarizes them.
const bodyCaveat = expect.stringMatching(
/^2 exported function record.*procedure_info is unavailable/u,
);
expect(resumed.json).toMatchObject({
normalized_result: { limitations: [bodyCaveat] },
});
const noop = await cli.run({
arguments: [
"decompile-all",
target,
"--provider",
"kuna",
"--output",
"out",
"--resume",
"--address-start",
"0X1000",
"--address-end",
"0X1040",
"--format",
"json",
],
cwd: root,
environment,
});
expect(noop.exitCode, JSON.stringify(noop.json)).toBe(0);
expect(noop.json).toMatchObject({
normalized_result: {
counts: { selected: 2, requested: 0, skipped: 2 },
limitations: [bodyCaveat],
},
});
const index = decompileDirectoryIndexSchema.parse(
JSON.parse(
await readFile(join(root, "out", "decompile-index.json"), "utf8"),
),
);
expect(index.limitations).toEqual([bodyCaveat]);
expect(index.functions.map((record) => record.body.available)).toEqual([
false,
false,
]);
},
);
});
@@ -0,0 +1,23 @@
import { expect } from "vitest";
import { cliTest } from "../../support/cli/cliFixture.js";
// An empty --target names no app. Like every other target-taking command,
// doctor refuses it before readiness checks instead of checking the
// working directory.
cliTest(
"refuses an empty doctor --target before any readiness check",
async ({ cli }) => {
const result = await cli.run({
arguments: ["doctor", "--target", "", "--json"],
});
expect(result.exitCode).toBe(1);
expect(JSON.parse(result.stdout)).toMatchObject({
code: "VALIDATION_ERROR",
fieldErrors: expect.arrayContaining([
expect.objectContaining({ path: "target" }),
]),
});
expect(result.stdout).not.toContain("target_readiness");
},
);
@@ -48,6 +48,8 @@ describe("client configuration filesystem lifecycle", () => {
for (const client of supportedClients(home))
if (client.markerPath !== undefined)
await mkdir(client.markerPath, { recursive: true });
// Gemini CLI follows its own session state, not the shared .gemini root.
await mkdir(join(home, ".gemini", "tmp"), { recursive: true });
const detected = await detectClients(home);
expect(detected.map(({ name }) => name)).toEqual([
"claude_code",
@@ -88,6 +90,21 @@ describe("client configuration filesystem lifecycle", () => {
expect(await detectClients(emptyHome)).toEqual([]);
});
it("does not detect Gemini CLI from Antigravity's shared .gemini storage", async () => {
const home = await createTestTempDirectory("rea-detect-gemini-");
const antigravity = supportedClients(home).find(
({ name }) => name === "antigravity",
);
if (antigravity?.markerPath === undefined)
throw new Error("Antigravity marker is missing");
await mkdir(antigravity.markerPath, { recursive: true });
const names = async () =>
(await detectClients(home)).map(({ name }) => name);
expect(await names()).toEqual(["antigravity"]);
await writeFile(join(home, ".gemini", "settings.json"), "{}\n");
expect(await names()).toEqual(["gemini_cli", "antigravity"]);
});
it("preserves unrelated Codex TOML and supports an installed command", async () => {
const home = await createTestTempDirectory("rea-toml-");
const configPath = join(home, ".codex/config.toml");
@@ -59,7 +59,7 @@ it("threads the selected host and environment into system diagnostics", async ()
expect(host.homeDirectory).toBe("/selected/home");
expect(host.platform).toBe("linux");
expect(host.architecture).toBe("x64");
await host.installationPaths?.();
await host.installationLaunchers?.();
expect(await host.macosVersion()).toBe("15.0");
await host.executable(process.execPath);
@@ -75,7 +75,7 @@ it("threads the selected host and environment into system diagnostics", async ()
it.each([
"macosVersion",
"installationPaths",
"installationLaunchers",
"brewHopperPath",
"executable",
"ilspyCmdVersion",
@@ -124,7 +124,7 @@ it.each(["complete", "failed"] as const)(
},
});
try {
const pending = host.installationPaths?.();
const pending = host.installationLaunchers?.();
if (outcome === "failed") await expect(pending).resolves.toEqual([]);
else
await expect(pending).rejects.toMatchObject({
@@ -169,7 +169,7 @@ fs.writeFileSync(${JSON.stringify(started)}, "ready");
environment: { HOME: home, PATH: home },
signal: controller.signal,
});
const pending = host.installationPaths!().then(
const pending = host.installationLaunchers!().then(
(value) => value,
(cause: unknown) => cause,
);
@@ -207,7 +207,7 @@ it.skipIf(process.platform === "win32")(
signal: controller.signal,
});
try {
await expect(host.installationPaths?.()).rejects.toMatchObject({
await expect(host.installationLaunchers?.()).rejects.toMatchObject({
_tag: "AnalysisCancelledError",
cause: reason,
});
@@ -337,3 +337,70 @@ it("reports no Homebrew Hopper when the cask has no receipt", async () => {
new Set(["--caskroom"]),
);
});
it("groups the Windows npm shims `where` lists under one installation", async () => {
const npm = "C:\\Users\\user\\AppData\\Roaming\\npm";
const other = "D:\\tools\\rea";
const listed = [
`${npm}\\rea`,
`${npm}\\rea.cmd`,
`${npm}\\REA.ps1`,
`${other}\\rea.cmd`,
];
const host = systemDoctorHost({
platform: "win32",
architecture: "x64",
environment: { PATH: "C:\\Windows" },
execFileOutput: async (command) => {
if (command !== "where")
throw new Error(`Unexpected diagnostic command: ${command}`);
const stdout = `${listed.join("\r\n")}\r\n`;
return {
stdout,
stderr: "",
stdoutBytes: Buffer.byteLength(stdout),
stderrBytes: 0,
};
},
});
const launchers = (await host.installationLaunchers?.()) ?? [];
// Every listed launcher stays visible; identity groups them by directory.
expect(launchers.map(({ path }) => path)).toEqual(listed);
expect(new Set(launchers.map(({ installation }) => installation)).size).toBe(
2,
);
});
it.skipIf(process.platform === "win32")(
"resolves POSIX launchers that reach one package through symlinks",
async () => {
const root = await createTestTempDirectory("rea-doctor-launchers-");
const scripts = join(root, "lib/node_modules/rea-agents/scripts");
await mkdir(scripts, { recursive: true });
await writeFile(join(scripts, "rea.mjs"), "", { mode: 0o755 });
const launchers = [join(root, "bin/rea"), join(root, "home/bin/rea")];
for (const launcher of launchers) {
await mkdir(dirname(launcher), { recursive: true });
await symlink(join(scripts, "rea.mjs"), launcher);
}
const host = systemDoctorHost({
platform: "linux",
architecture: "x64",
environment: { PATH: "/selected/bin" },
execFileOutput: async () => {
const stdout = `${launchers.join("\n")}\n`;
return {
stdout,
stderr: "",
stdoutBytes: Buffer.byteLength(stdout),
stderrBytes: 0,
};
},
});
const resolved = (await host.installationLaunchers?.()) ?? [];
expect(resolved.map(({ path }) => path)).toEqual(launchers);
expect(new Set(resolved.map(({ installation }) => installation)).size).toBe(
1,
);
},
);
@@ -354,23 +354,16 @@ it("rejects relative paths through in-memory MCP handlers", async () => {
}
});
it("rejects unpaired surrogates through in-memory MCP handlers without advertising them", async () => {
// advertisedPatternsVMode.test.ts proves no schema profile advertises the
// surrogate escape; this case proves the handlers still refuse such paths.
it("rejects unpaired surrogates through in-memory MCP handlers", async () => {
const firmwareCalls: string[] = [];
const { client, close } = await createMcp(firmwareCalls, "full");
const { client, close } = await createMcp(firmwareCalls);
try {
const absolute = resolve("rea-absolute-input");
const cases = inputCases(absolute, `${absolute}\ud800`);
const listed = await client.listTools();
const checked: string[] = [];
for (const { name, field, invalid, issuePath } of cases) {
const schema = listed.tools.find(
(tool) => tool.name === name,
)?.inputSchema;
if (schema === undefined)
throw new Error(`Missing advertised tool ${name}`);
// Strict pattern compilers refuse the complete tools request for a
// `\uD800-\uDFFF` range, so the rule is enforced only by the handler.
expect(JSON.stringify(schema), name).not.toMatch(/\\\\u[dD][89a-fA-F]/u);
const result = await client.callTool({ name, arguments: invalid });
expect(result.isError, `${name}.${field}`).toBe(true);
expect(parseMcpToolError(result).error, `${name}.${field}`).toMatchObject(
+1 -1
View File
@@ -190,7 +190,7 @@ it("retains terminal-call qualifications across live dossiers and snapshot-backe
expect(evidence.limitations, name).toContain(limitation);
expect(evidence.analysis_profile?.parameters).toMatchObject({
function_boundary_observations:
"ghidra-function-boundary-limitations-v4",
"ghidra-function-boundary-limitations-v5",
});
const replay = await harness.session.execute(name, evidence.parameters);
if (!replay.ok) throw replay.error;
+37 -3
View File
@@ -8,7 +8,7 @@ import {
MCP_CONNECTION_LOST,
MCP_TRANSPORT_ERROR,
MCP_CONNECTION_START_FAILED,
MCP_HTTP_LISTEN_FAILED,
mcpHttpListenFailed,
MCP_SHUTDOWN_FAILED,
SERVER_START_FAILED,
} from "../../../src/main/messages.js";
@@ -251,7 +251,9 @@ describe("MCP runtime HTTP transport errors", () => {
}),
),
).toBe(1);
expect(output).toEqual([`${refused.remediationAction}\n`]);
expect(output).toEqual([
`${mcpHttpListenFailed(refused.remediationAction)}\n`,
]);
expect(output[0]).toContain("Set MCP_ALLOWED_HOSTS");
expect(output[0]).not.toContain("Choose another --port");
});
@@ -273,6 +275,38 @@ describe("MCP runtime HTTP transport errors", () => {
}),
),
).toBe(1);
expect(output).toEqual([`${MCP_HTTP_LISTEN_FAILED}\n`]);
expect(output).toHaveLength(1);
expect(output[0]).toMatch(
/^REA could not listen for MCP HTTP connections\./u,
);
expect(output[0]).not.toContain("SECRET");
});
it.each([
["ENOTFOUND", /host name resolution/u],
["EADDRNOTAVAIL", /address assigned to this machine/u],
["EACCES", /permitted to bind/u],
["EADDRINUSE", /another --port or MCP_PORT/u],
])(
"reports %s listener recovery from its parsed failure",
async (code, recovery) => {
const output: string[] = [];
const cause = Object.assign(new Error(`SECRET ${code}`), { code });
expect(
await run(
dependencies({
serve: () => Promise.reject(new StreamableHttpListenError(cause)),
output,
shutdown: [],
exitCodes: [],
}),
),
).toBe(1);
expect(output).toHaveLength(1);
expect(output[0]).toMatch(recovery);
expect(output[0]).not.toContain("SECRET");
if (code !== "EADDRINUSE")
expect(output[0]).not.toContain("Choose another --port");
},
);
});
+122 -23
View File
@@ -385,29 +385,49 @@ describe("production MCP doctor family scopes", () => {
},
);
it("discovers an empty prompt inventory and explains a scope without session", async () => {
const result = await runProductionMcpDoctor({
command: process.execPath,
args: [resolve("scripts/rea.mjs"), "mcp"],
cwd: process.cwd(),
environment: { ...process.env, REA_MCP_TOOL_FAMILIES: "browser" },
});
expect(result).toMatchObject({
healthy: false,
adapter: "production-stdio",
inventory: {
prompts: { expected: 0, observed: 0, missing: [], unexpected: [] },
},
request_flow: { tool: "binary_session", ok: false },
});
expect(result.checks).toContainEqual({
name: "request-flow",
ok: false,
detail: expect.stringMatching(
/binary_session.*outside.*selected tool families.*include.*session family.*request.flow.*identity/iu,
),
});
});
it.each(["browser", "native"])(
"explains unobserved identity for a %s scope without session",
async (families) => {
const result = await runProductionMcpDoctor({
command: process.execPath,
args: [resolve("scripts/rea.mjs"), "mcp"],
cwd: process.cwd(),
environment: { ...process.env, REA_MCP_TOOL_FAMILIES: families },
});
expect(result).toMatchObject({
healthy: false,
adapter: "production-stdio",
inventory: {
prompts: { expected: 0, observed: 0, missing: [], unexpected: [] },
},
request_flow: { tool: "binary_session", ok: false },
identity: {
observed_package_version: null,
observed_catalog_digest: null,
},
});
const scopeLimitation =
/binary_session.*outside.*selected tool families.*include.*session family.*request.flow.*identity/iu;
expect(result.checks).toContainEqual({
name: "server-identity",
ok: false,
detail: expect.stringMatching(
new RegExp(`^identity not observed: ${scopeLimitation.source}`, "iu"),
),
});
expect(result.checks).not.toContainEqual(
expect.objectContaining({
name: "server-identity",
detail: expect.stringContaining("missing"),
}),
);
expect(result.checks).toContainEqual({
name: "request-flow",
ok: false,
detail: expect.stringMatching(scopeLimitation),
});
},
);
it("fails startup instead of misreporting inventory for an invalid scope", async () => {
const result = await runProductionMcpDoctor({
@@ -639,6 +659,14 @@ describe("production MCP doctor probe validation", () => {
ok: true,
detail: expect.any(String),
});
expect(result.checks).toContainEqual({
name: "server-identity",
ok: mode !== "invalid",
detail:
mode === "invalid"
? expect.stringMatching(/^identity not observed: .*output schema/iu)
: expect.stringMatching(/^package=.+; catalog=.+$/u),
});
if (mode === "invalid")
expect(result.checks).toContainEqual({
name: "request-flow",
@@ -772,6 +800,77 @@ describe("production MCP doctor child lifecycle", () => {
});
});
describe("production MCP doctor child ownership", () => {
it("does not hand an ambient target variable to the diagnostic child", async () => {
const root = await createTestTempDirectory("rea-mcp-doctor-target-free-");
const seenPath = join(root, "env.json");
const result = await runProductionMcpDoctor({
command: process.execPath,
args: [resolve("tests/fixtures/mcpDoctorEnvironment.mjs")],
cwd: root,
environment: {
...process.env,
HOPPER_TARGET_PATH: "/tmp/ambient-target",
HOPPER_TARGET_KIND: "hop",
REA_MCP_DOCTOR_SEEN_PATH: seenPath,
REA_MCP_DOCTOR_DEADLINE_MS: "5000",
},
});
expect(result.healthy).toBe(false);
const seen = JSON.parse(await readFile(seenPath, "utf8")) as Record<
string,
unknown
>;
expect(seen).not.toHaveProperty("HOPPER_TARGET_PATH");
expect(seen).not.toHaveProperty("HOPPER_TARGET_KIND");
// The rest of the caller's environment still reaches the child.
expect(seen.REA_MCP_DOCTOR_SEEN_PATH).toBe(seenPath);
});
it.each([
["in the launcher's process group", "0"],
["in its own session", "1"],
])(
"stops a descendant %s that outlives the launcher",
async (_case, detached) => {
const root = await createTestTempDirectory("rea-mcp-doctor-descendant-");
const pidPath = join(root, "pids.json");
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const pending = runProductionMcpDoctor({
command: process.execPath,
args: [resolve("tests/fixtures/mcpDoctorForkDescendant.mjs")],
cwd: process.cwd(),
environment: {
...process.env,
REA_MCP_DOCTOR_PID_PATH: pidPath,
REA_MCP_DOCTOR_DESCENDANT_DETACHED: detached,
REA_MCP_DOCTOR_DEADLINE_MS: "60000",
},
});
const pids = await vi.waitFor(
async () => {
const value = JSON.parse(await readFile(pidPath, "utf8")) as {
launcher: number;
descendant: number;
};
expect(Number.isSafeInteger(value.descendant)).toBe(true);
return value;
},
{ timeout: 10_000 },
);
await vi.advanceTimersByTimeAsync(60_000);
const result = await vi.waitFor(() => pending, { timeout: 10_000 });
vi.useRealTimers();
// The launcher exits at once, so only owned cleanup can stop the descendant.
// The SDK transport signaled only its direct child and reported success.
expect(result.healthy).toBe(false);
expect(result.checks.map(({ name }) => name)).toEqual(["transport"]);
expectDoctorDeadline(result.checks[0]?.detail, 60_000);
await expect(waitForExit(pids.descendant)).resolves.toBeUndefined();
},
);
});
describe("production MCP doctor request-flow deadline", () => {
it("names the applied deadline when binary_session exceeds it after the catalog", async () => {
const root = await createTestTempDirectory("rea-mcp-doctor-session-");
@@ -709,7 +709,8 @@ linux(
).toMatchObject({
ok: false,
error: {
reason: "integrity",
// The signature verified; only the feed's launcher digest differs.
reason: "launcher_digest_mismatch",
message: expect.stringContaining(f.release.main_exe_hash),
},
});
+10
View File
@@ -0,0 +1,10 @@
import { writeFile } from "node:fs/promises";
// Publish the environment the doctor handed this child, then exit before any
// protocol work so the doctor reports the startup failure under test.
if (process.env.REA_MCP_DOCTOR_SEEN_PATH !== undefined)
await writeFile(
process.env.REA_MCP_DOCTOR_SEEN_PATH,
JSON.stringify(process.env),
);
process.exit(0);
+24
View File
@@ -0,0 +1,24 @@
import { spawn } from "node:child_process";
import { writeFile } from "node:fs/promises";
// Start an inherited-stdio descendant, publish both PIDs, then exit so the
// doctor sees a dead launcher while the descendant keeps its output open. A
// detached descendant leads its own session, outside the launcher's group. It
// exits by itself after 30 seconds so a failed cleanup cannot leak it forever.
const descendant = spawn(
process.execPath,
[
"-e",
"setInterval(() => {}, 1_000); setTimeout(() => process.exit(0), 30_000);",
],
{
detached: process.env.REA_MCP_DOCTOR_DESCENDANT_DETACHED === "1",
stdio: "inherit",
},
);
if (process.env.REA_MCP_DOCTOR_PID_PATH !== undefined)
await writeFile(
process.env.REA_MCP_DOCTOR_PID_PATH,
JSON.stringify({ launcher: process.pid, descendant: descendant.pid }),
);
process.exit(0);