fix(process): preserve availability when macOS ownership preparation fails (#7856)

* fix(process): preserve availability when macOS ownership preparation fails

* test(process): verify affected macOS provider availability
This commit is contained in:
morluto authored and GitHub committed 2026-10-29 05:10:04 +08:00
1 parent 6c205d8a79
commit 29945d435d
3 files changed
+222 -12

No files matched your search

@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js";
import { OwnedCommandFailure } from "../process/OwnedCommand.js";
import { ownedProcessPrerequisiteAvailability } from "./ownedProcessAvailability.js";
describe("Darwin ownership prerequisite discovery", () => {
it("retains compiler diagnostics when a provider probe cannot prepare ownership", () => {
const preparation = new DarwinProcessOwnershipInspectionError(
"macOS process ownership helper compilation failed: missing DEVELOPER_DIR",
{
compilerOutcome: {
outcome: "exited",
code: 1,
signal: null,
stderr: "missing DEVELOPER_DIR",
stderrTruncated: false,
outputTruncated: false,
killed: false,
},
},
);
const wrapped = new ProviderAdapterError(
"apktool",
"inspect_apktool_client",
{
cause: new OwnedCommandFailure(
"process",
"preparation failed",
null,
null,
{
preparationFailure: preparation,
},
),
diagnostics: { stage: "ownership_preparation" },
},
);
expect(ownedProcessPrerequisiteAvailability(wrapped)).toMatchObject({
status: "unavailable",
code: "runtime_unavailable",
reason: expect.stringContaining("missing DEVELOPER_DIR") as unknown,
diagnostics: {
stage: "ownership_preparation",
compiler_outcome: { outcome: "exited", code: 1 },
},
});
});
it("does not downgrade cancellation, failed cleanup, or unrelated provider failures", () => {
const preparation = new DarwinProcessOwnershipInspectionError(
"compiler failed",
);
expect(
ownedProcessPrerequisiteAvailability(new AnalysisCancelledError("probe")),
).toBeUndefined();
expect(
ownedProcessPrerequisiteAvailability(
new ProviderAdapterError("apktool", "probe", {
cause: preparation,
diagnostics: { stage: "cleanup" },
}),
),
).toBeUndefined();
expect(
ownedProcessPrerequisiteAvailability(
new ProviderAdapterError("apktool", "probe", {
cause: preparation,
diagnostics: { stage: "ownership_preparation" },
cleanup: { reason: "still owned", resources: ["helper"] },
}),
),
).toBeUndefined();
expect(
ownedProcessPrerequisiteAvailability(
new DarwinProcessOwnershipInspectionError("cancelled", {
cancellation: { reason: "stop" },
}),
),
).toBeUndefined();
});
});
+64 -12
View File
@@ -1,10 +1,17 @@
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
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 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.";
// Provider adapters re-attribute capability errors and keep the original as cause.
const prerequisiteRefusal = (
cause: unknown,
@@ -23,21 +30,66 @@ const prerequisiteRefusal = (
return undefined;
};
// Recognize only failures attributed to owned-process preparation. Cancellation
// and failed cleanup remain errors so their owner and completion are not lost.
const darwinPrerequisiteRefusal = (
cause: unknown,
): DarwinProcessOwnershipInspectionError | undefined => {
let current = cause;
while (current instanceof ProviderAdapterError) {
if (
current.cleanupIncomplete ||
current.diagnostics?.stage !== "ownership_preparation"
)
return undefined;
current = current.cause;
}
if (current instanceof OwnedCommandFailure) {
if (
current.reason !== "process" ||
current.cleanupOwner !== undefined ||
current.cleanupFailure !== null ||
current.cleanupResult !== undefined ||
current.cancellation !== undefined
)
return undefined;
current = current.preparationFailure;
}
return current instanceof DarwinProcessOwnershipInspectionError &&
current.cleanupFailure === undefined &&
current.cleanupOwner === undefined &&
current.cancellation === undefined
? current
: 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,
},
};
if (refusal !== undefined)
return {
status: "unavailable",
code: "runtime_unavailable",
reason: `${refusal.reason}. ${REMEDIATION}`,
diagnostics: {
stage: "ownership_preparation",
prerequisite_provider_id: refusal.providerId,
prerequisite_operation: refusal.operation,
},
};
const darwin = darwinPrerequisiteRefusal(cause);
if (darwin === undefined) return undefined;
return {
status: "unavailable",
code: "runtime_unavailable",
reason: `${darwin.message}. ${DARWIN_REMEDIATION}`,
diagnostics: {
stage: "ownership_preparation",
...(darwin.compilerOutcome === undefined
? {}
: { compiler_outcome: jsonValueSchema.parse(darwin.compilerOutcome) }),
},
};
};
@@ -0,0 +1,74 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { expect } from "vitest";
import { z } from "incur";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { cliTest } from "../support/cli/cliFixture.js";
cliTest.skipIf(process.platform !== "darwin")(
"target-free MCP discovery survives an unavailable macOS compiler",
async () => {
const root = await createTestTempDirectory("rea-darwin-unavailable-");
const tmp = join(root, "tmp");
await mkdir(tmp);
const apktool = join(root, "apktool");
await writeFile(apktool, "#!/bin/sh\necho 2.9.3\n", { mode: 0o755 });
const transport = new StdioClientTransport({
command: process.execPath,
args: [resolve("scripts/rea.mjs"), "mcp"],
cwd: process.cwd(),
env: {
PATH: process.env.PATH ?? "",
HOME: root,
TMPDIR: tmp,
DEVELOPER_DIR: join(root, "missing-developer"),
REA_LOG_LEVEL: "silent",
REA_APKTOOL_COMMAND: apktool,
},
stderr: "pipe",
});
transport.stderr?.on("data", () => undefined);
const client = new Client({ name: "darwin-availability", version: "1" });
try {
await client.connect(transport);
const result = await client.callTool({
name: "binary_session",
arguments: {},
});
expect(result.isError, JSON.stringify(result)).not.toBe(true);
expect(result.structuredContent).toBeDefined();
const discovery = z
.object({
result: z.object({
tool_availability: z.object({
groups: z.array(
z.object({
names: z.array(z.string()),
available: z.boolean(),
reason: z.string(),
remediation: z.string().nullable(),
}),
),
}),
}),
})
.parse(result.structuredContent);
const apktoolGroup = discovery.result.tool_availability.groups.find(
({ names }) => names.includes("inspect_apktool_client"),
);
expect(apktoolGroup).toMatchObject({
available: false,
reason: "provider_unavailable",
remediation: expect.stringContaining(
"macOS process ownership helper compilation failed",
),
});
expect(apktoolGroup?.remediation).toContain("missing-developer");
} finally {
await client.close();
await transport.close();
}
},
);