diff --git a/src/javascript/sourceMaps/SourceMapCommand.ts b/src/javascript/sourceMaps/SourceMapCommand.ts index 4b24e8ed9a..0b7e17dd6f 100644 --- a/src/javascript/sourceMaps/SourceMapCommand.ts +++ b/src/javascript/sourceMaps/SourceMapCommand.ts @@ -106,14 +106,16 @@ export const runSourceMapCommand = async ( ? new AnalysisCancelledError(OPERATION, { cause: options.signal.reason, }) - : new ProviderAdapterError(PROVIDER, OPERATION, { - cause, - diagnostics: { - map_path: path, - error_message: message(cause), - phase: "process_ownership_preparation", - }, - }), + : cause instanceof AnalysisError + ? cause + : new ProviderAdapterError(PROVIDER, OPERATION, { + cause, + diagnostics: { + map_path: path, + error_message: message(cause), + phase: "process_ownership_preparation", + }, + }), ); } const deadline = new ProviderStartupDeadline( diff --git a/tests/boundary/javascript/sourceMapPreparation.test.ts b/tests/boundary/javascript/sourceMapPreparation.test.ts new file mode 100644 index 0000000000..5d9b01ff05 --- /dev/null +++ b/tests/boundary/javascript/sourceMapPreparation.test.ts @@ -0,0 +1,74 @@ +import { expect, it, vi } from "vitest"; +import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js"; +import { ProviderAdapterError } from "../../../src/domain/providerAdapterError.js"; +import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; +import { runSourceMapCommand } from "../../../src/javascript/sourceMaps/SourceMapCommand.js"; + +it.each([ + null, + new ProviderCleanupError("caller", ["caller-owned-resource"], {}), +])( + "preserves opaque cancellation during ownership preparation: %s", + async (reason) => { + const controller = new AbortController(); + const createRuntime = vi.fn(async () => { + throw new Error("Cancelled preparation must not acquire a runtime"); + }); + const launcher = vi.fn(async () => { + throw new Error("Cancelled preparation must not launch a codec"); + }); + const result = await runSourceMapCommand( + "{}", + "/selected.map", + { signal: controller.signal }, + { + prepareOwnership: async (signal) => { + controller.abort(reason); + signal?.throwIfAborted(); + }, + createRuntime, + launcher, + }, + ); + if (result.ok) throw new Error("Preparation must return cancellation"); + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cause).toBe(reason); + expect(result.error.cleanupIncomplete).toBe(false); + expect(result.error.cleanupResources).toEqual([]); + if (reason instanceof ProviderCleanupError) { + expect(reason.cleanupIncomplete).toBe(true); + expect(reason.cleanupResources).toEqual(["caller-owned-resource"]); + } + expect(createRuntime).not.toHaveBeenCalled(); + expect(launcher).not.toHaveBeenCalled(); + }, +); + +it("retains diagnostics for an unknown ownership preparation failure", async () => { + const cause = new Error("unexpected ownership failure"); + const createRuntime = vi.fn(async () => { + throw new Error("Failed preparation must not acquire a runtime"); + }); + const launcher = vi.fn(async () => { + throw new Error("Failed preparation must not launch a codec"); + }); + const result = await runSourceMapCommand("{}", "/selected.map", undefined, { + prepareOwnership: async () => { + throw cause; + }, + createRuntime, + launcher, + }); + if (result.ok) throw new Error("Preparation must return its failure"); + expect(result.error).toBeInstanceOf(ProviderAdapterError); + expect(result.error.cause).toBe(cause); + expect(result.error).toMatchObject({ + diagnostics: { + map_path: "/selected.map", + error_message: cause.message, + phase: "process_ownership_preparation", + }, + }); + expect(createRuntime).not.toHaveBeenCalled(); + expect(launcher).not.toHaveBeenCalled(); +}); diff --git a/tests/boundary/mcp/webSourceLocationMcp.test.ts b/tests/boundary/mcp/webSourceLocationMcp.test.ts index df40a0aecb..0afa5d0cbe 100644 --- a/tests/boundary/mcp/webSourceLocationMcp.test.ts +++ b/tests/boundary/mcp/webSourceLocationMcp.test.ts @@ -2,7 +2,8 @@ import { traceSourceMap } from "../../../src/javascript/sourceMaps/TraceSourceMa import { webSourceLocationArgs as args } from "../../fixtures/webSourceLocation.js"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { Ajv2020 } from "ajv/dist/2020.js"; -import { expect, it, onTestFinished } from "vitest"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { expect, it, onTestFinished, vi } from "vitest"; import { WebSourceLocationService } from "../../../src/application/WebSourceLocationService.js"; import { toolContract } from "../../../src/contracts/toolContracts.js"; import { parseEvidence } from "../../../src/domain/evidence.js"; @@ -10,6 +11,10 @@ import { ok } from "../../../src/domain/result.js"; import { createServer } from "../../../src/server/createServer.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { webSourceLocationFixture } from "../../fixtures/webSourceLocation.js"; +import { SourceMapDecoder } from "../../../src/javascript/sourceMaps/SourceMapDecoder.js"; +import { createSystemProcessOwnershipHost } from "../../../src/process/ProcessOwnershipObservation.js"; +import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; it("publishes valid SDK schemas and records the named module trace without a binary target", async () => { const service = new WebSourceLocationService( @@ -83,3 +88,54 @@ it("publishes valid SDK schemas and records the named module trace without a bin }); expect(invalid.isError).toBe(true); }); + +it("preserves missing Linux ps capability and recovery through source-map tracing", async () => { + const directory = await createTestTempDirectory("source-map-missing-ps-"); + const prepare = createSystemProcessOwnershipHost("linux", { + PATH: directory, + }).prepare; + if (prepare === undefined) + throw new Error("Ownership preparation is required"); + const createRuntime = vi.fn(async () => { + throw new Error("Missing prerequisite must not acquire a runtime"); + }); + const launcher = vi.fn(async () => { + throw new Error("Missing prerequisite must not launch a codec"); + }); + const decoder = new SourceMapDecoder({ + prepareOwnership: prepare, + createRuntime, + launcher, + }); + const harness = await createApplicationMcpHarness({ + serverOptions: { + webSourceLocation: new WebSourceLocationService( + { load: async () => ok(webSourceLocationFixture()) }, + decoder, + ), + }, + }); + onTestFinished(() => harness.close()); + for (let attempt = 0; attempt < 2; attempt += 1) { + const response = await harness.client.callTool({ + name: "trace_web_source_location", + arguments: args, + }); + const { error } = parseMcpToolError(response); + expect(error).toMatchObject({ + code: "capability_unavailable", + category: "unsupported_provider", + retryable: false, + message: expect.stringContaining("procps-compatible ps"), + remediation: { action: expect.stringContaining("procps-compatible ps") }, + details: { + provider_id: "process-ownership", + operation: "prepare_owned_process", + reason: expect.stringContaining("ENOENT"), + }, + }); + } + await harness.client.ping(); + expect(createRuntime).not.toHaveBeenCalled(); + expect(launcher).not.toHaveBeenCalled(); +});