fix(session): report a missing Linux ps as provider unavailability in status (#7180)

Keep binary_session available when Linux owned-child probes lack procps ps. Preserve typed ownership-prerequisite failures in direct execution and report per-provider readiness in status. Independently validated on latest main through real stdio MCP plus focused regression, static and documentation checks. Closes #7164.
This commit is contained in:
김준호 authored and GitHub committed 2026-10-26 20:35:09 +08:00
1 parent e3066c1705
commit 0211a07ec5
10 files changed
+298 -21

No files matched your search

+7
View File
@@ -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.
+3
View File
@@ -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;
}
};
+37
View File
@@ -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,
});
});
+3
View File
@@ -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;
}
}
@@ -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 });
@@ -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,
@@ -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,
},
};
};
+12 -7
View File
@@ -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,
@@ -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,
});
});
+76 -1
View File
@@ -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<unknown> }> = [];
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-");