mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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:
10 files changed
+298
-21
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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-");
|
||||
|
||||
Reference in new issue
Block a user