Compare commits

...
Author SHA1 Message Date
morluto d65c5d6c2a fix(process): preserve Linux ownership inspection failures 2026-11-08 01:39:34 +08:00
rishikeshgowdakk 206992c918 fix(reverse): restrict Linux ownership prerequisite remediation to missing ps and preserve system error codes 2026-11-07 22:43:36 +05:30
rishikeshgowdakk 1d04b7101b Merge remote-tracking branch 'upstream/main' into fix/objdump-discovery-launch-failure 2026-11-07 22:33:13 +05:30
rishikeshgowdakk 1b0c145fc5 test(reverse): use owning formatters for process ownership remediation assertions 2026-11-07 01:46:14 +05:30
rishikeshgowdakk 8a6bc89337 fix(reverse): rely on typed baseline admission failures and use owning repair formatter in tests 2026-11-07 00:58:51 +05:30
rishikeshgowdakk 4d4ac55293 fix(reverse): isolate Darwin baseline ps errors and provide platform-aware admission guidance 2026-11-06 19:06:17 +05:30
rishikeshgowdakk 5fefa2a703 fix(reverse): preserve command deadline timeout during Darwin ownership preparation 2026-11-06 18:11:24 +05:30
rishikeshgowdakk 63e0a66537 fix(reverse): disambiguate cleanup declaration in provider observation 2026-11-06 17:39:33 +05:30
rishikeshgowdakk 2d39e5ffc5 fix(reverse): preserve Darwin process ownership preparation error across command launch 2026-11-06 17:29:51 +05:30
rishikeshgowdakk c708021878 Merge remote-tracking branch 'upstream/main' into fix/objdump-discovery-launch-failure
# Conflicts:
#	src/application/reverse/ReverseEngineeringService.ts
2026-11-06 17:03:44 +05:30
rishikeshgowdakk c982d90e00 fix(reverse): exclude process ownership admission failures from command launch state 2026-11-06 16:52:19 +05:30
rishikeshgowdakk 953a5edf76 fix(reverse): preserve host capacity across Linux process ownership preparation 2026-11-06 05:36:43 +05:30
rishikeshgowdakk b7e7dfe9e5 fix(reverse): distinguish host capacity exhaustion from command launch failures 2026-11-06 05:16:47 +05:30
rishikeshgowdakk e4ef852808 test(reverse): bound lifecycle acknowledgement and join launched workers 2026-11-06 04:22:04 +05:30
rishikeshgowdakk d6a0d66893 test(reverse): unblock gated test workers before awaiting service closure 2026-11-06 04:12:58 +05:30
rishikeshgowdakk e62d29888f test(reverse): verify overlapping command launch dispatch ordering 2026-11-06 04:05:21 +05:30
rishikeshgowdakk 64a4a8dba1 fix(objdump): reflect observed launch failure in discovery availability
Distinguish a statically registered standalone command from an executable
whose launch has failed in the active server session. Discovery leaves
unprobed commands optimistically available, reports an observed launch
failure as provider_unavailable with the command remediation, keeps the
tool callable for retries, and clears the failure after a subsequent
successful launch.

Fixes #8826.
2026-11-06 03:50:27 +05:30
18 changed files with 1756 additions and 79 deletions

No files matched your search

+7
View File
@@ -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
+4 -1
View File
@@ -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) => {
+13
View File
@@ -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(
+11 -7
View File
@@ -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 } => ({
+19 -2
View File
@@ -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)';
+1 -1
View File
@@ -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",
+106 -16
View File
@@ -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,
+25 -9
View File
@@ -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,
},
}
: {}),
};
},
};
})();
+5
View File
@@ -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