mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 13:56:47 -10:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d65c5d6c2a | ||
|
|
206992c918 | ||
|
|
1d04b7101b | ||
|
|
1b0c145fc5 | ||
|
|
8a6bc89337 | ||
|
|
4d4ac55293 | ||
|
|
5fefa2a703 | ||
|
|
63e0a66537 | ||
|
|
2d39e5ffc5 | ||
|
|
c708021878 | ||
|
|
c982d90e00 | ||
|
|
953a5edf76 | ||
|
|
b7e7dfe9e5 | ||
|
|
e4ef852808 | ||
|
|
d6a0d66893 | ||
|
|
e62d29888f | ||
|
|
64a4a8dba1 |
No files matched your search
@@ -948,6 +948,13 @@ from an older request does not override a newer outcome. Request deadlines and
|
||||
cancellations neither mark nor clear availability. A new provider client starts
|
||||
without these outcomes.
|
||||
|
||||
Standalone command operations (such as `inspect_with_objdump` and
|
||||
`execute_rizin_command`) similarly reflect observed executable launch failures:
|
||||
an unprobed executable is reported available; once a launch failure is observed,
|
||||
discovery reports the operation unavailable with the executable remediation
|
||||
until a request for it dispatched later succeeds. Direct retry remains available
|
||||
without restarting REA.
|
||||
|
||||
`detail` selects the projection and the result echoes it. The default
|
||||
`summary` keeps open state, target identity, the binding, current-route
|
||||
`capabilities`, run, activity and health status, and each provider candidate's
|
||||
|
||||
@@ -25,7 +25,10 @@ while differing in supported options and output.
|
||||
Failure to launch the selected executable reports provider unavailability, the
|
||||
configured command and the host error under `boundary: "provider-launch"`.
|
||||
Recovery concerns `REA_OBJDUMP_COMMAND`, PATH and execution permission, including
|
||||
for target-free architecture listing. Artifact admission keeps separate input
|
||||
for target-free architecture listing. In MCP discovery (`binary_session`), an
|
||||
unprobed executable is reported available; observing a launch failure marks the
|
||||
operation unavailable with its remediation until a later dispatched request
|
||||
succeeds. Artifact admission keeps separate input
|
||||
and filesystem-read diagnostics. Invalid operation/selector combinations retain
|
||||
their rejected fields in input issues before artifact reads or provider launch.
|
||||
|
||||
|
||||
@@ -2,13 +2,16 @@ import type { ProviderAvailability } from "./AnalysisProvider.js";
|
||||
import type { JebAnalysisAvailability } from "./jeb/JebAnalysisPort.js";
|
||||
import type { AvailabilityPolicy } from "./CapabilityInventory.js";
|
||||
import type { OptionalProviderLoadFailures } from "./OptionalObservationProviders.js";
|
||||
import type { CommandProviderLaunchFailures } from "./reverse/ReverseEngineeringService.js";
|
||||
|
||||
export interface CapabilityAvailabilityFacts {
|
||||
readonly probes?: Partial<CapabilityAvailabilityProbes>;
|
||||
readonly observeProviderLoadFailures?: () => OptionalProviderLoadFailures;
|
||||
readonly observeCommandProviderFailures?: () => CommandProviderLaunchFailures;
|
||||
readonly optionalProviderLoadFailures?:
|
||||
| OptionalProviderLoadFailures
|
||||
| undefined;
|
||||
readonly commandProviderFailures?: CommandProviderLaunchFailures | undefined;
|
||||
readonly hostUnsupported?: AvailabilityPolicy["hostUnsupported"];
|
||||
readonly optionalFeatures?: Pick<
|
||||
AvailabilityPolicy,
|
||||
|
||||
@@ -1115,6 +1115,34 @@ it("keeps independent native sessions discoverable without an unrelated binary t
|
||||
);
|
||||
});
|
||||
|
||||
it("reports standalone reverse-engineering tools unavailable when launch failure is observed", () => {
|
||||
const repair =
|
||||
"Set REA_OBJDUMP_COMMAND to an installed executable GNU objdump command.";
|
||||
const entries = buildCapabilityInventory(status(), {
|
||||
...enabledPolicy,
|
||||
commandProviderFailures: {
|
||||
inspect_with_objdump: {
|
||||
reason: "Could not launch selected objdump executable",
|
||||
remediation: repair,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(
|
||||
entries.find((entry) => entry.name === "inspect_with_objdump"),
|
||||
).toMatchObject({
|
||||
available: false,
|
||||
reason: "provider_unavailable",
|
||||
remediation: repair,
|
||||
});
|
||||
expect(
|
||||
entries.find((entry) => entry.name === "execute_rizin_command"),
|
||||
).toMatchObject({
|
||||
available: true,
|
||||
reason: "available",
|
||||
remediation: null,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["darwin", "linux", "win32"] as const)(
|
||||
"reports the persistent debugger start host restriction on %s",
|
||||
(platform) => {
|
||||
|
||||
@@ -16,6 +16,10 @@ import type {
|
||||
OptionalProviderLoadFailures,
|
||||
OptionalProviderLoadFailure,
|
||||
} from "./OptionalObservationProviders.js";
|
||||
import type {
|
||||
CommandProviderLaunchFailures,
|
||||
ReverseEngineeringOperation,
|
||||
} from "./reverse/ReverseEngineeringService.js";
|
||||
|
||||
import {
|
||||
expandProviderCapabilities,
|
||||
@@ -102,6 +106,7 @@ export type AvailabilityPolicy = {
|
||||
readonly evmLimiterAvailability?: ProviderAvailability;
|
||||
readonly processCaptureEnabled: boolean;
|
||||
readonly optionalProviderLoadFailures?: OptionalProviderLoadFailures;
|
||||
readonly commandProviderFailures?: CommandProviderLaunchFailures;
|
||||
readonly binaryLayoutEnabled?: boolean;
|
||||
readonly recordedCrashEnabled?: boolean;
|
||||
readonly firmwareInspectionEnabled?: boolean;
|
||||
@@ -885,8 +890,16 @@ const providerAvailability = ({
|
||||
descriptors,
|
||||
kind,
|
||||
name,
|
||||
policy,
|
||||
}: AvailabilityContext): Availability => {
|
||||
if (kind === "enhanced") return composedAvailability(name, descriptors);
|
||||
const launchFailure =
|
||||
policy.commandProviderFailures?.[name as ReverseEngineeringOperation];
|
||||
if (launchFailure !== undefined)
|
||||
return {
|
||||
reason: "provider_unavailable",
|
||||
remediation: launchFailure.remediation,
|
||||
};
|
||||
const descriptor = descriptors.get(name);
|
||||
if (
|
||||
descriptor === undefined &&
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { ElectronBoundarySummary } from "../../domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { electronSemanticsState } from "./ElectronBoundaryAnalysis.js";
|
||||
import {
|
||||
electronSemanticsState,
|
||||
createElectronIpcContext,
|
||||
summarizeElectronBoundaries,
|
||||
type ElectronIpcContext,
|
||||
} from "./ElectronBoundaryAnalysis.js";
|
||||
import { ELECTRON_IDENTITY_LIMITATION } from "../../domain/javascript/javascriptElectronMemberWrites.js";
|
||||
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
|
||||
import {
|
||||
@@ -47,11 +52,6 @@ import {
|
||||
} from "./JavaScriptArtifactGraphStructure.js";
|
||||
import { addJavaScriptBundlerNodes } from "./JavaScriptArtifactGraphBundlers.js";
|
||||
import { addElectronBoundaries } from "./ElectronBoundaryGraph.js";
|
||||
import {
|
||||
createElectronIpcContext,
|
||||
summarizeElectronBoundaries,
|
||||
type ElectronIpcContext,
|
||||
} from "./ElectronBoundaryAnalysis.js";
|
||||
|
||||
/**
|
||||
* Build a validated application graph with cooperative immutable ownership.
|
||||
|
||||
@@ -4,8 +4,68 @@ import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js";
|
||||
import { OwnedCommandFailure } from "../process/OwnedCommand.js";
|
||||
import {
|
||||
createSystemProcessOwnershipHost,
|
||||
LinuxOwnedProcessPrerequisiteError,
|
||||
} from "../process/ProcessOwnershipObservation.js";
|
||||
import { ownedProcessPrerequisiteAvailability } from "./ownedProcessAvailability.js";
|
||||
|
||||
describe("Linux process table prerequisite discovery", () => {
|
||||
it.each(["ENOENT", "EACCES", "EIO"])(
|
||||
"reports %s with its own recovery",
|
||||
async (code) => {
|
||||
const cause = Object.assign(new Error(`ps failed: ${code}`), { code });
|
||||
const host = createSystemProcessOwnershipHost(
|
||||
"linux",
|
||||
{},
|
||||
{
|
||||
execFileOutput: async () => {
|
||||
throw cause;
|
||||
},
|
||||
},
|
||||
);
|
||||
let failure: unknown;
|
||||
try {
|
||||
await host.prepare?.();
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
expect(failure).toBeInstanceOf(LinuxOwnedProcessPrerequisiteError);
|
||||
expect(failure).toMatchObject({
|
||||
cause,
|
||||
diagnostics: { system_code: code },
|
||||
});
|
||||
const availability = ownedProcessPrerequisiteAvailability(failure);
|
||||
expect(availability).toMatchObject({
|
||||
status: "unavailable",
|
||||
reason: expect.stringContaining(code),
|
||||
diagnostics: { system_code: code },
|
||||
});
|
||||
if (availability?.status !== "unavailable")
|
||||
throw new Error("expected unavailability");
|
||||
expect(availability?.remediation).toContain(
|
||||
code === "ENOENT" ? "procps" : code,
|
||||
);
|
||||
if (code !== "ENOENT")
|
||||
expect(availability?.remediation).not.toContain("Install");
|
||||
},
|
||||
);
|
||||
|
||||
it("does not convert host spawn capacity exhaustion to unavailability", async () => {
|
||||
const cause = Object.assign(new Error("capacity"), { code: "EAGAIN" });
|
||||
const host = createSystemProcessOwnershipHost(
|
||||
"linux",
|
||||
{},
|
||||
{
|
||||
execFileOutput: async () => {
|
||||
throw cause;
|
||||
},
|
||||
},
|
||||
);
|
||||
await expect(host.prepare?.()).rejects.toBe(cause);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Darwin ownership prerequisite discovery", () => {
|
||||
it("retains compiler diagnostics when a provider probe cannot prepare ownership", () => {
|
||||
const preparation = new DarwinProcessOwnershipInspectionError(
|
||||
|
||||
@@ -3,12 +3,12 @@ import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js";
|
||||
import { OwnedCommandFailure } from "../process/OwnedCommand.js";
|
||||
import { LinuxOwnedProcessPrerequisiteError } from "../process/ProcessOwnershipObservation.js";
|
||||
import {
|
||||
LINUX_PROCESS_OWNERSHIP_REMEDIATION,
|
||||
LinuxOwnedProcessPrerequisiteError,
|
||||
} from "../process/ProcessOwnershipObservation.js";
|
||||
import type { ProviderAvailability } from "./AnalysisProvider.js";
|
||||
|
||||
const REMEDIATION =
|
||||
"Install your distribution's procps or procps-ng package, or put a procps-compatible ps on REA's PATH, then retry.";
|
||||
|
||||
const DARWIN_REMEDIATION =
|
||||
"Check the selected Xcode or Command Line Tools and Swift compiler configuration, then restart REA and retry.";
|
||||
|
||||
@@ -68,18 +68,22 @@ export const ownedProcessPrerequisiteAvailability = (
|
||||
cause: unknown,
|
||||
): ProviderAvailability | undefined => {
|
||||
const refusal = prerequisiteRefusal(cause);
|
||||
if (refusal !== undefined)
|
||||
if (refusal !== undefined) {
|
||||
const remediation =
|
||||
refusal.remediationAction ?? LINUX_PROCESS_OWNERSHIP_REMEDIATION;
|
||||
return {
|
||||
status: "unavailable",
|
||||
code: "runtime_unavailable",
|
||||
reason: `${refusal.reason}. ${REMEDIATION}`,
|
||||
remediation: REMEDIATION,
|
||||
reason: `${refusal.reason}. ${remediation}`,
|
||||
remediation,
|
||||
diagnostics: {
|
||||
stage: "ownership_preparation",
|
||||
prerequisite_provider_id: refusal.providerId,
|
||||
prerequisite_operation: refusal.operation,
|
||||
...refusal.diagnostics,
|
||||
},
|
||||
};
|
||||
}
|
||||
const darwin = darwinPrerequisiteRefusal(cause);
|
||||
if (darwin === undefined) return undefined;
|
||||
return {
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import {
|
||||
DEFAULT_REVERSE_COMMAND_TIMEOUT_MS,
|
||||
@@ -40,11 +40,20 @@ import type {
|
||||
import {
|
||||
execFileOutput,
|
||||
execFileOutputFailure,
|
||||
HOST_SPAWN_CAPACITY_CODES,
|
||||
} from "../../process/ExecFileOutput.js";
|
||||
import {
|
||||
OwnedCommandFailure,
|
||||
runOwnedCommand,
|
||||
} from "../../process/OwnedCommand.js";
|
||||
import { DarwinProcessOwnershipInspectionError } from "../../process/DarwinProcessRunTokenReader.js";
|
||||
import {
|
||||
DARWIN_PROCESS_OWNERSHIP_REMEDIATION,
|
||||
LinuxOwnedProcessPrerequisiteError,
|
||||
processOwnershipAdmissionRecovery,
|
||||
systemProcessOwnershipHost,
|
||||
} from "../../process/ProcessOwnershipObservation.js";
|
||||
import { ProcessAdmissionError } from "../../process/ProcessAdmissionError.js";
|
||||
import type { ProviderProcessSupervisor } from "../../process/ProviderProcess.js";
|
||||
import { absoluteCallerPath } from "../../filesystem/callerPath.js";
|
||||
import { parseAnalysisInput } from "../AnalysisInput.js";
|
||||
@@ -53,6 +62,26 @@ export type ReverseEngineeringOperation =
|
||||
| "inspect_with_objdump"
|
||||
| "execute_rizin_command";
|
||||
|
||||
export interface CommandProviderLaunchFailure {
|
||||
readonly reason: string;
|
||||
readonly remediation: string;
|
||||
}
|
||||
|
||||
export type CommandProviderLaunchFailures = Readonly<
|
||||
Partial<Record<ReverseEngineeringOperation, CommandProviderLaunchFailure>>
|
||||
>;
|
||||
|
||||
interface CommandLaunchOutcome {
|
||||
failure:
|
||||
| {
|
||||
readonly dispatch: number;
|
||||
readonly reason: string;
|
||||
readonly remediation: string;
|
||||
}
|
||||
| undefined;
|
||||
succeeded: number;
|
||||
}
|
||||
|
||||
const MAX_COMBINED_OUTPUT_BYTES = 16 * 1024 * 1024;
|
||||
export const REVERSE_COMMAND_OUTPUT_LIMITATION = `REA stopped collecting output at its ${String(MAX_COMBINED_OUTPUT_BYTES)}-byte cap and requested termination of the provider process; the captured stdout and stderr are a prefix and command completion is unknown. Narrow the command or page its output.`;
|
||||
// Eight concurrent captures bound retained provider output to 128 MiB.
|
||||
@@ -126,6 +155,11 @@ export class ReverseEngineeringService {
|
||||
>();
|
||||
#closing = false;
|
||||
#closePromise: Promise<void> | undefined;
|
||||
#dispatched = 0;
|
||||
readonly #outcomes = new Map<
|
||||
ReverseEngineeringOperation,
|
||||
CommandLaunchOutcome
|
||||
>();
|
||||
|
||||
constructor(options: ReverseEngineeringServiceOptions) {
|
||||
this.#environment = { ...(options.environment ?? process.env) };
|
||||
@@ -188,6 +222,38 @@ export class ReverseEngineeringService {
|
||||
}
|
||||
|
||||
/** Retry cleanup for provider process groups retained after an incomplete stop. */
|
||||
#outcome(operation: ReverseEngineeringOperation): CommandLaunchOutcome {
|
||||
const existing = this.#outcomes.get(operation);
|
||||
if (existing !== undefined) return existing;
|
||||
const created: CommandLaunchOutcome = { failure: undefined, succeeded: 0 };
|
||||
this.#outcomes.set(operation, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Current launch failures observed for caller-configured standalone tools. */
|
||||
observedLaunchFailures(): CommandProviderLaunchFailures {
|
||||
const result: Partial<
|
||||
Record<ReverseEngineeringOperation, CommandProviderLaunchFailure>
|
||||
> = {};
|
||||
for (const [operation, outcome] of this.#outcomes) {
|
||||
if (
|
||||
outcome.failure !== undefined &&
|
||||
outcome.failure.dispatch > outcome.succeeded
|
||||
) {
|
||||
result[operation] = {
|
||||
reason: outcome.failure.reason,
|
||||
remediation: outcome.failure.remediation,
|
||||
};
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Forget runtime launch outcomes observed for standalone tools. */
|
||||
clearObservedFailures(): void {
|
||||
this.#outcomes.clear();
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
this.#closing = true;
|
||||
this.#closePromise ??= this.#closeActiveOperationsAndCleanup().catch(
|
||||
@@ -458,6 +524,7 @@ export class ReverseEngineeringService {
|
||||
let failure: AnalysisError | undefined;
|
||||
let cleanupOwner: ProviderProcessSupervisor | undefined;
|
||||
let earlyFailure: AnalysisError | undefined;
|
||||
const dispatch = ++this.#dispatched;
|
||||
try {
|
||||
const output = await this.#run(command, args, {
|
||||
env: {
|
||||
@@ -508,7 +575,13 @@ export class ReverseEngineeringService {
|
||||
},
|
||||
);
|
||||
const capture = execFileOutputFailure(cause);
|
||||
const code = capture?.code ?? commandFailureCode(cause);
|
||||
const code =
|
||||
capture?.code ??
|
||||
commandFailureCode(cause) ??
|
||||
(cause instanceof LinuxOwnedProcessPrerequisiteError
|
||||
? (execFileOutputFailure(cause.cause)?.code ??
|
||||
commandFailureCode(cause.cause))
|
||||
: undefined);
|
||||
if (code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER")
|
||||
outputLimitReached = true;
|
||||
if (earlyFailure === undefined && code === "E2BIG")
|
||||
@@ -525,6 +598,165 @@ export class ReverseEngineeringService {
|
||||
},
|
||||
{ cause, ...ownedCleanupOptions(cause) },
|
||||
);
|
||||
if (
|
||||
earlyFailure === undefined &&
|
||||
typeof code === "string" &&
|
||||
HOST_SPAWN_CAPACITY_CODES.has(code)
|
||||
) {
|
||||
const provider =
|
||||
operation === "inspect_with_objdump" ? "objdump" : "rizin";
|
||||
const setting =
|
||||
operation === "inspect_with_objdump"
|
||||
? "REA_OBJDUMP_COMMAND"
|
||||
: "REA_RIZIN_COMMAND";
|
||||
earlyFailure = new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
"capacity",
|
||||
`The host ran out of process, memory or file-descriptor capacity (${code}) to start ${provider}.`,
|
||||
{ spawn_error: code, command },
|
||||
{
|
||||
cause,
|
||||
retryable: true,
|
||||
remediationAction: `The host ran out of process, memory or file-descriptor capacity (${code}). Close unused processes or files, or raise the process or open-file limit of the REA process, then retry; ${setting} does not need to change.`,
|
||||
diagnostics: {
|
||||
boundary: "host-capacity",
|
||||
command,
|
||||
system_code: code,
|
||||
},
|
||||
...ownedCleanupOptions(cause),
|
||||
},
|
||||
);
|
||||
}
|
||||
if (
|
||||
earlyFailure === undefined &&
|
||||
cause instanceof LinuxOwnedProcessPrerequisiteError
|
||||
) {
|
||||
earlyFailure = cause;
|
||||
}
|
||||
const admissionFailure =
|
||||
cause instanceof ProcessAdmissionError
|
||||
? cause
|
||||
: cause instanceof OwnedOutputFailure &&
|
||||
cause.cause instanceof ProcessAdmissionError
|
||||
? cause.cause
|
||||
: typeof cause === "object" &&
|
||||
cause !== null &&
|
||||
Reflect.get(cause, "_tag") === "ProcessAdmissionError"
|
||||
? (cause as ProcessAdmissionError)
|
||||
: undefined;
|
||||
if (earlyFailure === undefined && admissionFailure !== undefined) {
|
||||
const provider =
|
||||
operation === "inspect_with_objdump" ? "objdump" : "rizin";
|
||||
const setting =
|
||||
operation === "inspect_with_objdump"
|
||||
? "REA_OBJDUMP_COMMAND"
|
||||
: "REA_RIZIN_COMMAND";
|
||||
const rawSystemCode =
|
||||
admissionFailure.detail?.system_code ??
|
||||
(typeof admissionFailure.cause === "object" &&
|
||||
admissionFailure.cause !== null
|
||||
? (execFileOutputFailure(admissionFailure.cause)?.code ??
|
||||
commandFailureCode(admissionFailure.cause))
|
||||
: undefined);
|
||||
const admissionSystemCode =
|
||||
typeof rawSystemCode === "string" ? rawSystemCode : undefined;
|
||||
const recovery = processOwnershipAdmissionRecovery(
|
||||
admissionFailure.code,
|
||||
systemProcessOwnershipHost.platform ?? process.platform,
|
||||
admissionSystemCode,
|
||||
);
|
||||
earlyFailure = new AnalysisCapabilityUnavailableError(
|
||||
"process-ownership",
|
||||
"prepare_owned_process",
|
||||
`Process ownership admission failed (${admissionFailure.code}${admissionSystemCode === undefined ? "" : `, ${admissionSystemCode}`}) before starting ${provider}: ${admissionFailure.message}`,
|
||||
{
|
||||
cause,
|
||||
userMessage: `Process ownership admission failed (${admissionFailure.code}${admissionSystemCode === undefined ? "" : `, ${admissionSystemCode}`}) before starting ${provider}.`,
|
||||
remediationAction: `${recovery} ${setting} does not need to change.`,
|
||||
diagnostics: {
|
||||
boundary: "process-ownership",
|
||||
system_code: admissionSystemCode ?? admissionFailure.code,
|
||||
...(admissionSystemCode === undefined
|
||||
? {}
|
||||
: { admission_code: admissionFailure.code }),
|
||||
},
|
||||
...ownedCleanupOptions(cause),
|
||||
},
|
||||
);
|
||||
}
|
||||
const darwinFailure = darwinPreparationError(cause);
|
||||
if (earlyFailure === undefined && darwinFailure !== undefined) {
|
||||
const darwinCleanup =
|
||||
darwinFailure.cleanupFailure !== undefined
|
||||
? {
|
||||
reason: darwinFailure.cleanupFailure.message,
|
||||
resources: darwinFailure.cleanupFailure.cleanupResources,
|
||||
}
|
||||
: (ownedCleanupOptions(cause).cleanup ?? undefined);
|
||||
if (code === "ETIMEDOUT") {
|
||||
earlyFailure = new AnalysisTimeoutError(operation, timeoutMs, {
|
||||
cause,
|
||||
...(darwinCleanup === undefined ? {} : { cleanup: darwinCleanup }),
|
||||
...(darwinFailure.cleanupOwner === undefined
|
||||
? {}
|
||||
: { cleanupOwner: darwinFailure.cleanupOwner }),
|
||||
...reverseCommandTimeoutGuidance(timeoutMs),
|
||||
});
|
||||
} else if (
|
||||
darwinFailure.cancellation !== undefined ||
|
||||
commandCancelled(signal, cause, code)
|
||||
) {
|
||||
earlyFailure = new AnalysisCancelledError(operation, {
|
||||
cause:
|
||||
callerCancellationCause(signal, cause) ??
|
||||
darwinFailure.cancellation?.reason,
|
||||
...(darwinCleanup === undefined ? {} : { cleanup: darwinCleanup }),
|
||||
...(darwinFailure.cleanupOwner === undefined
|
||||
? {}
|
||||
: { cleanupOwner: darwinFailure.cleanupOwner }),
|
||||
});
|
||||
} else {
|
||||
const provider =
|
||||
operation === "inspect_with_objdump" ? "objdump" : "rizin";
|
||||
const setting =
|
||||
operation === "inspect_with_objdump"
|
||||
? "REA_OBJDUMP_COMMAND"
|
||||
: "REA_RIZIN_COMMAND";
|
||||
earlyFailure = new AnalysisCapabilityUnavailableError(
|
||||
"process-ownership",
|
||||
"prepare_owned_process",
|
||||
`Darwin process ownership inspection could not be prepared: ${darwinFailure.message}`,
|
||||
{
|
||||
cause,
|
||||
userMessage: darwinFailure.message,
|
||||
remediationAction: `${DARWIN_PROCESS_OWNERSHIP_REMEDIATION} ${setting} does not need to change.`,
|
||||
diagnostics: {
|
||||
boundary: "process-ownership",
|
||||
stage: "ownership_preparation",
|
||||
...(darwinFailure.compilerOutcome === undefined
|
||||
? {}
|
||||
: {
|
||||
compiler_outcome: jsonValueSchema.parse(
|
||||
darwinFailure.compilerOutcome,
|
||||
),
|
||||
}),
|
||||
},
|
||||
...(darwinFailure.capturedOutput === undefined
|
||||
? {}
|
||||
: { capturedOutput: darwinFailure.capturedOutput }),
|
||||
...(darwinCleanup === undefined
|
||||
? {}
|
||||
: { cleanup: darwinCleanup }),
|
||||
...(darwinFailure.cleanupOwner === undefined
|
||||
? {}
|
||||
: { cleanupOwner: darwinFailure.cleanupOwner }),
|
||||
},
|
||||
);
|
||||
}
|
||||
if (darwinFailure.cleanupFailure !== undefined) {
|
||||
earlyFailure.bindCleanupDependencies([darwinFailure.cleanupFailure]);
|
||||
}
|
||||
}
|
||||
if (
|
||||
earlyFailure === undefined &&
|
||||
(capture === undefined ||
|
||||
@@ -656,8 +888,29 @@ export class ReverseEngineeringService {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (earlyFailure !== undefined)
|
||||
if (earlyFailure !== undefined) {
|
||||
if (
|
||||
earlyFailure instanceof AnalysisCapabilityUnavailableError &&
|
||||
earlyFailure.diagnostics?.boundary === "provider-launch" &&
|
||||
earlyFailure.providerId === profile.identity.id
|
||||
) {
|
||||
const outcome = this.#outcome(operation);
|
||||
if (
|
||||
(outcome.failure === undefined ||
|
||||
dispatch > outcome.failure.dispatch) &&
|
||||
dispatch > outcome.succeeded
|
||||
) {
|
||||
outcome.failure = {
|
||||
dispatch,
|
||||
reason: earlyFailure.userMessage ?? earlyFailure.message,
|
||||
remediation:
|
||||
earlyFailure.remediationAction ??
|
||||
`Set ${operation === "inspect_with_objdump" ? "REA_OBJDUMP_COMMAND" : "REA_RIZIN_COMMAND"} to an installed executable command.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return err(this.#registerCleanupFailure(earlyFailure, cleanupOwner));
|
||||
}
|
||||
if (failure !== undefined && cleanupOwner !== undefined)
|
||||
failure = this.#registerCleanupFailure(failure, cleanupOwner);
|
||||
let digestAfter: string | null = null;
|
||||
@@ -817,6 +1070,13 @@ export class ReverseEngineeringService {
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (
|
||||
failure?._tag !== "AnalysisCancelledError" &&
|
||||
failure?._tag !== "AnalysisTimeoutError"
|
||||
) {
|
||||
const outcome = this.#outcome(operation);
|
||||
outcome.succeeded = Math.max(outcome.succeeded, dispatch);
|
||||
}
|
||||
return failure === undefined
|
||||
? ok(evidence)
|
||||
: err(failure.retainPartialObservation(evidence));
|
||||
@@ -1012,6 +1272,28 @@ const ownedCleanupOptions = (
|
||||
? { cleanup: cause.cleanup }
|
||||
: {};
|
||||
|
||||
const darwinPreparationError = (
|
||||
cause: unknown,
|
||||
): DarwinProcessOwnershipInspectionError | undefined => {
|
||||
if (cause instanceof DarwinProcessOwnershipInspectionError) return cause;
|
||||
if (
|
||||
typeof cause === "object" &&
|
||||
cause !== null &&
|
||||
Reflect.get(cause, "name") === "DarwinProcessOwnershipInspectionError"
|
||||
)
|
||||
return cause as DarwinProcessOwnershipInspectionError;
|
||||
if (
|
||||
cause instanceof OwnedCommandFailure &&
|
||||
cause.preparationFailure !== undefined
|
||||
)
|
||||
return darwinPreparationError(cause.preparationFailure);
|
||||
if (cause instanceof OwnedOutputFailure)
|
||||
return darwinPreparationError(cause.cause);
|
||||
if (cause instanceof Error && "cause" in cause && cause.cause !== undefined)
|
||||
return darwinPreparationError(cause.cause);
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const reverseCommandTimeoutGuidance = (
|
||||
timeoutMs: number,
|
||||
): { readonly userMessage: string; readonly remediationAction: string } => ({
|
||||
|
||||
@@ -44,6 +44,10 @@ import {
|
||||
} from "./javascriptRecovery.js";
|
||||
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { createReverseEngineeringService } from "./reverseEngineering.js";
|
||||
import type {
|
||||
ReverseEngineeringService,
|
||||
CommandProviderLaunchFailures,
|
||||
} from "../application/reverse/ReverseEngineeringService.js";
|
||||
import { FirmwareAnalysisService } from "../application/firmware/FirmwareAnalysisService.js";
|
||||
import {
|
||||
createFirmwareAnalysisProvider,
|
||||
@@ -148,6 +152,7 @@ export interface CapabilityServiceOptions {
|
||||
readonly electronActiveObservation?: ElectronActiveObservationPort;
|
||||
readonly javascriptRuntimeObservation?: JavaScriptRuntimeObservationPort;
|
||||
readonly fridaInstrumentation?: FridaInstrumentationPort;
|
||||
readonly reverseEngineering?: ReverseEngineeringService;
|
||||
}
|
||||
|
||||
const tsharkLocatable = (environment: Readonly<NodeJS.ProcessEnv>): boolean => {
|
||||
@@ -379,8 +384,13 @@ const modules = {
|
||||
}),
|
||||
),
|
||||
artifactResources: service(() => new ArtifactResourceScope()),
|
||||
reverseEngineering: service(({ providerEnvironment }) =>
|
||||
createReverseEngineeringService(providerEnvironment),
|
||||
reverseEngineering: service(
|
||||
({ providerEnvironment, options }) =>
|
||||
options.reverseEngineering ??
|
||||
createReverseEngineeringService(providerEnvironment),
|
||||
(_context, value) => ({
|
||||
observeCommandProviderFailures: () => value.observedLaunchFailures(),
|
||||
}),
|
||||
),
|
||||
firmwareAnalysis: service(
|
||||
({ environment, options }) =>
|
||||
@@ -613,6 +623,7 @@ export const createCapabilityServices = (context: CapabilityHostContext) => {
|
||||
> = {};
|
||||
const probes: NonNullable<CapabilityAvailabilityFacts["probes"]> = {};
|
||||
const failureObservers: (() => OptionalProviderLoadFailures)[] = [];
|
||||
const commandFailureObservers: (() => CommandProviderLaunchFailures)[] = [];
|
||||
const availability = { hostUnsupported, optionalFeatures };
|
||||
// This is a key-preserving map of a source-owned typed declaration, not an
|
||||
// external value parser or a generic operation dispatcher.
|
||||
@@ -633,6 +644,10 @@ export const createCapabilityServices = (context: CapabilityHostContext) => {
|
||||
Object.assign(probes, instance.availability?.probes);
|
||||
const observeFailure = instance.availability?.observeProviderLoadFailures;
|
||||
if (observeFailure !== undefined) failureObservers.push(observeFailure);
|
||||
const observeCommandFailure =
|
||||
instance.availability?.observeCommandProviderFailures;
|
||||
if (observeCommandFailure !== undefined)
|
||||
commandFailureObservers.push(observeCommandFailure);
|
||||
return [
|
||||
id,
|
||||
instance.ownership === "host"
|
||||
@@ -647,6 +662,8 @@ export const createCapabilityServices = (context: CapabilityHostContext) => {
|
||||
probes,
|
||||
observeProviderLoadFailures: (): OptionalProviderLoadFailures =>
|
||||
Object.assign({}, ...failureObservers.map((observe) => observe())),
|
||||
observeCommandProviderFailures: (): CommandProviderLaunchFailures =>
|
||||
Object.assign({}, ...commandFailureObservers.map((observe) => observe())),
|
||||
close: () => scope.close(),
|
||||
};
|
||||
};
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
AnalysisTimeoutError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { PrivateRuntimeRoot } from "../../process/PrivateRuntimeRoot.js";
|
||||
import {
|
||||
createSystemProcessOwnershipHost,
|
||||
LinuxOwnedProcessPrerequisiteError,
|
||||
} from "../../process/ProcessOwnershipObservation.js";
|
||||
import {
|
||||
ProviderProcessSupervisor,
|
||||
spawnOwnedProviderProcess,
|
||||
@@ -80,6 +84,40 @@ const runChild = (
|
||||
|
||||
const exitsAfterHoldingOutput =
|
||||
'const {spawn}=require("node:child_process"); spawn(process.execPath,["-e","setTimeout(()=>{},400)"],{stdio:["ignore","inherit","inherit"]}); process.stderr.write("physical exit 7"); process.exit(7)';
|
||||
|
||||
it("preserves Linux process inspection failure and recovery before launching the codec", async () => {
|
||||
const cause = Object.assign(new Error("ps permission denied"), {
|
||||
code: "EACCES",
|
||||
});
|
||||
const host = createSystemProcessOwnershipHost(
|
||||
"linux",
|
||||
{},
|
||||
{
|
||||
execFileOutput: async () => {
|
||||
throw cause;
|
||||
},
|
||||
},
|
||||
);
|
||||
const result = await runSourceMapCommand(
|
||||
"{}",
|
||||
"/probe/source.map",
|
||||
undefined,
|
||||
{
|
||||
prepareOwnership: () => host.prepare!(),
|
||||
launcher: async () => {
|
||||
throw new Error("must not launch");
|
||||
},
|
||||
},
|
||||
unexpectedCleanup,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("expected prerequisite failure");
|
||||
expect(result.error).toBeInstanceOf(LinuxOwnedProcessPrerequisiteError);
|
||||
expect(result.error).toMatchObject({
|
||||
cause,
|
||||
remediationAction: expect.stringContaining("EACCES"),
|
||||
});
|
||||
});
|
||||
const exitsThenDescendantExceedsStderrBudget =
|
||||
'const {spawn}=require("node:child_process"); spawn(process.execPath,["-e",`setTimeout(()=>{process.stderr.write("x".repeat(1024*1024+64)); setInterval(()=>{},1000)},25)`],{stdio:["ignore","inherit","inherit"]}); process.stderr.write("physical exit 7"); process.exit(7)';
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ export const execFileOutputFailure = (
|
||||
};
|
||||
|
||||
/** Spawn errnos reporting exhausted host capacity rather than an unusable command. */
|
||||
const HOST_SPAWN_CAPACITY_CODES: ReadonlySet<string> = new Set([
|
||||
export const HOST_SPAWN_CAPACITY_CODES: ReadonlySet<string> = new Set([
|
||||
"EAGAIN",
|
||||
"EMFILE",
|
||||
"ENFILE",
|
||||
|
||||
@@ -19,7 +19,12 @@ import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.
|
||||
|
||||
import { launcherIdentityFailure } from "./ProcessOwnershipIdentity.js";
|
||||
import { descendantsOf, liveProcesses } from "./ProcessOwnershipProcessTree.js";
|
||||
import { execFileOutput } from "./ExecFileOutput.js";
|
||||
import {
|
||||
execFileOutput,
|
||||
execFileOutputFailure,
|
||||
HOST_SPAWN_CAPACITY_CODES,
|
||||
spawnCapacityExhausted,
|
||||
} from "./ExecFileOutput.js";
|
||||
import {
|
||||
createDarwinProcessRunTokenReader,
|
||||
type DarwinProcessReaderCompiler,
|
||||
@@ -1180,8 +1185,11 @@ export const createSystemProcessOwnershipHost = (
|
||||
readonly darwinModuleCachePath?: string;
|
||||
/** Deadline for Darwin ownership-helper compilation. */
|
||||
readonly compileTimeoutMs?: number;
|
||||
/** Subprocess output execution seam for testing process listing failures. */
|
||||
readonly execFileOutput?: typeof execFileOutput;
|
||||
} = {},
|
||||
): ProcessOwnershipHost => {
|
||||
const runExecFileOutput = options.execFileOutput ?? execFileOutput;
|
||||
const darwinModuleCachePath =
|
||||
options.darwinModuleCachePath ??
|
||||
hostEnvironment.REA_DARWIN_MODULE_CACHE_PATH;
|
||||
@@ -1218,7 +1226,7 @@ export const createSystemProcessOwnershipHost = (
|
||||
: undefined;
|
||||
const listProcesses = async (signal?: AbortSignal) => {
|
||||
if (platform === "win32") return [];
|
||||
const { stdout } = await execFileOutput(
|
||||
const { stdout } = await runExecFileOutput(
|
||||
"ps",
|
||||
["-axo", "pid=,ppid=,pgid=,uid=,stat=,command="],
|
||||
{
|
||||
@@ -1434,17 +1442,49 @@ export const createSystemProcessOwnershipHost = (
|
||||
);
|
||||
}
|
||||
};
|
||||
const safeListProcesses = async (signal?: AbortSignal) => {
|
||||
try {
|
||||
return await listProcesses(signal);
|
||||
} catch (cause: unknown) {
|
||||
if (isExpectedAbort(cause, signal)) throw cause;
|
||||
const failure = execFileOutputFailure(cause);
|
||||
if (
|
||||
spawnCapacityExhausted(failure) ||
|
||||
(typeof cause === "object" &&
|
||||
cause !== null &&
|
||||
typeof Reflect.get(cause, "code") === "string" &&
|
||||
HOST_SPAWN_CAPACITY_CODES.has(Reflect.get(cause, "code") as string))
|
||||
)
|
||||
throw cause;
|
||||
const rawCode =
|
||||
failure?.code ??
|
||||
(typeof cause === "object" && cause !== null
|
||||
? Reflect.get(cause, "code")
|
||||
: undefined);
|
||||
const systemCode = typeof rawCode === "string" ? rawCode : undefined;
|
||||
if (platform === "linux")
|
||||
throw new LinuxOwnedProcessPrerequisiteError(cause, {
|
||||
missing: systemCode === "ENOENT",
|
||||
systemCode,
|
||||
});
|
||||
throw new ProcessAdmissionError(
|
||||
"baseline_unavailable",
|
||||
systemCode === undefined ? undefined : { system_code: systemCode },
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
};
|
||||
const captureBaseline = async (
|
||||
signal?: AbortSignal,
|
||||
): Promise<ProcessOwnershipBaseline> => {
|
||||
if (platform !== "linux" && darwinTokens === undefined) return [];
|
||||
const processes = liveProcesses(await listProcesses(signal));
|
||||
const processes = liveProcesses(await safeListProcesses(signal));
|
||||
let identities = await processIdentities(processes, signal);
|
||||
const unreadable = processes.filter(
|
||||
({ pid }) => identities.get(pid)?.state !== "readable",
|
||||
);
|
||||
if (unreadable.length > 0) {
|
||||
const stillLive = liveProcesses(await listProcesses(signal)).filter(
|
||||
const stillLive = liveProcesses(await safeListProcesses(signal)).filter(
|
||||
({ pid }) => unreadable.some((entry) => entry.pid === pid),
|
||||
);
|
||||
const retried = await processIdentities(stillLive, signal);
|
||||
@@ -1452,7 +1492,7 @@ export const createSystemProcessOwnershipHost = (
|
||||
const unresolved = stillLive.filter(
|
||||
({ pid }) => identities.get(pid)?.state !== "readable",
|
||||
);
|
||||
const liveAfterRetry = liveProcesses(await listProcesses(signal));
|
||||
const liveAfterRetry = liveProcesses(await safeListProcesses(signal));
|
||||
const stillUnresolved = unresolved.filter(({ pid }) =>
|
||||
liveAfterRetry.some((entry) => entry.pid === pid),
|
||||
);
|
||||
@@ -1475,14 +1515,9 @@ export const createSystemProcessOwnershipHost = (
|
||||
prepare: async (signal) => {
|
||||
await darwinTokens?.prepare(signal);
|
||||
if (platform === "linux") {
|
||||
try {
|
||||
const processes = await listProcesses(signal);
|
||||
if (!processes.some(({ pid }) => pid === process.pid))
|
||||
throw new ProcessAdmissionError("current_process_unreported");
|
||||
} catch (cause: unknown) {
|
||||
if (isExpectedAbort(cause, signal)) throw cause;
|
||||
throw new LinuxOwnedProcessPrerequisiteError(cause);
|
||||
}
|
||||
const processes = await safeListProcesses(signal);
|
||||
if (!processes.some(({ pid }) => pid === process.pid))
|
||||
throw new ProcessAdmissionError("current_process_unreported");
|
||||
}
|
||||
},
|
||||
listProcesses,
|
||||
@@ -1518,7 +1553,7 @@ export const createSystemProcessOwnershipHost = (
|
||||
environment.REA_PROCESS_RUN_ID = observation.runId;
|
||||
return environment;
|
||||
}
|
||||
const { stdout } = await execFileOutput(
|
||||
const { stdout } = await runExecFileOutput(
|
||||
"ps",
|
||||
["eww", "-p", String(pid)],
|
||||
{
|
||||
@@ -1661,17 +1696,72 @@ export const signalProcessWithStartIdentity = async (
|
||||
}
|
||||
};
|
||||
|
||||
/** Actionable remediation when Linux `ps` is missing or incompatible. */
|
||||
export const LINUX_PROCESS_OWNERSHIP_REMEDIATION =
|
||||
"Install your distribution's procps or procps-ng package, or put a procps-compatible ps on REA's PATH, then retry.";
|
||||
|
||||
/** Refusal before an owned launch when Linux `ps` is missing or incompatible. */
|
||||
export class LinuxOwnedProcessPrerequisiteError extends AnalysisCapabilityUnavailableError {
|
||||
constructor(cause: unknown) {
|
||||
const reason = `Linux process ownership inspection requires a procps-compatible ps on REA's PATH before launching a child: ${errorMessage(cause)}`;
|
||||
constructor(
|
||||
cause: unknown,
|
||||
options: {
|
||||
readonly missing: boolean;
|
||||
readonly systemCode?: string | undefined;
|
||||
} = { missing: true },
|
||||
) {
|
||||
const reason = options.missing
|
||||
? `Linux process ownership inspection requires a procps-compatible ps on REA's PATH before launching a child: ${errorMessage(cause)}`
|
||||
: `Linux process ownership inspection could not obtain the process table using ps: ${errorMessage(cause)}`;
|
||||
super("process-ownership", "prepare_owned_process", reason, {
|
||||
cause,
|
||||
userMessage: reason,
|
||||
remediationAction: options.missing
|
||||
? LINUX_PROCESS_OWNERSHIP_REMEDIATION
|
||||
: processOwnershipAdmissionRecovery(
|
||||
"baseline_unavailable",
|
||||
"linux",
|
||||
options.systemCode,
|
||||
),
|
||||
...(options.systemCode === undefined
|
||||
? {}
|
||||
: { diagnostics: { system_code: options.systemCode } }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Actionable remediation for Darwin process ownership inspection preparation failure. */
|
||||
export const DARWIN_PROCESS_OWNERSHIP_REMEDIATION =
|
||||
"Check the selected Xcode or Command Line Tools and Swift compiler configuration, then restart REA and retry.";
|
||||
|
||||
/** Actionable recovery guidance for process ownership admission failures. */
|
||||
export const processOwnershipAdmissionRecovery = (
|
||||
code: string,
|
||||
platform: NodeJS.Platform = systemProcessOwnershipHost.platform ??
|
||||
process.platform,
|
||||
systemCode?: string,
|
||||
): string => {
|
||||
if (code === "identity_snapshot_unavailable")
|
||||
return platform === "darwin"
|
||||
? "Check host process churn and ensure REA has permission to inspect process identities, then retry once process state settles."
|
||||
: "Check host process churn and ensure REA has permission to inspect /proc entries for running processes, then retry once process state settles.";
|
||||
if (code === "current_process_unreported")
|
||||
return platform === "darwin"
|
||||
? "Ensure the host process table reports the running REA process under ps, then retry."
|
||||
: "Ensure the host process table reports the running REA process under /proc or ps, then retry.";
|
||||
if (code === "baseline_unavailable") {
|
||||
if (systemCode === "ENOENT")
|
||||
return platform === "darwin"
|
||||
? "Ensure ps is available on PATH and process ownership inspection is supported on macOS before launching captured children."
|
||||
: LINUX_PROCESS_OWNERSHIP_REMEDIATION;
|
||||
if (systemCode !== undefined)
|
||||
return `Verify host process table inspection can execute ps (${systemCode}) before launching captured children, then retry.`;
|
||||
return platform === "darwin"
|
||||
? "Ensure ps is available on PATH and process ownership inspection is supported on macOS before launching captured children."
|
||||
: "Ensure process ownership inspection is supported on the host platform before launching captured children.";
|
||||
}
|
||||
return "Verify host process ownership inspection prerequisites before retrying.";
|
||||
};
|
||||
|
||||
/** Prepare native token inspection before REA launches a captured child. */
|
||||
export const prepareProcessOwnershipInspection = async (
|
||||
signal?: AbortSignal,
|
||||
|
||||
@@ -288,15 +288,31 @@ export const createServer = (
|
||||
policy: (): AvailabilityPolicy => {
|
||||
const value = basePolicy();
|
||||
const failures = capabilities.observeProviderLoadFailures();
|
||||
return Object.keys(failures).length === 0
|
||||
? value
|
||||
: {
|
||||
...value,
|
||||
optionalProviderLoadFailures: {
|
||||
...value.optionalProviderLoadFailures,
|
||||
...failures,
|
||||
},
|
||||
};
|
||||
const commandFailures =
|
||||
capabilities.observeCommandProviderFailures();
|
||||
const hasFailures = Object.keys(failures).length > 0;
|
||||
const hasCommandFailures =
|
||||
Object.keys(commandFailures).length > 0;
|
||||
if (!hasFailures && !hasCommandFailures) return value;
|
||||
return {
|
||||
...value,
|
||||
...(hasFailures
|
||||
? {
|
||||
optionalProviderLoadFailures: {
|
||||
...value.optionalProviderLoadFailures,
|
||||
...failures,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(hasCommandFailures
|
||||
? {
|
||||
commandProviderFailures: {
|
||||
...value.commandProviderFailures,
|
||||
...commandFailures,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -69,6 +69,11 @@ export const sessionAvailabilityPolicy = (
|
||||
: {
|
||||
optionalProviderLoadFailures: defaults.optionalProviderLoadFailures,
|
||||
}),
|
||||
...(defaults.commandProviderFailures === undefined
|
||||
? {}
|
||||
: {
|
||||
commandProviderFailures: defaults.commandProviderFailures,
|
||||
}),
|
||||
...(defaults.hostUnsupported === undefined
|
||||
? {}
|
||||
: {
|
||||
|
||||
@@ -628,38 +628,42 @@ it("reports the runtime JADX repair and restores invalid launch availability on
|
||||
}
|
||||
});
|
||||
|
||||
it("reports the owned-process prerequisite as JADX unavailability, not a probe failure", async () => {
|
||||
const root = await createTestTempDirectory("rea-jadx-ps-prerequisite-");
|
||||
const jar = join(root, "jadx-headless-mcp.jar");
|
||||
const javaHome = await createFakeJdk(root);
|
||||
await writeJadxJarInventory(jar);
|
||||
const environment = { REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome };
|
||||
await expect(inspectJadxAvailability(environment)).resolves.toMatchObject({
|
||||
status: "available",
|
||||
});
|
||||
it.each(["ENOENT", "EACCES"])(
|
||||
"reports ps %s as JADX unavailability, not a probe failure",
|
||||
async (code) => {
|
||||
const root = await createTestTempDirectory("rea-jadx-ps-prerequisite-");
|
||||
const jar = join(root, "jadx-headless-mcp.jar");
|
||||
const javaHome = await createFakeJdk(root);
|
||||
await writeJadxJarInventory(jar);
|
||||
const environment = { REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome };
|
||||
await expect(inspectJadxAvailability(environment)).resolves.toMatchObject({
|
||||
status: "available",
|
||||
});
|
||||
|
||||
vi.spyOn(systemProcessOwnershipHost, "prepare").mockImplementation(
|
||||
async () => {
|
||||
throw new LinuxOwnedProcessPrerequisiteError(
|
||||
new Error("spawn ps ENOENT"),
|
||||
);
|
||||
},
|
||||
);
|
||||
onTestFinished(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
vi.spyOn(systemProcessOwnershipHost, "prepare").mockImplementation(
|
||||
async () => {
|
||||
throw new LinuxOwnedProcessPrerequisiteError(
|
||||
new Error(`spawn ps ${code}`),
|
||||
{ missing: code === "ENOENT", systemCode: code },
|
||||
);
|
||||
},
|
||||
);
|
||||
onTestFinished(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
await expect(inspectJadxAvailability(environment)).resolves.toMatchObject({
|
||||
status: "unavailable",
|
||||
code: "runtime_unavailable",
|
||||
reason: expect.stringContaining("procps-compatible ps") as unknown,
|
||||
diagnostics: { stage: "ownership_preparation" },
|
||||
});
|
||||
await expect(
|
||||
resolveConfiguration(environment, "inspect_android_package"),
|
||||
).rejects.toMatchObject({
|
||||
_tag: "AnalysisCapabilityUnavailableError",
|
||||
providerId: "process-ownership",
|
||||
reason: expect.stringContaining("spawn ps ENOENT") as unknown,
|
||||
});
|
||||
});
|
||||
await expect(inspectJadxAvailability(environment)).resolves.toMatchObject({
|
||||
status: "unavailable",
|
||||
code: "runtime_unavailable",
|
||||
reason: expect.stringContaining(`spawn ps ${code}`) as unknown,
|
||||
diagnostics: { stage: "ownership_preparation", system_code: code },
|
||||
});
|
||||
await expect(
|
||||
resolveConfiguration(environment, "inspect_android_package"),
|
||||
).rejects.toMatchObject({
|
||||
_tag: "AnalysisCapabilityUnavailableError",
|
||||
providerId: "process-ownership",
|
||||
reason: expect.stringContaining(`spawn ps ${code}`) as unknown,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -195,3 +195,102 @@ it("returns an unready IDA registration's repair from an explicit open_binary se
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"marks inspect_with_objdump unavailable in binary_session after launch failure and restores it after repair",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-objdump-remediation-");
|
||||
const missingCommand = join(root, "missing-objdump");
|
||||
const { client, close } = await createApplicationMcpHarness({
|
||||
serverOptions: {
|
||||
environment: {
|
||||
REA_OBJDUMP_COMMAND: missingCommand,
|
||||
},
|
||||
},
|
||||
clientIdentity: { name: "objdump-remediation", version: "1" },
|
||||
schemaValidator: schemaValidation,
|
||||
});
|
||||
try {
|
||||
const initialSession = await client.callTool({
|
||||
name: "binary_session",
|
||||
arguments: { detail: "complete" },
|
||||
});
|
||||
expect(initialSession.isError).not.toBe(true);
|
||||
const initialStatus = toolContract("binary_session").outputSchema.parse(
|
||||
initialSession.structuredContent,
|
||||
).result;
|
||||
const initialTools = new Map(
|
||||
expandToolAvailability(
|
||||
toolAvailabilityInventory.parse(initialStatus.tool_availability),
|
||||
).map((tool) => [tool.name, tool]),
|
||||
);
|
||||
expect(initialTools.get("inspect_with_objdump")).toMatchObject({
|
||||
available: true,
|
||||
reason: "available",
|
||||
remediation: null,
|
||||
});
|
||||
|
||||
const failed = (await client.callTool({
|
||||
name: "inspect_with_objdump",
|
||||
arguments: { operation: "architectures" },
|
||||
})) as CallToolResult;
|
||||
expect(failed.isError).toBe(true);
|
||||
const parsedFailure = parseMcpToolError(failed);
|
||||
expect(parsedFailure.error.code).toBe("capability_unavailable");
|
||||
expect(parsedFailure.error.details).toMatchObject({
|
||||
diagnostics: {
|
||||
boundary: "provider-launch",
|
||||
command: missingCommand,
|
||||
},
|
||||
});
|
||||
|
||||
const afterFailedSession = await client.callTool({
|
||||
name: "binary_session",
|
||||
arguments: { detail: "complete" },
|
||||
});
|
||||
expect(afterFailedSession.isError).not.toBe(true);
|
||||
const afterFailedStatus = toolContract(
|
||||
"binary_session",
|
||||
).outputSchema.parse(afterFailedSession.structuredContent).result;
|
||||
const afterFailedTools = new Map(
|
||||
expandToolAvailability(
|
||||
toolAvailabilityInventory.parse(afterFailedStatus.tool_availability),
|
||||
).map((tool) => [tool.name, tool]),
|
||||
);
|
||||
expect(afterFailedTools.get("inspect_with_objdump")).toMatchObject({
|
||||
available: false,
|
||||
reason: "provider_unavailable",
|
||||
remediation: expect.stringContaining("REA_OBJDUMP_COMMAND"),
|
||||
});
|
||||
|
||||
await writeFile(missingCommand, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
|
||||
|
||||
const retried = await client.callTool({
|
||||
name: "inspect_with_objdump",
|
||||
arguments: { operation: "architectures" },
|
||||
});
|
||||
expect(retried.isError).not.toBe(true);
|
||||
|
||||
const recoveredSession = await client.callTool({
|
||||
name: "binary_session",
|
||||
arguments: { detail: "complete" },
|
||||
});
|
||||
expect(recoveredSession.isError).not.toBe(true);
|
||||
const recoveredStatus = toolContract("binary_session").outputSchema.parse(
|
||||
recoveredSession.structuredContent,
|
||||
).result;
|
||||
const recoveredTools = new Map(
|
||||
expandToolAvailability(
|
||||
toolAvailabilityInventory.parse(recoveredStatus.tool_availability),
|
||||
).map((tool) => [tool.name, tool]),
|
||||
);
|
||||
expect(recoveredTools.get("inspect_with_objdump")).toMatchObject({
|
||||
available: true,
|
||||
reason: "available",
|
||||
remediation: null,
|
||||
});
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
},
|
||||
);
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user