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]); + }, +);