fix(objdump): reflect observed launch failure in discovery availability (#9051)

* 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.

* test(reverse): verify overlapping command launch dispatch ordering

* test(reverse): unblock gated test workers before awaiting service closure

* test(reverse): bound lifecycle acknowledgement and join launched workers

* fix(reverse): distinguish host capacity exhaustion from command launch failures

* fix(reverse): preserve host capacity across Linux process ownership preparation

* fix(reverse): exclude process ownership admission failures from command launch state

* fix(reverse): preserve Darwin process ownership preparation error across command launch

* fix(reverse): disambiguate cleanup declaration in provider observation

* fix(reverse): preserve command deadline timeout during Darwin ownership preparation

* fix(reverse): isolate Darwin baseline ps errors and provide platform-aware admission guidance

* fix(reverse): rely on typed baseline admission failures and use owning repair formatter in tests

* test(reverse): use owning formatters for process ownership remediation assertions

* fix(reverse): restrict Linux ownership prerequisite remediation to missing ps and preserve system error codes

* fix(reverse): retain typed process ownership prerequisite with preserved error codes

* fix(reverse): remove unvalidated error label fallbacks and keep admission tags out of system_code

* fix(reverse): keep Windows native runner failures out of command-launch state

* fix(process): preserve current_process_unreported refusal in Linux provider readiness

* fix(process): distinguish no-code ps failure from missing ps and cover rizin launch failure

* fix(windows): classify process_spawn host-control rejections as RuntimeControlUnavailableError
This commit is contained in:
Rishikesh Gowda K K authored and GitHub committed 2026-11-09 17:39:34 +08:00
1 parent 068c319e82
commit 891371e738
21 files changed
+2698 -100

No files matched your search

+7
View File
@@ -990,6 +990,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,
@@ -1122,6 +1122,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,
@@ -101,6 +105,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;
@@ -903,8 +908,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,11 +1,121 @@
import { describe, expect, it } from "vitest";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
} from "../domain/analysisErrorCore.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js";
import { OwnedCommandFailure } from "../process/OwnedCommand.js";
import { ProcessAdmissionError } from "../process/ProcessAdmissionError.js";
import {
createSystemProcessOwnershipHost,
LinuxOwnedProcessPrerequisiteError,
processOwnershipAdmissionRecovery,
} 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("reports a ps probe failure with no error code without blaming missing procps", async () => {
const cause = new Error("ps failed without error 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,
reason: expect.stringContaining(
"could not obtain the process table using ps: ps failed without error code",
),
});
const availability = ownedProcessPrerequisiteAvailability(failure);
expect(availability).toMatchObject({
status: "unavailable",
code: "runtime_unavailable",
remediation: processOwnershipAdmissionRecovery(
"baseline_unavailable",
"linux",
),
diagnostics: {
stage: "ownership_preparation",
prerequisite_provider_id: "process-ownership",
prerequisite_operation: "prepare_owned_process",
},
});
if (availability?.status !== "unavailable")
throw new Error("expected unavailability");
expect(availability.diagnostics).not.toHaveProperty("system_code");
expect(availability.remediation).not.toContain("procps");
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(
@@ -82,3 +192,71 @@ describe("Darwin ownership prerequisite discovery", () => {
).toBeUndefined();
});
});
describe("Linux ownership prerequisite discovery", () => {
it("restricts procps/PATH remediation to missing ps and preserves system error codes", () => {
const psMissing = new LinuxOwnedProcessPrerequisiteError(
Object.assign(new Error("spawn ps ENOENT"), { code: "ENOENT" }),
);
const wrappedMissing = new AnalysisCapabilityUnavailableError(
"jadx",
"inspect_jadx_client",
"prerequisite failed",
{
cause: psMissing,
diagnostics: { stage: "ownership_preparation" },
},
);
expect(ownedProcessPrerequisiteAvailability(wrappedMissing)).toMatchObject({
status: "unavailable",
code: "runtime_unavailable",
remediation: expect.stringContaining("procps"),
diagnostics: {
stage: "ownership_preparation",
system_code: "ENOENT",
},
});
const eioError = new LinuxOwnedProcessPrerequisiteError(
Object.assign(new Error("ps EIO"), { code: "EIO" }),
"EIO",
);
const wrappedEio = new AnalysisCapabilityUnavailableError(
"jadx",
"inspect_jadx_client",
"prerequisite failed",
{
cause: eioError,
diagnostics: { stage: "ownership_preparation" },
},
);
const eioAvailability = ownedProcessPrerequisiteAvailability(wrappedEio);
expect(eioAvailability).toMatchObject({
status: "unavailable",
code: "runtime_unavailable",
remediation: expect.stringContaining("EIO"),
diagnostics: {
stage: "ownership_preparation",
system_code: "EIO",
},
});
expect(eioAvailability?.status).toBe("unavailable");
if (eioAvailability?.status === "unavailable") {
expect(eioAvailability.remediation).not.toContain("procps");
expect(eioAvailability.remediation).not.toContain("PATH");
}
const unreported = new LinuxOwnedProcessPrerequisiteError(
new ProcessAdmissionError("current_process_unreported"),
);
expect(ownedProcessPrerequisiteAvailability(unreported)).toMatchObject({
status: "unavailable",
code: "runtime_unavailable",
remediation: expect.stringContaining("/proc or ps"),
diagnostics: {
stage: "ownership_preparation",
admission_code: "current_process_unreported",
},
});
});
});
+16 -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,27 @@ 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?.admission_code === undefined
? {}
: { admission_code: refusal.diagnostics.admission_code }),
...(refusal.diagnostics?.system_code === undefined
? {}
: { system_code: refusal.diagnostics.system_code }),
},
};
}
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,12 +40,27 @@ 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 { RuntimeControlUnavailableError } from "../../process/RuntimeControlUnavailableError.js";
import { WindowsNativeAuthorityUnavailableError } from "../../windows/WindowsNativeLoader.js";
import {
WindowsNativeSpawnControlError,
windowsNativeSpawnControlError,
} from "../../windows/WindowsOwnedProcess.js";
import { absoluteCallerPath } from "../../filesystem/callerPath.js";
import { parseAnalysisInput } from "../AnalysisInput.js";
@@ -53,6 +68,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 +161,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 +228,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 +530,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 +581,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 +604,201 @@ 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
: 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",
admission_code: admissionFailure.code,
...(admissionSystemCode === undefined
? {}
: { system_code: admissionSystemCode }),
},
...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]);
}
}
const controlFailure = runtimeControlPreparationError(cause);
if (
earlyFailure === undefined &&
controlFailure !== undefined &&
code !== "ETIMEDOUT" &&
!commandCancelled(signal, cause, code)
) {
const setting =
operation === "inspect_with_objdump"
? "REA_OBJDUMP_COMMAND"
: "REA_RIZIN_COMMAND";
const remediation =
controlFailure.remediationAction === undefined
? `${setting} does not need to change.`
: `${controlFailure.remediationAction} ${setting} does not need to change.`;
earlyFailure = new AnalysisCapabilityUnavailableError(
"process-ownership",
"prepare_owned_process",
controlFailure.message,
{
cause,
retryable: controlFailure.retryable,
userMessage: controlFailure.message,
remediationAction: remediation,
diagnostics: {
boundary: "process-ownership",
control_code: controlFailure.code,
...(controlFailure instanceof
WindowsNativeAuthorityUnavailableError
? { failure_stage: controlFailure.failureStage }
: {}),
...(controlFailure instanceof WindowsNativeSpawnControlError &&
controlFailure.constraint !== undefined
? { constraint: controlFailure.constraint }
: {}),
},
...ownedCleanupOptions(cause),
},
);
}
if (
earlyFailure === undefined &&
(capture === undefined ||
@@ -656,8 +930,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 +1112,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 +1314,33 @@ const ownedCleanupOptions = (
? { cleanup: cause.cleanup }
: {};
const darwinPreparationError = (
cause: unknown,
): DarwinProcessOwnershipInspectionError | undefined => {
if (cause instanceof DarwinProcessOwnershipInspectionError) return cause;
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 runtimeControlPreparationError = (
cause: unknown,
): RuntimeControlUnavailableError | undefined => {
if (cause instanceof RuntimeControlUnavailableError) return cause;
if (cause instanceof OwnedOutputFailure)
return runtimeControlPreparationError(cause.cause);
if (cause instanceof OwnedCommandFailure && cause.reason === "process")
return runtimeControlPreparationError(cause.cause);
return windowsNativeSpawnControlError(cause);
};
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,41 @@ 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",
+150 -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,11 @@ 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 LinuxOwnedProcessPrerequisiteError(
new ProcessAdmissionError("current_process_unreported"),
);
}
},
listProcesses,
@@ -1518,7 +1555,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 +1698,114 @@ 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.";
/** Options describing why Linux process ownership inspection could not run `ps`. */
export interface LinuxOwnedProcessPrerequisiteOptions {
readonly missing?: boolean;
readonly systemCode?: string | undefined;
}
/** 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)}`;
readonly systemCode: string | undefined;
readonly admissionCode: string | undefined;
constructor(
cause: unknown,
optionsOrSystemCode: LinuxOwnedProcessPrerequisiteOptions | string = {},
) {
const options: LinuxOwnedProcessPrerequisiteOptions =
typeof optionsOrSystemCode === "string"
? {
missing: optionsOrSystemCode === "ENOENT",
systemCode: optionsOrSystemCode,
}
: optionsOrSystemCode;
const admissionCode =
cause instanceof ProcessAdmissionError ? cause.code : undefined;
const rawCode =
options.systemCode ??
execFileOutputFailure(cause)?.code ??
(typeof cause === "object" && cause !== null
? Reflect.get(cause, "code")
: undefined);
const resolvedCode =
admissionCode === undefined && typeof rawCode === "string"
? rawCode
: undefined;
const isMissing =
admissionCode === undefined &&
(options.missing ??
(resolvedCode === "ENOENT" || resolvedCode === undefined));
const reason =
admissionCode !== undefined || isMissing
? `Linux process ownership inspection requires a procps-compatible ps on REA's PATH before launching a child: ${errorMessage(cause)}`
: resolvedCode !== undefined
? `Linux process ownership inspection failed to execute ps (${resolvedCode}): ${errorMessage(cause)}`
: `Linux process ownership inspection could not obtain the process table using ps: ${errorMessage(cause)}`;
const remediation =
admissionCode !== undefined
? processOwnershipAdmissionRecovery(admissionCode, "linux")
: isMissing
? LINUX_PROCESS_OWNERSHIP_REMEDIATION
: processOwnershipAdmissionRecovery(
"baseline_unavailable",
"linux",
resolvedCode,
);
super("process-ownership", "prepare_owned_process", reason, {
cause,
userMessage: reason,
remediationAction: remediation,
diagnostics: {
boundary: "process-ownership",
...(admissionCode === undefined
? {}
: { admission_code: admissionCode }),
...(resolvedCode === undefined ? {} : { system_code: resolvedCode }),
},
});
this.systemCode = resolvedCode;
this.admissionCode = admissionCode;
}
}
/** 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,
+2 -11
View File
@@ -1,6 +1,6 @@
import {
systemWindowsNativeAuthority,
WINDOWS_NATIVE_LOAD_REFUSED_REMEDIATION,
windowsNativeFailureRemediation,
type WindowsNativeLoadResult,
} from "../windows/WindowsNativeLoader.js";
@@ -82,16 +82,7 @@ export const inspectWindowsNativeReadiness = (
: failureStage === "artifact" || failureStage === "load"
? "runtime_missing"
: "runtime_unavailable",
remediation:
failureStage === "host"
? "Use a Windows x64 host and a supported Node.js runtime with Node-API 8 or newer."
: failureStage === "load"
? WINDOWS_NATIVE_LOAD_REFUSED_REMEDIATION
: failureStage === "artifact"
? "Use the matching Windows x64 REA package, or restore native/windows/build/manifest.json and native/windows/build/rea-windows-x64.node from the same-version rea-agents tarball or build them with native/windows/README.md. The loader checks version, ABI, architecture, and digest before granting authority; no download or install is performed automatically."
: failureStage === "inspection"
? "Install the matching Windows x64 REA package; its native inspection must prove every required security control."
: "Inspect the preserved native self-test error. Check that the process's temporary directory is writable local NTFS and that its filesystem, DACL, Job Object, and cleanup operations are permitted. Resolve the reported constraint, then rerun rea doctor; reinstalling Ghidra or Java cannot fix this self-test.",
remediation: windowsNativeFailureRemediation(failureStage),
};
};
+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
? {}
: {
+37 -1
View File
@@ -10,6 +10,7 @@ import {
parseWindowsNativeManifest,
validateWindowsNativeArtifact,
} from "../domain/windowsNativeArtifact.js";
import { RuntimeControlUnavailableError } from "../process/RuntimeControlUnavailableError.js";
/** A native resource remains opaque until the addon validates its object tag. */
export const windowsHandleSchema = z.custom<object>(
@@ -92,6 +93,40 @@ const missingWindowsNativeFileReason = (file: string): string =>
export const WINDOWS_NATIVE_LOAD_REFUSED_REMEDIATION =
"The file matched the package manifest, so restoring or reinstalling it will not help. If Windows reports that an Application Control policy blocked the file, Smart App Control or an App Control for Business policy refused it. Windows native controls, which Ghidra and JADX need on Windows, stay unavailable; rea setup can still register clients. Run REA on a host whose policy allows this file, or ask the REA maintainers about a signed build.";
export type WindowsNativeFailureStage = Exclude<
WindowsNativeLoadResult,
{ available: true }
>["failureStage"];
/** Stage-specific recovery guidance for an unavailable Windows native authority. */
export const windowsNativeFailureRemediation = (
failureStage: WindowsNativeFailureStage,
): string =>
failureStage === "host"
? "Use a Windows x64 host and a supported Node.js runtime with Node-API 8 or newer."
: failureStage === "load"
? WINDOWS_NATIVE_LOAD_REFUSED_REMEDIATION
: failureStage === "artifact"
? "Use the matching Windows x64 REA package, or restore native/windows/build/manifest.json and native/windows/build/rea-windows-x64.node from the same-version rea-agents tarball or build them with native/windows/README.md. The loader checks version, ABI, architecture, and digest before granting authority; no download or install is performed automatically."
: failureStage === "inspection"
? "Install the matching Windows x64 REA package; its native inspection must prove every required security control."
: "Inspect the preserved native self-test error. Check that the process's temporary directory is writable local NTFS and that its filesystem, DACL, Job Object, and cleanup operations are permitted. Resolve the reported constraint, then rerun rea doctor; reinstalling Ghidra or Java cannot fix this self-test.";
/** Refusal when the Windows native authority cannot be loaded or self-tested before launch. */
export class WindowsNativeAuthorityUnavailableError extends RuntimeControlUnavailableError {
readonly code = "windows-native-authority-unavailable";
readonly failureStage: WindowsNativeFailureStage;
constructor(loaded: Exclude<WindowsNativeLoadResult, { available: true }>) {
super(loaded.reason, {
retryable: loaded.retryable === true,
remediationAction: windowsNativeFailureRemediation(loaded.failureStage),
});
this.name = "WindowsNativeAuthorityUnavailableError";
this.failureStage = loaded.failureStage;
}
}
const readPackagedWindowsNativeFile = <T>(file: string, read: () => T): T => {
try {
return read();
@@ -223,6 +258,7 @@ export const systemWindowsNativeAuthority = (): WindowsNativeLoadResult => {
/** Require the verified backend when an operation depends on native authority. */
export const requireWindowsNativeAuthority = (): WindowsNativeAuthority => {
const loaded = systemWindowsNativeAuthority();
if (!loaded.available) throw new Error(loaded.reason);
if (!loaded.available)
throw new WindowsNativeAuthorityUnavailableError(loaded);
return loaded.authority;
};
+93 -1
View File
@@ -1,10 +1,12 @@
import { once } from "node:events";
import { describe, expect, it, vi } from "vitest";
import { RuntimeControlUnavailableError } from "../process/RuntimeControlUnavailableError.js";
import type { WindowsNativeAuthority } from "./WindowsNativeLoader.js";
import {
quoteWindowsProcessArgument,
WindowsNativeControlUnavailableError,
WindowsNativeSpawnControlError,
WindowsOwnedProcess,
} from "./WindowsOwnedProcess.js";
@@ -286,7 +288,97 @@ describe("Windows Job Object memory limits", () => {
jobMemoryLimit: true,
appliedLimit: () => LIMIT - 4096,
});
expect(() => launch(native, LIMIT)).toThrow();
expect(() => launch(native, LIMIT)).toThrow(WindowsNativeSpawnControlError);
});
});
describe("Windows process_spawn control vs executable classification", () => {
it("wraps process_spawn host-control rejections as RuntimeControlUnavailableError while preserving executable launch failures", () => {
const { native } = authority();
const affinityFailure = Object.assign(
new Error(
"Caller CPU affinity cannot bound an owned process in another processor group [C:\\runtime\\bun.exe] (Win32 87): The parameter is incorrect.",
),
{
code: "ERR_REA_WINDOWS_NATIVE",
constraint:
"Caller CPU affinity cannot bound an owned process in another processor group",
requestedPath: "C:\\runtime\\bun.exe",
win32Code: 87,
},
);
const affinityAuthority: WindowsNativeAuthority = {
...native,
call: (operation) => {
if (operation === "process_spawn") throw affinityFailure;
throw new Error(`Unmodeled native operation ${operation}`);
},
};
expect(() => launch(affinityAuthority)).toThrow(
expect.objectContaining({
constructor: WindowsNativeSpawnControlError,
code: "windows-native-spawn-control-unavailable",
constraint:
"Caller CPU affinity cannot bound an owned process in another processor group",
win32Code: 87,
retryable: true,
cause: affinityFailure,
}),
);
const jobMembershipFailure = Object.assign(
new Error(
"Suspended process has no owned Job Object membership [C:\\runtime\\bun.exe] (Win32 5): Access is denied.",
),
{
code: "EACCES",
constraint: "Suspended process has no owned Job Object membership",
requestedPath: "C:\\runtime\\bun.exe",
win32Code: 5,
},
);
const jobMembershipAuthority: WindowsNativeAuthority = {
...native,
call: (operation) => {
if (operation === "process_spawn") throw jobMembershipFailure;
throw new Error(`Unmodeled native operation ${operation}`);
},
};
let caughtMembership: unknown;
try {
launch(jobMembershipAuthority);
} catch (cause) {
caughtMembership = cause;
}
expect(caughtMembership).toBeInstanceOf(RuntimeControlUnavailableError);
expect(caughtMembership).toBeInstanceOf(WindowsNativeSpawnControlError);
const executableFailure = Object.assign(
new Error(
"Create process atomically inside owned Job Object failed [C:\\runtime\\bun.exe] (Win32 2): The system cannot find the file specified.",
),
{
code: "ENOENT",
constraint: "Create process atomically inside owned Job Object failed",
requestedPath: "C:\\runtime\\bun.exe",
win32Code: 2,
},
);
const executableAuthority: WindowsNativeAuthority = {
...native,
call: (operation) => {
if (operation === "process_spawn") throw executableFailure;
throw new Error(`Unmodeled native operation ${operation}`);
},
};
let caughtExecutable: unknown;
try {
launch(executableAuthority);
} catch (cause) {
caughtExecutable = cause;
}
expect(caughtExecutable).toBe(executableFailure);
expect(caughtExecutable).not.toBeInstanceOf(RuntimeControlUnavailableError);
});
});
+142 -13
View File
@@ -66,6 +66,119 @@ export class WindowsNativeControlUnavailableError extends RuntimeControlUnavaila
}
}
const WINDOWS_EXECUTABLE_SPAWN_CONSTRAINTS = new Set([
"Create process atomically inside owned Job Object failed",
"Owned Windows process requires an absolute executable path",
]);
const WINDOWS_CONTROL_SPAWN_CONSTRAINTS = [
"Wrong native process launch argument count",
"Job memory limit must be a positive safe integer",
"Job memory limit must be a whole number of pages",
"Create owned Job Object failed",
"Apply kill-on-owner-close Job Object policy failed",
"Read Job Object policy failed",
"Job Object policy permits unexpected breakaway",
"Job Object memory limit was not applied exactly",
"Create owned Job Object notification port failed",
"Associate owned Job Object notification port failed",
"Create owned process output pipe failed",
"Protect parent process pipe handle inheritance failed",
"Open owned process null input failed",
"Initialize process attributes failed",
"Windows atomic process attribute is unavailable",
"Invalid child environment name",
"Empty child environment entry",
"Compare Windows environment names failed",
"Open suspended process default-owner token failed",
"Read suspended process user identity failed",
"Set suspended process file owner failed",
"Read caller process affinity failed",
"Preserve caller process affinity failed",
"Caller CPU affinity cannot bound an owned process in another processor group",
"Preserve caller process priority failed",
"Suspended process has no owned Job Object membership",
"Resume job-owned process failed",
] as const;
const WINDOWS_SPAWN_CONTROL_REMEDIATION =
"Inspect the preserved Windows native process launch control error. Ensure the host permits owned Job Object assignment, token normalization, pipe inheritance, and caller CPU affinity/priority preservation across processor groups, then retry.";
/** A required host or Job Object control was rejected during native process_spawn. */
export class WindowsNativeSpawnControlError extends RuntimeControlUnavailableError {
readonly code = "windows-native-spawn-control-unavailable";
readonly constraint: string | undefined;
readonly win32Code: number | undefined;
constructor(cause: unknown, message?: string) {
const constraint =
cause instanceof Error &&
typeof Reflect.get(cause, "constraint") === "string"
? Reflect.get(cause, "constraint")
: undefined;
const win32Code =
cause instanceof Error &&
typeof Reflect.get(cause, "win32Code") === "number"
? Reflect.get(cause, "win32Code")
: undefined;
super(message ?? (cause instanceof Error ? cause.message : String(cause)), {
cause,
retryable: true,
remediationAction: WINDOWS_SPAWN_CONTROL_REMEDIATION,
});
this.name = "WindowsNativeSpawnControlError";
this.constraint = constraint;
this.win32Code = win32Code;
}
}
/** Distinguish target executable launch failures from native host-control rejections in process_spawn. */
export const isWindowsExecutableLaunchFailure = (cause: unknown): boolean => {
if (!(cause instanceof Error)) return false;
const constraint = Reflect.get(cause, "constraint");
if (typeof constraint === "string" && constraint.length > 0) {
return WINDOWS_EXECUTABLE_SPAWN_CONSTRAINTS.has(constraint);
}
if (
[...WINDOWS_EXECUTABLE_SPAWN_CONSTRAINTS].some((prefix) =>
cause.message.startsWith(prefix),
)
) {
return true;
}
if (
WINDOWS_CONTROL_SPAWN_CONSTRAINTS.some((prefix) =>
cause.message.startsWith(prefix),
)
) {
return false;
}
const code = Reflect.get(cause, "code");
return typeof code === "string" && code !== "ERR_REA_WINDOWS_NATIVE";
};
/** Wrap a raw native process_spawn host-control rejection as a RuntimeControlUnavailableError. */
export const windowsNativeSpawnControlError = (
cause: unknown,
): WindowsNativeSpawnControlError | undefined => {
if (cause instanceof WindowsNativeSpawnControlError) return cause;
if (!(cause instanceof Error)) return undefined;
const constraint = Reflect.get(cause, "constraint");
if (typeof constraint === "string" && constraint.length > 0) {
return WINDOWS_EXECUTABLE_SPAWN_CONSTRAINTS.has(constraint)
? undefined
: new WindowsNativeSpawnControlError(cause);
}
if (
WINDOWS_CONTROL_SPAWN_CONSTRAINTS.some((prefix) =>
cause.message.startsWith(prefix),
)
) {
return new WindowsNativeSpawnControlError(cause);
}
return undefined;
};
/** Encode one ordinary Windows argv token using the CRT backslash rules. */
export const quoteWindowsProcessArgument = (value: string): string => {
if (value.includes("\0"))
@@ -141,19 +254,35 @@ export class WindowsOwnedProcess extends EventEmitter {
let controls: unknown[] = protocolStdin ? [true] : [];
if (this.#memoryLimitBytes !== undefined)
controls = [protocolStdin, this.#memoryLimitBytes];
const spawned = this.#authority.call("process_spawn", [
command,
line,
cwd ?? "",
entries,
...controls,
]);
const launched =
this.#memoryLimitBytes === undefined
? launchSchema.parse(spawned)
: launchSchema
.extend({ jobMemoryLimitBytes: z.literal(this.#memoryLimitBytes) })
.parse(spawned);
let spawned: unknown;
try {
spawned = this.#authority.call("process_spawn", [
command,
line,
cwd ?? "",
entries,
...controls,
]);
} catch (cause: unknown) {
if (isWindowsExecutableLaunchFailure(cause)) throw cause;
throw new WindowsNativeSpawnControlError(cause);
}
let launched: z.infer<typeof launchSchema>;
try {
launched =
this.#memoryLimitBytes === undefined
? launchSchema.parse(spawned)
: launchSchema
.extend({
jobMemoryLimitBytes: z.literal(this.#memoryLimitBytes),
})
.parse(spawned);
} catch (cause: unknown) {
throw new WindowsNativeSpawnControlError(
cause,
"Windows native process launch did not confirm required Job Object controls.",
);
}
this.pid = launched.pid;
this.#handle = launched.handle;
this.stdin = protocolStdin
@@ -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,
});
},
);
@@ -193,3 +193,205 @@ 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();
}
},
);
it.skipIf(process.platform === "win32")(
"marks execute_rizin_command unavailable in binary_session after real target launch failure and restores it after repair",
async () => {
const root = await createTestTempDirectory("rea-rizin-remediation-");
const target = join(root, "sample.bin");
const missingRizin = join(root, "missing-rizin");
await writeFile(target, new Uint8Array([0x7f, 0x45, 0x4c, 0x46]));
const { client, close } = await createApplicationMcpHarness({
serverOptions: {
environment: {
REA_RIZIN_COMMAND: missingRizin,
},
},
clientIdentity: { name: "rizin-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("execute_rizin_command")).toMatchObject({
available: true,
reason: "available",
remediation: null,
});
const failed = (await client.callTool({
name: "execute_rizin_command",
arguments: { path: target, command: "iI" },
})) as CallToolResult;
expect(failed.isError).toBe(true);
const parsedFailure = parseMcpToolError(failed);
expect(parsedFailure.error.code).toBe("capability_unavailable");
expect(parsedFailure.error.details).toMatchObject({
provider_id: "rizin",
diagnostics: {
boundary: "provider-launch",
command: missingRizin,
system_code: "ENOENT",
},
});
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("execute_rizin_command")).toMatchObject({
available: false,
reason: "provider_unavailable",
remediation: expect.stringContaining("REA_RIZIN_COMMAND"),
});
await writeFile(missingRizin, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
const retried = await client.callTool({
name: "execute_rizin_command",
arguments: { path: target, command: "iI" },
});
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("execute_rizin_command")).toMatchObject({
available: true,
reason: "available",
remediation: null,
});
} finally {
await close();
}
},
);
File diff suppressed because it is too large. Load diff