mirror of
https://github.com/morluto/rea.git
synced 2026-11-13 18:49:25 -10:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b158b2d1d | ||
|
|
899f77d840 | ||
|
|
aa848cf1d2 | ||
|
|
82f3bfb6e8 | ||
|
|
5557d9b3eb | ||
|
|
3a13b088e6 | ||
|
|
d412569293 | ||
|
|
1b019a73d8 | ||
|
|
d427fc05c3 | ||
|
|
3f7d0d2d21 | ||
|
|
5bb6591d58 | ||
|
|
7db1b61790 | ||
|
|
ad2111fac6 | ||
|
|
9d082b144a | ||
|
|
e8c838e8bd | ||
|
|
809f0d69db |
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -226,6 +226,7 @@ export type LinuxHopperInstallResult =
|
||||
| "archive_write"
|
||||
| "launcher_inspection"
|
||||
| "integrity"
|
||||
| "launcher_digest_mismatch"
|
||||
| "authorization_or_package_manager"
|
||||
| "launcher_missing"
|
||||
| "runtime_dependencies"
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`;
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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"));
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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
|
||||
? {}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
Reference in new issue
Block a user