diff --git a/src/application/ownedProcessAvailability.test.ts b/src/application/ownedProcessAvailability.test.ts new file mode 100644 index 0000000000..45eb826689 --- /dev/null +++ b/src/application/ownedProcessAvailability.test.ts @@ -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(); + }); +}); diff --git a/src/application/ownedProcessAvailability.ts b/src/application/ownedProcessAvailability.ts index 865440f8c2..918d777714 100644 --- a/src/application/ownedProcessAvailability.ts +++ b/src/application/ownedProcessAvailability.ts @@ -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) }), + }, + }; }; diff --git a/tests/acceptance/darwinAvailabilityMcp.test.ts b/tests/acceptance/darwinAvailabilityMcp.test.ts new file mode 100644 index 0000000000..e271f2a67c --- /dev/null +++ b/tests/acceptance/darwinAvailabilityMcp.test.ts @@ -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(); + } + }, +);