From 2e5171e391b83f8a467d0861fd452c1f78621fd8 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:03:57 +0800 Subject: [PATCH] fix(browser): retain historical capture outcomes through cleanup --- docs/web-network-captures.md | 6 + .../WebNetworkCaptureService.test.ts | 166 ++++++++++++++- src/application/WebNetworkCaptureService.ts | 70 +++++- src/browser/history/CaptureFailures.test.ts | 40 ++++ src/browser/history/CaptureFailures.ts | 24 ++- .../history/HistoricalCaptureDecoder.ts | 110 ++++++---- src/domain/analysisErrorBase.ts | 24 ++- src/domain/explicitSensitiveFailure.test.ts | 49 +++++ src/domain/explicitSensitiveFailure.ts | 50 ++++- .../browser/historicalCaptureDecoder.test.ts | 201 +++++++++++++++++- 10 files changed, 680 insertions(+), 60 deletions(-) diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index f397b59346..5a5283c672 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -55,6 +55,12 @@ it as inline Evidence to `inspect-analysis-view`; the same summary and capture page contracts apply. Large complete records can still exceed MCP's response budget; export the retained Evidence when a single record cannot fit. +If decoding completes before cancellation or private-root cleanup fails, the +failure retains the completed capture as Evidence with the requested record +selection and explicit sensitivity exclusions. Earlier decode failures remain +primary. Failed cleanup stays linked to its exact owner, including through +redaction, and its live status clears only after a confirmed retry. + ## Formats and upstream profiles HAR uses unchanged `har-schema@2.0.0` draft-06 schemas, `ajv@8.20.0`, diff --git a/src/application/WebNetworkCaptureService.test.ts b/src/application/WebNetworkCaptureService.test.ts index 3fc26eba6b..7dfc1d8ab7 100644 --- a/src/application/WebNetworkCaptureService.test.ts +++ b/src/application/WebNetworkCaptureService.test.ts @@ -1,7 +1,7 @@ import { expect, it } from "vitest"; import { WebNetworkCaptureService } from "./WebNetworkCaptureService.js"; import { historicalHar } from "../../tests/fixtures/historicalHar.js"; -import { ok } from "../domain/result.js"; +import { err, ok } from "../domain/result.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { parseEvidence } from "../domain/evidence.js"; import { @@ -12,6 +12,11 @@ import { webNetworkCaptureSchema, type WebNetworkCapture, } from "../domain/webNetworkCapture.js"; +import { + AnalysisCleanupLease, + AnalysisCleanupLeaseGroup, +} from "../artifacts/AnalysisCleanupLease.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; it.each(["REDACTED", "[", "]", "/capture.har"])( "excludes sensitive path text even when a replacement marker would collide: %s", @@ -166,6 +171,100 @@ it("retains valid completed capture Evidence on late cancellation and preserves if (!invalid.ok) expect(invalid.error).toBeInstanceOf(AnalysisOutputError); }); +it("projects completed raw capture facts on failure through the caller's selection and sensitivity rules", async () => { + const report = fixture(); + report.artifact.path = "/private-marker/capture.har"; + report.limitations = ["private-marker producer limitation"]; + const reason = { request: "cancel after decoder completion" }; + const error = new AnalysisCancelledError("inspect_web_network_capture", { + cause: reason, + partialObservation: report, + }); + const result = await new WebNetworkCaptureService({ + inspect: () => Promise.resolve({ ok: false, error }), + }).inspect({ + capture_path: report.artifact.path, + format: "har", + record_ordinals: [0], + sensitive_values: ["private-marker"], + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Cancellation must remain the primary result"); + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.cause).toBe(reason); + const partial = parseEvidence(result.error.partialObservation); + expect(partial).toMatchObject({ + operation: "inspect_web_network_capture", + parameters: { record_ordinals: [0] }, + subject: { local_path: "" }, + normalized_result: { + artifact: { path: "", sha256: "a".repeat(64) }, + records: [{ ordinal: 0 }], + }, + }); + expect(JSON.stringify(partial)).not.toContain("private-marker"); +}); + +it("does not expose a malformed capture-shaped partial observation", async () => { + const malformed = fixture(); + malformed.artifact.path = "/private-marker"; + malformed.artifact.sha256 = "invalid"; + const error = new AnalysisCancelledError("inspect_web_network_capture", { + partialObservation: malformed, + }); + const result = await new WebNetworkCaptureService({ + inspect: () => Promise.resolve({ ok: false, error }), + }).inspect({ + capture_path: "/capture.har", + format: "har", + sensitive_values: ["private-marker"], + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.partialObservation).toBeUndefined(); +}); + +it("keeps a redacted cleanup error linked to its live owner through service close", async () => { + const report = fixture(); + const cleanupPath = "/tmp/private-marker-capture-root"; + const lease = new AnalysisCleanupLease(async () => {}, [cleanupPath]); + const original = new ProviderCleanupError( + "test-capture", + [cleanupPath], + { reason: "private-marker cleanup remains pending" }, + { + operation: "inspect_web_network_capture", + partialObservation: report, + }, + ); + new AnalysisCleanupLeaseGroup(original, [lease]); + const service = new WebNetworkCaptureService({ + inspect: () => Promise.resolve(err(original)), + close: () => lease.release(), + }); + const result = await service.inspect({ + capture_path: report.artifact.path, + format: "har", + sensitive_values: ["private-marker"], + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Retained cleanup must remain a failure"); + const returnedError = result.error; + expect(returnedError.cleanupIncomplete).toBe(true); + expect(returnedError.cleanupResources).toEqual([ + "/tmp/[REDACTED]-capture-root", + ]); + expect(JSON.stringify(projectAnalysisError(returnedError))).not.toContain( + "private-marker", + ); + expect(parseEvidence(returnedError.partialObservation)).toMatchObject({ + operation: "inspect_web_network_capture", + }); + + await service.close(); + expect(returnedError.cleanupIncomplete).toBe(false); + expect(returnedError.cleanupResources).toEqual([]); +}); + it.each([ ["producer"], ["artifact"], @@ -379,3 +478,68 @@ it.each(["12345", "67890", "234", "789"])( expect(parseEvidence(result.value)).toEqual(result.value); }, ); + +it("preserves a pre-abort reason without entering the capture adapter", async () => { + const reason = new Error("stop historical capture inspection"); + const controller = new AbortController(); + controller.abort(reason); + let providerCalls = 0; + const service = new WebNetworkCaptureService({ + inspect: async () => { + providerCalls += 1; + return ok(fixture()); + }, + }); + + const result = await service.inspect( + { + capture_path: "/capture.har", + format: "har", + sensitive_values: [], + }, + { signal: controller.signal }, + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cause).toBe(reason); + } + expect(providerCalls).toBe(0); +}); + +it("reports malformed retained facts while keeping their cleanup owner retryable", async () => { + const report = fixture(); + report.artifact.sha256 = "invalid"; + const lease = new AnalysisCleanupLease(async () => {}, [ + "/tmp/private-marker-root", + ]); + const cleanup = new ProviderCleanupError( + "test-historical-port", + ["/tmp/private-marker-root"], + { reason: "close denied" }, + { operation: "inspect_web_network_capture", partialObservation: report }, + ); + new AnalysisCleanupLeaseGroup(cleanup, [lease]); + const service = new WebNetworkCaptureService({ + inspect: async () => err(cleanup), + close: async () => { + await lease.release(); + }, + }); + const result = await service.inspect({ + capture_path: "/capture.har", + format: "har", + sensitive_values: ["private-marker"], + }); + if (result.ok) throw new Error("Expected malformed retained evidence"); + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.cleanupPrimary).toBeInstanceOf(AnalysisOutputError); + expect(result.error.partialObservation).toBeUndefined(); + expect(JSON.stringify(projectAnalysisError(result.error))).not.toContain( + "private-marker", + ); + await service.close(); + expect(result.error.cleanupIncomplete).toBe(false); + expect(projectAnalysisError(result.error).code).toBe("unreadable_output"); +}); diff --git a/src/application/WebNetworkCaptureService.ts b/src/application/WebNetworkCaptureService.ts index b755ae570f..0c70daccd9 100644 --- a/src/application/WebNetworkCaptureService.ts +++ b/src/application/WebNetworkCaptureService.ts @@ -19,8 +19,11 @@ import { inspectWebNetworkCaptureInputSchema, captureSensitiveValues, webNetworkCaptureSchema, + type InspectWebNetworkCaptureInput, + type WebNetworkCapture, } from "../domain/webNetworkCapture.js"; import { completeProviderEvidence } from "./ProviderOperationExecution.js"; +import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; const OPERATION = "inspect_web_network_capture"; @@ -39,7 +42,11 @@ export class WebNetworkCaptureService { options?: ExecutionOptions, ): Promise> { if (options?.signal?.aborted) - return err(new AnalysisCancelledError(OPERATION)); + return err( + new AnalysisCancelledError(OPERATION, { + cause: options.signal.reason, + }), + ); const failure = (error: AnalysisError): Result => err(redactExplicitFailure(error, captureSensitiveValues(rawInput))); const parsed = inspectWebNetworkCaptureInputSchema.safeParse(rawInput); @@ -74,7 +81,37 @@ export class WebNetworkCaptureService { ]), ); const loaded = await this.port.inspect(input, options); - if (!loaded.ok) return failure(loaded.error); + if (!loaded.ok) { + const partial = webNetworkCaptureSchema.safeParse( + loaded.error.partialObservation, + ); + let partialFailure: AnalysisError | undefined; + if (partial.success) { + const projected = this.#projectCapture(input, partial.data); + if (projected.ok) loaded.error.partialObservation = projected.value; + else partialFailure = projected.error; + } else if (isCapturePartialCandidate(loaded.error.partialObservation)) { + partialFailure = new AnalysisOutputError( + OPERATION, + "Capture adapter retained malformed historical evidence.", + { cause: partial.error }, + ); + } + if (partialFailure !== undefined) { + loaded.error.partialObservation = undefined; + partialFailure.retainSecondaryFailure(loaded.error); + return failure( + loaded.error.cleanupIncomplete + ? analysisErrorWithCleanupFailure( + partialFailure, + loaded.error, + OPERATION, + ).bindCleanupDependencies([partialFailure, loaded.error]) + : partialFailure, + ); + } + return failure(loaded.error); + } const report = webNetworkCaptureSchema.safeParse(loaded.value); if (!report.success) return failure( @@ -83,10 +120,18 @@ export class WebNetworkCaptureService { "Capture adapter returned malformed historical evidence.", ), ); - const value = report.data; + const projected = this.#projectCapture(input, report.data); + if (!projected.ok) return failure(projected.error); + return completeProviderEvidence(projected.value, options?.signal); + } + + #projectCapture( + input: InspectWebNetworkCaptureInput, + value: WebNetworkCapture, + ): Result { const artifactSha256 = value.artifact.sha256; if (artifactSha256 === null) - return failure( + return err( new AnalysisOutputError( OPERATION, "Capture adapter omitted its observed artifact digest before explicit projection.", @@ -98,7 +143,7 @@ export class WebNetworkCaptureService { value.total_records !== value.records.length || value.records.some((record, index) => record.ordinal !== index) ) - return failure( + return err( new AnalysisOutputError( OPERATION, "Capture adapter changed the selected artifact, format or original record sequence.", @@ -113,7 +158,7 @@ export class WebNetworkCaptureService { for (const ordinal of selected) { const record = safeValue.records[ordinal]; if (record === undefined) - return failure( + return err( new AnalysisInputError(OPERATION, undefined, [ { path: ["record_ordinals"], @@ -158,7 +203,7 @@ export class WebNetworkCaptureService { record_ordinals: selected, sensitive_value_count: input.sensitive_values.length, }); - return completeProviderEvidence( + return ok( createEvidence( safeSha256 === null ? undefined @@ -201,7 +246,16 @@ export class WebNetworkCaptureService { safePath === "" ? [] : [{ kind: "artifact-path", path: safePath }], }, ), - options?.signal, ); } } + +const isCapturePartialCandidate = (value: unknown): boolean => { + if (typeof value !== "object" || value === null || Array.isArray(value)) + return false; + const candidate = value as Record; + return ( + "artifact" in candidate && + ["format", "records", "decoder"].some((field) => field in candidate) + ); +}; diff --git a/src/browser/history/CaptureFailures.test.ts b/src/browser/history/CaptureFailures.test.ts index 047722ac5f..d6268645ab 100644 --- a/src/browser/history/CaptureFailures.test.ts +++ b/src/browser/history/CaptureFailures.test.ts @@ -286,3 +286,43 @@ it.each(["io", "unavailable"] as const)( }); }, ); + +it("preserves the exact cancellation reason and an earlier reader failure", () => { + const input = inspectWebNetworkCaptureInputSchema.parse({ + capture_path: "/selected/input.har", + format: "har", + }); + const reason = { request: "caller cancellation" }; + const controller = new AbortController(); + controller.abort(reason); + const cancelledCommand = new OwnedCommandFailure( + "cancelled", + "Command cancelled", + null, + null, + { cancellation: { reason } }, + ); + expect( + historicalCaptureFailure(input, cancelledCommand, "decoder", { + signal: controller.signal, + }).cause, + ).toBe(reason); + const cancelledRead = new ArtifactReaderFailure( + "cancelled", + "Read cancelled", + { cancellation: { reason } }, + ); + expect( + historicalCaptureFailure(input, cancelledRead, "capture-read", { + signal: controller.signal, + }).cause, + ).toBe(reason); + + const changed = historicalCaptureFailure( + input, + new ArtifactReaderFailure("integrity", "Observed changed capture"), + "capture-read", + { signal: controller.signal }, + ); + expect(changed._tag).toBe("AnalysisArtifactChangedError"); +}); diff --git a/src/browser/history/CaptureFailures.ts b/src/browser/history/CaptureFailures.ts index 1b35aea7d5..6c42ac8e3b 100644 --- a/src/browser/history/CaptureFailures.ts +++ b/src/browser/history/CaptureFailures.ts @@ -57,7 +57,14 @@ export const historicalCaptureFailure = ( { operation: OPERATION, cause }, ); if (cause.reason === "cancelled") - return new AnalysisCancelledError(OPERATION); + return new AnalysisCancelledError(OPERATION, { + cause: + cause.cancellation === undefined + ? options?.signal?.aborted === true + ? options.signal.reason + : undefined + : cause.cancellation.reason, + }); if (cause.reason === "timeout") return new AnalysisTimeoutError( OPERATION, @@ -91,10 +98,6 @@ export const historicalCaptureFailure = ( cause, ); } - if (options?.signal?.aborted) - return new AnalysisCancelledError(OPERATION, { - cause: options.signal.reason, - }); if ( phase === "capture-read" && cause instanceof ArtifactReaderFailure && @@ -163,6 +166,17 @@ export const historicalCaptureFailure = ( message: `Selected capture could not be read (${String(cause.code)}): ${input.capture_path}.`, }, ]); + if ( + options?.signal?.aborted === true && + (cause === options.signal.reason || + (cause instanceof Error && + cause.name === "AbortError" && + "code" in cause && + cause.code === "ABORT_ERR")) + ) + return new AnalysisCancelledError(OPERATION, { + cause: options.signal.reason, + }); return new ProviderAdapterError(input.format, OPERATION, { cause, diagnostics: { diff --git a/src/browser/history/HistoricalCaptureDecoder.ts b/src/browser/history/HistoricalCaptureDecoder.ts index 992527b88c..550c96ca67 100644 --- a/src/browser/history/HistoricalCaptureDecoder.ts +++ b/src/browser/history/HistoricalCaptureDecoder.ts @@ -17,7 +17,12 @@ import { z } from "zod"; import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; -import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; +import { bindArtifactFileCleanup } from "../../artifacts/ArtifactFileOperation.js"; +import { + ArtifactResourceScope, + type ArtifactCleanupAttempt, +} from "../../artifacts/ArtifactResourceScope.js"; +import { AnalysisCleanupLeaseGroup } from "../../artifacts/AnalysisCleanupLease.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { AnalysisCapabilityUnavailableError, @@ -183,13 +188,16 @@ export class HistoricalCaptureDecoder { ); reply = replySchema.parse(JSON.parse(replyFile.bytes.toString("utf8"))); } catch (cause: unknown) { + if (cause instanceof ArtifactReaderFailure) throw cause; if ( - cause instanceof ArtifactReaderFailure && - cause.cleanup !== undefined + options?.signal?.aborted === true && + (cause === options.signal.reason || + (cause instanceof Error && + cause.name === "AbortError" && + "code" in cause && + cause.code === "ABORT_ERR")) ) throw cause; - if (options?.signal?.aborted) - throw new AnalysisCancelledError(OPERATION); throw new AnalysisOutputError( OPERATION, `Owned capture decoder reply failed for ${input.capture_path}: ${replyFailureReason(cause)}`, @@ -296,38 +304,59 @@ export class HistoricalCaptureDecoder { ), ); } + const completedCapture = result.ok ? result.value : undefined; + let cleanupFailure: + | Extract + | undefined; if (runtime !== undefined) { const cleanup = await this.#resources.release({ kind: "private-runtime-root", resource: runtime.path, root: runtime, }); - if (cleanup.kind === "failed") { - return err( - redactExplicitFailure( - new ProviderCleanupError( - input.format, - [runtime.path], - { - capture_path: input.capture_path, - previous_error: result.ok - ? null - : projectAnalysisError(result.error), - reason: - cleanup.cause instanceof Error - ? cleanup.cause.message - : String(cleanup.cause), - }, - { operation: OPERATION, cause: cleanup.cause }, - ), - input.sensitive_values, - ), - ); - } + if (cleanup.kind === "failed") cleanupFailure = cleanup; } - return result.ok && options?.signal?.aborted - ? err(new AnalysisCancelledError(OPERATION)) - : result; + if (result.ok && options?.signal?.aborted) + result = err( + new AnalysisCancelledError(OPERATION, { + cause: options.signal.reason, + }), + ); + if (cleanupFailure !== undefined) { + const cleanupError = new ProviderCleanupError( + input.format, + [cleanupFailure.owner.resource], + { + capture_path: input.capture_path, + reason: + cleanupFailure.cause instanceof Error + ? cleanupFailure.cause.message + : String(cleanupFailure.cause), + }, + { + operation: OPERATION, + cause: cleanupFailure.cause, + }, + ); + const failure = result.ok + ? cleanupError + : analysisErrorWithCleanupFailure( + result.error, + cleanupError, + OPERATION, + ); + if (completedCapture !== undefined) + failure.retainPartialObservation(completedCapture); + new AnalysisCleanupLeaseGroup(cleanupError, [cleanupFailure.lease]); + const redacted = redactExplicitFailure(failure, input.sensitive_values); + if (!result.ok) + redacted.bindCleanupDependencies([result.error, cleanupError]); + new AnalysisCleanupLeaseGroup(redacted, [cleanupFailure.lease]); + return err(redacted); + } + if (!result.ok && completedCapture !== undefined) + result.error.retainPartialObservation(completedCapture); + return result; } private withReaderCleanup( @@ -339,23 +368,30 @@ export class HistoricalCaptureDecoder { cause.cleanup === undefined ) return primary; - if (cause.reason === "unavailable") - return new ProviderCleanupError( + if (cause.reason === "unavailable") { + const cleanup = new ProviderCleanupError( "web-network-capture", cause.cleanup.resources, { reason: cause.cleanup.reason }, { operation: OPERATION, cause }, ); + bindArtifactFileCleanup(cause, cleanup); + return cleanup; + } const cleanup = new ProviderCleanupError( "web-network-capture", cause.cleanup.resources, - { - reason: cause.cleanup.reason, - previous_error: projectAnalysisError(primary), - }, + { reason: cause.cleanup.reason }, { operation: OPERATION, cause }, ); - return analysisErrorWithCleanupFailure(primary, cleanup, OPERATION); + bindArtifactFileCleanup(cause, cleanup); + const composed = analysisErrorWithCleanupFailure( + primary, + cleanup, + OPERATION, + ).bindCleanupDependencies([primary, cleanup]); + bindArtifactFileCleanup(cause, composed); + return composed; } } diff --git a/src/domain/analysisErrorBase.ts b/src/domain/analysisErrorBase.ts index 8105a574ee..1e4ceef10e 100644 --- a/src/domain/analysisErrorBase.ts +++ b/src/domain/analysisErrorBase.ts @@ -72,6 +72,9 @@ export abstract class AnalysisError extends Error { readonly #cleanupPrimary: AnalysisError | undefined; // Undefined means unbound; a bound empty set records confirmed completion. #cleanupDependencies: Set | undefined; + #cleanupResourceProjection: + | { readonly cleanupResources: readonly string[] } + | undefined; #cleanupDependents: WeakRef[] = []; readonly diagnostics: Readonly> | undefined; readonly capturedOutput: AnalysisCapturedOutput | undefined; @@ -118,12 +121,14 @@ export abstract class AnalysisError extends Error { this.#cleanupResourceOwner?.cleanupResources ?? this.#ownedCleanupResources ); + const pending = [...this.#cleanupDependencies].filter( + (dependency) => dependency.cleanupIncomplete, + ); + if (pending.length === 0) return []; + if (this.#cleanupResourceProjection !== undefined) + return this.#cleanupResourceProjection.cleanupResources; return [ - ...new Set( - [...this.#cleanupDependencies] - .filter((dependency) => dependency.cleanupIncomplete) - .flatMap((dependency) => dependency.cleanupResources), - ), + ...new Set(pending.flatMap((dependency) => dependency.cleanupResources)), ]; } @@ -154,8 +159,13 @@ export abstract class AnalysisError extends Error { } /** Keep a composed error incomplete until all explicitly owned source errors resolve. */ - bindCleanupDependencies(dependencies: readonly AnalysisError[]): this { + bindCleanupDependencies( + dependencies: readonly AnalysisError[], + resourceProjection?: { readonly cleanupResources: readonly string[] }, + ): this { const pending = (this.#cleanupDependencies ??= new Set()); + if (resourceProjection !== undefined) + this.#cleanupResourceProjection = resourceProjection; for (const dependency of dependencies) { if (!dependency.cleanupIncomplete || pending.has(dependency)) continue; pending.add(dependency); @@ -240,10 +250,12 @@ import type { NativeAotSnapshotObservation } from "./native/nativeAotSnapshotObs import type { FileSnapshotPartialObservation } from "./fileSnapshotObservation.js"; import type { WebModuleResolutionObservation } from "./webModuleTrace.js"; +import type { WebNetworkCapture } from "./webNetworkCapture.js"; /** Provider-neutral evidence collected before a typed analysis failure. */ export type AnalysisPartialObservation = | WebModuleResolutionObservation + | WebNetworkCapture | NativeLoadImagePartialObservation | NativeAotSnapshotObservation | FileSnapshotPartialObservation diff --git a/src/domain/explicitSensitiveFailure.test.ts b/src/domain/explicitSensitiveFailure.test.ts index 757e9887a4..603097f12b 100644 --- a/src/domain/explicitSensitiveFailure.test.ts +++ b/src/domain/explicitSensitiveFailure.test.ts @@ -12,6 +12,8 @@ import { import { projectAnalysisError } from "./analysisErrorProjection.js"; import { ProviderCleanupError } from "./providerCleanupError.js"; import { ProviderAdapterError } from "./providerAdapterError.js"; +import { analysisErrorWithCleanupFailure } from "./analysisErrorCleanup.js"; +import { resolvedAnalysisError } from "./analysisErrorBase.js"; import { redactExplicitFailure, redactExplicitPointer, @@ -103,6 +105,53 @@ it("preserves cleanup failure authority and resources while excluding diagnostic expect(JSON.stringify(projected)).toContain('"exit_code":2'); }); +it("redacts cleanup primaries and mirrors the source error's live status", () => { + const primary = new AnalysisInputError( + "inspect_web_network_capture", + undefined, + [ + { + path: ["capture_path", "/root/private-marker/capture.har"], + reason: "invalid_value", + message: "Selected private-marker capture was invalid", + }, + ], + ); + const cleanup = new ProviderCleanupError( + "har", + ["/tmp/private-marker-runtime"], + { reason: "private-marker runtime close failed" }, + { operation: "inspect_web_network_capture" }, + ); + const original = analysisErrorWithCleanupFailure( + primary, + cleanup, + "inspect_web_network_capture", + ); + + const redacted = redactExplicitFailure(original, ["private-marker"]); + expect(redacted.cleanupIncomplete).toBe(true); + expect(redacted.cleanupResources).toEqual(["/tmp/[REDACTED]-runtime"]); + expect(JSON.stringify(projectAnalysisError(redacted))).not.toContain( + "private-marker", + ); + + original.resolveCleanup(); + expect(redacted.cleanupIncomplete).toBe(false); + const resolved = resolvedAnalysisError(redacted); + expect(JSON.stringify(projectAnalysisError(resolved))).not.toContain( + "private-marker", + ); + expect(projectAnalysisError(resolved).details).toMatchObject({ + issues: [ + { + path: ["capture_path", "/root"], + reason: "invalid_value", + }, + ], + }); +}); + it("keeps unsupported profile reasons distinct from malformed input", () => { const error = new AnalysisCapabilityUnavailableError( "mitmproxy", diff --git a/src/domain/explicitSensitiveFailure.ts b/src/domain/explicitSensitiveFailure.ts index 7bb7c61703..d14cb443f5 100644 --- a/src/domain/explicitSensitiveFailure.ts +++ b/src/domain/explicitSensitiveFailure.ts @@ -40,6 +40,18 @@ export const redactExplicitFailure = ( values: readonly string[], ): AnalysisError => { if (values.length === 0) return error; + const redacted = redactFailure(error, values); + return redacted === error + ? error + : mirrorCleanup(error, redacted, (value) => + redactExplicitText(value, values), + ); +}; + +const redactFailure = ( + error: AnalysisError, + values: readonly string[], +): AnalysisError => { const text = (value: string): string => redactExplicitText(value, values); const path = ( parts: readonly (string | number)[], @@ -76,6 +88,11 @@ export const redactExplicitFailure = ( }; const context = { cause: error, + ...(error.cleanupPrimary === undefined + ? {} + : { + cleanupPrimary: redactExplicitFailure(error.cleanupPrimary, values), + }), ...(error.diagnostics === undefined ? {} : { diagnostics: diagnostics(error.diagnostics) }), @@ -100,6 +117,7 @@ export const redactExplicitFailure = ( if (error instanceof AnalysisCancelledError) return new AnalysisCancelledError(error.operation, { ...context, + cause: error.cause, partialObservation: error.partialObservation, }); if (error instanceof AnalysisTimeoutError) @@ -178,9 +196,37 @@ export const redactExplicitFailure = ( error.providerId, error.cleanupResources.map(text), diagnostics(error.diagnostics ?? {}), - { operation: error.operation, cause: error }, + { + ...context, + operation: error.operation, + ...(error.partialObservation === undefined + ? {} + : { partialObservation: error.partialObservation }), + }, ); if (error instanceof ProviderAdapterError) - return new ProviderAdapterError(error.providerId, error.operation, context); + return new ProviderAdapterError(error.providerId, error.operation, { + ...context, + ...(error.partialObservation === undefined + ? {} + : { partialObservation: error.partialObservation }), + ...(error.userMessage === undefined + ? {} + : { userMessage: text(error.userMessage) }), + ...(error.remediationAction === undefined + ? {} + : { remediationAction: text(error.remediationAction) }), + }); return error; }; + +const mirrorCleanup = ( + source: AnalysisError, + redacted: Value, + redact: (value: string) => string, +): Value => + redacted.bindCleanupDependencies([source], { + get cleanupResources(): readonly string[] { + return source.cleanupResources.map(redact); + }, + }); diff --git a/tests/boundary/browser/historicalCaptureDecoder.test.ts b/tests/boundary/browser/historicalCaptureDecoder.test.ts index 240c04d43a..aeb26b83cb 100644 --- a/tests/boundary/browser/historicalCaptureDecoder.test.ts +++ b/tests/boundary/browser/historicalCaptureDecoder.test.ts @@ -178,6 +178,7 @@ it("retains a failed capture reader and retries it before a fresh load", async ( const first = await decoder.inspect(input); expect(first.ok).toBe(false); if (first.ok) throw new Error("Reader close failure must fail inspection"); + expect(first.error.cleanupIncomplete).toBe(true); expect(first.error.cleanupResources).toContain(path); expect(decoderCalls).toBe(0); @@ -192,6 +193,8 @@ it("retains a failed capture reader and retries it before a fresh load", async ( allowClose = true; await decoder.close(); expect(closeAttempts).toBe(3); + expect(first.error.cleanupIncomplete).toBe(false); + expect(first.error.cleanupResources).toEqual([]); } finally { allowClose = true; await decoder.close().catch(() => undefined); @@ -363,6 +366,199 @@ it("cancels after snapshot acquisition before launch and removes private input d await expect(access(privatePath)).rejects.toMatchObject({ code: "ENOENT" }); }); +it("preserves the exact cancellation reason observed during final runtime close", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-historical-final-cancel-")); + onTestFinished(() => rm(root, { recursive: true, force: true })); + const path = join(root, "input.har"); + const text = JSON.stringify(historicalHar()); + await writeFile(path, text); + const controller = new AbortController(); + const reason = { request: "cancel during runtime close" }; + const decoder = new HistoricalCaptureDecoder( + [ + { + format: "har", + identity: HAR_CAPTURE_PROVIDER_IDENTITY, + command: async (_, runtimePath) => { + await writeFile( + join(runtimePath, "reply.json"), + JSON.stringify({ ok: true, value: decodeHarCapture(text, []) }), + ); + return { + command: process.execPath, + arguments: ["-e", "process.exit(0)"], + }; + }, + }, + ], + process.env, + async () => { + const runtime = await PrivateRuntimeRoot.create({ + prefix: "rea-final-cancel-proof-", + }); + return { + path: runtime.path, + close: async () => { + controller.abort(reason); + await runtime.close(); + }, + }; + }, + ); + const result = await decoder.inspect( + inspectWebNetworkCaptureInputSchema.parse({ + capture_path: path, + format: "har", + }), + { signal: controller.signal }, + ); + if (result.ok) throw new Error("Finalization cancellation must fail"); + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.cause).toBe(reason); + expect(result.error.partialObservation).toMatchObject({ + artifact: { path, sha256: expect.any(String) }, + records: [{ ordinal: 0 }], + }); +}); + +it("retains completed capture facts and binds final root cleanup to its retry lease", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-historical-report-cleanup-")); + onTestFinished(() => rm(root, { recursive: true, force: true })); + const path = join(root, "input.har"); + const text = JSON.stringify(historicalHar()); + await writeFile(path, text); + let runtimePath: string | undefined; + let allowClose = false; + let runtime: PrivateRuntimeRoot | undefined; + const decoder = new HistoricalCaptureDecoder( + [ + { + format: "har", + identity: HAR_CAPTURE_PROVIDER_IDENTITY, + command: async (_, path) => { + await writeFile( + join(path, "reply.json"), + JSON.stringify({ ok: true, value: decodeHarCapture(text, []) }), + ); + return { + command: process.execPath, + arguments: ["-e", "process.exit(0)"], + }; + }, + }, + ], + process.env, + async () => { + runtime = await PrivateRuntimeRoot.create({ + prefix: "rea-report-cleanup-proof-", + }); + runtimePath = runtime.path; + onTestFinished(() => runtime?.close()); + return { + path: runtime.path, + close: async () => { + if (!allowClose) + throw new Error("injected private root close failure"); + await runtime?.close(); + }, + }; + }, + ); + const result = await decoder.inspect( + inspectWebNetworkCaptureInputSchema.parse({ + capture_path: path, + format: "har", + }), + ); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Root close failure must be reported"); + expect(result.error.cleanupIncomplete).toBe(true); + expect(runtimePath).toBeDefined(); + expect(result.error.cleanupResources).toContain(runtimePath); + expect(result.error.partialObservation).toMatchObject({ + artifact: { path, sha256: expect.any(String) }, + records: [{ ordinal: 0 }], + }); + + allowClose = true; + await decoder.close(); + expect(result.error.cleanupIncomplete).toBe(false); + expect(result.error.cleanupResources).toEqual([]); +}); + +it("keeps late cancellation primary when runtime close also fails, then clears on retry", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-historical-cancel-cleanup-")); + onTestFinished(() => rm(root, { recursive: true, force: true })); + const path = join(root, "input.har"); + const text = JSON.stringify(historicalHar()); + await writeFile(path, text); + const controller = new AbortController(); + const reason = { request: "cancel during failed root close" }; + let runtime: PrivateRuntimeRoot | undefined; + let closeAttempts = 0; + const decoder = new HistoricalCaptureDecoder( + [ + { + format: "har", + identity: HAR_CAPTURE_PROVIDER_IDENTITY, + command: async (_, runtimePath) => { + await writeFile( + join(runtimePath, "reply.json"), + JSON.stringify({ ok: true, value: decodeHarCapture(text, []) }), + ); + return { + command: process.execPath, + arguments: ["-e", "process.exit(0)"], + }; + }, + }, + ], + process.env, + async () => { + runtime = await PrivateRuntimeRoot.create({ + prefix: "rea-cancel-cleanup-proof-", + }); + onTestFinished(() => runtime?.close()); + return { + path: runtime.path, + close: async () => { + closeAttempts += 1; + if (closeAttempts === 1) { + controller.abort(reason); + throw new Error("injected close failure after cancellation"); + } + await runtime?.close(); + }, + }; + }, + ); + const result = await decoder.inspect( + inspectWebNetworkCaptureInputSchema.parse({ + capture_path: path, + format: "har", + }), + { signal: controller.signal }, + ); + if (result.ok) throw new Error("Cancellation and failed close must fail"); + expect(result.error._tag).toBe("ProviderAdapterError"); + expect(result.error.cleanupPrimary?._tag).toBe("AnalysisCancelledError"); + expect(result.error.cleanupPrimary?.cause).toBe(reason); + expect(JSON.stringify(result.error.diagnostics)).not.toContain( + "partial_observation", + ); + expect(result.error.partialObservation).toMatchObject({ + artifact: { path, sha256: expect.any(String) }, + records: [{ ordinal: 0 }], + }); + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.cleanupResources).toContain(runtime?.path); + + await decoder.close(); + expect(closeAttempts).toBe(2); + expect(result.error.cleanupIncomplete).toBe(false); + expect(result.error.cleanupResources).toEqual([]); +}); + it.each([ { reason: "input-limit", tag: "AnalysisInputError" }, { reason: "expanded-limit", tag: "AnalysisInputError" }, @@ -560,12 +756,15 @@ it.each(["format", "process"])( }), ); if (result.ok) throw new Error("Cleanup failure required"); + expect(result.error.cleanupPrimary?._tag).toBe( + kind === "format" ? "AnalysisInputError" : "ProviderAdapterError", + ); const projected = projectAnalysisError(result.error); expect(projected).toMatchObject({ code: "cleanup_incomplete", details: { diagnostics: { - previous_error: + primary_error: kind === "format" ? { category: "invalid_input",