From b4b5c37ab3de1e0f8f4369dcad26fd6ceb18aa85 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:29:25 +0800 Subject: [PATCH] fix(web): retain admitted provenance through incomplete traces --- src/application/WebModuleTraceService.test.ts | 7 +- src/application/WebModuleTraceService.ts | 39 ++++- src/application/WebSourceLocationService.ts | 102 +++++++++--- src/domain/analysisErrorBase.ts | 2 + src/domain/webTraceObservation.ts | 23 +++ tests/acceptance/webSourceLocationCli.test.ts | 38 ++++- .../application/webTraceObservation.test.ts | 153 ++++++++++++++++++ 7 files changed, 327 insertions(+), 37 deletions(-) create mode 100644 src/domain/webTraceObservation.ts create mode 100644 tests/boundary/application/webTraceObservation.test.ts diff --git a/src/application/WebModuleTraceService.test.ts b/src/application/WebModuleTraceService.test.ts index 6713520997..8949a4c783 100644 --- a/src/application/WebModuleTraceService.test.ts +++ b/src/application/WebModuleTraceService.test.ts @@ -323,7 +323,10 @@ it.each(["incomplete", "provider-failure"])( if (result.ok) throw new Error("expected original failure"); expect(result.error).toBeInstanceOf(AnalysisOutputError); if (kind === "provider-failure") expect(result.error).toBe(primary); - expect(result.error.partialObservation).toBeUndefined(); + expect(result.error.partialObservation).toMatchObject({ + kind: "web-trace-observation", + analysis: "incomplete", + }); }, ); @@ -410,7 +413,7 @@ it.each([ }); } else expect(result.error.partialObservation).toHaveProperty( - "kind", + "resolver_observation.kind", "web-module-resolution", ); }, diff --git a/src/application/WebModuleTraceService.ts b/src/application/WebModuleTraceService.ts index 84d27286ae..ae8f599ab0 100644 --- a/src/application/WebModuleTraceService.ts +++ b/src/application/WebModuleTraceService.ts @@ -1,7 +1,10 @@ import { closeOwnedWebResources } from "./closeOwnedWebResources.js"; import { isDeepStrictEqual } from "node:util"; import { z } from "zod"; -import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import { + primaryAnalysisError, + type AnalysisError, +} from "../domain/analysisErrorBase.js"; import { AnalysisCancelledError, AnalysisCapabilityUnavailableError, @@ -122,10 +125,28 @@ export class WebModuleTraceService { const bound = validateSelectedWebScript(input, data, OPERATION); if (!bound.ok) return bound; const selected = bound.value; + const incomplete = (error: AnalysisError): Result => { + const resolverObservation = + webModuleResolutionObservationSchema.safeParse( + error.partialObservation, + ); + error.partialObservation = { + ...(resolverObservation.success + ? { resolver_observation: resolverObservation.data } + : {}), + kind: "web-trace-observation", + analysis: "incomplete", + manifest: data.manifestFile, + source: data.sourceFile, + ...(data.importMap === null ? {} : { import_map: data.importMap.file }), + }; + primaryAnalysisError(error).partialObservation = error.partialObservation; + return err(error); + }; const importerUrl = input.importer_url ?? selected.url; if (input.importer_url === undefined) { const importerAdmission = admitContext(importerUrl); - if (!importerAdmission.ok) return importerAdmission; + if (!importerAdmission.ok) return incomplete(importerAdmission.error); } const syntax = collectWebModuleImports(data.source, data.sourceFile.path); const literals = syntax.imports @@ -135,7 +156,7 @@ export class WebModuleTraceService { let resolutionFailure: AnalysisError | undefined; if (literals.length > 0) { if (options?.signal?.aborted) - return err( + return incomplete( new AnalysisCancelledError(OPERATION, { cause: options.signal.reason, }), @@ -157,7 +178,7 @@ export class WebModuleTraceService { execution.value, ); if (!report.success) - return err( + return incomplete( new AnalysisOutputError( OPERATION, "Resolver returned malformed or incomplete native evidence.", @@ -173,12 +194,12 @@ export class WebModuleTraceService { !isDeepStrictEqual(observation.data.request, request) || observation.data.batch.resolutions.length !== literals.length ) - return execution; + return incomplete(execution.error); resolution = observation.data.batch; resolutionFailure = execution.error; } if (resolution.resolutions.length !== literals.length) - return err( + return incomplete( new AnalysisOutputError( OPERATION, "Resolver did not return every selected literal specifier.", @@ -196,7 +217,7 @@ export class WebModuleTraceService { else { const reported = resolution?.resolutions[literalIndex++]; if (reported === undefined) - return err( + return incomplete( new AnalysisOutputError( OPERATION, "Resolver omitted a selected literal specifier.", @@ -243,7 +264,9 @@ export class WebModuleTraceService { limitations: LIMITATIONS, }); if (!normalized.success) - return err(new AnalysisOutputError(OPERATION, normalized.error.message)); + return incomplete( + new AnalysisOutputError(OPERATION, normalized.error.message), + ); const evidence = createEvidence( { path: data.sourceFile.path, diff --git a/src/application/WebSourceLocationService.ts b/src/application/WebSourceLocationService.ts index 58d20c556d..42ae1146f5 100644 --- a/src/application/WebSourceLocationService.ts +++ b/src/application/WebSourceLocationService.ts @@ -1,7 +1,10 @@ import { closeOwnedWebResources } from "./closeOwnedWebResources.js"; import { createHash } from "node:crypto"; import { z } from "zod"; -import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import { + primaryAnalysisError, + type AnalysisError, +} from "../domain/analysisErrorBase.js"; import { AnalysisCancelledError, AnalysisInputError, @@ -22,6 +25,7 @@ import { type WebSourceLocationInput, type WebSourceMapReport, } from "../domain/webSourceLocation.js"; +import type { WebTraceObservation } from "../domain/webTraceObservation.js"; import { webSourceOffset } from "../domain/webSourcePosition.js"; import type { ExecutionOptions } from "./AnalysisProvider.js"; import { @@ -109,11 +113,54 @@ export class WebSourceLocationService { const bound = validateSelectedWebScript(input, data, OPERATION); if (!bound.ok) return bound; const map = data.sourceMap; + const observation: WebTraceObservation = { + kind: "web-trace-observation", + analysis: "incomplete", + manifest: data.manifestFile, + source: data.sourceFile, + }; + const incomplete = (error: AnalysisError): Result => { + const retained = webSourceMapReportSchema.safeParse( + error.partialObservation, + ); + error.partialObservation = { + ...observation, + ...(retained.success + ? { + source_map_observation: { + association: + observation.source_map !== undefined && + matchesSelectedReport(input, map.file.sha256, retained.data) + ? ("selected" as const) + : ("independent" as const), + report: retained.data, + }, + } + : {}), + }; + primaryAnalysisError(error).partialObservation = error.partialObservation; + return err(error); + }; const pairing = verifyPairing(input, data); - if (!pairing.ok) return pairing; - const generatedOffset = pairing.value; + if (!pairing.ok) return incomplete(pairing.error); + observation.source_map = map.file; + const generatedOffset = webSourceOffset( + data.source, + input.generated_position, + ); + if (generatedOffset === undefined) + return incomplete( + new AnalysisInputError(OPERATION, undefined, [ + { + path: ["generated_position"], + reason: "invalid_format", + message: + "Selected generated position is outside the retained script text (one-based lines; zero-based UTF-16 columns).", + }, + ]), + ); if (options?.signal?.aborted) - return err( + return incomplete( new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }), ); const traced = await this.decoder.trace( @@ -128,9 +175,9 @@ export class WebSourceLocationService { }, options, ); - if (!traced.ok) return traced; + if (!traced.ok) return incomplete(traced.error); const report = verifyReport(input, map.file.sha256, traced.value); - if (!report.ok) return report; + if (!report.ok) return incomplete(report.error); const decodeTimeoutMs = input.decode_timeout_ms ?? DEFAULT_WEB_SOURCE_MAP_DECODE_TIMEOUT_MS; const limitations = [ @@ -151,7 +198,10 @@ export class WebSourceLocationService { execution: "unknown", limitations, }); - if (!normalized.success) return invalidOutput(normalized.error.message); + if (!normalized.success) + return incomplete( + new AnalysisOutputError(OPERATION, normalized.error.message), + ); return completeProviderEvidence( createEvidence( { @@ -214,7 +264,7 @@ const admitContext = ( const verifyPairing = ( input: WebSourceLocationInput, data: z.output, -): Result => { +): Result => { const map = data.sourceMap; if ( map.file.path !== input.source_map.path || @@ -226,16 +276,7 @@ const verifyPairing = ( return invalidOutput( "Artifact port changed the selected source-map byte identity or URL context.", ); - const generatedOffset = webSourceOffset( - data.source, - input.generated_position, - ); - if (generatedOffset === undefined) - return invalid( - ["generated_position"], - "Selected generated position is outside the retained script text (one-based lines; zero-based UTF-16 columns).", - ); - return ok(generatedOffset); + return ok(null); }; const verifyReport = ( @@ -248,14 +289,23 @@ const verifyReport = ( return invalidOutput( "Codec port returned malformed source-map point evidence.", ); - if ( - report.data.source_map_sha256 !== sha256 || - report.data.url_context !== input.source_map.url || - report.data.lookup.requested.line !== input.generated_position.line || - report.data.lookup.requested.column !== input.generated_position.column - ) - return invalidOutput( - "Codec port changed the selected map, URL context or generated point.", + if (!matchesSelectedReport(input, sha256, report.data)) + return err( + new AnalysisOutputError( + OPERATION, + "Codec port changed the selected map, URL context or generated point.", + { partialObservation: report.data }, + ), ); return ok(report.data); }; + +const matchesSelectedReport = ( + input: WebSourceLocationInput, + sha256: string, + report: WebSourceMapReport, +): boolean => + report.source_map_sha256 === sha256 && + report.url_context === input.source_map.url && + report.lookup.requested.line === input.generated_position.line && + report.lookup.requested.column === input.generated_position.column; diff --git a/src/domain/analysisErrorBase.ts b/src/domain/analysisErrorBase.ts index d7e7fd9997..56e6b6fbe4 100644 --- a/src/domain/analysisErrorBase.ts +++ b/src/domain/analysisErrorBase.ts @@ -295,11 +295,13 @@ import type { WebNetworkCapture } from "./webNetworkCapture.js"; import type { FridaDeviceList, FridaProcessList } from "./fridaDiscovery.js"; import type { CutterBridgeDiscovery } from "./cutterDiscovery.js"; +import type { WebTraceObservation } from "./webTraceObservation.js"; import type { WebArtifactAcquisition } from "./webArtifactAcquisition.js"; /** Provider-neutral evidence collected before a typed analysis failure. */ export type AnalysisPartialObservation = | WebArtifactAcquisition + | WebTraceObservation | CutterBridgeDiscovery | FridaDeviceList | FridaProcessList diff --git a/src/domain/webTraceObservation.ts b/src/domain/webTraceObservation.ts new file mode 100644 index 0000000000..d29c3abac5 --- /dev/null +++ b/src/domain/webTraceObservation.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; +import { webArtifactFileSchema } from "./webScriptArtifacts.js"; +import { webModuleResolutionObservationSchema } from "./webModuleTrace.js"; +import { webSourceMapReportSchema } from "./webSourceLocation.js"; + +/** Admitted identities survive an incomplete trace without claiming analysis success. */ +export const webTraceObservationSchema = z.strictObject({ + kind: z.literal("web-trace-observation"), + analysis: z.literal("incomplete"), + manifest: webArtifactFileSchema, + source: webArtifactFileSchema, + source_map: webArtifactFileSchema.optional(), + import_map: webArtifactFileSchema.optional(), + // Producer request provenance is independent of the selected trace request. + resolver_observation: webModuleResolutionObservationSchema.optional(), + source_map_observation: z + .strictObject({ + association: z.enum(["selected", "independent"]), + report: webSourceMapReportSchema, + }) + .optional(), +}); +export type WebTraceObservation = z.output; diff --git a/tests/acceptance/webSourceLocationCli.test.ts b/tests/acceptance/webSourceLocationCli.test.ts index f8ed61eff1..2efc953468 100644 --- a/tests/acceptance/webSourceLocationCli.test.ts +++ b/tests/acceptance/webSourceLocationCli.test.ts @@ -7,6 +7,7 @@ import { getDefaultEnvironment, } from "@modelcontextprotocol/client/stdio"; import { fileURLToPath } from "node:url"; +import { createHash } from "node:crypto"; import { mkdir, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { webSourceLocationFixture } from "../fixtures/webSourceLocation.js"; @@ -121,7 +122,21 @@ cliTest( expect(invalid.exitCode).toBe(1); expect(invalid.json).toMatchObject({ code: "invalid_request", - details: { issues: [{ path: ["generated_position"] }] }, + details: { + issues: [{ path: ["generated_position"] }], + partial_observation: { + kind: "web-trace-observation", + analysis: "incomplete", + manifest: { path: manifest }, + source: { path: join(root, "files", "modules", "main.js") }, + source_map: { + path: map, + sha256: createHash("sha256") + .update(fixture.sourceMap.text) + .digest("hex"), + }, + }, + }, }); }, // Allow cold CLI and MCP ownership preparation across the full parity sequence. @@ -198,6 +213,27 @@ cliTest( const failure = { code: "invalid_request", details: { + partial_observation: { + kind: "web-trace-observation", + analysis: "incomplete", + manifest: { + path: manifest, + sha256: createHash("sha256") + .update(JSON.stringify(fixture.manifest)) + .digest("hex"), + }, + source: { + path: join(root, "files", "modules", "main.js"), + sha256: createHash("sha256") + .update(fixture.source) + .digest("hex"), + }, + source_map: { + path, + sha256: createHash("sha256").update(text).digest("hex"), + bytes: Buffer.byteLength(text), + }, + }, issues: [ { path: ["source_map", "path"], diff --git a/tests/boundary/application/webTraceObservation.test.ts b/tests/boundary/application/webTraceObservation.test.ts new file mode 100644 index 0000000000..472c168738 --- /dev/null +++ b/tests/boundary/application/webTraceObservation.test.ts @@ -0,0 +1,153 @@ +import { expect, it } from "vitest"; +import { WebSourceLocationService } from "../../../src/application/WebSourceLocationService.js"; +import { WebModuleTraceService } from "../../../src/application/WebModuleTraceService.js"; +import { AnalysisOutputError } from "../../../src/domain/analysisErrorCore.js"; +import { err, ok } from "../../../src/domain/result.js"; +import { webTraceObservationSchema } from "../../../src/domain/webTraceObservation.js"; +import { traceSourceMap } from "../../../src/javascript/sourceMaps/TraceSourceMap.js"; +import { + webSourceLocationArgs, + webSourceLocationFixture, +} from "../../fixtures/webSourceLocation.js"; +import { webModuleArtifactsFixture } from "../../fixtures/webModuleTrace.js"; + +it("retains the exact admitted codec report on a late typed failure", async () => { + const data = webSourceLocationFixture(); + const report = traceSourceMap( + data.sourceMap.text, + data.sourceMap.url, + webSourceLocationArgs.generated_position, + ); + const failure = new AnalysisOutputError( + "trace_web_source_location", + "late cleanup failure", + { partialObservation: report }, + ); + const service = new WebSourceLocationService( + { load: async () => ok(data) }, + { trace: async () => err(failure) }, + ); + const result = await service.trace(webSourceLocationArgs); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBe(failure); + expect( + webTraceObservationSchema.parse(result.error.partialObservation) + .source_map_observation, + ).toEqual({ association: "selected", report }); +}); + +it("does not attribute a codec report to a different selected map", async () => { + const data = webSourceLocationFixture(); + const report = traceSourceMap( + data.sourceMap.text, + "https://other.test/map", + webSourceLocationArgs.generated_position, + ); + const failure = new AnalysisOutputError( + "trace_web_source_location", + "codec failure", + { partialObservation: report }, + ); + const service = new WebSourceLocationService( + { load: async () => ok(data) }, + { trace: async () => err(failure) }, + ); + const result = await service.trace(webSourceLocationArgs); + if (result.ok) throw new Error("expected failure"); + const observation = webTraceObservationSchema.parse( + result.error.partialObservation, + ); + expect(observation.source_map_observation).toEqual({ + association: "independent", + report, + }); + expect(observation.source).toEqual(data.sourceFile); +}); + +it("retains source identities on resolver failure without claiming native resolution", async () => { + const data = { + ...webModuleArtifactsFixture(), + importMap: { + file: { + path: "/analysis/import-map.json", + sha256: "c".repeat(64), + bytes: 14, + }, + baseUrl: "https://app.test/", + value: { imports: {} }, + }, + }; + const failure = new AnalysisOutputError( + "trace_web_module_imports", + "resolver failed", + ); + const service = new WebModuleTraceService( + { load: async () => ok(data) }, + { resolve: async () => err(failure) }, + ); + const result = await service.trace({ + manifest_path: data.manifestFile.path, + script_index: 0, + import_map: { + path: data.importMap.file.path, + base_url: data.importMap.baseUrl, + }, + }); + if (result.ok) throw new Error("expected failure"); + expect(result.error).toBe(failure); + expect( + webTraceObservationSchema.parse(result.error.partialObservation), + ).toEqual({ + kind: "web-trace-observation", + analysis: "incomplete", + manifest: data.manifestFile, + source: data.sourceFile, + import_map: data.importMap.file, + }); +}); + +it("retains source identities but does not admit a changed selected map", async () => { + const data = webSourceLocationFixture(); + data.sourceMap.file.path = "/different/map"; + const service = new WebSourceLocationService( + { load: async () => ok(data) }, + { + trace: async () => { + throw new Error("codec must not run"); + }, + }, + ); + const result = await service.trace(webSourceLocationArgs); + if (result.ok) throw new Error("expected failure"); + const observation = webTraceObservationSchema.parse( + result.error.partialObservation, + ); + expect(observation.source_map).toBeUndefined(); + expect(observation.source).toEqual(data.sourceFile); +}); + +it("retains independently attributed codec observations returned in a success carrier", async () => { + const data = webSourceLocationFixture(); + const report = traceSourceMap( + data.sourceMap.text, + "https://other.test/map", + webSourceLocationArgs.generated_position, + ); + const service = new WebSourceLocationService( + { load: async () => ok(data) }, + { trace: async () => ok(report) }, + ); + const result = await service.trace(webSourceLocationArgs); + if (result.ok) + throw new Error("foreign report must not establish selected point"); + expect(result.error).toBeInstanceOf(AnalysisOutputError); + const observation = webTraceObservationSchema.parse( + result.error.partialObservation, + ); + expect(observation.source_map_observation).toEqual({ + association: "independent", + report, + }); + expect(observation.source_map).toEqual(data.sourceMap.file); +});