diff --git a/docs/installation.md b/docs/installation.md index ca3e9bd43c..73b3da1cb9 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -412,6 +412,13 @@ The public error code varies by caller: `capability_unavailable`, only a generic startup error. Check this prerequisite before following provider-specific recovery advice. +`binary_session` still returns status on such a host. Providers whose +readiness check reaches the owned-child launch, such as Ghidra's Java probe, +JADX and Apktool, report themselves unavailable with a reason naming the +missing `ps`. A provider that fails an earlier configuration check, such as +JADX without `REA_JADX_MCP_JAR`, keeps that reason. The other providers keep +their own status. `open_binary` keeps the refusal described above. + ## Diagnose, update, and remove `rea doctor --json` is strictly read-only. `rea update` updates only the npm or Bun global installation that owns the running CLI. Source checkouts and package-runner copies must be updated through the mechanism that owns them; a fresh package-runner invocation can use `npx rea-agents@latest` or `bunx rea-agents@latest`. `rea uninstall` removes only REA-owned agent registrations and skill files; `--purge-data` additionally removes REA cache and state paths. diff --git a/src/android/JadxConfiguration.ts b/src/android/JadxConfiguration.ts index c51bd113f6..cd614af68e 100644 --- a/src/android/JadxConfiguration.ts +++ b/src/android/JadxConfiguration.ts @@ -27,6 +27,7 @@ import type { AndroidOperation } from "../domain/android/androidAnalysis.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { err, ok, type Result } from "../domain/result.js"; import type { ProviderAvailability } from "../application/AnalysisProvider.js"; +import { ownedProcessPrerequisiteAvailability } from "../application/ownedProcessAvailability.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; @@ -228,6 +229,8 @@ export const inspectJadxAvailability = async ( reason: cause.message, diagnostics: { ...cause.diagnostics, failure_kind: cause.kind }, }; + const prerequisite = ownedProcessPrerequisiteAvailability(cause); + if (prerequisite !== undefined) return prerequisite; throw cause; } }; diff --git a/src/apktool/ApktoolProvider.test.ts b/src/apktool/ApktoolProvider.test.ts index 0c1b1e8424..c3fafe37ca 100644 --- a/src/apktool/ApktoolProvider.test.ts +++ b/src/apktool/ApktoolProvider.test.ts @@ -14,6 +14,10 @@ import { join } from "node:path"; import { describe, expect, onTestFinished, test, vi } from "vitest"; import { ApktoolProvider } from "./ApktoolProvider.js"; +import { + LinuxOwnedProcessPrerequisiteError, + systemProcessOwnershipHost, +} from "../process/ProcessOwnershipObservation.js"; import { ApktoolWorkspace } from "./ApktoolWorkspace.js"; import { ApktoolResourceAnalysisService } from "../application/apktool/ApktoolResourceAnalysisService.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; @@ -1710,3 +1714,36 @@ it.each([ } }, ); + +it("reports the owned-process prerequisite as Apktool unavailability, not a probe failure", async () => { + const { command } = await writeApktoolStub(() => VERSION_BLOCK); + vi.spyOn(systemProcessOwnershipHost, "prepare").mockImplementation( + async () => { + throw new LinuxOwnedProcessPrerequisiteError( + new Error("spawn ps ENOENT"), + ); + }, + ); + onTestFinished(() => { + vi.restoreAllMocks(); + }); + + const availability = await providerOver(command).inspectAvailability(); + expect(availability).toMatchObject({ + status: "unavailable", + code: "runtime_unavailable", + reason: expect.stringContaining("spawn ps ENOENT") as unknown, + diagnostics: { stage: "ownership_preparation" }, + }); + expect(availability.reason).toContain("procps"); + + const projected = await refusedBy(providerOver(command), { + operation: "inspect_apktool_client", + input: {}, + }); + expect(projected).toMatchObject({ code: "capability_unavailable" }); + expect(projected.details).toMatchObject({ + provider_id: "process-ownership", + reason: expect.stringContaining("spawn ps ENOENT") as unknown, + }); +}); diff --git a/src/apktool/ApktoolProvider.ts b/src/apktool/ApktoolProvider.ts index 265288c9f3..8b935d4857 100644 --- a/src/apktool/ApktoolProvider.ts +++ b/src/apktool/ApktoolProvider.ts @@ -55,6 +55,7 @@ import { } from "../process/OwnedCommand.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { WindowsBatchCommandError } from "../process/WindowsBatchCommand.js"; +import { ownedProcessPrerequisiteAvailability } from "../application/ownedProcessAvailability.js"; import { ApktoolConfigurationFailure, apktoolConfigurationAvailability, @@ -350,6 +351,8 @@ export class ApktoolProvider { if (cause instanceof ApktoolConfigurationFailure) { return apktoolConfigurationAvailability(cause); } + const prerequisite = ownedProcessPrerequisiteAvailability(cause); + if (prerequisite !== undefined) return prerequisite; throw cause; } } diff --git a/src/application/binary/AnalysisProviderRegistry.test.ts b/src/application/binary/AnalysisProviderRegistry.test.ts index 904633935d..c464390a76 100644 --- a/src/application/binary/AnalysisProviderRegistry.test.ts +++ b/src/application/binary/AnalysisProviderRegistry.test.ts @@ -16,10 +16,12 @@ import type { BinaryTarget } from "../../domain/binaryTargetTypes.js"; import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; import { + AnalysisCapabilityUnavailableError, AnalysisUnsupportedTargetError, AnalysisCancelledError, } from "../../domain/analysisErrorCore.js"; import { ProviderSelectionError } from "../../domain/providerSelectionError.js"; +import { LinuxOwnedProcessPrerequisiteError } from "../../process/ProcessOwnershipObservation.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; import { err, ok } from "../../domain/result.js"; @@ -109,6 +111,55 @@ it("uses one owner readiness snapshot for candidate availability and capabilitie }); }); +it("reports a missing Linux ps as one candidate's unavailability and keeps the others", async () => { + const base = candidate("alpha").provider; + const gamma = candidate("gamma").provider; + const refused: AnalysisProviderCandidate = { + ...base, + // Mirrors a provider adapter re-attributing the refusal to its own operation. + inspectReadinessSnapshot: () => { + throw new AnalysisCapabilityUnavailableError( + "process-ownership", + "ghidra:inspection", + "ps is unavailable", + { + cause: new LinuxOwnedProcessPrerequisiteError( + new Error("spawn ps ENOENT"), + ), + }, + ); + }, + }; + + const statuses = await new AnalysisProviderRegistry([ + refused, + gamma, + ]).candidates(); + expect(statuses).toMatchObject([ + { + provider: { id: "alpha" }, + availability: { + status: "unavailable", + code: "runtime_unavailable", + reason: expect.stringContaining("spawn ps ENOENT") as unknown, + diagnostics: { stage: "ownership_preparation" }, + }, + capabilities: [{ operation: "address_name" }], + }, + { provider: { id: "gamma" }, availability: { status: "available" } }, + ]); + + const selected = await new AnalysisProviderRegistry([refused]).select( + DATABASE_TARGET, + "alpha", + ); + if (selected.ok) throw new Error("expected the prerequisite refusal"); + expect(selected.error).toMatchObject({ + _tag: "AnalysisCapabilityUnavailableError", + providerId: "process-ownership", + }); +}); + it("selects the sole available provider and honors explicit selection precedence", async () => { const alpha = candidate("alpha"); const beta = candidate("beta", { available: false }); diff --git a/src/application/binary/AnalysisProviderRegistry.ts b/src/application/binary/AnalysisProviderRegistry.ts index b4dfda8ce1..752f992116 100644 --- a/src/application/binary/AnalysisProviderRegistry.ts +++ b/src/application/binary/AnalysisProviderRegistry.ts @@ -35,6 +35,7 @@ import type { AnalysisProviderCandidate, AnalysisProviderBoundClientFactory, AnalysisProviderInspectionOptions, + AnalysisProviderReadinessSnapshot, CapabilityDescriptor, ProviderAvailability, ProviderIdentity, @@ -46,6 +47,7 @@ import { type AnalysisProviderCandidateEvaluation, } from "./AnalysisProviderEvaluation.js"; import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js"; +import { ownedProcessPrerequisiteAvailability } from "../ownedProcessAvailability.js"; /** Deterministic discovery truth for one configured deep provider. */ export interface AnalysisProviderCandidateStatus { @@ -733,21 +735,32 @@ const candidateStatus = async ( const { signal, scope, resourceScope, operation } = context; if (signal.aborted) throw new AnalysisCancelledError(operation, { cause: signal.reason }); - const readiness = - provider.inspectReadinessSnapshot === undefined - ? { - availability: await provider.inspectAvailability({ + let readiness: AnalysisProviderReadinessSnapshot; + try { + readiness = + provider.inspectReadinessSnapshot === undefined + ? { + availability: await provider.inspectAvailability({ + signal, + scope, + resourceScope, + }), + capabilities: provider.capabilities(), + } + : await provider.inspectReadinessSnapshot({ signal, scope, resourceScope, - }), - capabilities: provider.capabilities(), - } - : await provider.inspectReadinessSnapshot({ - signal, - scope, - resourceScope, - }); + }); + } catch (cause: unknown) { + // Status reports the gap per provider; execution keeps the prerequisite's owner. + const availability = + operation === "binary_session" + ? ownedProcessPrerequisiteAvailability(cause) + : undefined; + if (availability === undefined) throw cause; + readiness = { availability, capabilities: provider.capabilities() }; + } return await projectCandidateStatus(provider, target, readiness, { signal, scope, diff --git a/src/application/ownedProcessAvailability.ts b/src/application/ownedProcessAvailability.ts new file mode 100644 index 0000000000..865440f8c2 --- /dev/null +++ b/src/application/ownedProcessAvailability.ts @@ -0,0 +1,43 @@ +import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js"; +import { 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."; + +// Provider adapters re-attribute capability errors and keep the original as cause. +const prerequisiteRefusal = ( + cause: unknown, +): LinuxOwnedProcessPrerequisiteError | undefined => { + if ( + !(cause instanceof AnalysisCapabilityUnavailableError) || + cause.cleanupIncomplete + ) + return undefined; + for ( + let current: unknown = cause; + current instanceof AnalysisCapabilityUnavailableError; + current = current.cause + ) + if (current instanceof LinuxOwnedProcessPrerequisiteError) return current; + return undefined; +}; + +/** A host prerequisite gap is the probed provider's unavailability, not a probe failure. */ +export const ownedProcessPrerequisiteAvailability = ( + cause: unknown, +): ProviderAvailability | undefined => { + const refusal = prerequisiteRefusal(cause); + return refusal === undefined + ? undefined + : { + status: "unavailable", + code: "runtime_unavailable", + reason: `${refusal.reason}. ${REMEDIATION}`, + diagnostics: { + stage: "ownership_preparation", + prerequisite_provider_id: refusal.providerId, + prerequisite_operation: refusal.operation, + }, + }; +}; diff --git a/src/process/ProcessOwnershipObservation.ts b/src/process/ProcessOwnershipObservation.ts index d3b43c36c9..a50e161e36 100644 --- a/src/process/ProcessOwnershipObservation.ts +++ b/src/process/ProcessOwnershipObservation.ts @@ -1254,13 +1254,7 @@ export const createSystemProcessOwnershipHost = ( throw new Error("ps did not report REA's current process"); } catch (cause: unknown) { if (isExpectedAbort(cause, signal)) throw cause; - const reason = `Linux process ownership inspection requires a procps-compatible ps on REA's PATH before launching a child: ${errorMessage(cause)}`; - throw new AnalysisCapabilityUnavailableError( - "process-ownership", - "prepare_owned_process", - reason, - { cause, userMessage: reason }, - ); + throw new LinuxOwnedProcessPrerequisiteError(cause); } } }, @@ -1440,6 +1434,17 @@ export const signalProcessWithStartIdentity = async ( } }; +/** 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)}`; + super("process-ownership", "prepare_owned_process", reason, { + cause, + userMessage: reason, + }); + } +} + /** Prepare native token inspection before REA launches a captured child. */ export const prepareProcessOwnershipInspection = async ( signal?: AbortSignal, diff --git a/tests/boundary/android/jadxConfiguration.test.ts b/tests/boundary/android/jadxConfiguration.test.ts index e3c49f02b1..6260e8c1f3 100644 --- a/tests/boundary/android/jadxConfiguration.test.ts +++ b/tests/boundary/android/jadxConfiguration.test.ts @@ -12,7 +12,7 @@ import { } from "node:fs/promises"; import { join } from "node:path"; -import { expect, it as test, onTestFinished } from "vitest"; +import { expect, it as test, onTestFinished, vi } from "vitest"; import { inspectJadxAvailability as inspectAvailability, @@ -20,6 +20,10 @@ import { validateJadxConfiguration, } from "../../../src/android/JadxConfiguration.js"; import { AnalysisCapabilityUnavailableError } from "../../../src/domain/analysisErrorCore.js"; +import { + LinuxOwnedProcessPrerequisiteError, + systemProcessOwnershipHost, +} from "../../../src/process/ProcessOwnershipObservation.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { writeOrderedZip } from "../../fixtures/artifactEntryOrder.js"; import { writeJadxJarInventory } from "../../fixtures/android/jadxJar.js"; @@ -623,3 +627,39 @@ it("reports the runtime JADX repair and restores invalid launch availability on await provider.close(); } }); + +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", + }); + + vi.spyOn(systemProcessOwnershipHost, "prepare").mockImplementation( + async () => { + throw new LinuxOwnedProcessPrerequisiteError( + new Error("spawn ps ENOENT"), + ); + }, + ); + 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, + }); +}); diff --git a/tests/boundary/mcp/sessionMcp.test.ts b/tests/boundary/mcp/sessionMcp.test.ts index c58679df32..b68e4a2a37 100644 --- a/tests/boundary/mcp/sessionMcp.test.ts +++ b/tests/boundary/mcp/sessionMcp.test.ts @@ -3,7 +3,7 @@ import { readFile, realpath, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import type { CallToolResult } from "@modelcontextprotocol/server"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -23,6 +23,11 @@ import type { AdbDeviceAnalysisPort } from "../../../src/application/adb/AdbDevi import type { ApktoolResourceAnalysisPort } from "../../../src/application/apktool/ApktoolResourceAnalysisPort.js"; import { observed as ok } from "../../fixtures/analysisExecution.js"; import { createServer } from "../../../src/server/createServer.js"; +import { ApktoolProvider } from "../../../src/apktool/ApktoolProvider.js"; +import { + LinuxOwnedProcessPrerequisiteError, + systemProcessOwnershipHost, +} from "../../../src/process/ProcessOwnershipObservation.js"; import { silentLogger } from "../../../src/logger.js"; import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; import { MAX_JSON_DEPTH } from "../../../src/domain/jsonValue.js"; @@ -47,6 +52,7 @@ const SNAPSHOT_PROFILE = createAnalysisProfile( const resources: Array<{ close(): Promise }> = []; let directory: string | undefined; afterEach(async () => { + vi.restoreAllMocks(); await Promise.all(resources.splice(0).map((resource) => resource.close())); if (directory !== undefined) await rm(directory, { recursive: true, force: true }); @@ -217,6 +223,75 @@ describe("public provider availability", () => { }); }); +describe("owned-process prerequisite during status discovery", () => { + it.skipIf(process.platform === "win32")( + "keeps every other provider's status when the Apktool probe cannot prepare an owned child", + async () => { + directory = await createTestTempDirectory("rea-mcp-ps-prerequisite-"); + const launcher = join(directory, "apktool"); + await writeFile(launcher, "#!/bin/sh\nprintf '2.9.3\\n'\n", { + mode: 0o755, + }); + const apktoolAnalysis = new ApktoolProvider({ + environment: { REA_APKTOOL_COMMAND: launcher }, + }); + vi.spyOn(systemProcessOwnershipHost, "prepare").mockImplementation( + async () => { + throw new LinuxOwnedProcessPrerequisiteError( + new Error("spawn ps ENOENT"), + ); + }, + ); + const adbDeviceAnalysis: AdbDeviceAnalysisPort = { + inspectAvailability: async () => ({ + status: "available", + code: null, + reason: null, + diagnostics: { version: "1.0.41" }, + }), + close: async () => {}, + execute: async () => ok(null), + }; + const closed: string[] = []; + const session = createTestBinarySession(provider(closed)); + const server = createServer( + { kind: "session", session }, + { adbDeviceAnalysis, apktoolAnalysis, logger: silentLogger }, + ); + const mcp = new Client({ name: "ps-prerequisite", version: "1.0.0" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(mcp, server, apktoolAnalysis); + await server.connect(serverTransport); + await mcp.connect(clientTransport); + + const reply = await mcp.callTool({ + name: "binary_session", + arguments: {}, + }); + expect(reply.isError ?? false).toBe(false); + const status = structured(reply); + const availability = toolAvailabilitySummaryInventory.parse( + (status.result as { tool_availability: unknown }).tool_availability, + ); + const apktool = availability.groups.find(({ names }) => + names.includes("inspect_apktool_client"), + ); + expect(apktool).toMatchObject({ + available: false, + reason: "provider_unavailable", + }); + expect(apktool?.remediation).toContain("procps-compatible ps"); + expect(apktool?.remediation).toContain("spawn ps ENOENT"); + expect( + availability.groups.find(({ names }) => + names.includes("inspect_adb_client"), + ), + ).toMatchObject({ available: true, reason: "available" }); + }, + ); +}); + describe("target-free MCP fixture host isolation", () => { it("uses fixed unavailable ports instead of probing installed Android tools", async () => { directory = await createTestTempDirectory("rea-mcp-host-isolation-");