From 891371e738dc0f3ecff106462397250506ad367b Mon Sep 17 00:00:00 2001 From: Rishikesh Gowda K K Date: Mon, 9 Nov 2026 15:09:34 +0530 Subject: [PATCH] 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 --- docs/reference/mcp-contracts.md | 7 + docs/reverse-engineering-tools.md | 5 +- src/application/CapabilityAvailability.ts | 3 + src/application/CapabilityInventory.test.ts | 28 + src/application/CapabilityInventory.ts | 13 + .../ownedProcessAvailability.test.ts | 180 ++- src/application/ownedProcessAvailability.ts | 23 +- .../reverse/ReverseEngineeringService.ts | 335 +++- src/composition/capabilityServices.ts | 21 +- .../sourceMaps/SourceMapCommand.test.ts | 39 + src/process/ExecFileOutput.ts | 2 +- src/process/ProcessOwnershipObservation.ts | 166 +- src/process/WindowsAuthority.ts | 13 +- src/server/createServer.ts | 34 +- src/server/sessionAvailabilityPolicy.ts | 5 + src/windows/WindowsNativeLoader.ts | 38 +- src/windows/WindowsOwnedProcess.test.ts | 94 +- src/windows/WindowsOwnedProcess.ts | 155 +- .../android/jadxConfiguration.test.ts | 70 +- .../mcp/capabilityRemediationMcp.test.ts | 202 +++ .../reverseProviderLaunchErrors.test.ts | 1365 ++++++++++++++++- 21 files changed, 2698 insertions(+), 100 deletions(-) diff --git a/docs/reference/mcp-contracts.md b/docs/reference/mcp-contracts.md index a76114362c..1a583779d2 100644 --- a/docs/reference/mcp-contracts.md +++ b/docs/reference/mcp-contracts.md @@ -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 diff --git a/docs/reverse-engineering-tools.md b/docs/reverse-engineering-tools.md index 2da34ef8d9..3cacd4602b 100644 --- a/docs/reverse-engineering-tools.md +++ b/docs/reverse-engineering-tools.md @@ -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. diff --git a/src/application/CapabilityAvailability.ts b/src/application/CapabilityAvailability.ts index 73659b9762..78db0c6eef 100644 --- a/src/application/CapabilityAvailability.ts +++ b/src/application/CapabilityAvailability.ts @@ -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; readonly observeProviderLoadFailures?: () => OptionalProviderLoadFailures; + readonly observeCommandProviderFailures?: () => CommandProviderLaunchFailures; readonly optionalProviderLoadFailures?: | OptionalProviderLoadFailures | undefined; + readonly commandProviderFailures?: CommandProviderLaunchFailures | undefined; readonly hostUnsupported?: AvailabilityPolicy["hostUnsupported"]; readonly optionalFeatures?: Pick< AvailabilityPolicy, diff --git a/src/application/CapabilityInventory.test.ts b/src/application/CapabilityInventory.test.ts index f1d054fc1a..3e3cd98a46 100644 --- a/src/application/CapabilityInventory.test.ts +++ b/src/application/CapabilityInventory.test.ts @@ -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) => { diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index 4397899b9a..a32b375d29 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -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 && diff --git a/src/application/ownedProcessAvailability.test.ts b/src/application/ownedProcessAvailability.test.ts index 45eb826689..13a97d8dfb 100644 --- a/src/application/ownedProcessAvailability.test.ts +++ b/src/application/ownedProcessAvailability.test.ts @@ -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", + }, + }); + }); +}); diff --git a/src/application/ownedProcessAvailability.ts b/src/application/ownedProcessAvailability.ts index 9c60cea3e9..6b81dc8e49 100644 --- a/src/application/ownedProcessAvailability.ts +++ b/src/application/ownedProcessAvailability.ts @@ -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 { diff --git a/src/application/reverse/ReverseEngineeringService.ts b/src/application/reverse/ReverseEngineeringService.ts index 1185f028cc..42a948a950 100644 --- a/src/application/reverse/ReverseEngineeringService.ts +++ b/src/application/reverse/ReverseEngineeringService.ts @@ -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> +>; + +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 | 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 + > = {}; + 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 { 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 } => ({ diff --git a/src/composition/capabilityServices.ts b/src/composition/capabilityServices.ts index 0b238bf2d5..8e8215c22f 100644 --- a/src/composition/capabilityServices.ts +++ b/src/composition/capabilityServices.ts @@ -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): 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 = {}; 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(), }; }; diff --git a/src/javascript/sourceMaps/SourceMapCommand.test.ts b/src/javascript/sourceMaps/SourceMapCommand.test.ts index 81d7bbebe0..fd5c188115 100644 --- a/src/javascript/sourceMaps/SourceMapCommand.test.ts +++ b/src/javascript/sourceMaps/SourceMapCommand.test.ts @@ -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)'; diff --git a/src/process/ExecFileOutput.ts b/src/process/ExecFileOutput.ts index a96837152e..09563bd531 100644 --- a/src/process/ExecFileOutput.ts +++ b/src/process/ExecFileOutput.ts @@ -132,7 +132,7 @@ export const execFileOutputFailure = ( }; /** Spawn errnos reporting exhausted host capacity rather than an unusable command. */ -const HOST_SPAWN_CAPACITY_CODES: ReadonlySet = new Set([ +export const HOST_SPAWN_CAPACITY_CODES: ReadonlySet = new Set([ "EAGAIN", "EMFILE", "ENFILE", diff --git a/src/process/ProcessOwnershipObservation.ts b/src/process/ProcessOwnershipObservation.ts index 57d960e814..804ae93eb1 100644 --- a/src/process/ProcessOwnershipObservation.ts +++ b/src/process/ProcessOwnershipObservation.ts @@ -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 => { 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, diff --git a/src/process/WindowsAuthority.ts b/src/process/WindowsAuthority.ts index 7fa4ed7366..85d23bf73f 100644 --- a/src/process/WindowsAuthority.ts +++ b/src/process/WindowsAuthority.ts @@ -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), }; }; diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 9593c51798..89aef18a95 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -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, + }, + } + : {}), + }; }, }; })(); diff --git a/src/server/sessionAvailabilityPolicy.ts b/src/server/sessionAvailabilityPolicy.ts index 6883639778..286f500ede 100644 --- a/src/server/sessionAvailabilityPolicy.ts +++ b/src/server/sessionAvailabilityPolicy.ts @@ -69,6 +69,11 @@ export const sessionAvailabilityPolicy = ( : { optionalProviderLoadFailures: defaults.optionalProviderLoadFailures, }), + ...(defaults.commandProviderFailures === undefined + ? {} + : { + commandProviderFailures: defaults.commandProviderFailures, + }), ...(defaults.hostUnsupported === undefined ? {} : { diff --git a/src/windows/WindowsNativeLoader.ts b/src/windows/WindowsNativeLoader.ts index b91f5e6cf4..df6a18b421 100644 --- a/src/windows/WindowsNativeLoader.ts +++ b/src/windows/WindowsNativeLoader.ts @@ -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( @@ -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) { + super(loaded.reason, { + retryable: loaded.retryable === true, + remediationAction: windowsNativeFailureRemediation(loaded.failureStage), + }); + this.name = "WindowsNativeAuthorityUnavailableError"; + this.failureStage = loaded.failureStage; + } +} + const readPackagedWindowsNativeFile = (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; }; diff --git a/src/windows/WindowsOwnedProcess.test.ts b/src/windows/WindowsOwnedProcess.test.ts index d51f22bab3..665ef148fa 100644 --- a/src/windows/WindowsOwnedProcess.test.ts +++ b/src/windows/WindowsOwnedProcess.test.ts @@ -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); }); }); diff --git a/src/windows/WindowsOwnedProcess.ts b/src/windows/WindowsOwnedProcess.ts index 51c8245bf1..c7bbe1ebe0 100644 --- a/src/windows/WindowsOwnedProcess.ts +++ b/src/windows/WindowsOwnedProcess.ts @@ -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; + 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 diff --git a/tests/boundary/android/jadxConfiguration.test.ts b/tests/boundary/android/jadxConfiguration.test.ts index 6260e8c1f3..0ec203b95c 100644 --- a/tests/boundary/android/jadxConfiguration.test.ts +++ b/tests/boundary/android/jadxConfiguration.test.ts @@ -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, + }); + }, +); diff --git a/tests/boundary/mcp/capabilityRemediationMcp.test.ts b/tests/boundary/mcp/capabilityRemediationMcp.test.ts index 389767edb6..ef73997b77 100644 --- a/tests/boundary/mcp/capabilityRemediationMcp.test.ts +++ b/tests/boundary/mcp/capabilityRemediationMcp.test.ts @@ -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(); + } + }, +); diff --git a/tests/composition/reverseProviderLaunchErrors.test.ts b/tests/composition/reverseProviderLaunchErrors.test.ts index 8513f9b08e..477f1cef57 100644 --- a/tests/composition/reverseProviderLaunchErrors.test.ts +++ b/tests/composition/reverseProviderLaunchErrors.test.ts @@ -1,13 +1,33 @@ import { rm } from "node:fs/promises"; import { resolve } from "node:path"; -import { expect, it } from "vitest"; +import { expect, it, vi } from "vitest"; import { OwnedCommandFailure } from "../../src/process/OwnedCommand.js"; +import { + DarwinProcessOwnershipInspectionError, + type DarwinCompilerOutcome, +} from "../../src/process/DarwinProcessRunTokenReader.js"; +import { + DARWIN_PROCESS_OWNERSHIP_REMEDIATION, + LINUX_PROCESS_OWNERSHIP_REMEDIATION, + LinuxOwnedProcessPrerequisiteError, + createSystemProcessOwnershipHost, + processOwnershipAdmissionRecovery, + systemProcessOwnershipHost, +} from "../../src/process/ProcessOwnershipObservation.js"; +import { ProcessAdmissionError } from "../../src/process/ProcessAdmissionError.js"; +import { + WindowsNativeAuthorityUnavailableError, + type WindowsNativeAuthority, +} from "../../src/windows/WindowsNativeLoader.js"; +import { WindowsOwnedProcess } from "../../src/windows/WindowsOwnedProcess.js"; +import { AnalysisCapabilityUnavailableError } from "../../src/domain/analysisErrorCore.js"; import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js"; import { createArtifact, providerSnapshot, } from "../support/reverseEngineeringCompositionFixtures.js"; import { TestReverseEngineeringService } from "../support/reverseEngineeringService.js"; +import { awaitAcknowledgement } from "../fixtures/lifecycleAcknowledgement.js"; it.each(["ENOENT", "EACCES", "EPERM", "ENOTDIR"] as const)( "preserves provider-launch %s and executable recovery independently of target admission", @@ -62,6 +82,878 @@ it.each(["ENOENT", "EACCES", "EPERM", "ENOTDIR"] as const)( }, ); +it.each(["EAGAIN", "EMFILE", "ENFILE", "ENOMEM"] as const)( + "treats objdump host capacity spawn failure %s as retryable resource constraint without recording launch failure", + async (code) => { + const { directory, path } = await createArtifact(); + const command = resolve(directory, "configured-objdump"); + const hostFailure = Object.assign( + new Error("controlled host capacity rejection"), + { code }, + ); + const service = new TestReverseEngineeringService({ + environment: { REA_OBJDUMP_COMMAND: command }, + runOwned: async () => { + throw new OwnedCommandFailure("process", "launch failed", null, null, { + cause: hostFailure, + }); + }, + }); + try { + for (const input of [ + { operation: "architectures" }, + { path, operation: "file_headers" }, + ]) { + const result = await service.inspectWithObjdump(input); + if (result.ok) + throw new Error("Expected host capacity resource constraint failure"); + const projected = projectAnalysisError(result.error); + expect(projected).toMatchObject({ + code: "resource_constraint", + retryable: true, + details: { + resource: "capacity", + reported_limits: { + spawn_error: code, + command, + }, + diagnostics: { + boundary: "host-capacity", + command, + system_code: code, + }, + }, + remediation: { + action: expect.stringContaining( + "REA_OBJDUMP_COMMAND does not need to change", + ), + }, + }); + expect(projected.message).toContain(code); + expect(projected.remediation.action).toContain(code); + } + expect(service.observedLaunchFailures()).toEqual({}); + } finally { + await service.close(); + await rm(directory, { recursive: true, force: true }); + } + }, +); + +it.each(["EAGAIN", "EMFILE", "ENFILE", "ENOMEM"] as const)( + "treats rizin host capacity spawn failure %s as retryable resource constraint without recording launch failure", + async (code) => { + const { directory, path } = await createArtifact(); + const command = resolve(directory, "configured-rizin"); + const hostFailure = Object.assign( + new Error("controlled host capacity rejection"), + { code }, + ); + const service = new TestReverseEngineeringService({ + environment: { REA_RIZIN_COMMAND: command }, + runOwned: async () => { + throw new OwnedCommandFailure("process", "launch failed", null, null, { + cause: hostFailure, + }); + }, + }); + try { + const result = await service.executeRizinCommand({ + path, + command: "iI", + }); + if (result.ok) + throw new Error( + "Expected rizin host capacity resource constraint failure", + ); + const projected = projectAnalysisError(result.error); + expect(projected).toMatchObject({ + code: "resource_constraint", + retryable: true, + details: { + resource: "capacity", + reported_limits: { + spawn_error: code, + command, + }, + diagnostics: { + boundary: "host-capacity", + command, + system_code: code, + }, + }, + remediation: { + action: expect.stringContaining( + "REA_RIZIN_COMMAND does not need to change", + ), + }, + }); + expect(projected.message).toContain(code); + expect(projected.remediation.action).toContain(code); + expect(service.observedLaunchFailures()).toEqual({}); + } finally { + await service.close(); + await rm(directory, { recursive: true, force: true }); + } + }, +); + +it.each(["EAGAIN", "EMFILE", "ENFILE", "ENOMEM"] as const)( + "treats host capacity failure during ownership preparation (%s) as retryable resource constraint without recording launch failure", + async (code) => { + const { directory } = await createArtifact(); + const command = resolve(directory, "configured-objdump"); + const hostFailure = Object.assign( + new Error("controlled ps host capacity rejection"), + { code }, + ); + const prepareSpy = vi + .spyOn(systemProcessOwnershipHost, "prepare") + .mockRejectedValue(hostFailure); + const service = new TestReverseEngineeringService({ + environment: { REA_OBJDUMP_COMMAND: command }, + }); + try { + const result = await service.inspectWithObjdump({ + operation: "architectures", + }); + expect(result.ok).toBe(false); + if (!result.ok) { + const projected = projectAnalysisError(result.error); + expect(projected).toMatchObject({ + code: "resource_constraint", + retryable: true, + details: { + resource: "capacity", + diagnostics: { + boundary: "host-capacity", + system_code: code, + }, + }, + remediation: { + action: expect.stringContaining( + "REA_OBJDUMP_COMMAND does not need to change", + ), + }, + }); + expect(projected.message).toContain(code); + expect(projected.remediation.action).toContain(code); + } + expect(service.observedLaunchFailures()).toEqual({}); + } finally { + prepareSpy.mockRestore(); + await service.close(); + await rm(directory, { recursive: true, force: true }); + } + }, +); + +it.each(["EAGAIN", "EMFILE", "ENFILE", "ENOMEM"] as const)( + "preserves host capacity when wrapped in LinuxOwnedProcessPrerequisiteError (%s) without recording launch failure", + async (code) => { + const { directory } = await createArtifact(); + const command = resolve(directory, "configured-objdump"); + const hostFailure = Object.assign( + new Error("controlled ps host capacity rejection"), + { code }, + ); + const prepareSpy = vi + .spyOn(systemProcessOwnershipHost, "prepare") + .mockRejectedValue(new LinuxOwnedProcessPrerequisiteError(hostFailure)); + const service = new TestReverseEngineeringService({ + environment: { REA_OBJDUMP_COMMAND: command }, + }); + try { + const result = await service.inspectWithObjdump({ + operation: "architectures", + }); + expect(result.ok).toBe(false); + if (!result.ok) { + const projected = projectAnalysisError(result.error); + expect(projected).toMatchObject({ + code: "resource_constraint", + retryable: true, + details: { + resource: "capacity", + diagnostics: { + boundary: "host-capacity", + system_code: code, + }, + }, + remediation: { + action: expect.stringContaining( + "REA_OBJDUMP_COMMAND does not need to change", + ), + }, + }); + } + expect(service.observedLaunchFailures()).toEqual({}); + } finally { + prepareSpy.mockRestore(); + await service.close(); + await rm(directory, { recursive: true, force: true }); + } + }, +); + +it("preserves missing Linux ps prerequisite error without blaming configured command", async () => { + const { directory } = await createArtifact(); + const command = resolve(directory, "configured-objdump"); + const psMissing = Object.assign(new Error("spawn ps ENOENT"), { + code: "ENOENT", + }); + const prepareSpy = vi + .spyOn(systemProcessOwnershipHost, "prepare") + .mockRejectedValue(new LinuxOwnedProcessPrerequisiteError(psMissing)); + const service = new TestReverseEngineeringService({ + environment: { REA_OBJDUMP_COMMAND: command }, + }); + try { + const result = await service.inspectWithObjdump({ + operation: "architectures", + }); + expect(result.ok).toBe(false); + if (!result.ok) { + const projected = projectAnalysisError(result.error); + expect(projected.code).toBe("capability_unavailable"); + expect(projected.details).toMatchObject({ + provider_id: "process-ownership", + }); + expect(projected.message).toContain("procps-compatible ps"); + expect(projected.remediation.action).toContain( + LINUX_PROCESS_OWNERSHIP_REMEDIATION, + ); + expect(projected.remediation.action).not.toContain("REA_OBJDUMP_COMMAND"); + } + expect(service.observedLaunchFailures()).toEqual({}); + } finally { + prepareSpy.mockRestore(); + await service.close(); + await rm(directory, { recursive: true, force: true }); + } +}); + +it("preserves Darwin process ownership inspection preparation error without blaming objdump command", async () => { + const { directory } = await createArtifact(); + const command = resolve(directory, "configured-objdump"); + const compilerOutcome: DarwinCompilerOutcome = { + outcome: "exited", + code: 1, + signal: null, + stderr: + "xcrun: error: unable to find utility 'swiftc', not a developer tool or in PATH", + stderrTruncated: false, + outputTruncated: false, + killed: false, + }; + const darwinError = new DarwinProcessOwnershipInspectionError( + "Swift compiler failed to build process reader helper", + { compilerOutcome }, + ); + const prepareSpy = vi + .spyOn(systemProcessOwnershipHost, "prepare") + .mockRejectedValue(darwinError); + const service = new TestReverseEngineeringService({ + environment: { REA_OBJDUMP_COMMAND: command }, + }); + try { + const result = await service.inspectWithObjdump({ + operation: "architectures", + }); + expect(result.ok).toBe(false); + if (!result.ok) { + const projected = projectAnalysisError(result.error); + expect(projected.code).toBe("capability_unavailable"); + expect(projected.details).toMatchObject({ + provider_id: "process-ownership", + }); + expect(result.error.diagnostics).toMatchObject({ + boundary: "process-ownership", + stage: "ownership_preparation", + compiler_outcome: expect.objectContaining({ + outcome: "exited", + code: 1, + }), + }); + expect(projected.message).toContain("Swift compiler failed"); + expect(projected.remediation.action).toContain( + DARWIN_PROCESS_OWNERSHIP_REMEDIATION, + ); + expect(projected.remediation.action).toContain( + "REA_OBJDUMP_COMMAND does not need to change", + ); + } + expect(service.observedLaunchFailures()).toEqual({}); + } finally { + prepareSpy.mockRestore(); + await service.close(); + await rm(directory, { recursive: true, force: true }); + } +}); + +it("preserves Darwin process ownership inspection error with cleanup state without blaming rizin command", async () => { + const { directory, path } = await createArtifact(); + const command = resolve(directory, "configured-rizin"); + const cleanupError = new AnalysisCapabilityUnavailableError( + "process-ownership", + "close_binary", + "staging cleanup failed", + { cleanup: { reason: "rmdir failed", resources: ["/tmp/helper"] } }, + ); + const darwinError = new DarwinProcessOwnershipInspectionError( + "Swift compiler failed to build process reader helper", + { cleanupFailure: cleanupError }, + ); + const prepareSpy = vi + .spyOn(systemProcessOwnershipHost, "prepare") + .mockRejectedValue(darwinError); + const service = new TestReverseEngineeringService({ + environment: { REA_RIZIN_COMMAND: command }, + }); + try { + const result = await service.executeRizinCommand({ + path, + command: "iI", + }); + expect(result.ok).toBe(false); + if (!result.ok) { + const projected = projectAnalysisError(result.error); + expect(projected.code).toBe("cleanup_incomplete"); + expect(projected.details).toMatchObject({ + provider_id: "process-ownership", + }); + expect(projected.remediation.action).toContain( + DARWIN_PROCESS_OWNERSHIP_REMEDIATION, + ); + expect(projected.remediation.action).toContain( + "REA_RIZIN_COMMAND does not need to change", + ); + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.cleanup).toMatchObject({ + reason: expect.stringContaining("staging cleanup failed"), + resources: ["/tmp/helper"], + }); + } + expect(service.observedLaunchFailures()).toEqual({}); + } finally { + prepareSpy.mockRestore(); + await service.close(); + await rm(directory, { recursive: true, force: true }); + } +}); + +it("preserves command deadline timeout when macOS helper preparation aborts and cleanup fails", async () => { + const { directory, path } = await createArtifact(); + const command = resolve(directory, "configured-objdump"); + const cleanupError = new AnalysisCapabilityUnavailableError( + "process-ownership", + "close_binary", + "helper staging directory cleanup failed", + { cleanup: { reason: "rmdir failed", resources: ["/tmp/helper"] } }, + ); + const darwinError = new DarwinProcessOwnershipInspectionError( + "Darwin process ownership helper preparation was cancelled", + { + cancellation: { reason: "timeout" }, + cleanupFailure: cleanupError, + }, + ); + const service = new TestReverseEngineeringService({ + environment: { REA_OBJDUMP_COMMAND: command }, + runOwned: async () => { + throw new OwnedCommandFailure( + "timeout", + "Command deadline elapsed during ownership preparation.", + null, + null, + { + cause: darwinError, + preparationFailure: darwinError, + }, + ); + }, + }); + try { + const result = await service.inspectWithObjdump({ + path, + operation: "file_headers", + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error._tag).toBe("AnalysisTimeoutError"); + const projected = projectAnalysisError(result.error); + expect(projected.code).toBe("cleanup_incomplete"); + expect(result.error.cleanupIncomplete).toBe(true); + expect(projected.remediation.action).toContain("command_timeout_ms"); + expect(projected.remediation.action).not.toContain("REA_OBJDUMP_COMMAND"); + expect(result.error.cleanup).toMatchObject({ + reason: expect.stringContaining( + "helper staging directory cleanup failed", + ), + resources: ["/tmp/helper"], + }); + } + expect(service.observedLaunchFailures()).toEqual({}); + } finally { + await service.close(); + await rm(directory, { recursive: true, force: true }); + } +}); + +it("preserves command deadline timeout when macOS helper preparation aborts without cleanup failure", async () => { + const { directory, path } = await createArtifact(); + const command = resolve(directory, "configured-objdump"); + const darwinError = new DarwinProcessOwnershipInspectionError( + "Darwin process ownership helper preparation was cancelled", + { + cancellation: { reason: "timeout" }, + }, + ); + const service = new TestReverseEngineeringService({ + environment: { REA_OBJDUMP_COMMAND: command }, + runOwned: async () => { + throw new OwnedCommandFailure( + "timeout", + "Command deadline elapsed during ownership preparation.", + null, + null, + { + cause: darwinError, + preparationFailure: darwinError, + }, + ); + }, + }); + try { + const result = await service.inspectWithObjdump({ + path, + operation: "file_headers", + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error._tag).toBe("AnalysisTimeoutError"); + const projected = projectAnalysisError(result.error); + expect(projected.code).toBe("provider_timeout"); + expect(result.error.cleanupIncomplete).toBe(false); + expect(projected.remediation.action).toContain("command_timeout_ms"); + expect(projected.remediation.action).not.toContain("REA_OBJDUMP_COMMAND"); + } + expect(service.observedLaunchFailures()).toEqual({}); + } finally { + await service.close(); + await rm(directory, { recursive: true, force: true }); + } +}); + +it("uses platform-aware recovery without /proc when Darwin baseline capture rejects identity_snapshot_unavailable", async () => { + const { directory } = await createArtifact(); + const command = resolve(directory, "configured-objdump"); + const platformSpy = vi + .spyOn(systemProcessOwnershipHost, "platform", "get") + .mockReturnValue("darwin"); + const captureSpy = vi + .spyOn(systemProcessOwnershipHost, "captureBaseline") + .mockRejectedValue( + new ProcessAdmissionError("identity_snapshot_unavailable", { + live_processes: 2, + }), + ); + const service = new TestReverseEngineeringService({ + environment: { REA_OBJDUMP_COMMAND: command }, + }); + try { + const result = await service.inspectWithObjdump({ + operation: "architectures", + }); + expect(result.ok).toBe(false); + if (!result.ok) { + const projected = projectAnalysisError(result.error); + expect(projected.code).toBe("capability_unavailable"); + expect(projected.details).toMatchObject({ + provider_id: "process-ownership", + diagnostics: { + boundary: "process-ownership", + admission_code: "identity_snapshot_unavailable", + }, + }); + expect(projected.details?.diagnostics).not.toHaveProperty("system_code"); + expect(projected.remediation.action).toContain( + processOwnershipAdmissionRecovery( + "identity_snapshot_unavailable", + "darwin", + ), + ); + expect(projected.remediation.action).not.toContain("/proc"); + expect(projected.remediation.action).toContain( + "REA_OBJDUMP_COMMAND does not need to change", + ); + } + expect(service.observedLaunchFailures()).toEqual({}); + } finally { + captureSpy.mockRestore(); + platformSpy.mockRestore(); + await service.close(); + await rm(directory, { recursive: true, force: true }); + } +}); + +it("preserves Darwin baseline_unavailable admission failure without blaming configured command", async () => { + const { directory } = await createArtifact(); + const command = resolve(directory, "configured-objdump"); + const platformSpy = vi + .spyOn(systemProcessOwnershipHost, "platform", "get") + .mockReturnValue("darwin"); + const psMissing = Object.assign(new Error("spawn ps ENOENT"), { + code: "ENOENT", + path: "ps", + syscall: "spawn ps", + }); + const captureSpy = vi + .spyOn(systemProcessOwnershipHost, "captureBaseline") + .mockRejectedValue( + new ProcessAdmissionError("baseline_unavailable", undefined, { + cause: psMissing, + }), + ); + const service = new TestReverseEngineeringService({ + environment: { REA_OBJDUMP_COMMAND: command }, + }); + try { + const result = await service.inspectWithObjdump({ + operation: "architectures", + }); + expect(result.ok).toBe(false); + if (!result.ok) { + const projected = projectAnalysisError(result.error); + expect(projected.code).toBe("capability_unavailable"); + expect(projected.details).toMatchObject({ + provider_id: "process-ownership", + diagnostics: { + boundary: "process-ownership", + admission_code: "baseline_unavailable", + system_code: "ENOENT", + }, + }); + expect(projected.remediation.action).toContain( + processOwnershipAdmissionRecovery("baseline_unavailable", "darwin"), + ); + expect(projected.remediation.action).not.toContain("/proc"); + expect(projected.remediation.action).toContain( + "REA_OBJDUMP_COMMAND does not need to change", + ); + } + expect(service.observedLaunchFailures()).toEqual({}); + } finally { + captureSpy.mockRestore(); + platformSpy.mockRestore(); + await service.close(); + await rm(directory, { recursive: true, force: true }); + } +}); + +it("preserves provider-launch ENOENT when configured command path contains ps substring", async () => { + const { directory } = await createArtifact(); + const command = resolve(directory, "apps", "objdump"); + const service = new TestReverseEngineeringService({ + environment: { REA_OBJDUMP_COMMAND: command }, + }); + try { + const result = await service.inspectWithObjdump({ + operation: "architectures", + }); + expect(result.ok).toBe(false); + if (!result.ok) { + const projected = projectAnalysisError(result.error); + expect(projected.code).toBe("capability_unavailable"); + expect(projected.details).toMatchObject({ + provider_id: "objdump.command", + diagnostics: { + boundary: "provider-launch", + command, + system_code: "ENOENT", + }, + }); + expect(projected.remediation.action).toContain( + "Set REA_OBJDUMP_COMMAND to an installed executable GNU objdump command", + ); + } + expect(service.observedLaunchFailures()).toMatchObject({ + inspect_with_objdump: { + reason: expect.stringContaining(command), + remediation: expect.stringContaining("REA_OBJDUMP_COMMAND"), + }, + }); + } finally { + await service.close(); + await rm(directory, { recursive: true, force: true }); + } +}); + +it.each(["identity_snapshot_unavailable", "baseline_unavailable"] as const)( + "preserves Linux process ownership admission failure in baseline capture (%s) without blaming objdump command", + async (code) => { + const { directory } = await createArtifact(); + const command = resolve(directory, "configured-objdump"); + const captureSpy = vi + .spyOn(systemProcessOwnershipHost, "captureBaseline") + .mockRejectedValue( + new ProcessAdmissionError( + code, + code === "identity_snapshot_unavailable" + ? { live_processes: 2 } + : undefined, + ), + ); + const service = new TestReverseEngineeringService({ + environment: { REA_OBJDUMP_COMMAND: command }, + }); + try { + const result = await service.inspectWithObjdump({ + operation: "architectures", + }); + expect(result.ok).toBe(false); + if (!result.ok) { + const projected = projectAnalysisError(result.error); + expect(projected.code).toBe("capability_unavailable"); + expect(projected.details).toMatchObject({ + provider_id: "process-ownership", + diagnostics: { + boundary: "process-ownership", + admission_code: code, + }, + }); + expect(projected.details?.diagnostics).not.toHaveProperty( + "system_code", + ); + expect(projected.message).toContain(code); + expect(projected.remediation.action).toContain( + processOwnershipAdmissionRecovery(code, "linux"), + ); + expect(projected.remediation.action).toContain( + "REA_OBJDUMP_COMMAND does not need to change", + ); + } + expect(service.observedLaunchFailures()).toEqual({}); + } finally { + captureSpy.mockRestore(); + await service.close(); + await rm(directory, { recursive: true, force: true }); + } + }, +); + +it("preserves Linux process ownership admission failure in baseline capture without blaming rizin command", async () => { + const { directory, path } = await createArtifact(); + const command = resolve(directory, "configured-rizin"); + const captureSpy = vi + .spyOn(systemProcessOwnershipHost, "captureBaseline") + .mockRejectedValue( + new ProcessAdmissionError("identity_snapshot_unavailable", { + live_processes: 1, + }), + ); + const service = new TestReverseEngineeringService({ + environment: { REA_RIZIN_COMMAND: command }, + }); + try { + const result = await service.executeRizinCommand({ + path, + command: "iI", + }); + expect(result.ok).toBe(false); + if (!result.ok) { + const projected = projectAnalysisError(result.error); + expect(projected.code).toBe("capability_unavailable"); + expect(projected.details).toMatchObject({ + provider_id: "process-ownership", + diagnostics: { + boundary: "process-ownership", + admission_code: "identity_snapshot_unavailable", + }, + }); + expect(projected.details?.diagnostics).not.toHaveProperty("system_code"); + expect(projected.message).toContain("identity_snapshot_unavailable"); + expect(projected.remediation.action).toContain( + processOwnershipAdmissionRecovery( + "identity_snapshot_unavailable", + "linux", + ), + ); + expect(projected.remediation.action).toContain( + "REA_RIZIN_COMMAND does not need to change", + ); + } + expect(service.observedLaunchFailures()).toEqual({}); + } finally { + captureSpy.mockRestore(); + await service.close(); + await rm(directory, { recursive: true, force: true }); + } +}); + +it("restricts Linux process ownership prerequisite remediation to missing ps and preserves system error codes", async () => { + const eioError = Object.assign(new Error("ps EIO"), { code: "EIO" }); + const psMissing = Object.assign(new Error("spawn ps ENOENT"), { + code: "ENOENT", + }); + const epermError = Object.assign(new Error("spawn ps EPERM"), { + code: "EPERM", + }); + + let currentError: Error = eioError; + const host = createSystemProcessOwnershipHost( + "linux", + {}, + { + execFileOutput: async () => { + throw currentError; + }, + }, + ); + + currentError = eioError; + const eioFailure = (await host + .captureBaseline?.() + .catch((error: unknown) => error)) as LinuxOwnedProcessPrerequisiteError; + expect(eioFailure).toBeInstanceOf(LinuxOwnedProcessPrerequisiteError); + expect(eioFailure.remediationAction).toContain( + processOwnershipAdmissionRecovery("baseline_unavailable", "linux", "EIO"), + ); + expect(eioFailure.remediationAction).not.toContain("procps"); + expect(eioFailure.remediationAction).not.toContain("PATH"); + expect(eioFailure.diagnostics).toMatchObject({ + boundary: "process-ownership", + system_code: "EIO", + }); + + currentError = epermError; + const epermFailure = (await host + .captureBaseline?.() + .catch((error: unknown) => error)) as LinuxOwnedProcessPrerequisiteError; + expect(epermFailure).toBeInstanceOf(LinuxOwnedProcessPrerequisiteError); + expect(epermFailure.remediationAction).toContain( + processOwnershipAdmissionRecovery("baseline_unavailable", "linux", "EPERM"), + ); + expect(epermFailure.remediationAction).not.toContain("procps"); + expect(epermFailure.remediationAction).not.toContain("PATH"); + expect(epermFailure.diagnostics).toMatchObject({ + boundary: "process-ownership", + system_code: "EPERM", + }); + + currentError = psMissing; + const missingFailure = (await host + .captureBaseline?.() + .catch((error: unknown) => error)) as LinuxOwnedProcessPrerequisiteError; + expect(missingFailure).toBeInstanceOf(LinuxOwnedProcessPrerequisiteError); + expect(missingFailure.remediationAction).toContain( + LINUX_PROCESS_OWNERSHIP_REMEDIATION, + ); + expect(missingFailure.diagnostics).toMatchObject({ + boundary: "process-ownership", + system_code: "ENOENT", + }); + + const noCodeError = new Error("ps failed with no error code"); + currentError = noCodeError; + const noCodeFailure = (await host + .captureBaseline?.() + .catch((error: unknown) => error)) as LinuxOwnedProcessPrerequisiteError; + expect(noCodeFailure).toBeInstanceOf(LinuxOwnedProcessPrerequisiteError); + expect(noCodeFailure.reason).toContain( + "could not obtain the process table using ps: ps failed with no error code", + ); + expect(noCodeFailure.remediationAction).toContain( + processOwnershipAdmissionRecovery("baseline_unavailable", "linux"), + ); + expect(noCodeFailure.remediationAction).not.toContain("procps"); + expect(noCodeFailure.remediationAction).not.toContain("PATH"); + expect(noCodeFailure.diagnostics).toMatchObject({ + boundary: "process-ownership", + }); + expect(noCodeFailure.diagnostics).not.toHaveProperty("system_code"); + + const emptyTableHost = createSystemProcessOwnershipHost( + "linux", + {}, + { + execFileOutput: async () => ({ + stdout: " 1 0 1 0 Ss /sbin/init\n", + stderr: "", + stdoutBytes: 40, + stderrBytes: 0, + }), + }, + ); + const unreportedFailure = (await emptyTableHost + .prepare?.() + .catch((error: unknown) => error)) as LinuxOwnedProcessPrerequisiteError; + expect(unreportedFailure).toBeInstanceOf(LinuxOwnedProcessPrerequisiteError); + expect(unreportedFailure.remediationAction).toContain( + processOwnershipAdmissionRecovery("current_process_unreported", "linux"), + ); + expect(unreportedFailure.diagnostics).toMatchObject({ + boundary: "process-ownership", + admission_code: "current_process_unreported", + }); + expect(unreportedFailure.diagnostics).not.toHaveProperty("system_code"); +}); + +it.each(["EIO", "EPERM"] as const)( + "preserves system error code (%s) on Linux baseline capture without blaming procps/PATH or objdump command", + async (systemCode) => { + const { directory } = await createArtifact(); + const command = resolve(directory, "configured-objdump"); + const systemError = Object.assign(new Error(`spawn ps ${systemCode}`), { + code: systemCode, + path: "ps", + syscall: "spawn ps", + }); + const captureSpy = vi + .spyOn(systemProcessOwnershipHost, "captureBaseline") + .mockRejectedValue( + new LinuxOwnedProcessPrerequisiteError(systemError, systemCode), + ); + const service = new TestReverseEngineeringService({ + environment: { REA_OBJDUMP_COMMAND: command }, + }); + try { + const result = await service.inspectWithObjdump({ + operation: "architectures", + }); + expect(result.ok).toBe(false); + if (!result.ok) { + const projected = projectAnalysisError(result.error); + expect(projected.code).toBe("capability_unavailable"); + expect(projected.details).toMatchObject({ + provider_id: "process-ownership", + diagnostics: { + boundary: "process-ownership", + system_code: systemCode, + }, + }); + expect(projected.remediation.action).toContain( + processOwnershipAdmissionRecovery( + "baseline_unavailable", + "linux", + systemCode, + ), + ); + expect(projected.remediation.action).not.toContain("procps"); + expect(projected.remediation.action).not.toContain("PATH"); + expect(projected.remediation.action).not.toContain( + "REA_OBJDUMP_COMMAND", + ); + } + expect(service.observedLaunchFailures()).toEqual({}); + } finally { + captureSpy.mockRestore(); + await service.close(); + await rm(directory, { recursive: true, force: true }); + } + }, +); + it("preserves target-read permission diagnostics before provider launch", async () => { let launches = 0; const path = resolve("read-denied-artifact"); @@ -114,3 +1006,474 @@ it("retains successful architecture and artifact output for an admitted provider await rm(directory, { recursive: true, force: true }); } }); + +it("tracks observed launch failures and clears them after subsequent successful launch", async () => { + const { directory, path } = await createArtifact(); + const command = resolve(directory, "failing-objdump"); + let failLaunch = true; + const service = new TestReverseEngineeringService({ + environment: { REA_OBJDUMP_COMMAND: command }, + runOwned: async () => { + if (failLaunch) { + throw new OwnedCommandFailure("process", "launch failed", null, null, { + cause: Object.assign(new Error("missing executable"), { + code: "ENOENT", + }), + }); + } + return providerSnapshot(); + }, + }); + try { + expect(service.observedLaunchFailures()).toEqual({}); + const failed = await service.inspectWithObjdump({ + operation: "architectures", + }); + expect(failed.ok).toBe(false); + expect(service.observedLaunchFailures()).toMatchObject({ + inspect_with_objdump: { + reason: expect.stringContaining(command), + remediation: expect.stringContaining("REA_OBJDUMP_COMMAND"), + }, + }); + + failLaunch = false; + const succeeded = await service.inspectWithObjdump({ + operation: "architectures", + }); + expect(succeeded.ok).toBe(true); + expect(service.observedLaunchFailures()).toEqual({}); + } finally { + await service.close(); + await rm(directory, { recursive: true, force: true }); + } +}); + +function createGate() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +it("preserves newer launch failure when older successful launch completes out of order and clears on newer recovery", async () => { + const { directory } = await createArtifact(); + const command = resolve(directory, "failing-objdump"); + const call1Started = createGate(); + const call1Release = createGate(); + let callCount = 0; + const service = new TestReverseEngineeringService({ + environment: { REA_OBJDUMP_COMMAND: command }, + runOwned: async () => { + callCount++; + const currentCall = callCount; + if (currentCall === 1) { + call1Started.resolve(); + await call1Release.promise; + return providerSnapshot(); + } + if (currentCall === 2) { + throw new OwnedCommandFailure("process", "launch failed", null, null, { + cause: Object.assign(new Error("missing executable"), { + code: "ENOENT", + }), + }); + } + return providerSnapshot(); + }, + }); + let call1Promise: + | ReturnType + | undefined; + try { + expect(service.observedLaunchFailures()).toEqual({}); + + call1Promise = service.inspectWithObjdump({ + operation: "architectures", + }); + await awaitAcknowledgement( + call1Started.promise, + call1Promise, + "provider command", + ); + + const call2Result = await service.inspectWithObjdump({ + operation: "architectures", + }); + expect(call2Result.ok).toBe(false); + expect(service.observedLaunchFailures()).toMatchObject({ + inspect_with_objdump: { + reason: expect.stringContaining(command), + remediation: expect.stringContaining("REA_OBJDUMP_COMMAND"), + }, + }); + + call1Release.resolve(); + const call1Result = await call1Promise; + expect(call1Result.ok).toBe(true); + + // Call 1 succeeded with an older dispatch than call 2's failure; failure must not be cleared + expect(service.observedLaunchFailures()).toMatchObject({ + inspect_with_objdump: { + reason: expect.stringContaining(command), + remediation: expect.stringContaining("REA_OBJDUMP_COMMAND"), + }, + }); + + const call3Result = await service.inspectWithObjdump({ + operation: "architectures", + }); + expect(call3Result.ok).toBe(true); + expect(service.observedLaunchFailures()).toEqual({}); + } finally { + call1Release.resolve(); + if (call1Promise !== undefined) { + await call1Promise.catch(() => {}); + } + await service.close(); + await rm(directory, { recursive: true, force: true }); + } +}); + +it("ignores older launch failure when newer successful launch completes first and records newer failure", async () => { + const { directory } = await createArtifact(); + const command = resolve(directory, "failing-objdump"); + const call1Started = createGate(); + const call1Release = createGate(); + let callCount = 0; + const launchError = () => + new OwnedCommandFailure("process", "launch failed", null, null, { + cause: Object.assign(new Error("missing executable"), { + code: "ENOENT", + }), + }); + const service = new TestReverseEngineeringService({ + environment: { REA_OBJDUMP_COMMAND: command }, + runOwned: async () => { + callCount++; + const currentCall = callCount; + if (currentCall === 1) { + call1Started.resolve(); + await call1Release.promise; + throw launchError(); + } + if (currentCall === 2) { + return providerSnapshot(); + } + throw launchError(); + }, + }); + let call1Promise: + | ReturnType + | undefined; + try { + expect(service.observedLaunchFailures()).toEqual({}); + + call1Promise = service.inspectWithObjdump({ + operation: "architectures", + }); + await awaitAcknowledgement( + call1Started.promise, + call1Promise, + "provider command", + ); + + const call2Result = await service.inspectWithObjdump({ + operation: "architectures", + }); + expect(call2Result.ok).toBe(true); + expect(service.observedLaunchFailures()).toEqual({}); + + call1Release.resolve(); + const call1Result = await call1Promise; + expect(call1Result.ok).toBe(false); + + // Older failure must not clobber newer success + expect(service.observedLaunchFailures()).toEqual({}); + + const call3Result = await service.inspectWithObjdump({ + operation: "architectures", + }); + expect(call3Result.ok).toBe(false); + expect(service.observedLaunchFailures()).toMatchObject({ + inspect_with_objdump: { + reason: expect.stringContaining(command), + remediation: expect.stringContaining("REA_OBJDUMP_COMMAND"), + }, + }); + } finally { + call1Release.resolve(); + if (call1Promise !== undefined) { + await call1Promise.catch(() => {}); + } + await service.close(); + await rm(directory, { recursive: true, force: true }); + } +}); + +it("preserves provider-launch ENOENT for a real execute_rizin_command target and records launch failure", async () => { + const { directory, path } = await createArtifact(); + const command = resolve(directory, "missing-rizin"); + const service = new TestReverseEngineeringService({ + environment: { REA_RIZIN_COMMAND: command }, + }); + try { + const result = await service.executeRizinCommand({ + path, + command: "iI", + }); + expect(result.ok).toBe(false); + if (!result.ok) { + const projected = projectAnalysisError(result.error); + expect(projected.code).toBe("capability_unavailable"); + expect(projected.details).toMatchObject({ + provider_id: "rizin", + diagnostics: { + boundary: "provider-launch", + command, + system_code: "ENOENT", + }, + }); + expect(projected.remediation.action).toContain( + "Set REA_RIZIN_COMMAND to an installed executable Rizin command", + ); + } + expect(service.observedLaunchFailures()).toMatchObject({ + execute_rizin_command: { + reason: expect.stringContaining(command), + remediation: expect.stringContaining("REA_RIZIN_COMMAND"), + }, + }); + } finally { + await service.close(); + await rm(directory, { recursive: true, force: true }); + } +}); + +it("keeps Windows native runner load and self-test failures out of command-launch state", async () => { + const { directory } = await createArtifact(); + const command = resolve(directory, "configured-objdump"); + const authorityError = new WindowsNativeAuthorityUnavailableError({ + available: false, + failureStage: "self-test", + retryable: true, + reason: "REA Windows native self-test failed: Job Object assignment denied", + }); + const service = new TestReverseEngineeringService({ + environment: { REA_OBJDUMP_COMMAND: command }, + runOwned: async () => { + throw authorityError; + }, + }); + try { + const result = await service.inspectWithObjdump({ + operation: "architectures", + }); + expect(result.ok).toBe(false); + if (!result.ok) { + const projected = projectAnalysisError(result.error); + expect(projected).toMatchObject({ + code: "capability_unavailable", + retryable: true, + details: { + provider_id: "process-ownership", + operation: "prepare_owned_process", + diagnostics: { + boundary: "process-ownership", + control_code: "windows-native-authority-unavailable", + failure_stage: "self-test", + }, + }, + }); + expect(projected.remediation.action).toContain( + "REA_OBJDUMP_COMMAND does not need to change", + ); + expect(projected.remediation.action).not.toContain( + "Set REA_OBJDUMP_COMMAND", + ); + } + expect(service.observedLaunchFailures()).toEqual({}); + } finally { + await service.close(); + await rm(directory, { recursive: true, force: true }); + } +}); + +const fakeWindowsAuthority = ( + throwOnSpawn: () => Error, +): WindowsNativeAuthority => ({ + inspection: { + abiVersion: 1, + nodeApiVersion: 8, + architecture: "x64", + filesystem: { + requestedPath: "C:\\probe", + finalPath: "C:\\probe", + filesystem: "NTFS", + volumeSerial: "0".repeat(16), + fileId: "0".repeat(32), + size: 0, + directory: true, + }, + privateDacl: true, + privateBridgeDescriptorAccess: true, + atomicJobAssignment: true, + killOnOwnerClose: true, + protocolStdin: true, + jobMemoryLimit: true, + }, + call: (operation) => { + if (operation === "process_spawn") throw throwOnSpawn(); + throw new Error(`Unmodeled native operation ${operation}`); + }, +}); + +it("keeps Windows process_spawn host-control rejections out of command-launch state while recording CreateProcessW executable failures", async () => { + const { directory, path } = await createArtifact(); + const command = "C:\\tools\\llvm-objdump.exe"; + const rizinCommand = "C:\\tools\\rizin.exe"; + let spawnFailure: Error = Object.assign( + new Error( + `Caller CPU affinity cannot bound an owned process in another processor group [${command}] (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: command, + win32Code: 87, + }, + ); + const authority = fakeWindowsAuthority(() => spawnFailure); + const service = new TestReverseEngineeringService({ + environment: { + REA_OBJDUMP_COMMAND: command, + REA_RIZIN_COMMAND: rizinCommand, + }, + runOwned: async (spawn) => { + new WindowsOwnedProcess( + spawn.command, + spawn.arguments, + spawn.cwd, + spawn.env ?? {}, + spawn.windowsVerbatimArguments ?? false, + { authority }, + ); + return providerSnapshot(); + }, + }); + try { + const objdumpControlResult = await service.inspectWithObjdump({ + path, + operation: "file_headers", + }); + expect(objdumpControlResult.ok).toBe(false); + if (!objdumpControlResult.ok) { + const projected = projectAnalysisError(objdumpControlResult.error); + expect(projected).toMatchObject({ + code: "capability_unavailable", + retryable: true, + details: { + provider_id: "process-ownership", + operation: "prepare_owned_process", + diagnostics: { + boundary: "process-ownership", + control_code: "windows-native-spawn-control-unavailable", + constraint: + "Caller CPU affinity cannot bound an owned process in another processor group", + }, + }, + }); + expect(projected.remediation.action).toContain( + "REA_OBJDUMP_COMMAND does not need to change", + ); + expect(projected.remediation.action).not.toContain( + "Set REA_OBJDUMP_COMMAND", + ); + } + + spawnFailure = Object.assign( + new Error( + `Suspended process has no owned Job Object membership [${rizinCommand}] (Win32 5): Access is denied.`, + ), + { + code: "EACCES", + constraint: "Suspended process has no owned Job Object membership", + requestedPath: rizinCommand, + win32Code: 5, + }, + ); + const rizinControlResult = await service.executeRizinCommand({ + path, + command: "iI", + }); + expect(rizinControlResult.ok).toBe(false); + if (!rizinControlResult.ok) { + const projected = projectAnalysisError(rizinControlResult.error); + expect(projected).toMatchObject({ + code: "capability_unavailable", + retryable: true, + details: { + provider_id: "process-ownership", + operation: "prepare_owned_process", + diagnostics: { + boundary: "process-ownership", + control_code: "windows-native-spawn-control-unavailable", + constraint: "Suspended process has no owned Job Object membership", + }, + }, + }); + expect(projected.remediation.action).toContain( + "REA_RIZIN_COMMAND does not need to change", + ); + expect(projected.remediation.action).not.toContain( + "Set REA_RIZIN_COMMAND", + ); + } + expect(service.observedLaunchFailures()).toEqual({}); + + spawnFailure = Object.assign( + new Error( + `Create process atomically inside owned Job Object failed [${command}] (Win32 2): The system cannot find the file specified.`, + ), + { + code: "ENOENT", + constraint: "Create process atomically inside owned Job Object failed", + requestedPath: command, + win32Code: 2, + }, + ); + const executableResult = await service.inspectWithObjdump({ + path, + operation: "file_headers", + }); + expect(executableResult.ok).toBe(false); + if (!executableResult.ok) { + const projected = projectAnalysisError(executableResult.error); + expect(projected).toMatchObject({ + code: "capability_unavailable", + details: { + provider_id: "objdump.command", + operation: "inspect_with_objdump", + diagnostics: { + boundary: "provider-launch", + command, + system_code: "ENOENT", + }, + }, + }); + } + expect(service.observedLaunchFailures()).toEqual({ + inspect_with_objdump: { + reason: expect.stringContaining(JSON.stringify(command)), + remediation: expect.stringContaining("REA_OBJDUMP_COMMAND"), + }, + }); + } finally { + await service.close(); + await rm(directory, { recursive: true, force: true }); + } +});