From 3b698645a086d80156d9196fd296cbb4195fc888 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 16 Oct 2026 06:46:25 +0800 Subject: [PATCH] fix(evidence): retain completed observations on late cancellation Validate completed reports before cancellation projection, retain valid Evidence and captured command diagnostics, and convert raw cleanup partials at their application owner. Record partial Evidence consistently across MCP handlers. Keep page inspection separate from unfinished bundle analysis. Refs #4261 --- .../BrowserObservationService.test.ts | 161 +++++++++++++++ src/application/BrowserObservationService.ts | 185 +++++++++++++----- .../BrowserScenarioCaptureService.test.ts | 78 ++++++++ .../BrowserScenarioCaptureService.ts | 66 ++++++- src/application/ProviderOperationExecution.ts | 29 ++- .../WebNetworkCaptureService.test.ts | 41 ++++ src/application/WebNetworkCaptureService.ts | 85 ++++---- src/application/WebRuntimeService.test.ts | 93 +++++++++ src/application/WebRuntimeService.ts | 89 +++++++-- .../WebSourceLocationService.test.ts | 44 ++++- src/application/WebSourceLocationService.ts | 67 ++++--- .../BinaryLayoutService.test.ts | 35 +++- .../binaryDiagnostics/BinaryLayoutService.ts | 46 +++-- .../PeResourcesService.test.ts | 43 +++- .../binaryDiagnostics/PeResourcesService.ts | 39 ++-- .../RecordedCrashService.test.ts | 35 +++- .../binaryDiagnostics/RecordedCrashService.ts | 52 ++--- .../electron/ElectronFuseService.test.ts | 71 +++++++ .../electron/ElectronFuseService.ts | 41 ++-- .../evm/EvmInterfaceService.test.ts | 42 +++- src/application/evm/EvmInterfaceService.ts | 32 +-- src/application/go/GoBinaryService.test.ts | 25 ++- src/application/go/GoBinaryService.ts | 63 +++--- .../javascript/ElectronEvidence.ts | 3 +- .../ElectronObservationService.test.ts | 150 ++++++++++++++ .../javascript/ElectronObservationService.ts | 109 +++++++++-- .../JavaScriptRuntimeObservationEvidence.ts | 3 +- ...avaScriptRuntimeObservationService.test.ts | 142 +++++++++++++- .../JavaScriptRuntimeObservationService.ts | 112 ++++++++--- .../rust/RustBinaryService.test.ts | 39 ++++ src/application/rust/RustBinaryService.ts | 9 +- src/domain/analysisErrorBase.ts | 8 + src/server/contractToolHandler.ts | 23 +-- src/server/observationEvidenceDelivery.ts | 35 ++++ src/server/registerBrowserScenarioTool.ts | 11 +- src/server/registerBrowserTools.ts | 11 +- src/server/registerElectronTools.ts | 27 +-- ...gisterJavaScriptRuntimeObservationTools.ts | 11 +- ...observationPartialEvidenceDelivery.test.ts | 160 +++++++++++++++ 39 files changed, 1897 insertions(+), 418 deletions(-) create mode 100644 src/application/BrowserObservationService.test.ts create mode 100644 src/application/BrowserScenarioCaptureService.test.ts create mode 100644 src/application/electron/ElectronFuseService.test.ts create mode 100644 src/application/javascript/ElectronObservationService.test.ts create mode 100644 src/server/observationEvidenceDelivery.ts create mode 100644 tests/boundary/mcp/observationPartialEvidenceDelivery.test.ts diff --git a/src/application/BrowserObservationService.test.ts b/src/application/BrowserObservationService.test.ts new file mode 100644 index 0000000000..213885c2f2 --- /dev/null +++ b/src/application/BrowserObservationService.test.ts @@ -0,0 +1,161 @@ +import { expect, it } from "vitest"; +import type { BrowserObservationPort } from "./BrowserObservationPort.js"; +import { listBrowserTargets } from "./BrowserObservationService.js"; +import type { BrowserTargetList } from "../domain/browserObservationSchemas.js"; +import { parseEvidence } from "../domain/evidence.js"; +import { AnalysisOutputError } from "../domain/analysisErrorCore.js"; +import { BrowserObservationError } from "../domain/browserObservationError.js"; +import { ok } from "../domain/result.js"; +import { passiveNetworkCapture } from "../../tests/fixtures/passiveNetworkCapture.js"; +import { inspectWebPage } from "./BrowserObservationService.js"; +import { analyzeWebBundleInputSchema } from "../domain/webBundleAnalysis.js"; +import { inspectWebPageInputSchema } from "../domain/browserObservation.js"; +import { analyzeWebBundle } from "./BrowserObservationService.js"; + +const input = { + cdp_endpoint: { + transport: "http_discovery" as const, + url: "http://127.0.0.1:9222", + recorded: "http://127.0.0.1:9222", + }, + allowed_origins: [], +}; + +const report: BrowserTargetList = { + browser: { + product: "Chrome", + protocol_version: "1.3", + revision: "1", + user_agent: "Chrome fixture", + js_version: "V8 fixture", + }, + targets: [], + excluded: { disallowed_origin: 0, unsupported_url: 0, non_page: 0 }, + limitations: [], +}; + +const provider = ( + listTargets: BrowserObservationPort["listTargets"], +): BrowserObservationPort => + ({ + identity: () => ({ id: "browser-fixture", name: "Fixture", version: "1" }), + listTargets, + }) as unknown as BrowserObservationPort; + +it("retains completed browser inventory Evidence when the provider completes after abort", async () => { + const controller = new AbortController(); + const result = await listBrowserTargets( + provider(async () => { + controller.abort(); + return ok(report); + }), + input, + { signal: controller.signal }, + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(parseEvidence(result.error.partialObservation)).toMatchObject({ + operation: "list_browser_targets", + normalized_result: report, + }); + } +}); + +it("does not let late cancellation hide invalid completed browser output", async () => { + const malformed = { + ...report, + limitations: [() => undefined], + } as unknown as BrowserTargetList; + const output = await listBrowserTargets( + provider(async () => ok(malformed)), + input, + ); + expect(output).toMatchObject({ + ok: false, + error: { _tag: "AnalysisOutputError" }, + }); + if (!output.ok) expect(output.error).toBeInstanceOf(AnalysisOutputError); + + const controller = new AbortController(); + const late = await listBrowserTargets( + provider(async () => { + controller.abort(); + return ok(malformed); + }), + input, + { signal: controller.signal }, + ); + expect(late).toMatchObject({ + ok: false, + error: { _tag: "AnalysisOutputError" }, + }); + if (!late.ok) expect(late.error).toBeInstanceOf(AnalysisOutputError); +}); + +it("converts a raw page inspection retained after browser cleanup failure", async () => { + const inspection = passiveNetworkCapture(); + const failure = new BrowserObservationError( + "inspect_web_page", + "cleanup_failed", + { + cleanup: { + reason: "target detach failed", + resources: ["browser target"], + }, + partialObservation: inspection, + }, + ); + const browser: BrowserObservationPort = { + identity: () => ({ id: "browser-fixture", name: "Fixture", version: "1" }), + listTargets: async () => ok(report), + inspectPage: async () => ({ ok: false, error: failure }), + } as unknown as BrowserObservationPort; + const pageInput = inspectWebPageInputSchema.parse({ + cdp_endpoint: input.cdp_endpoint.recorded, + allowed_origins: [], + target_id: "page", + observation_ms: 0, + include_script_sources: false, + }); + const result = await inspectWebPage(browser, pageInput); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe(failure); + expect(result.error.cleanupIncomplete).toBe(true); + expect(parseEvidence(result.error.partialObservation)).toMatchObject({ + operation: "inspect_web_page", + normalized_result: inspection, + }); + } +}); + +it("retains captured page Evidence when bundle analysis fails after capture", async () => { + const inspection = passiveNetworkCapture(); + const failure = new BrowserObservationError( + "analyze_web_bundle", + "cancelled", + { partialObservation: inspection }, + ); + const browser = { + identity: () => ({ id: "browser-fixture", name: "Fixture", version: "1" }), + analyzeBundle: async () => ({ ok: false as const, error: failure }), + } as unknown as BrowserObservationPort; + const bundleInput = analyzeWebBundleInputSchema.parse({ + ...input, + cdp_endpoint: input.cdp_endpoint.recorded, + target_id: "page", + observation_ms: 0, + include_script_sources: true, + }); + const result = await analyzeWebBundle(browser, bundleInput); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe(failure); + expect(failure.operation).toBe("analyze_web_bundle"); + expect(parseEvidence(result.error.partialObservation)).toMatchObject({ + operation: "inspect_web_page", + normalized_result: inspection, + }); + } +}); diff --git a/src/application/BrowserObservationService.ts b/src/application/BrowserObservationService.ts index 0637774c4c..87dfc8db37 100644 --- a/src/application/BrowserObservationService.ts +++ b/src/application/BrowserObservationService.ts @@ -14,12 +14,16 @@ import type { import { AnalysisCancelledError, AnalysisCapabilityUnavailableError, + AnalysisOutputError, } from "../domain/analysisErrorCore.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { err, ok, type Result } from "../domain/result.js"; import type { ExecutionOptions } from "./AnalysisProvider.js"; import type { BrowserObservationPort } from "./BrowserObservationPort.js"; import { createBrowserEvidence } from "./BrowserEvidence.js"; +import { jsonValueSchema } from "../domain/jsonValue.js"; +import { completeProviderEvidence } from "./ProviderOperationExecution.js"; +import { webPageInspectionSchema } from "../domain/browserObservationSchemas.js"; /** List browser targets within the endpoint and origin scope in the request. */ export const listBrowserTargets = async ( @@ -29,17 +33,16 @@ export const listBrowserTargets = async ( ): Promise> => { const ready = requireBrowser(browser, "list_browser_targets"); if (!ready.ok) return ready; - const result = await ready.value.listTargets(input, options); - if (result.ok && options.signal?.aborted === true) + if (options.signal?.aborted) return err(new AnalysisCancelledError("list_browser_targets")); + const result = await ready.value.listTargets(input, options); return result.ok - ? ok( - createBrowserEvidence( - "list_browser_targets", - input, - result.value, - ready.value.identity(), - ), + ? browserEvidence( + "list_browser_targets", + input, + result.value, + ready.value.identity(), + options, ) : result; }; @@ -52,17 +55,23 @@ export const inspectWebPage = async ( ): Promise> => { const ready = requireBrowser(browser, "inspect_web_page"); if (!ready.ok) return ready; - const result = await ready.value.inspectPage(input, options); - if (result.ok && options.signal?.aborted === true) + if (options.signal?.aborted) return err(new AnalysisCancelledError("inspect_web_page")); + const result = await ready.value.inspectPage(input, options); + if (!result.ok) + return retainPageInspection( + result.error, + input, + ready.value, + "inspect_web_page", + ); return result.ok - ? ok( - createBrowserEvidence( - "inspect_web_page", - input, - result.value, - ready.value.identity(), - ), + ? browserEvidence( + "inspect_web_page", + input, + result.value, + ready.value.identity(), + options, ) : result; }; @@ -75,17 +84,22 @@ export const analyzeWebBundle = async ( ): Promise> => { const ready = requireBrowser(browser, "analyze_web_bundle"); if (!ready.ok) return ready; - const analyzed = await ready.value.analyzeBundle(input, options); - if (analyzed.ok && options.signal?.aborted === true) + if (options.signal?.aborted) return err(new AnalysisCancelledError("analyze_web_bundle")); - if (!analyzed.ok) return analyzed; - return ok( - createBrowserEvidence( - "analyze_web_bundle", + const analyzed = await ready.value.analyzeBundle(input, options); + if (!analyzed.ok) + return retainPageInspection( + analyzed.error, input, - analyzed.value, - ready.value.identity(), - ), + ready.value, + "analyze_web_bundle", + ); + return browserEvidence( + "analyze_web_bundle", + input, + analyzed.value, + ready.value.identity(), + options, ); }; @@ -97,17 +111,16 @@ export const observeWebSession = async ( ): Promise> => { const ready = requireBrowser(browser, "observe_web_session"); if (!ready.ok) return ready; - const observed = await ready.value.observeSession(input, options); - if (observed.ok && options.signal?.aborted === true) + if (options.signal?.aborted) return err(new AnalysisCancelledError("observe_web_session")); + const observed = await ready.value.observeSession(input, options); return observed.ok - ? ok( - createBrowserEvidence( - "observe_web_session", - input, - observed.value, - ready.value.identity(), - ), + ? browserEvidence( + "observe_web_session", + input, + observed.value, + ready.value.identity(), + options, ) : observed; }; @@ -120,17 +133,16 @@ export const discoverWebMcpTools = async ( ): Promise> => { const ready = requireBrowser(browser, "discover_webmcp_tools"); if (!ready.ok) return ready; - const discovered = await ready.value.discoverWebMcpTools(input, options); - if (discovered.ok && options.signal?.aborted === true) + if (options.signal?.aborted) return err(new AnalysisCancelledError("discover_webmcp_tools")); + const discovered = await ready.value.discoverWebMcpTools(input, options); return discovered.ok - ? ok( - createBrowserEvidence( - "discover_webmcp_tools", - input, - discovered.value, - ready.value.identity(), - ), + ? browserEvidence( + "discover_webmcp_tools", + input, + discovered.value, + ready.value.identity(), + options, ) : discovered; }; @@ -163,17 +175,16 @@ export const captureWebScreenshot = async ( ): Promise> => { const ready = requireBrowser(browser, "capture_web_screenshot"); if (!ready.ok) return ready; - const captured = await ready.value.captureScreenshot(input, options); - if (captured.ok && options.signal?.aborted === true) + if (options.signal?.aborted) return err(new AnalysisCancelledError("capture_web_screenshot")); + const captured = await ready.value.captureScreenshot(input, options); return captured.ok - ? ok( - createBrowserEvidence( - "capture_web_screenshot", - input, - captured.value, - ready.value.identity(), - ), + ? browserEvidence( + "capture_web_screenshot", + input, + captured.value, + ready.value.identity(), + options, ) : captured; }; @@ -211,3 +222,69 @@ const requireBrowser = ( ), ) : ok(browser); + +const browserEvidence = ( + operation: Parameters[0], + input: Parameters[1], + result: Parameters[2], + provider: Parameters[3], + options: ExecutionOptions, +): Result => { + const json = jsonValueSchema.safeParse(result); + if (!json.success) + return err( + new AnalysisOutputError( + operation, + "Browser provider returned non-JSON observation data.", + { + cause: json.error, + }, + ), + ); + return completeProviderEvidence( + ok(createBrowserEvidence(operation, input, result, provider)), + operation, + options, + ); +}; + +/** Convert a provider-retained page capture without claiming bundle analysis completed. */ +const retainPageInspection = ( + error: AnalysisError, + input: InspectWebPageInput | AnalyzeWebBundleInput, + provider: BrowserObservationPort, + errorOperation: "inspect_web_page" | "analyze_web_bundle", +): Result => { + const partial = error.partialObservation; + if ( + partial === undefined || + "evidence_id" in partial || + !("target" in partial) || + !("network" in partial) || + !("capture_window" in partial) + ) + return err(error); + const inspection = webPageInspectionSchema.safeParse(partial); + if (!inspection.success) { + if (error instanceof AnalysisCancelledError) + return err( + new AnalysisOutputError( + errorOperation, + "Browser provider retained a malformed completed page inspection.", + { cause: inspection.error }, + ), + ); + return err(error); + } + const pageInput: InspectWebPageInput = + "fetch_source_maps" in input + ? (({ fetch_source_maps: _fetchSourceMaps, ...page }) => page)(input) + : input; + error.partialObservation = createBrowserEvidence( + "inspect_web_page", + pageInput, + inspection.data, + provider.identity(), + ); + return err(error); +}; diff --git a/src/application/BrowserScenarioCaptureService.test.ts b/src/application/BrowserScenarioCaptureService.test.ts new file mode 100644 index 0000000000..9645358d6a --- /dev/null +++ b/src/application/BrowserScenarioCaptureService.test.ts @@ -0,0 +1,78 @@ +import { expect, it } from "vitest"; + +import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { BrowserObservationError } from "../domain/browserObservationError.js"; +import { parseEvidence } from "../domain/evidence.js"; +import { browserScenarioInputSchema } from "../domain/browserScenario.js"; +import { err } from "../domain/result.js"; +import { scriptScenarioFixture } from "../../tests/fixtures/webScriptCapture.js"; +import type { BrowserScenarioCapturePort } from "./BrowserScenarioCapturePort.js"; +import { captureBrowserScenario } from "./BrowserScenarioCaptureService.js"; + +const scenario = browserScenarioInputSchema.parse({ + browser: { mode: "launch", executable_path: "/fixture/chrome" }, + start_url: { url: "https://fixture.test/", query: [] }, + actions: [{ step_id: "settle", action: "wait_for_timeout", duration_ms: 1 }], +}); +const capture = scriptScenarioFixture(); + +it("converts a retained raw scenario capture into partial Evidence", async () => { + const failure = new AnalysisCancelledError("capture_browser_scenario", { + partialObservation: { + kind: "browser-scenario-observation", + capture, + }, + }); + const provider: BrowserScenarioCapturePort = { + identity: () => ({ + id: "scenario-fixture", + name: "Scenario fixture", + version: "1", + }), + captureScenario: async () => err(failure), + }; + const result = await captureBrowserScenario(provider, scenario); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe(failure); + expect(parseEvidence(result.error.partialObservation)).toMatchObject({ + operation: "capture_browser_scenario", + normalized_result: capture, + }); + } +}); + +it("converts a raw scenario capture retained after cleanup failure", async () => { + const failure = new BrowserObservationError( + "capture_browser_scenario", + "cleanup_failed", + { + cleanup: { + reason: "browser close failed", + resources: ["browser process"], + }, + partialObservation: { + kind: "browser-scenario-observation", + capture, + }, + }, + ); + const provider: BrowserScenarioCapturePort = { + identity: () => ({ + id: "scenario-fixture", + name: "Scenario fixture", + version: "1", + }), + captureScenario: async () => err(failure), + }; + const result = await captureBrowserScenario(provider, scenario); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe(failure); + expect(result.error.cleanupIncomplete).toBe(true); + expect(parseEvidence(result.error.partialObservation)).toMatchObject({ + operation: "capture_browser_scenario", + normalized_result: capture, + }); + } +}); diff --git a/src/application/BrowserScenarioCaptureService.ts b/src/application/BrowserScenarioCaptureService.ts index 3dbd488c67..7f912ca5c4 100644 --- a/src/application/BrowserScenarioCaptureService.ts +++ b/src/application/BrowserScenarioCaptureService.ts @@ -1,11 +1,17 @@ import type { ExecutionOptions } from "./AnalysisProvider.js"; import type { BrowserScenarioCapturePort } from "./BrowserScenarioCapturePort.js"; import { createBrowserScenarioEvidence } from "./BrowserScenarioEvidence.js"; -import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js"; +import { + AnalysisCancelledError, + AnalysisCapabilityUnavailableError, + AnalysisOutputError, +} from "../domain/analysisErrorCore.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import type { Evidence } from "../domain/evidence.js"; import type { BrowserScenario } from "../domain/browserScenario.js"; import { err, type Result } from "../domain/result.js"; +import { browserScenarioCaptureSchema } from "../domain/browserScenarioCapture.js"; +import { completeProviderEvidence } from "./ProviderOperationExecution.js"; const OPERATION = "capture_browser_scenario" as const; const PROVIDER_ID = "rea-playwright-browser-scenario"; @@ -24,15 +30,55 @@ export const captureBrowserScenario = async ( "browser scenario provider is not configured", ), ); + if (options.signal?.aborted) + return err(new AnalysisCancelledError(OPERATION)); const captured = await provider.captureScenario(scenario, options); - return captured.ok - ? { - ok: true, - value: createBrowserScenarioEvidence( - scenario, - captured.value, - provider.identity(), - ), + if (!captured.ok) { + const partial = captured.error.partialObservation; + if ( + partial !== undefined && + "kind" in partial && + partial.kind === "browser-scenario-observation" + ) { + const report = browserScenarioCaptureSchema.safeParse(partial.capture); + if (!report.success) { + if (captured.error instanceof AnalysisCancelledError) + return err( + new AnalysisOutputError( + OPERATION, + "Browser scenario provider retained a malformed completed capture.", + { cause: report.error }, + ), + ); + return captured; } - : captured; + captured.error.partialObservation = createBrowserScenarioEvidence( + scenario, + report.data, + provider.identity(), + ); + } + return captured; + } + const report = browserScenarioCaptureSchema.safeParse(captured.value); + if (!report.success) + return err( + new AnalysisOutputError( + OPERATION, + "Browser scenario provider returned a malformed completed capture.", + { cause: report.error }, + ), + ); + return completeProviderEvidence( + { + ok: true, + value: createBrowserScenarioEvidence( + scenario, + report.data, + provider.identity(), + ), + }, + OPERATION, + options, + ); }; diff --git a/src/application/ProviderOperationExecution.ts b/src/application/ProviderOperationExecution.ts index 966a43f299..928366e1ac 100644 --- a/src/application/ProviderOperationExecution.ts +++ b/src/application/ProviderOperationExecution.ts @@ -1,5 +1,8 @@ import { z } from "zod"; -import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import type { + AnalysisError, + AnalysisErrorOptions, +} from "../domain/analysisErrorBase.js"; import { AnalysisCancelledError, AnalysisInputError, @@ -27,6 +30,24 @@ export interface ProviderOperationPort< ): Promise>; } +/** Preserve valid completed Evidence when cancellation arrives at completion. */ +export const completeProviderEvidence = ( + completed: Result, + operation: string, + options?: ExecutionOptions, + errorOptions?: Omit, +): Result => { + if (!completed.ok) return completed; + return options?.signal?.aborted + ? err( + new AnalysisCancelledError(operation, { + ...errorOptions, + partialObservation: completed.value, + }), + ) + : completed; +}; + /** * Shared CLI/MCP admission, execution, and Evidence composition for * single-request providers. The caller retains its operation vocabulary and @@ -69,9 +90,5 @@ export const executeProviderOperation = async < // the caller no longer wants. The observation it completed is still this // provider's Evidence, so the cancellation carries it as the partial // observation instead of discarding a finished result. - if (options?.signal?.aborted) - return err( - new AnalysisCancelledError(operation, { partialObservation: evidence }), - ); - return ok(evidence); + return completeProviderEvidence(ok(evidence), operation, options); }; diff --git a/src/application/WebNetworkCaptureService.test.ts b/src/application/WebNetworkCaptureService.test.ts index 0105e2d3f9..3fc26eba6b 100644 --- a/src/application/WebNetworkCaptureService.test.ts +++ b/src/application/WebNetworkCaptureService.test.ts @@ -4,6 +4,10 @@ import { historicalHar } from "../../tests/fixtures/historicalHar.js"; import { ok } from "../domain/result.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { parseEvidence } from "../domain/evidence.js"; +import { + AnalysisCancelledError, + AnalysisOutputError, +} from "../domain/analysisErrorCore.js"; import { webNetworkCaptureSchema, type WebNetworkCapture, @@ -125,6 +129,43 @@ it("rejects an adapter omitting its original observed digest before caller proje if (!result.ok) expect(result.error._tag).toBe("AnalysisOutputError"); }); +it("retains valid completed capture Evidence on late cancellation and preserves invalid output", async () => { + const controller = new AbortController(); + const report = fixture(); + const late = await new WebNetworkCaptureService({ + inspect: () => { + controller.abort(); + return Promise.resolve(ok(report)); + }, + }).inspect( + { capture_path: report.artifact.path, format: "har" }, + { signal: controller.signal }, + ); + expect(late.ok).toBe(false); + if (!late.ok) { + expect(late.error).toBeInstanceOf(AnalysisCancelledError); + expect(parseEvidence(late.error.partialObservation)).toMatchObject({ + operation: "inspect_web_network_capture", + normalized_result: expect.objectContaining({ total_records: 1 }), + }); + } + + const malformed = fixture(); + malformed.artifact.path = "/other.har"; + const outputController = new AbortController(); + const invalid = await new WebNetworkCaptureService({ + inspect: () => { + outputController.abort(); + return Promise.resolve(ok(malformed)); + }, + }).inspect( + { capture_path: "/capture.har", format: "har" }, + { signal: outputController.signal }, + ); + expect(invalid.ok).toBe(false); + if (!invalid.ok) expect(invalid.error).toBeInstanceOf(AnalysisOutputError); +}); + it.each([ ["producer"], ["artifact"], diff --git a/src/application/WebNetworkCaptureService.ts b/src/application/WebNetworkCaptureService.ts index 5a3d5cfeaf..6e6724ff30 100644 --- a/src/application/WebNetworkCaptureService.ts +++ b/src/application/WebNetworkCaptureService.ts @@ -20,6 +20,7 @@ import { captureSensitiveValues, webNetworkCaptureSchema, } from "../domain/webNetworkCapture.js"; +import { completeProviderEvidence } from "./ProviderOperationExecution.js"; const OPERATION = "inspect_web_network_capture"; @@ -74,8 +75,6 @@ export class WebNetworkCaptureService { ); const loaded = await this.port.inspect(input, options); if (!loaded.ok) return failure(loaded.error); - if (options?.signal?.aborted) - return failure(new AnalysisCancelledError(OPERATION)); const report = webNetworkCaptureSchema.safeParse(loaded.value); if (!report.success) return failure( @@ -159,45 +158,55 @@ export class WebNetworkCaptureService { record_ordinals: selected, sensitive_value_count: input.sensitive_values.length, }); - return ok( - createEvidence( - safeSha256 === null - ? undefined - : { - path: safePath, - sha256: safeSha256, - format: "file", - }, - value.decoder, - { - operation: OPERATION, - parameters, - result: { - ...safeValue, - artifact: { ...value.artifact, path: safePath, sha256: safeSha256 }, - records, - limitations: - safeSha256 === null - ? [...limitations, subjectUnavailableReason] - : limitations, - }, - ...(safeValue.format !== "qlog" - ? {} + return completeProviderEvidence( + ok( + createEvidence( + safeSha256 === null + ? undefined : { - analysisProfile: createAnalysisProfile(value.decoder, { - qlog_profile: safeValue.qlog.profile, - clock: safeValue.qlog.clock, - scope: safeValue.qlog.scope, + path: safePath, + sha256: safeSha256, + format: "file", + }, + value.decoder, + { + operation: OPERATION, + parameters, + result: { + ...safeValue, + artifact: { + ...value.artifact, + path: safePath, + sha256: safeSha256, + }, + records, + limitations: + safeSha256 === null + ? [...limitations, subjectUnavailableReason] + : limitations, + }, + ...(safeValue.format !== "qlog" + ? {} + : { + analysisProfile: createAnalysisProfile(value.decoder, { + qlog_profile: safeValue.qlog.profile, + clock: safeValue.qlog.clock, + scope: safeValue.qlog.scope, + }), }), - }), - confidence: "observed", - authority: "historical-reference", - limitations, - ...(safeSha256 === null ? { subjectUnavailableReason } : {}), - locations: - safePath === "" ? [] : [{ kind: "artifact-path", path: safePath }], - }, + confidence: "observed", + authority: "historical-reference", + limitations, + ...(safeSha256 === null ? { subjectUnavailableReason } : {}), + locations: + safePath === "" + ? [] + : [{ kind: "artifact-path", path: safePath }], + }, + ), ), + OPERATION, + options, ); } } diff --git a/src/application/WebRuntimeService.test.ts b/src/application/WebRuntimeService.test.ts index db3b601ec4..b9709f2eb4 100644 --- a/src/application/WebRuntimeService.test.ts +++ b/src/application/WebRuntimeService.test.ts @@ -2,10 +2,14 @@ import { expect, it } from "vitest"; import { WebRuntimeService } from "./WebRuntimeService.js"; import { recordingWebRuntimePort, + webExecutionFixture, webListenersFixture, } from "./WebRuntimeService.fixture.js"; import { err, ok } from "../domain/result.js"; import { AnalysisTimeoutError } from "../domain/analysisErrorCore.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { parseEvidence } from "../domain/evidence.js"; +import { AnalysisOutputError } from "../domain/analysisErrorCore.js"; it("rejects malformed input and prior cancellation without calling a provider", async () => { const provider = recordingWebRuntimePort(); @@ -59,3 +63,92 @@ it("preserves actual provider failures and rejects changed target/selector ident if (changed.ok) throw new Error("Changed selection must be rejected"); expect(changed.error._tag).toBe("AnalysisOutputError"); }); + +it("converts raw execution retained by provider cleanup failure into partial Evidence", async () => { + const args = { + cdp_endpoint: "http://127.0.0.1:9222", + target_id: "selected-page", + observation_ms: 5, + }; + const execution = webExecutionFixture(args.target_id, args.observation_ms); + const failure = new ProviderCleanupError( + "rea-cdp-browser", + ["selected-page"], + { reason: "target detach failed" }, + { operation: "observe_web_execution", partialObservation: execution }, + ); + const result = await new WebRuntimeService({ + identity: () => ({ + id: "web-runtime", + name: "Runtime fixture", + version: "1", + }), + observeExecution: async () => err(failure), + inspectEventListeners: async () => err(failure), + }).observe(args); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe(failure); + expect(result.error.cleanupIncomplete).toBe(true); + expect(parseEvidence(result.error.partialObservation)).toMatchObject({ + operation: "observe_web_execution", + normalized_result: execution, + }); + } +}); + +it("retains completed execution Evidence on late abort and keeps invalid output primary", async () => { + const input = { + cdp_endpoint: "http://127.0.0.1:9222", + target_id: "selected-page", + observation_ms: 5, + }; + const unused = new AnalysisTimeoutError( + "inspect_web_event_listeners", + 20_000, + ); + const controller = new AbortController(); + const completed = await new WebRuntimeService({ + identity: () => ({ + id: "web-runtime", + name: "Runtime fixture", + version: "1", + }), + observeExecution: async () => { + controller.abort(); + return ok(webExecutionFixture(input.target_id, input.observation_ms)); + }, + inspectEventListeners: async () => err(unused), + }).observe(input, { signal: controller.signal }); + expect(completed.ok).toBe(false); + if (!completed.ok) + expect(parseEvidence(completed.error.partialObservation)).toMatchObject({ + operation: "observe_web_execution", + normalized_result: webExecutionFixture( + input.target_id, + input.observation_ms, + ), + }); + + const late = new AbortController(); + const invalid = await new WebRuntimeService({ + identity: () => ({ + id: "web-runtime", + name: "Runtime fixture", + version: "1", + }), + observeExecution: async () => { + late.abort(); + return ok(webExecutionFixture(input.target_id, input.observation_ms)); + }, + inspectEventListeners: async () => err(unused), + }).observe( + { ...input, allowed_origins: ["https://allowed.example"] }, + { signal: late.signal }, + ); + expect(invalid).toMatchObject({ + ok: false, + error: { _tag: "AnalysisOutputError" }, + }); + if (!invalid.ok) expect(invalid.error).toBeInstanceOf(AnalysisOutputError); +}); diff --git a/src/application/WebRuntimeService.ts b/src/application/WebRuntimeService.ts index da402ba49b..f924b557bd 100644 --- a/src/application/WebRuntimeService.ts +++ b/src/application/WebRuntimeService.ts @@ -7,7 +7,7 @@ import { AnalysisOutputError, } from "../domain/analysisErrorCore.js"; import { createEvidence, type Evidence } from "../domain/evidence.js"; -import { jsonObjectSchema, jsonValueSchema } from "../domain/jsonValue.js"; +import { jsonObjectSchema } from "../domain/jsonValue.js"; import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; import { err, ok, type Result } from "../domain/result.js"; import { @@ -18,6 +18,7 @@ import { inspectWebEventListenersInputSchema, webEventListenersSchema, } from "../domain/webEventListeners.js"; +import { completeProviderEvidence } from "./ProviderOperationExecution.js"; /** Shared public workflows keep instrumentation and listener inspection independently selectable. */ export class WebRuntimeService { @@ -37,7 +38,35 @@ export class WebRuntimeService { ); if (!parsed.ok) return parsed; const result = await this.provider.observeExecution(parsed.value, options); - if (!result.ok) return result; + if (!result.ok) { + const partial = result.error.partialObservation; + if ( + partial !== undefined && + !("evidence_id" in partial) && + "instrumentation" in partial && + "window" in partial + ) { + const verifiedPartial = webExecutionSchema.safeParse(partial); + if (verifiedPartial.success) { + const evidence = this.evidence( + operation, + parsed.value, + verifiedPartial.data, + ); + if (evidence.ok) result.error.partialObservation = evidence.value; + else if (result.error instanceof AnalysisCancelledError) + return evidence; + } else if (result.error instanceof AnalysisCancelledError) + return err( + new AnalysisOutputError( + operation, + "Provider retained a malformed completed execution observation.", + { cause: verifiedPartial.error }, + ), + ); + } + return result; + } const verified = webExecutionSchema.safeParse(result.value); if (!verified.success) return err(new AnalysisOutputError(operation, verified.error.message)); @@ -71,7 +100,35 @@ export class WebRuntimeService { parsed.value, options, ); - if (!result.ok) return result; + if (!result.ok) { + const partial = result.error.partialObservation; + if ( + partial !== undefined && + !("evidence_id" in partial) && + "selected_node" in partial && + "listeners" in partial + ) { + const verifiedPartial = webEventListenersSchema.safeParse(partial); + if (verifiedPartial.success) { + const evidence = this.evidence( + operation, + parsed.value, + verifiedPartial.data, + ); + if (evidence.ok) result.error.partialObservation = evidence.value; + else if (result.error instanceof AnalysisCancelledError) + return evidence; + } else if (result.error instanceof AnalysisCancelledError) + return err( + new AnalysisOutputError( + operation, + "Provider retained malformed completed event-listener data.", + { cause: verifiedPartial.error }, + ), + ); + } + return result; + } const verified = webEventListenersSchema.safeParse(result.value); if (!verified.success) return err(new AnalysisOutputError(operation, verified.error.message)); @@ -98,8 +155,6 @@ export class WebRuntimeService { | z.infer, options?: ExecutionOptions, ): Result { - if (options?.signal?.aborted) - return err(new AnalysisCancelledError(operation)); if ( input.allowed_origins.length > 0 && !input.allowed_origins.includes(result.target.origin) @@ -110,16 +165,20 @@ export class WebRuntimeService { "Provider returned a document outside the selected origin scope.", ), ); - return ok( - createEvidence(undefined, this.provider.identity(), { - predicateType: `rea.${operation.replaceAll("_", "-")}`, - operation, - parameters: jsonObjectSchema.parse(input), - result: jsonValueSchema.parse(result), - confidence: "observed", - authority: "external-service", - limitations: result.limitations, - }), + return completeProviderEvidence( + ok( + createEvidence(undefined, this.provider.identity(), { + predicateType: `rea.${operation.replaceAll("_", "-")}`, + operation, + parameters: jsonObjectSchema.parse(input), + result, + confidence: "observed", + authority: "external-service", + limitations: result.limitations, + }), + ), + operation, + options, ); } } diff --git a/src/application/WebSourceLocationService.test.ts b/src/application/WebSourceLocationService.test.ts index 81f621cd2d..4a13482a51 100644 --- a/src/application/WebSourceLocationService.test.ts +++ b/src/application/WebSourceLocationService.test.ts @@ -5,6 +5,11 @@ import { webSourceMapDecoderFixture as decoder, } from "../../tests/fixtures/webSourceLocation.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { parseEvidence } from "../domain/evidence.js"; +import { + AnalysisCancelledError, + AnalysisOutputError, +} from "../domain/analysisErrorCore.js"; import { ok } from "../domain/result.js"; import { webSourceLocationResultSchema } from "../domain/webSourceLocation.js"; import { WebSourceLocationService } from "./WebSourceLocationService.js"; @@ -159,5 +164,42 @@ it("honors cancellation both before acquisition and across decoder completion", }, }).trace(args, { signal: controller.signal }); if (late.ok) throw new Error("expected cancellation"); - expect(late.error._tag).toBe("AnalysisCancelledError"); + expect(late.error).toBeInstanceOf(AnalysisCancelledError); + expect(parseEvidence(late.error.partialObservation)).toMatchObject({ + operation: "trace_web_source_location", + confidence: "derived", + }); + + const malformedDecoder: WebSourceMapPort = { + trace: async (input, options) => { + const decoded = await decoder.trace(input, options); + return decoded.ok + ? ok({ ...decoded.value, source_map_sha256: "b".repeat(64) }) + : decoded; + }, + }; + const malformed = await new WebSourceLocationService( + { load: () => Promise.resolve(ok(webSourceLocationFixture())) }, + malformedDecoder, + ).trace(args); + expect(malformed.ok).toBe(false); + if (!malformed.ok) + expect(malformed.error).toBeInstanceOf(AnalysisOutputError); + + const invalidController = new AbortController(); + const malformedLate = await new WebSourceLocationService( + { load: () => Promise.resolve(ok(webSourceLocationFixture())) }, + { + trace: async (input, options) => { + const decoded = await decoder.trace(input, options); + invalidController.abort(); + return decoded.ok + ? ok({ ...decoded.value, source_map_sha256: "b".repeat(64) }) + : decoded; + }, + }, + ).trace(args, { signal: invalidController.signal }); + expect(malformedLate.ok).toBe(false); + if (!malformedLate.ok) + expect(malformedLate.error).toBeInstanceOf(AnalysisOutputError); }); diff --git a/src/application/WebSourceLocationService.ts b/src/application/WebSourceLocationService.ts index d084bff5ab..563a7b81ad 100644 --- a/src/application/WebSourceLocationService.ts +++ b/src/application/WebSourceLocationService.ts @@ -34,6 +34,7 @@ import type { WebSourceLocationArtifactPort, WebSourceMapPort, } from "./WebSourceLocationPorts.js"; +import { completeProviderEvidence } from "./ProviderOperationExecution.js"; const OPERATION = "trace_web_source_location"; const artifactsSchema = selectedWebScriptArtifactsSchema.extend({ @@ -126,8 +127,6 @@ export class WebSourceLocationService { if (!traced.ok) return traced; const report = verifyReport(input, map.file.sha256, traced.value); if (!report.ok) return report; - if (options?.signal?.aborted) - return err(new AnalysisCancelledError(OPERATION)); const limitations = [ ...LIMITATIONS, codecBudgetLimitation(report.value.runtime), @@ -147,37 +146,41 @@ export class WebSourceLocationService { limitations, }); if (!normalized.success) return invalidOutput(normalized.error.message); - return ok( - createEvidence( - { - path: data.sourceFile.path, - sha256: data.sourceFile.sha256, - format: "file", - }, - { - id: "rea-web-source-location", - name: "REA captured source-map point trace", - version: "1", - }, - { - operation: OPERATION, - parameters: jsonObjectSchema.parse(input), - result: normalized.data, - rawResult: { source_map_text: map.text }, - confidence: "derived", - authority: "historical-reference", - limitations, - locations: [ - data.sourceFile.path, - data.manifestFile.path, - map.file.path, - ].map((path) => ({ kind: "artifact-path", path })), - evidenceLinks: - data.manifest.source_evidence_id === null - ? [] - : [data.manifest.source_evidence_id], - }, + return completeProviderEvidence( + ok( + createEvidence( + { + path: data.sourceFile.path, + sha256: data.sourceFile.sha256, + format: "file", + }, + { + id: "rea-web-source-location", + name: "REA captured source-map point trace", + version: "1", + }, + { + operation: OPERATION, + parameters: jsonObjectSchema.parse(input), + result: normalized.data, + rawResult: { source_map_text: map.text }, + confidence: "derived", + authority: "historical-reference", + limitations, + locations: [ + data.sourceFile.path, + data.manifestFile.path, + map.file.path, + ].map((path) => ({ kind: "artifact-path", path })), + evidenceLinks: + data.manifest.source_evidence_id === null + ? [] + : [data.manifest.source_evidence_id], + }, + ), ), + OPERATION, + options, ); } } diff --git a/src/application/binaryDiagnostics/BinaryLayoutService.test.ts b/src/application/binaryDiagnostics/BinaryLayoutService.test.ts index 76a8d85518..7e0a02e69e 100644 --- a/src/application/binaryDiagnostics/BinaryLayoutService.test.ts +++ b/src/application/binaryDiagnostics/BinaryLayoutService.test.ts @@ -76,18 +76,23 @@ it("cancels before invocation and after an acquired observation", async () => { return Promise.resolve(ok(binaryLayoutFixture())); }, }); - expect( - await service.inspect( - { path: "/artifacts/source-owned.elf" }, - { signal: controller.signal }, - ), - ).toMatchObject({ + const late = await service.inspect( + { path: "/artifacts/source-owned.elf" }, + { signal: controller.signal }, + ); + expect(late).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError", capturedOutput: binaryLayoutFixture().diagnostics, }, }); + if (!late.ok) { + expect(parseEvidence(late.error.partialObservation)).toMatchObject({ + operation: "inspect_binary_layout", + normalized_result: binaryLayoutFixture(), + }); + } const untouched = new BinaryLayoutService({ inspectAndClose(input, options) { return this.inspect(input, options); @@ -142,5 +147,23 @@ it.each(["different-path", "invalid-digest", "outside-range"])( capturedOutput: fixture.diagnostics, }, }); + const controller = new AbortController(); + const lateInvalid = await new BinaryLayoutService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, + identity: BINARY_LAYOUT_TEST_PROVIDER, + inspect: () => { + controller.abort(); + return Promise.resolve(ok(fixture)); + }, + }).inspect( + { path: "/artifacts/source-owned.elf" }, + { signal: controller.signal }, + ); + expect(lateInvalid).toMatchObject({ + ok: false, + error: { _tag: "AnalysisOutputError" }, + }); }, ); diff --git a/src/application/binaryDiagnostics/BinaryLayoutService.ts b/src/application/binaryDiagnostics/BinaryLayoutService.ts index 68859b64ae..ae2d729e1f 100644 --- a/src/application/binaryDiagnostics/BinaryLayoutService.ts +++ b/src/application/binaryDiagnostics/BinaryLayoutService.ts @@ -14,6 +14,7 @@ import { } from "../../domain/native/binaryLayout.js"; import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; import { err, ok, type Result } from "../../domain/result.js"; +import { completeProviderEvidence } from "../ProviderOperationExecution.js"; const OPERATION = "inspect_binary_layout"; @@ -79,8 +80,6 @@ export class BinaryLayoutService { const outputOptions = diagnostics.success ? { capturedOutput: diagnostics.data } : undefined; - if (options?.signal?.aborted) - return err(new AnalysisCancelledError(OPERATION, outputOptions)); const report = binaryLayoutSchema.safeParse(inspected.value); if (!report.success || report.data.artifact.path !== input.data.path) return err( @@ -91,26 +90,31 @@ export class BinaryLayoutService { ), ); const value = report.data; - return ok( - createEvidence( - { - path: value.artifact.path, - format: "elf", - architecture: "x86_64", - sha256: value.artifact.sha256, - }, - this.provider.identity, - { - operation: OPERATION, - parameters: input.data, - result: value, - // The decoded report is the only representation; do not repeat it. - rawResult: null, - confidence: "observed", - limitations: value.limitations, - locations: [{ kind: "artifact-path", path: value.artifact.path }], - }, + return completeProviderEvidence( + ok( + createEvidence( + { + path: value.artifact.path, + format: "elf", + architecture: "x86_64", + sha256: value.artifact.sha256, + }, + this.provider.identity, + { + operation: OPERATION, + parameters: input.data, + result: value, + // The decoded report is the only representation; do not repeat it. + rawResult: null, + confidence: "observed", + limitations: value.limitations, + locations: [{ kind: "artifact-path", path: value.artifact.path }], + }, + ), ), + OPERATION, + options, + outputOptions, ); } } diff --git a/src/application/binaryDiagnostics/PeResourcesService.test.ts b/src/application/binaryDiagnostics/PeResourcesService.test.ts index 8f451ca9f0..d6c58431f2 100644 --- a/src/application/binaryDiagnostics/PeResourcesService.test.ts +++ b/src/application/binaryDiagnostics/PeResourcesService.test.ts @@ -6,6 +6,11 @@ import { inspectPeResourcesInputSchema } from "../../domain/native/peResources.j import { parsePeResources } from "../../native/pe/PeResourceParser.js"; import { peResourceFixture } from "../../native/pe/PeResources.fixture.js"; import { ok } from "../../domain/result.js"; +import { parseEvidence } from "../../domain/evidence.js"; +import { + AnalysisCancelledError, + AnalysisOutputError, +} from "../../domain/analysisErrorCore.js"; const observedReport = () => { const { bytes } = peResourceFixture(); @@ -45,7 +50,7 @@ it.each([ }, ); -it("discards an observation if the caller cancels while the provider completes", async () => { +it("retains a completed observation on late cancellation and keeps output errors primary", async () => { const report = await observedReport(); const controller = new AbortController(); const service = new PeResourcesService({ @@ -55,10 +60,34 @@ it("discards an observation if the caller cancels while the provider completes", return Promise.resolve(ok(report)); }, }); - expect( - await service.inspect( - { path: report.artifact.path }, - { signal: controller.signal }, - ), - ).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } }); + const late = await service.inspect( + { path: report.artifact.path }, + { signal: controller.signal }, + ); + expect(late).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + if (!late.ok) { + expect(parseEvidence(late.error.partialObservation)).toMatchObject({ + operation: "inspect_pe_resources", + normalized_result: report, + }); + } + + const malformed = await observedReport(); + malformed.artifact.path = "/other.exe"; + const outputController = new AbortController(); + const invalid = await new PeResourcesService({ + identity: PE_RESOURCES_PROVIDER, + inspect: () => { + outputController.abort(); + return Promise.resolve(ok(malformed)); + }, + }).inspect( + { path: "/fixtures/example.exe" }, + { signal: outputController.signal }, + ); + expect(invalid.ok).toBe(false); + if (!invalid.ok) expect(invalid.error).toBeInstanceOf(AnalysisOutputError); }); diff --git a/src/application/binaryDiagnostics/PeResourcesService.ts b/src/application/binaryDiagnostics/PeResourcesService.ts index 87de771a40..b119a7cac3 100644 --- a/src/application/binaryDiagnostics/PeResourcesService.ts +++ b/src/application/binaryDiagnostics/PeResourcesService.ts @@ -16,6 +16,7 @@ import { } from "../../domain/native/peResources.js"; import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; import { err, ok, type Result } from "../../domain/result.js"; +import { completeProviderEvidence } from "../ProviderOperationExecution.js"; /** Shared caller validation, output parsing and Evidence provenance for CLI/MCP. */ export class PeResourcesService { @@ -51,8 +52,6 @@ export class PeResourcesService { ); const result = await this.provider.inspect(input.data, options); if (!result.ok) return result; - if (options?.signal?.aborted) - return err(new AnalysisCancelledError(operation)); const report = peResourcesSchema.safeParse(result.value); if ( !report.success || @@ -70,23 +69,27 @@ export class PeResourcesService { ), ); const value = report.data; - return ok( - createEvidence( - { - path: value.artifact.path, - format: "pe", - sha256: value.artifact.sha256, - }, - this.provider.identity, - { - operation, - parameters: input.data, - result: value, - confidence: "observed", - limitations: value.limitations, - locations: [{ kind: "artifact-path", path: value.artifact.path }], - }, + return completeProviderEvidence( + ok( + createEvidence( + { + path: value.artifact.path, + format: "pe", + sha256: value.artifact.sha256, + }, + this.provider.identity, + { + operation, + parameters: input.data, + result: value, + confidence: "observed", + limitations: value.limitations, + locations: [{ kind: "artifact-path", path: value.artifact.path }], + }, + ), ), + operation, + options, ); } } diff --git a/src/application/binaryDiagnostics/RecordedCrashService.test.ts b/src/application/binaryDiagnostics/RecordedCrashService.test.ts index 8d8f8e94cd..5e1bf9bf79 100644 --- a/src/application/binaryDiagnostics/RecordedCrashService.test.ts +++ b/src/application/binaryDiagnostics/RecordedCrashService.test.ts @@ -105,18 +105,23 @@ it("cancels before invocation and after an acquired observation", async () => { return Promise.resolve(ok(recordedCrashFixture())); }, }); - expect( - await service.inspect( - { path: "/artifacts/source-owned.core" }, - { signal: controller.signal }, - ), - ).toMatchObject({ + const late = await service.inspect( + { path: "/artifacts/source-owned.core" }, + { signal: controller.signal }, + ); + expect(late).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError", capturedOutput: recordedCrashFixture().diagnostics, }, }); + if (!late.ok) { + expect(parseEvidence(late.error.partialObservation)).toMatchObject({ + operation: "inspect_recorded_crash", + normalized_result: recordedCrashFixture(), + }); + } const untouched = new RecordedCrashService({ inspectAndClose(input, options) { return this.inspect(input, options); @@ -170,5 +175,23 @@ it.each(["different-path", "invalid-digest", "outside-range"])( capturedOutput: fixture.diagnostics, }, }); + const controller = new AbortController(); + const lateInvalid = await new RecordedCrashService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, + identity: RECORDED_CRASH_TEST_PROVIDER, + inspect: () => { + controller.abort(); + return Promise.resolve(ok(fixture)); + }, + }).inspect( + { path: "/artifacts/source-owned.core" }, + { signal: controller.signal }, + ); + expect(lateInvalid).toMatchObject({ + ok: false, + error: { _tag: "AnalysisOutputError" }, + }); }, ); diff --git a/src/application/binaryDiagnostics/RecordedCrashService.ts b/src/application/binaryDiagnostics/RecordedCrashService.ts index 67a57ca9a3..25872d9e3f 100644 --- a/src/application/binaryDiagnostics/RecordedCrashService.ts +++ b/src/application/binaryDiagnostics/RecordedCrashService.ts @@ -15,6 +15,7 @@ import { } from "../../domain/native/recordedCrash.js"; import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; import { err, ok, type Result } from "../../domain/result.js"; +import { completeProviderEvidence } from "../ProviderOperationExecution.js"; const OPERATION = "inspect_recorded_crash"; @@ -80,8 +81,6 @@ export class RecordedCrashService { const outputOptions = diagnostics.success ? { capturedOutput: diagnostics.data } : undefined; - if (options?.signal?.aborted) - return err(new AnalysisCancelledError(OPERATION, outputOptions)); const report = recordedCrashSchema.safeParse(inspected.value); if ( !report.success || @@ -97,29 +96,34 @@ export class RecordedCrashService { ), ); const value = report.data; - return ok( - createEvidence( - { - path: value.artifact.path, - format: "elf", - architecture: - RECORDED_LINUX_CORE_PROFILES[value.architecture] - .evidenceArchitecture, - sha256: value.artifact.sha256, - }, - this.provider.identity, - { - operation: OPERATION, - parameters: input.data, - result: value, - // The decoded report is the only representation; do not repeat it. - rawResult: null, - confidence: - value.debugger.status === "available" ? "derived" : "observed", - limitations: value.limitations, - locations: [{ kind: "artifact-path", path: value.artifact.path }], - }, + return completeProviderEvidence( + ok( + createEvidence( + { + path: value.artifact.path, + format: "elf", + architecture: + RECORDED_LINUX_CORE_PROFILES[value.architecture] + .evidenceArchitecture, + sha256: value.artifact.sha256, + }, + this.provider.identity, + { + operation: OPERATION, + parameters: input.data, + result: value, + // The decoded report is the only representation; do not repeat it. + rawResult: null, + confidence: + value.debugger.status === "available" ? "derived" : "observed", + limitations: value.limitations, + locations: [{ kind: "artifact-path", path: value.artifact.path }], + }, + ), ), + OPERATION, + options, + outputOptions, ); } } diff --git a/src/application/electron/ElectronFuseService.test.ts b/src/application/electron/ElectronFuseService.test.ts new file mode 100644 index 0000000000..59eeec14bc --- /dev/null +++ b/src/application/electron/ElectronFuseService.test.ts @@ -0,0 +1,71 @@ +import { expect, it } from "vitest"; +import type { ElectronFuseReport } from "../../domain/electron/electronFuses.js"; +import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; +import { parseEvidence } from "../../domain/evidence.js"; +import { ok } from "../../domain/result.js"; +import { ElectronFuseService } from "./ElectronFuseService.js"; + +const identity = { id: "fuse-fixture", name: "Fixture", version: "1" }; +const report: ElectronFuseReport = { + artifact: { + selected_path: "/artifacts/app", + resolved_path: "/artifacts/app", + sha256: "a".repeat(64), + bytes: 0, + format: "elf", + }, + slices: [ + { + index: 0, + file_offset: 0, + size: 0, + architecture: null, + sentinel_count: 0, + sentinels: [], + }, + ], + runtime_implications: [], + limitations: ["source-owned fixture"], +}; + +it("retains completed fuse Evidence when the provider completes after abort", async () => { + const controller = new AbortController(); + const result = await new ElectronFuseService({ + identity, + inspect: async () => { + controller.abort(); + return ok(report); + }, + }).inspect( + { path: report.artifact.selected_path }, + { signal: controller.signal }, + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(parseEvidence(result.error.partialObservation)).toMatchObject({ + operation: "inspect_electron_fuses", + normalized_result: report, + }); + } +}); + +it("preserves malformed fuse output ahead of late cancellation", async () => { + const malformed = { + ...report, + artifact: { ...report.artifact, selected_path: "/artifacts/other" }, + }; + const controller = new AbortController(); + const invalid = await new ElectronFuseService({ + identity, + inspect: async () => { + controller.abort(); + return ok(malformed); + }, + }).inspect( + { path: report.artifact.selected_path }, + { signal: controller.signal }, + ); + expect(invalid.ok).toBe(false); + if (!invalid.ok) expect(invalid.error).toBeInstanceOf(AnalysisOutputError); +}); diff --git a/src/application/electron/ElectronFuseService.ts b/src/application/electron/ElectronFuseService.ts index 9719a6fe20..eac540bcd2 100644 --- a/src/application/electron/ElectronFuseService.ts +++ b/src/application/electron/ElectronFuseService.ts @@ -16,6 +16,7 @@ import { import { createEvidence, type Evidence } from "../../domain/evidence.js"; import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; import { err, ok, type Result } from "../../domain/result.js"; +import { completeProviderEvidence } from "../ProviderOperationExecution.js"; const OPERATION = "inspect_electron_fuses"; @@ -49,8 +50,6 @@ export class ElectronFuseService { ); const inspected = await this.provider.inspect(input.data, options); if (!inspected.ok) return inspected; - if (options?.signal?.aborted) - return err(new AnalysisCancelledError(OPERATION)); const report = electronFuseReportSchema.safeParse(inspected.value); if ( !report.success || @@ -64,24 +63,28 @@ export class ElectronFuseService { ); const value = report.data; const architecture = subjectArchitecture(value); - return ok( - createEvidence( - { - path: value.artifact.resolved_path, - sha256: value.artifact.sha256, - format: value.artifact.format, - ...(architecture === undefined ? {} : { architecture }), - }, - this.provider.identity, - { - operation: OPERATION, - parameters: input.data, - result: value, - confidence: "observed", - limitations: value.limitations, - locations: locationsFor(value), - }, + return completeProviderEvidence( + ok( + createEvidence( + { + path: value.artifact.resolved_path, + sha256: value.artifact.sha256, + format: value.artifact.format, + ...(architecture === undefined ? {} : { architecture }), + }, + this.provider.identity, + { + operation: OPERATION, + parameters: input.data, + result: value, + confidence: "observed", + limitations: value.limitations, + locations: locationsFor(value), + }, + ), ), + OPERATION, + options, ); } } diff --git a/src/application/evm/EvmInterfaceService.test.ts b/src/application/evm/EvmInterfaceService.test.ts index 03f5017424..f07537d45e 100644 --- a/src/application/evm/EvmInterfaceService.test.ts +++ b/src/application/evm/EvmInterfaceService.test.ts @@ -6,7 +6,12 @@ import { evmInterfaceFixture, } from "../../../tests/fixtures/evm/interface.js"; import { err, ok } from "../../domain/result.js"; -import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js"; +import { + AnalysisCancelledError, + AnalysisCapabilityUnavailableError, + AnalysisOutputError, +} from "../../domain/analysisErrorCore.js"; +import { parseEvidence } from "../../domain/evidence.js"; it.each([ { path: "relative.hex", encoding: "hex" }, @@ -83,18 +88,24 @@ it("preserves meaningful provider failure and cancellation before/after executio return Promise.resolve(ok(evmInterfaceExecution(observed))); }, }); - expect( - await cancelled.inspect( - { path: "/artifacts/source-owned.hex", encoding: "hex" }, - { signal: controller.signal }, - ), - ).toMatchObject({ + const late = await cancelled.inspect( + { path: "/artifacts/source-owned.hex", encoding: "hex" }, + { signal: controller.signal }, + ); + expect(late).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError", capturedOutput: observed.diagnostics, }, }); + if (!late.ok) { + expect(late.error).toBeInstanceOf(AnalysisCancelledError); + expect(parseEvidence(late.error.partialObservation)).toMatchObject({ + operation: "inspect_evm_interface", + normalized_result: observed, + }); + } const untouched = new EvmInterfaceService({ inspect: () => { throw new Error("must not start"); @@ -107,3 +118,20 @@ it("preserves meaningful provider failure and cancellation before/after executio ), ).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } }); }); + +it("keeps malformed interface output ahead of a late cancellation", async () => { + const controller = new AbortController(); + const malformed = evmInterfaceFixture(); + malformed.artifact.path = "/artifacts/other.hex"; + const result = await new EvmInterfaceService({ + inspect: () => { + controller.abort(); + return Promise.resolve(ok(evmInterfaceExecution(malformed))); + }, + }).inspect( + { path: "/artifacts/source-owned.hex", encoding: "hex" }, + { signal: controller.signal }, + ); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError); +}); diff --git a/src/application/evm/EvmInterfaceService.ts b/src/application/evm/EvmInterfaceService.ts index 86ac778658..c0812ca2de 100644 --- a/src/application/evm/EvmInterfaceService.ts +++ b/src/application/evm/EvmInterfaceService.ts @@ -15,6 +15,7 @@ import { } from "../../domain/evm/evmInterface.js"; import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; import { err, ok, type Result } from "../../domain/result.js"; +import { completeProviderEvidence } from "../ProviderOperationExecution.js"; const OPERATION = "inspect_evm_interface"; const diagnosticsSchema = z.object({ @@ -59,8 +60,6 @@ export class EvmInterfaceService { const outputOptions = diagnostics.success ? { capturedOutput: diagnostics.data.diagnostics } : undefined; - if (options?.signal?.aborted) - return err(new AnalysisCancelledError(OPERATION, outputOptions)); const execution = inspected.value; const report = evmInterfaceSchema.safeParse(execution.result); if ( @@ -78,18 +77,23 @@ export class EvmInterfaceService { outputOptions, ), ); - return ok( - createEvidence(execution.subject, execution.provider, { - operation: OPERATION, - parameters: input.data, - result: report.data, - ...(execution.rawResult === null - ? {} - : { rawResult: execution.rawResult }), - confidence: "inferred", - locations: execution.locations, - limitations: execution.limitations, - }), + return completeProviderEvidence( + ok( + createEvidence(execution.subject, execution.provider, { + operation: OPERATION, + parameters: input.data, + result: report.data, + ...(execution.rawResult === null + ? {} + : { rawResult: execution.rawResult }), + confidence: "inferred", + locations: execution.locations, + limitations: execution.limitations, + }), + ), + OPERATION, + options, + outputOptions, ); } } diff --git a/src/application/go/GoBinaryService.test.ts b/src/application/go/GoBinaryService.test.ts index 127fe94135..1c6aa41f6d 100644 --- a/src/application/go/GoBinaryService.test.ts +++ b/src/application/go/GoBinaryService.test.ts @@ -6,6 +6,7 @@ import { AnalysisOutputError, } from "../../domain/analysisErrorCore.js"; import type { GoBinary } from "../../domain/go/goBinary.js"; +import { parseEvidence } from "../../domain/evidence.js"; import { ok } from "../../domain/result.js"; const identity = { @@ -166,8 +167,13 @@ it("honors cancellation before reading and after a completed observation", async { signal: controller.signal }, ); expect(completed.ok).toBe(false); - if (!completed.ok) + if (!completed.ok) { expect(completed.error).toBeInstanceOf(AnalysisCancelledError); + expect(parseEvidence(completed.error.partialObservation)).toMatchObject({ + operation: "inspect_go_binary", + normalized_result: report, + }); + } const before = await service.inspect( { path: report.artifact.path }, { signal: controller.signal }, @@ -176,3 +182,20 @@ it("honors cancellation before reading and after a completed observation", async if (!before.ok) expect(before.error).toBeInstanceOf(AnalysisCancelledError); expect(calls).toBe(1); }); + +it("keeps malformed provider output ahead of a late cancellation", async () => { + const controller = new AbortController(); + const malformed = { + ...report, + artifact: { ...report.artifact, path: "/other" }, + }; + const result = await new GoBinaryService({ + identity, + inspect: () => { + controller.abort(); + return Promise.resolve(ok(malformed)); + }, + }).inspect({ path: report.artifact.path }, { signal: controller.signal }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError); +}); diff --git a/src/application/go/GoBinaryService.ts b/src/application/go/GoBinaryService.ts index 2b9928f820..818526b687 100644 --- a/src/application/go/GoBinaryService.ts +++ b/src/application/go/GoBinaryService.ts @@ -15,6 +15,7 @@ import { } from "../../domain/go/goBinary.js"; import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; import { err, ok, type Result } from "../../domain/result.js"; +import { completeProviderEvidence } from "../ProviderOperationExecution.js"; const OPERATION = "inspect_go_binary"; const architectureForSubject = (architecture: string) => { @@ -64,8 +65,6 @@ export class GoBinaryService { ); const inspected = await this.provider.inspect(input.data, options); if (!inspected.ok) return inspected; - if (options?.signal?.aborted) - return err(new AnalysisCancelledError(OPERATION)); const report = goBinarySchema.safeParse(inspected.value); if (!report.success || report.data.artifact.path !== input.data.path) return err( @@ -76,35 +75,39 @@ export class GoBinaryService { ); const value = report.data; const architecture = architectureForSubject(value.architecture); - return ok( - createEvidence( - { - path: value.artifact.path, - sha256: value.artifact.sha256, - format: value.format === "macho" ? "mach-o" : value.format, - ...(architecture === undefined ? {} : { architecture }), - }, - this.provider.identity, - { - operation: OPERATION, - parameters: input.data, - result: value, - confidence: "observed", - limitations: value.limitations, - locations: [ - { kind: "artifact-path", path: value.artifact.path }, - ...(value.build_info === null - ? [] - : [ - { - kind: "file-offset-range" as const, - start: value.build_info.header_offset, - end: value.build_info.header_offset + 32, - }, - ]), - ], - }, + return completeProviderEvidence( + ok( + createEvidence( + { + path: value.artifact.path, + sha256: value.artifact.sha256, + format: value.format === "macho" ? "mach-o" : value.format, + ...(architecture === undefined ? {} : { architecture }), + }, + this.provider.identity, + { + operation: OPERATION, + parameters: input.data, + result: value, + confidence: "observed", + limitations: value.limitations, + locations: [ + { kind: "artifact-path", path: value.artifact.path }, + ...(value.build_info === null + ? [] + : [ + { + kind: "file-offset-range" as const, + start: value.build_info.header_offset, + end: value.build_info.header_offset + 32, + }, + ]), + ], + }, + ), ), + OPERATION, + options, ); } } diff --git a/src/application/javascript/ElectronEvidence.ts b/src/application/javascript/ElectronEvidence.ts index 056bb085df..1b2e919683 100644 --- a/src/application/javascript/ElectronEvidence.ts +++ b/src/application/javascript/ElectronEvidence.ts @@ -4,7 +4,6 @@ import { type Evidence, type EvidenceObservation, } from "../../domain/evidence.js"; -import { jsonValueSchema } from "../../domain/jsonValue.js"; import type { ElectronPageInspection, ElectronTargetList, @@ -28,7 +27,7 @@ export const createElectronEvidence = ( : "rea.electron-page-inspection", operation, parameters: parameters(input), - result: jsonValueSchema.parse(result), + result, confidence: "observed", authority: "external-service", environment: diff --git a/src/application/javascript/ElectronObservationService.test.ts b/src/application/javascript/ElectronObservationService.test.ts new file mode 100644 index 0000000000..8fbf6fef92 --- /dev/null +++ b/src/application/javascript/ElectronObservationService.test.ts @@ -0,0 +1,150 @@ +import { expect, it } from "vitest"; + +import { classifyBrowserCompleteness } from "../../domain/browserCompleteness.js"; +import { + AnalysisCancelledError, + AnalysisOutputError, +} from "../../domain/analysisErrorCore.js"; +import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; +import { parseEvidence } from "../../domain/evidence.js"; +import { + electronPageInspectionSchema, + electronTargetListSchema, + inspectElectronPageInputSchema, +} from "../../domain/javascript/electronObservation.js"; +import { err } from "../../domain/result.js"; +import type { ElectronObservationPort } from "./ElectronObservationPort.js"; +import { + inspectElectronPage, + listElectronTargets, +} from "./ElectronObservationService.js"; + +const identity = { id: "electron-fixture", name: "Fixture", version: "1" }; +const input = inspectElectronPageInputSchema.parse({ + cdp_endpoint: "http://127.0.0.1:9222", + target_id: "electron-page", + observation_ms: 0, +}); +const inspection = electronPageInspectionSchema.parse({ + browser: { + product: "Electron", + protocol_version: "1.3", + revision: "fixture", + user_agent: "Electron fixture", + js_version: "V8 fixture", + }, + target: { + target_id: "electron-page", + type: "page", + title: "Fixture", + file_path: "/app/index.html", + attached: false, + }, + capture_window: { + started_at: "2026-01-01T00:00:00.000Z", + ended_at: "2026-01-01T00:00:00.000Z", + observation_ms: 0, + }, + completeness: classifyBrowserCompleteness({ + policyFilteredSections: new Set(), + attachLimitedSections: new Set(), + truncatedSections: new Set(), + unavailableSections: new Set(), + excluded: [], + droppedEvents: { + scripts: 0, + network_requests: 0, + console_events: 0, + websocket_connections: 0, + websocket_frames: 0, + webmcp_tools: 0, + timeline_events: 0, + }, + }), + frames: [], + dom: { total_nodes: 0, nodes: [] }, + scripts: { total: 0, items: [] }, + resources: [], + workers: [], + limitations: [], +}); + +it("converts a retained raw Electron inspection into partial Evidence", async () => { + const failure = new AnalysisCancelledError("inspect_electron_page", { + partialObservation: inspection, + }); + const provider: ElectronObservationPort = { + identity: () => identity, + listTargets: async () => + err(new AnalysisCancelledError("list_electron_targets")), + inspectPage: async () => err(failure), + }; + const result = await inspectElectronPage(provider, input); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe(failure); + expect(parseEvidence(result.error.partialObservation)).toMatchObject({ + operation: "inspect_electron_page", + normalized_result: inspection, + }); + } +}); + +it("converts a raw inspection retained by provider cleanup failure", async () => { + const failure = new ProviderCleanupError( + "rea-cdp-electron", + ["electron-page"], + { reason: "target detach failed" }, + { operation: "inspect_electron_page", partialObservation: inspection }, + ); + const provider: ElectronObservationPort = { + identity: () => identity, + listTargets: async () => + err(new AnalysisCancelledError("list_electron_targets")), + inspectPage: async () => err(failure), + }; + const result = await inspectElectronPage(provider, input); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe(failure); + expect(result.error.cleanupIncomplete).toBe(true); + expect(parseEvidence(result.error.partialObservation)).toMatchObject({ + operation: "inspect_electron_page", + normalized_result: inspection, + }); + } +}); + +it("returns a typed output error for malformed Electron target output after abort", async () => { + const controller = new AbortController(); + const malformed = { + browser: { + product: "Electron", + protocol_version: "1.3", + revision: "fixture", + user_agent: "Electron fixture", + js_version: "V8 fixture", + }, + targets: [], + excluded: { unsupported_url: 0, non_page: 0 }, + limitations: [() => undefined], + } as unknown as ReturnType; + const provider: ElectronObservationPort = { + identity: () => identity, + listTargets: async () => { + controller.abort(); + return { ok: true, value: malformed }; + }, + inspectPage: async () => + err(new AnalysisCancelledError("inspect_electron_page")), + }; + const result = await listElectronTargets( + provider, + { + cdp_endpoint: "http://127.0.0.1:9222", + }, + { signal: controller.signal }, + ); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError); +}); diff --git a/src/application/javascript/ElectronObservationService.ts b/src/application/javascript/ElectronObservationService.ts index 307738a0f0..3923c6b661 100644 --- a/src/application/javascript/ElectronObservationService.ts +++ b/src/application/javascript/ElectronObservationService.ts @@ -5,10 +5,19 @@ import type { InspectElectronPageInput, ListElectronTargetsInput, } from "../../domain/javascript/electronObservation.js"; -import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js"; +import { + AnalysisCancelledError, + AnalysisCapabilityUnavailableError, + AnalysisOutputError, +} from "../../domain/analysisErrorCore.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { err, ok, type Result } from "../../domain/result.js"; import { createElectronEvidence } from "./ElectronEvidence.js"; +import { + electronPageInspectionSchema, + electronTargetListSchema, +} from "../../domain/javascript/electronObservation.js"; +import { completeProviderEvidence } from "../ProviderOperationExecution.js"; /** List local file pages exposed by the explicitly selected endpoint. */ export const listElectronTargets = async ( @@ -18,17 +27,31 @@ export const listElectronTargets = async ( ): Promise> => { const ready = requireProvider(provider, "list_electron_targets"); if (!ready.ok) return ready; + if (options.signal?.aborted) + return err(new AnalysisCancelledError("list_electron_targets")); const result = await ready.value.listTargets(input, options); - return result.ok - ? ok( - createElectronEvidence( - "list_electron_targets", - input, - result.value, - ready.value.identity(), - ), - ) - : result; + if (!result.ok) return result; + const report = electronTargetListSchema.safeParse(result.value); + if (!report.success) + return err( + new AnalysisOutputError( + "list_electron_targets", + "Electron provider returned a malformed target list.", + { cause: report.error }, + ), + ); + return completeProviderEvidence( + ok( + createElectronEvidence( + "list_electron_targets", + input, + report.data, + ready.value.identity(), + ), + ), + "list_electron_targets", + options, + ); }; /** Inspect one local file page from the explicitly selected endpoint. */ @@ -39,17 +62,61 @@ export const inspectElectronPage = async ( ): Promise> => { const ready = requireProvider(provider, "inspect_electron_page"); if (!ready.ok) return ready; + if (options.signal?.aborted) + return err(new AnalysisCancelledError("inspect_electron_page")); const result = await ready.value.inspectPage(input, options); - return result.ok - ? ok( - createElectronEvidence( - "inspect_electron_page", - input, - result.value, - ready.value.identity(), - ), - ) - : result; + if (!result.ok) { + if ( + result.error.partialObservation !== undefined && + !("evidence_id" in result.error.partialObservation) && + "target" in result.error.partialObservation && + "dom" in result.error.partialObservation && + "capture_window" in result.error.partialObservation + ) { + const report = electronPageInspectionSchema.safeParse( + result.error.partialObservation, + ); + if (!report.success) { + if (result.error instanceof AnalysisCancelledError) + return err( + new AnalysisOutputError( + "inspect_electron_page", + "Electron provider retained a malformed completed page inspection.", + { cause: report.error }, + ), + ); + return result; + } + result.error.partialObservation = createElectronEvidence( + "inspect_electron_page", + input, + report.data, + ready.value.identity(), + ); + } + return result; + } + const report = electronPageInspectionSchema.safeParse(result.value); + if (!report.success) + return err( + new AnalysisOutputError( + "inspect_electron_page", + "Electron provider returned a malformed page inspection.", + { cause: report.error }, + ), + ); + return completeProviderEvidence( + ok( + createElectronEvidence( + "inspect_electron_page", + input, + report.data, + ready.value.identity(), + ), + ), + "inspect_electron_page", + options, + ); }; const requireProvider = ( diff --git a/src/application/javascript/JavaScriptRuntimeObservationEvidence.ts b/src/application/javascript/JavaScriptRuntimeObservationEvidence.ts index 24041bec3b..399f20b3f1 100644 --- a/src/application/javascript/JavaScriptRuntimeObservationEvidence.ts +++ b/src/application/javascript/JavaScriptRuntimeObservationEvidence.ts @@ -4,7 +4,6 @@ import { type Evidence, type EvidenceObservation, } from "../../domain/evidence.js"; -import { jsonValueSchema } from "../../domain/jsonValue.js"; import type { JavaScriptRuntimeObservation, JavaScriptRuntimeTargetList, @@ -30,7 +29,7 @@ export const createJavaScriptRuntimeObservationEvidence = ( : "rea.javascript-runtime-observation", operation, parameters: parameters(input), - result: jsonValueSchema.parse(result), + result, rawResult: null, confidence: "observed", authority: "external-service", diff --git a/src/application/javascript/JavaScriptRuntimeObservationService.test.ts b/src/application/javascript/JavaScriptRuntimeObservationService.test.ts index 2edb99b32b..eff4acf394 100644 --- a/src/application/javascript/JavaScriptRuntimeObservationService.test.ts +++ b/src/application/javascript/JavaScriptRuntimeObservationService.test.ts @@ -1,8 +1,14 @@ import { expect, it } from "vitest"; import { BrowserObservationError } from "../../domain/browserObservationError.js"; -import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js"; +import { + AnalysisCancelledError, + AnalysisOutputError, +} from "../../domain/analysisErrorCore.js"; import { err, ok } from "../../domain/result.js"; +import { parseEvidence } from "../../domain/evidence.js"; +import type { JavaScriptRuntimeTargetList } from "../../domain/javascript/javascriptRuntimeObservation.js"; +import { javascriptRuntimeObservationSchema } from "../../domain/javascript/javascriptRuntimeObservation.js"; import type { JavaScriptRuntimeObservationPort } from "./JavaScriptRuntimeObservationPort.js"; import { listJavaScriptRuntimeTargets, @@ -85,4 +91,138 @@ it("does not publish targets when cancellation arrives during provider cleanup", operation: "list_javascript_runtime_targets", }, }); + if (!result.ok) { + expect(parseEvidence(result.error.partialObservation)).toMatchObject({ + operation: "list_javascript_runtime_targets", + normalized_result: { targets: [] }, + }); + } +}); + +it("does not let late cancellation hide an invalid completed Inspector report", async () => { + const malformed = { + runtime: { + product: "Node.js", + protocol_version: "1.3", + v8_version: "12.0", + }, + targets: [], + excluded: { unsupported_location: -1, unconnectable: 0 }, + limitations: [], + } as unknown as JavaScriptRuntimeTargetList; + const output = await listJavaScriptRuntimeTargets( + { + identity: () => ({ id: "test", name: "Test", version: "1" }), + listTargets: async () => ok(malformed), + observe: async () => { + throw new Error("not used"); + }, + }, + { inspector_endpoint: "http://127.0.0.1:9229" }, + ); + expect(output).toMatchObject({ + ok: false, + error: { _tag: "AnalysisOutputError" }, + }); + if (!output.ok) expect(output.error).toBeInstanceOf(AnalysisOutputError); + + const controller = new AbortController(); + const late = await listJavaScriptRuntimeTargets( + { + identity: () => ({ id: "test", name: "Test", version: "1" }), + listTargets: async () => { + controller.abort(); + return ok(malformed); + }, + observe: async () => { + throw new Error("not used"); + }, + }, + { inspector_endpoint: "http://127.0.0.1:9229" }, + { signal: controller.signal }, + ); + expect(late).toMatchObject({ + ok: false, + error: { _tag: "AnalysisOutputError" }, + }); + if (!late.ok) expect(late.error).toBeInstanceOf(AnalysisOutputError); +}); + +it("converts a raw runtime observation retained by cleanup failure", async () => { + const observation = javascriptRuntimeObservationSchema.parse({ + runtime: { + product: "node.js/v24", + protocol_version: "1.3", + v8_version: "13", + }, + target: { + target_id: "target", + protocol_type: "node", + attached: false, + location: { kind: "file", file_path: "/fixture.js" }, + runtime_kind: "node", + runtime_kind_authority: "caller-declared-unverified", + }, + capture: { + observation_ms: 0, + events_observed: 0, + events_retained: 0, + events_dropped: 0, + metadata_bytes_retained: 0, + truncated: false, + truncation_reasons: [], + }, + scripts: { + items: [], + observed_total: 0, + excluded: { unsupported_location: 0, invalid_protocol_value: 0 }, + }, + execution_contexts: [], + directly_observed: ["Inspector target attached and queried."], + unavailable_without_instrumentation: [], + unknowns: [], + limitations: [], + }); + const failure = new BrowserObservationError( + "observe_javascript_runtime", + "cleanup_failed", + { + cleanup: { + reason: "Inspector close failed", + resources: ["browser_transport"], + }, + partialObservation: observation, + }, + ); + const result = await observeJavaScriptRuntime( + { + identity: () => ({ id: "test", name: "Test", version: "1" }), + listTargets: async () => + ok({ + runtime: { + product: "node.js/v24", + protocol_version: "1.3", + v8_version: "13", + }, + targets: [], + excluded: { unsupported_location: 0, unconnectable: 0 }, + limitations: [], + }), + observe: async () => err(failure), + }, + { + inspector_endpoint: "http://127.0.0.1:9229", + target_id: "target", + observation_ms: 0, + }, + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe(failure); + expect(result.error.cleanupIncomplete).toBe(true); + expect(parseEvidence(result.error.partialObservation)).toMatchObject({ + operation: "observe_javascript_runtime", + normalized_result: observation, + }); + } }); diff --git a/src/application/javascript/JavaScriptRuntimeObservationService.ts b/src/application/javascript/JavaScriptRuntimeObservationService.ts index c7776ec0ae..a56dae2b26 100644 --- a/src/application/javascript/JavaScriptRuntimeObservationService.ts +++ b/src/application/javascript/JavaScriptRuntimeObservationService.ts @@ -5,6 +5,7 @@ import type { Evidence } from "../../domain/evidence.js"; import { AnalysisCancelledError, AnalysisCapabilityUnavailableError, + AnalysisOutputError, } from "../../domain/analysisErrorCore.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import type { @@ -12,6 +13,11 @@ import type { ObserveJavaScriptRuntimeInput, } from "../../domain/javascript/javascriptRuntimeObservation.js"; import { err, ok, type Result } from "../../domain/result.js"; +import { completeProviderEvidence } from "../ProviderOperationExecution.js"; +import { + javascriptRuntimeObservationSchema, + javascriptRuntimeTargetListSchema, +} from "../../domain/javascript/javascriptRuntimeObservation.js"; /** List targets exposed by the explicitly selected loopback Inspector. */ export const listJavaScriptRuntimeTargets = async ( @@ -21,19 +27,26 @@ export const listJavaScriptRuntimeTargets = async ( ): Promise> => { const ready = requireProvider(provider, "list_javascript_runtime_targets"); if (!ready.ok) return ready; - const result = await ready.value.listTargets(input, options); - if (result.ok && options.signal?.aborted === true) + if (options.signal?.aborted) return err(new AnalysisCancelledError("list_javascript_runtime_targets")); - return result.ok - ? ok( - createJavaScriptRuntimeObservationEvidence( - "list_javascript_runtime_targets", - input, - result.value, - ready.value.identity(), - ), - ) - : result; + const result = await ready.value.listTargets(input, options); + if (!result.ok) return result; + const report = javascriptRuntimeTargetListSchema.safeParse(result.value); + if (!report.success) + return err( + new AnalysisOutputError( + "list_javascript_runtime_targets", + "Inspector returned a malformed target list.", + { cause: report.error }, + ), + ); + return runtimeEvidence( + "list_javascript_runtime_targets", + input, + report.data, + ready.value.identity(), + options, + ); }; /** Observe one attach-only Inspector target selected by the request. */ @@ -44,17 +57,51 @@ export const observeJavaScriptRuntime = async ( ): Promise> => { const ready = requireProvider(provider, "observe_javascript_runtime"); if (!ready.ok) return ready; - const result = await ready.value.observe(input, options); - if (!result.ok) return result; - if (options.signal?.aborted === true) + if (options.signal?.aborted) return err(new AnalysisCancelledError("observe_javascript_runtime")); - return ok( - createJavaScriptRuntimeObservationEvidence( - "observe_javascript_runtime", - input, - result.value, - ready.value.identity(), - ), + const result = await ready.value.observe(input, options); + if (!result.ok) { + const partial = result.error.partialObservation; + if ( + partial !== undefined && + !("evidence_id" in partial) && + !("kind" in partial) + ) { + const report = javascriptRuntimeObservationSchema.safeParse(partial); + if (!report.success && result.error instanceof AnalysisCancelledError) + return err( + new AnalysisOutputError( + "observe_javascript_runtime", + "Inspector retained a malformed completed runtime observation.", + { cause: report.error }, + ), + ); + if (report.success) + result.error.partialObservation = + createJavaScriptRuntimeObservationEvidence( + "observe_javascript_runtime", + input, + report.data, + ready.value.identity(), + ); + } + return result; + } + const report = javascriptRuntimeObservationSchema.safeParse(result.value); + if (!report.success) + return err( + new AnalysisOutputError( + "observe_javascript_runtime", + "Inspector returned a malformed runtime observation.", + { cause: report.error }, + ), + ); + return runtimeEvidence( + "observe_javascript_runtime", + input, + report.data, + ready.value.identity(), + options, ); }; @@ -71,3 +118,24 @@ const requireProvider = ( ), ) : ok(provider); + +const runtimeEvidence = ( + operation: Parameters[0], + input: Parameters[1], + result: Parameters[2], + provider: Parameters[3], + options: ExecutionOptions, +): Result => { + return completeProviderEvidence( + ok( + createJavaScriptRuntimeObservationEvidence( + operation, + input, + result, + provider, + ), + ), + operation, + options, + ); +}; diff --git a/src/application/rust/RustBinaryService.test.ts b/src/application/rust/RustBinaryService.test.ts index 7fbee615d0..d7c04de696 100644 --- a/src/application/rust/RustBinaryService.test.ts +++ b/src/application/rust/RustBinaryService.test.ts @@ -1,8 +1,10 @@ import { expect, it } from "vitest"; import { + AnalysisCancelledError, AnalysisInputError, AnalysisOutputError, } from "../../domain/analysisErrorCore.js"; +import { parseEvidence } from "../../domain/evidence.js"; import type { RustBinary } from "../../domain/rust/rustBinary.js"; import { ok } from "../../domain/result.js"; import { RustBinaryService } from "./RustBinaryService.js"; @@ -130,3 +132,40 @@ it.each([ expect(result.ok).toBe(false); if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError); }); + +it("retains a completed report on late cancellation and keeps output errors primary", async () => { + const controller = new AbortController(); + const completed = await new RustBinaryService({ + identity, + inspect: () => { + controller.abort(); + return Promise.resolve(ok(report)); + }, + }).inspect({ path: report.artifact.path }, { signal: controller.signal }); + expect(completed.ok).toBe(false); + if (!completed.ok) { + expect(completed.error).toBeInstanceOf(AnalysisCancelledError); + expect(parseEvidence(completed.error.partialObservation)).toMatchObject({ + operation: "inspect_rust_binary", + normalized_result: report, + }); + } + + const malformed = { + ...report, + artifact: { ...report.artifact, path: "/artifacts/other" }, + }; + const outputController = new AbortController(); + const invalid = await new RustBinaryService({ + identity, + inspect: () => { + outputController.abort(); + return Promise.resolve(ok(malformed)); + }, + }).inspect( + { path: report.artifact.path }, + { signal: outputController.signal }, + ); + expect(invalid.ok).toBe(false); + if (!invalid.ok) expect(invalid.error).toBeInstanceOf(AnalysisOutputError); +}); diff --git a/src/application/rust/RustBinaryService.ts b/src/application/rust/RustBinaryService.ts index b112b366d5..20a2e49cff 100644 --- a/src/application/rust/RustBinaryService.ts +++ b/src/application/rust/RustBinaryService.ts @@ -15,6 +15,7 @@ import { } from "../../domain/rust/rustBinary.js"; import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; import { err, ok, type Result } from "../../domain/result.js"; +import { completeProviderEvidence } from "../ProviderOperationExecution.js"; const OPERATION = "inspect_rust_binary"; @@ -61,8 +62,6 @@ export class RustBinaryService { ); const inspected = await this.provider.inspect(input.data, options); if (!inspected.ok) return inspected; - if (options?.signal?.aborted) - return err(new AnalysisCancelledError(OPERATION)); const report = rustBinarySchema.safeParse(inspected.value); if (!report.success || report.data.artifact.path !== input.data.path) return err( @@ -71,7 +70,11 @@ export class RustBinaryService { "Rust dependency reader returned malformed metadata or changed the selected artifact identity.", ), ); - return ok(evidenceFor(this.provider.identity, input.data, report.data)); + return completeProviderEvidence( + ok(evidenceFor(this.provider.identity, input.data, report.data)), + OPERATION, + options, + ); } } diff --git a/src/domain/analysisErrorBase.ts b/src/domain/analysisErrorBase.ts index a4c446f714..9574fdce52 100644 --- a/src/domain/analysisErrorBase.ts +++ b/src/domain/analysisErrorBase.ts @@ -126,6 +126,7 @@ import type { import type { FileOffsetPartialObservation } from "./native/fileOffsetPartialObservation.js"; import type { NativeCallPartialObservation } from "./native/nativeCallPartialObservation.js"; import type { ElectronActivePartialObservation } from "./javascript/electronActiveObservation.js"; +import type { ElectronPageInspection } from "./javascript/electronObservation.js"; import type { JavaScriptRuntimeObservation } from "./javascript/javascriptRuntimeObservation.js"; import type { ArtifactInventoryPartialObservation, @@ -148,6 +149,9 @@ import type { RecordedCrash, RecordedCrashPartialObservation, } from "./native/recordedCrash.js"; +import type { WebPageInspection } from "./browserObservationSchemas.js"; +import type { WebExecution } from "./webExecution.js"; +import type { WebEventListeners } from "./webEventListeners.js"; /** Provider-neutral evidence collected before a typed analysis failure. */ export type AnalysisPartialObservation = @@ -155,7 +159,11 @@ export type AnalysisPartialObservation = | NativeCallPartialObservation | FileOffsetPartialObservation | ElectronActivePartialObservation + | ElectronPageInspection | BrowserScenarioPartialObservation + | WebPageInspection + | WebExecution + | WebEventListeners | JavaScriptRuntimeObservation | ArtifactInventoryPartialObservation | ArtifactExtractionPartialObservation diff --git a/src/server/contractToolHandler.ts b/src/server/contractToolHandler.ts index e813f060fc..6ee0e5834b 100644 --- a/src/server/contractToolHandler.ts +++ b/src/server/contractToolHandler.ts @@ -11,6 +11,7 @@ import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import { logToolExecution } from "./toolLogging.js"; import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; import { runAdmittedToolOperation } from "./admittedToolOperation.js"; +import { deliverObservationEvidence } from "./observationEvidenceDelivery.js"; /** Application service executing one named contract operation. */ export interface ContractToolService { @@ -61,25 +62,11 @@ export const createEvidenceToolHandler = const result = await logToolExecution(logger, name, () => operation(input, { signal: context.mcpReq.signal }), ); - if (!result.ok) { - const partial = result.error.partialObservation; - if (partial !== undefined && "evidence_id" in partial) { - const recorded = recordEvidence?.(partial); - if (recorded !== undefined) - return server.delivery.toRecordedPartialErrorToolResult( - result.error, - partial.evidence_id, - recorded, - contract.returnsTargetContent, - ); - } - return server.delivery.toCallToolResult(result, contract); - } - const recorded = recordEvidence?.(result.value); - return server.delivery.toEvidenceToolResult( - result.value, + return deliverObservationEvidence( + server, contract, - recorded, + result, + recordEvidence, ); }; return runAdmittedToolOperation( diff --git a/src/server/observationEvidenceDelivery.ts b/src/server/observationEvidenceDelivery.ts new file mode 100644 index 0000000000..6338f6ce6e --- /dev/null +++ b/src/server/observationEvidenceDelivery.ts @@ -0,0 +1,35 @@ +import type { ToolContract } from "../contracts/toolContractTypes.js"; +import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import type { Evidence } from "../domain/evidence.js"; +import type { Result } from "../domain/result.js"; +import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; +import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; + +/** Record complete or completed-partial Evidence before projecting MCP output. */ +export const deliverObservationEvidence = ( + server: EvidenceMcpServer, + contract: ToolContract, + result: Result, + recordEvidence: EvidenceWriter["recordEvidence"] | undefined, +) => { + if (!result.ok) { + const partial = result.error.partialObservation; + if ( + partial !== undefined && + "evidence_id" in partial && + typeof partial.evidence_id === "string" + ) { + const recorded = recordEvidence?.(partial); + if (recorded !== undefined) + return server.delivery.toRecordedPartialErrorToolResult( + result.error, + partial.evidence_id, + recorded, + contract.returnsTargetContent, + ); + } + return server.delivery.toCallToolResult(result, contract); + } + const recorded = recordEvidence?.(result.value); + return server.delivery.toEvidenceToolResult(result.value, contract, recorded); +}; diff --git a/src/server/registerBrowserScenarioTool.ts b/src/server/registerBrowserScenarioTool.ts index a22d86cba2..b7cf1576cc 100644 --- a/src/server/registerBrowserScenarioTool.ts +++ b/src/server/registerBrowserScenarioTool.ts @@ -15,6 +15,7 @@ import { logToolExecution } from "./toolLogging.js"; import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; import { runAdmittedToolOperation } from "./admittedToolOperation.js"; +import { deliverObservationEvidence } from "./observationEvidenceDelivery.js"; interface BrowserScenarioToolRegistration { readonly logger: Logger; @@ -54,13 +55,11 @@ export const registerBrowserScenarioTool = ( signal: context.mcpReq.signal, }), ); - if (!result.ok) - return server.delivery.toCallToolResult(result, contract); - const recorded = options.recordEvidence?.(result.value); - return server.delivery.toEvidenceToolResult( - result.value, + return deliverObservationEvidence( + server, contract, - recorded, + result, + options.recordEvidence, ); }, ); diff --git a/src/server/registerBrowserTools.ts b/src/server/registerBrowserTools.ts index 6e7a984496..32c095f478 100644 --- a/src/server/registerBrowserTools.ts +++ b/src/server/registerBrowserTools.ts @@ -31,6 +31,7 @@ import { logToolExecution } from "./toolLogging.js"; import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; import { runAdmittedToolOperation } from "./admittedToolOperation.js"; +import { deliverObservationEvidence } from "./observationEvidenceDelivery.js"; interface BrowserToolRegistration { readonly logger: Logger; @@ -201,13 +202,11 @@ const runBrowserTool = async ( progress: mcpProgressReporter(context), }), ); - if (!result.ok) - return options.delivery.toCallToolResult(result, contract); - const recorded = options.recordEvidence?.(result.value); - return options.delivery.toEvidenceToolResult( - result.value, + return deliverObservationEvidence( + options.server, contract, - recorded, + result, + options.recordEvidence, ); }, ); diff --git a/src/server/registerElectronTools.ts b/src/server/registerElectronTools.ts index 96a518e0f2..f857702995 100644 --- a/src/server/registerElectronTools.ts +++ b/src/server/registerElectronTools.ts @@ -34,6 +34,7 @@ import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; import { runAdmittedToolOperation } from "./admittedToolOperation.js"; import { createEvidenceToolHandler } from "./contractToolHandler.js"; +import { deliverObservationEvidence } from "./observationEvidenceDelivery.js"; interface ElectronToolRegistration { readonly artifactResources: ArtifactResourceScope; @@ -204,29 +205,11 @@ const runElectronTool = async ( progress: mcpProgressReporter(context), }), ); - if (!result.ok) { - const partial = result.error.partialObservation; - if ( - partial !== undefined && - "evidence_id" in partial && - typeof partial.evidence_id === "string" - ) { - const recorded = options.recordEvidence?.(partial); - if (recorded !== undefined) - return options.delivery.toRecordedPartialErrorToolResult( - result.error, - partial.evidence_id, - recorded, - contract.returnsTargetContent, - ); - } - return options.delivery.toCallToolResult(result, contract); - } - const recorded = options.recordEvidence?.(result.value); - return options.delivery.toEvidenceToolResult( - result.value, + return deliverObservationEvidence( + options.server, contract, - recorded, + result, + options.recordEvidence, ); }, ); diff --git a/src/server/registerJavaScriptRuntimeObservationTools.ts b/src/server/registerJavaScriptRuntimeObservationTools.ts index 1594222c92..1d75647925 100644 --- a/src/server/registerJavaScriptRuntimeObservationTools.ts +++ b/src/server/registerJavaScriptRuntimeObservationTools.ts @@ -23,6 +23,7 @@ import { logToolExecution } from "./toolLogging.js"; import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; import { runAdmittedToolOperation } from "./admittedToolOperation.js"; +import { deliverObservationEvidence } from "./observationEvidenceDelivery.js"; interface RuntimeToolRegistration { readonly logger: Logger; @@ -93,13 +94,11 @@ const runRuntimeTool = async ( const result = await logToolExecution(options.logger, contract.name, () => execute(input, context.mcpReq.signal), ); - if (!result.ok) - return options.delivery.toCallToolResult(result, contract); - const recorded = options.recordEvidence?.(result.value); - return options.delivery.toEvidenceToolResult( - result.value, + return deliverObservationEvidence( + options.server, contract, - recorded, + result, + options.recordEvidence, ); }, ); diff --git a/tests/boundary/mcp/observationPartialEvidenceDelivery.test.ts b/tests/boundary/mcp/observationPartialEvidenceDelivery.test.ts new file mode 100644 index 0000000000..66e2afdcf2 --- /dev/null +++ b/tests/boundary/mcp/observationPartialEvidenceDelivery.test.ts @@ -0,0 +1,160 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { afterEach, expect, it } from "vitest"; + +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js"; +import { createEvidence, type Evidence } from "../../../src/domain/evidence.js"; +import { err, ok as resultOk } from "../../../src/domain/result.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { observed } from "../../fixtures/analysisExecution.js"; + +const resources: Array<{ close(): Promise }> = []; +const operations = [ + "list_browser_targets", + "capture_browser_scenario", + "inspect_electron_page", + "list_javascript_runtime_targets", +] as const; +type ObservationOperation = (typeof operations)[number]; + +afterEach(async () => { + await Promise.all(resources.splice(0).map((resource) => resource.close())); +}); + +it.each(operations)( + "records and references partial Evidence from %s", + async (selectedOperation) => { + const session = createTestBinarySession(() => ({ + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(resultOk(null)), + })); + const makeEvidence = (operation: ObservationOperation): Evidence => + createEvidence( + undefined, + { + id: "observation-fixture", + name: "Observation fixture", + version: "1", + }, + { + operation, + parameters: {}, + result: { completed: true }, + }, + ); + const evidence: Record = { + list_browser_targets: makeEvidence("list_browser_targets"), + capture_browser_scenario: makeEvidence("capture_browser_scenario"), + inspect_electron_page: makeEvidence("inspect_electron_page"), + list_javascript_runtime_targets: makeEvidence( + "list_javascript_runtime_targets", + ), + }; + const cancellation: Record = { + list_browser_targets: new AnalysisCancelledError("list_browser_targets", { + partialObservation: evidence.list_browser_targets, + }), + capture_browser_scenario: new AnalysisCancelledError( + "capture_browser_scenario", + { partialObservation: evidence.capture_browser_scenario }, + ), + inspect_electron_page: new AnalysisCancelledError( + "inspect_electron_page", + { partialObservation: evidence.inspect_electron_page }, + ), + list_javascript_runtime_targets: new AnalysisCancelledError( + "list_javascript_runtime_targets", + { partialObservation: evidence.list_javascript_runtime_targets }, + ), + }; + const identity = () => ({ + id: "observation-fixture", + name: "Observation fixture", + version: "1", + }); + const unused = (): never => { + throw new Error("unselected observation operation"); + }; + const server = createServer( + { kind: "session", session }, + { + browserObservation: { + identity, + listTargets: async () => err(cancellation.list_browser_targets), + inspectPage: unused, + analyzeBundle: unused, + observeSession: unused, + discoverWebMcpTools: unused, + compareCaptures: unused, + captureScreenshot: unused, + compareScreenshots: unused, + }, + browserScenarioCapture: { + identity, + captureScenario: async () => + err(cancellation.capture_browser_scenario), + }, + electronObservation: { + identity, + inspectPage: async () => err(cancellation.inspect_electron_page), + listTargets: unused, + }, + javascriptRuntimeObservation: { + identity, + listTargets: async () => + err(cancellation.list_javascript_runtime_targets), + observe: unused, + }, + }, + ); + const client = new Client({ + name: "partial-observation-delivery", + version: "1", + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(client, server, session); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const scenarioExample = toolContract("capture_browser_scenario") + .examples[0]; + if (scenarioExample === undefined) + throw new Error("browser scenario contract omitted its example"); + const inputs: Record> = { + list_browser_targets: { + cdp_endpoint: "http://127.0.0.1:9222", + }, + capture_browser_scenario: scenarioExample.input, + inspect_electron_page: { + cdp_endpoint: "http://127.0.0.1:9222", + target_id: "page", + observation_ms: 0, + }, + list_javascript_runtime_targets: { + inspector_endpoint: "http://127.0.0.1:9222", + }, + }; + const result = await client.callTool({ + name: selectedOperation, + arguments: inputs[selectedOperation], + }); + expect(result.isError).toBe(true); + expect(parseMcpToolError(result)).toMatchObject({ + error: { + code: "cancelled", + details: { + partial_observation: { + kind: "retained-evidence", + evidence_id: evidence[selectedOperation].evidence_id, + }, + }, + }, + }); + expect( + session.evidenceById(evidence[selectedOperation].evidence_id), + ).toEqual(evidence[selectedOperation]); + }, +);