fix(captures): preserve cleanup ownership and sensitive evidence

This commit is contained in:
morluto
2026-10-27 03:01:58 +08:00
parent 7a237aa721
commit 347494f3ab
9 changed files with 613 additions and 26 deletions
+10 -2
View File
@@ -65,8 +65,8 @@ export const historicalCaptureFailure = (
origin === "owned-or-classified" &&
cause instanceof OwnedCommandFailure
) {
if (cause.cleanupFailure !== null)
return new ProviderCleanupError(
if (cause.cleanupFailure !== null) {
const cleanup = new ProviderCleanupError(
input.format,
cause.resources,
{
@@ -83,6 +83,14 @@ export const historicalCaptureFailure = (
},
{ operation: OPERATION, cause },
);
return cause.cause instanceof OwnedCommandFailure
? analysisErrorWithCleanupFailure(
historicalCaptureFailure(input, cause.cause, phase, options, heap),
cleanup,
OPERATION,
)
: cleanup;
}
if (cause.reason === "cancelled")
return new AnalysisCancelledError(OPERATION, {
cause:
+88 -13
View File
@@ -1,3 +1,4 @@
import type { FileSnapshotPartialObservation } from "../../domain/fileSnapshotObservation.js";
import {
decodeCaptureProtobuf,
prepareCaptureProtobuf,
@@ -30,7 +31,10 @@ import {
ArtifactResourceScope,
type ArtifactCleanupAttempt,
} from "../../artifacts/ArtifactResourceScope.js";
import { AnalysisCleanupLeaseGroup } from "../../artifacts/AnalysisCleanupLease.js";
import {
AnalysisCleanupLease,
AnalysisCleanupLeaseGroup,
} from "../../artifacts/AnalysisCleanupLease.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import {
AnalysisCapabilityUnavailableError,
@@ -50,7 +54,10 @@ import {
type WebNetworkCapture,
} from "../../domain/webNetworkCapture.js";
import { snapshotEnvironment } from "../../process/snapshotEnvironment.js";
import { runOwnedCommand } from "../../process/OwnedCommand.js";
import {
OwnedCommandFailure,
runOwnedCommand,
} from "../../process/OwnedCommand.js";
import { PrivateRuntimeRoot } from "../../process/PrivateRuntimeRoot.js";
import { redactExplicitFailure } from "../../domain/explicitSensitiveFailure.js";
@@ -124,6 +131,9 @@ export class HistoricalCaptureDecoder {
): Promise<Result<WebNetworkCapture, AnalysisError>> {
const selectedEnvironment = snapshotEnvironment(this.environment);
let runtime: Pick<PrivateRuntimeRoot, "path" | "close"> | undefined;
const runtimeResources = new ArtifactResourceScope();
let processLease: AnalysisCleanupLease | undefined;
let sourceObservation: FileSnapshotPartialObservation | undefined;
let result: Result<WebNetworkCapture, AnalysisError>;
let phase: "capture-read" | "decoder" = "capture-read";
let encodedReplyBytes = 0;
@@ -149,6 +159,12 @@ export class HistoricalCaptureDecoder {
undefined,
this.#resources,
);
sourceObservation = {
kind: "file-snapshot-hash",
path: input.capture_path,
sha256: snapshot.sha256,
bytes: snapshot.bytes.length,
};
phase = "decoder";
runtime = await this.createRuntime();
const snapshotPath = join(runtime.path, "capture.snapshot");
@@ -201,7 +217,7 @@ export class HistoricalCaptureDecoder {
const decodedReply = await readHistoricalCaptureReply(
replyPath,
input.capture_path,
this.#resources,
runtimeResources,
options?.signal,
);
const reply = decodedReply.value;
@@ -375,6 +391,29 @@ export class HistoricalCaptureDecoder {
}
}
} catch (cause: unknown) {
if (
cause instanceof OwnedCommandFailure &&
cause.cleanupOwner !== undefined
) {
const supervisor = cause.cleanupOwner;
processLease = new AnalysisCleanupLease(async () => {
const stopped = await supervisor.stop();
if (stopped.status === "incomplete")
throw new ProviderCleanupError(
input.format,
cause.resources,
{
reason: stopped.reason,
},
{ operation: OPERATION, cause: stopped },
);
}, cause.resources);
runtimeResources.retain({
kind: "lease",
resource: runtime?.path ?? input.capture_path,
lease: processLease,
});
}
const error = historicalCaptureFailure(
input,
cause,
@@ -401,6 +440,8 @@ export class HistoricalCaptureDecoder {
error,
replyFinalization ?? cause,
);
if (processLease !== undefined)
new AnalysisCleanupLeaseGroup(failure, [processLease]);
if (phase === "capture-read") {
const snapshot = completedStableArtifactObservation(
cause,
@@ -408,6 +449,11 @@ export class HistoricalCaptureDecoder {
);
if (snapshot !== undefined) failure.retainPartialObservation(snapshot);
}
if (
failure.partialObservation === undefined &&
sourceObservation !== undefined
)
failure.retainPartialObservation(sourceObservation);
result = err(redactExplicitFailure(failure, input.sensitive_values));
}
const completedCapture = result.ok ? result.value : undefined;
@@ -415,12 +461,42 @@ export class HistoricalCaptureDecoder {
| Extract<ArtifactCleanupAttempt, { readonly kind: "failed" }>
| undefined;
if (runtime !== undefined) {
const cleanup = await this.#resources.release({
kind: "private-runtime-root",
resource: runtime.path,
root: runtime,
});
if (cleanup.kind === "failed") cleanupFailure = cleanup;
const root = runtime;
const lease = new AnalysisCleanupLease(
async () => {
await runtimeResources.close();
await root.close();
},
{
get cleanupResources() {
return [
...new Set([root.path, ...runtimeResources.cleanupResources]),
];
},
},
);
const owner = { kind: "lease" as const, resource: root.path, lease };
if (runtimeResources.hasPending) {
// Failed child cleanup is retried by the service owner, after active peers settle.
this.#resources.retain(owner);
cleanupFailure = {
kind: "failed",
owner,
lease,
cause: new ProviderCleanupError(
input.format,
lease.cleanupResources ?? [root.path],
{
reason:
"Capture decoder resources must be released before runtime removal.",
},
{ operation: OPERATION },
),
};
} else {
const cleanup = await this.#resources.release(owner);
if (cleanup.kind === "failed") cleanupFailure = cleanup;
}
}
if (result.ok && options?.signal?.aborted)
result = err(
@@ -454,11 +530,10 @@ export class HistoricalCaptureDecoder {
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);
failure.bindCleanupDependencies([result.error, cleanupError]);
new AnalysisCleanupLeaseGroup(failure, [cleanupFailure.lease]);
return err(redactExplicitFailure(failure, input.sensitive_values));
}
if (!result.ok && completedCapture !== undefined)
result.error.retainPartialObservation(completedCapture);
+10
View File
@@ -48,6 +48,8 @@ interface CliEvidenceDelivery {
readonly evidenceOutput: string | undefined;
/** Derived workflows publish their selected source closure with the result. */
readonly sourceEvidence?: readonly Evidence[];
/** Invocation-specific exclusions also cover errors from final service cleanup. */
readonly redactFailure?: (error: AnalysisError) => AnalysisError;
}
export const cliEvidenceDeliveryInputError = (
@@ -136,6 +138,10 @@ export const deliverCliAnalysisEvidence = async (
providerId,
operation,
signal,
undefined,
input.redactFailure === undefined
? {}
: { redactFailure: input.redactFailure },
);
const complete = await finishAnalysisCommand(
run,
@@ -143,6 +149,10 @@ export const deliverCliAnalysisEvidence = async (
providerId,
operation,
signal,
undefined,
input.redactFailure === undefined
? {}
: { redactFailure: input.redactFailure },
);
return publishCliAnalysisEvidence(complete, {
providerId,
+9 -3
View File
@@ -21,8 +21,14 @@ export const finishAnalysisCommand = async <
canonicalOperation: string,
signal: AbortSignal,
fallbackResource?: string,
options: { readonly failureProjection?: "initial" | "final" } = {},
options: {
readonly failureProjection?: "initial" | "final";
/** Apply invocation-specific exclusions after all owned cleanup is composed. */
readonly redactFailure?: (error: AnalysisError) => AnalysisError;
} = {},
): Promise<Value | ReturnType<typeof projectAnalysisError>> => {
const projectFailure = (error: AnalysisError) =>
projectAnalysisError(options.redactFailure?.(error) ?? error);
let result: Result<Value, AnalysisError>;
let cleanup: AnalysisError | undefined;
try {
@@ -40,7 +46,7 @@ export const finishAnalysisCommand = async <
// close retries and mutates the producer's live cleanup status.
const initialFailure =
!result.ok && options.failureProjection === "initial"
? projectAnalysisError(result.error)
? projectFailure(result.error)
: undefined;
try {
// Resource owners may resolve their retained errors during close. This
@@ -90,5 +96,5 @@ export const finishAnalysisCommand = async <
).bindCleanupDependencies([result.error, cleanup]),
};
}
return result.ok ? result.value : projectAnalysisError(result.error);
return result.ok ? result.value : projectFailure(result.error);
};
+3
View File
@@ -17,6 +17,7 @@ import {
import { localPathStringSchema } from "../domain/localPath.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import { explicitSensitiveValueOptions } from "./explicitSensitiveArguments.js";
import { redactExplicitFailure } from "../domain/explicitSensitiveFailure.js";
/** Expose offline historical capture inspection with the same selection contract as MCP. */
export const registerWebNetworkCaptureCommands = (
@@ -116,6 +117,8 @@ export const registerWebNetworkCaptureCommands = (
signal,
detail: options.detail,
evidenceOutput: options.evidence_output,
redactFailure: (error) =>
redactExplicitFailure(error, options.sensitiveValue ?? []),
});
},
),
+39 -2
View File
@@ -14,6 +14,39 @@ import { redactExplicitText } from "./explicitSensitiveValues.js";
import type { JsonValue } from "./jsonValue.js";
import { ProviderAdapterError } from "./providerAdapterError.js";
import { ProviderCleanupError } from "./providerCleanupError.js";
import {
fileSnapshotPartialObservationSchema,
type FileSnapshotPartialObservation,
} from "./fileSnapshotObservation.js";
/** Exclude whole identity values; redacted text is no longer a path or digest. */
const excludeSnapshotIdentity = (
value: unknown,
values: readonly string[],
): FileSnapshotPartialObservation | undefined => {
if (
value === null ||
typeof value !== "object" ||
!("kind" in value) ||
(value.kind !== "file-snapshot-hash" && value.kind !== "file-snapshot-read")
)
return undefined;
const parsed = fileSnapshotPartialObservationSchema.safeParse(value);
if (!parsed.success) return undefined;
const snapshot = parsed.data;
const exclude = (identity: string | null): string | null =>
identity !== null &&
values.some((literal) => literal !== "" && identity.includes(literal))
? null
: identity;
const path = exclude(snapshot.path);
if (snapshot.kind === "file-snapshot-hash") {
const sha256 = exclude(snapshot.sha256);
return { ...snapshot, path, sha256 };
}
const admitted_sha256 = exclude(snapshot.admitted_sha256);
return { ...snapshot, path, admitted_sha256 };
};
/** Keep a real JSON-pointer ancestor when a property identity is explicitly excluded. */
export const redactExplicitPointer = (
@@ -41,9 +74,11 @@ export const redactExplicitFailure = (
values: readonly string[],
): AnalysisError => {
if (values.length === 0) return error;
const snapshot = excludeSnapshotIdentity(error.partialObservation, values);
if (snapshot !== undefined) error.partialObservation = snapshot;
const redacted = redactFailure(error, values);
// Observation owners already apply format-aware exclusions. Keep their facts
// and identity across every diagnostic replacement, including input/capacity.
// Other observation owners already apply their format-aware exclusions.
// Keep those facts across diagnostic replacements, including input/capacity.
if (redacted !== error && error.partialObservation !== undefined)
redacted.retainPartialObservation(error.partialObservation);
return redacted === error
@@ -86,6 +121,8 @@ const redactFailure = (
.map(([key, item]) => [key, visit(item)]),
);
const visit = (value: JsonValue): JsonValue => {
const snapshot = excludeSnapshotIdentity(value, values);
if (snapshot !== undefined) return snapshot;
if (typeof value === "string") return text(value);
if (Array.isArray(value)) return value.map(visit);
if (typeof value === "object" && value !== null) return diagnostics(value);
+6 -4
View File
@@ -4,19 +4,21 @@ const byteCount = z.number().int().nonnegative().safe();
const sha256 = z.string().regex(/^[a-f0-9]{64}$/u);
/** Completed immutable-file facts retained when read finalization fails. */
// Null identity fields mean caller-declared exclusion, never an absent file or
// sensitivity inferred from its name. Unmarked identities remain exact.
export const fileSnapshotPartialObservationSchema = z.discriminatedUnion(
"kind",
[
z.strictObject({
kind: z.literal("file-snapshot-hash"),
path: z.string().min(1),
sha256,
path: z.string().min(1).nullable(),
sha256: sha256.nullable(),
bytes: byteCount,
}),
z.strictObject({
kind: z.literal("file-snapshot-read"),
path: z.string().min(1),
admitted_sha256: sha256,
path: z.string().min(1).nullable(),
admitted_sha256: sha256.nullable(),
capture: z.discriminatedUnion("status", [
z.strictObject({ status: z.literal("captured"), bytes: byteCount }),
z.strictObject({
@@ -1,3 +1,6 @@
import type { AnalysisError } from "../../../src/domain/analysisErrorBase.js";
import { ProviderProcessSupervisor } from "../../../src/process/ProviderProcess.js";
import { createDeferred } from "../../fixtures/binarySession.js";
import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js";
import {
access,
@@ -219,6 +222,177 @@ fs.writeFileSync(request.reply_path, ${JSON.stringify(JSON.stringify({ ok: true,
},
);
const expectOwnedDecoderPrimary = (
error: AnalysisError,
outcome: "cancelled" | "process",
reason: unknown,
) => {
const primary = primaryAnalysisError(error);
if (outcome === "cancelled") {
expect(primary._tag).toBe("AnalysisCancelledError");
expect(primary.cause).toBe(reason);
} else {
expect(primary.diagnostics).toMatchObject({
exit_code: 7,
stderr: "authored workload failure",
});
}
};
it.each(["cancelled", "process"] as const)(
"retains an unresolved decoder and its root across concurrent inspection and shutdown (%s)",
async (outcome) => {
const root = await mkdtemp(join(tmpdir(), "rea-capture-owned-cleanup-"));
onTestFinished(() => rm(root, { recursive: true, force: true }));
const capturePath = join(root, "capture.har");
const markerPath = join(root, "started.json");
const finishPath = join(root, "finish");
const text = JSON.stringify(historicalHar());
await writeFile(capturePath, text);
const peerEntered = createDeferred<void>();
const peerContinue = createDeferred<void>();
const controller = new AbortController();
const reason = { request: "cancel owned inert decoder" };
let targetPid: number | undefined;
let targetOwner: ProviderProcessSupervisor | undefined;
let denied = true;
let targetStops = 0;
const stop = ProviderProcessSupervisor.prototype.stop;
vi.spyOn(ProviderProcessSupervisor.prototype, "stop").mockImplementation(
function (this: ProviderProcessSupervisor, options) {
if (this.launch.process.pid === targetPid) {
targetOwner = this;
targetStops++;
if (denied)
return Promise.resolve({
status: "incomplete",
reason: "controlled stop refusal",
});
}
return stop.call(this, options);
},
);
let calls = 0;
let roots = 0;
let targetRuntime: string | undefined;
let targetRootCloses = 0;
let denyRoot = false;
const decoder = new HistoricalCaptureDecoder(
[
{
format: "har",
identity: HAR_CAPTURE_PROVIDER_IDENTITY,
command: async (_, runtimePath) => {
if (calls++ === 0)
return {
command: process.execPath,
arguments: [
"-e",
`const fs = require("node:fs"); fs.writeFileSync(process.argv[1], JSON.stringify(process.pid)); setInterval(() => { if (fs.existsSync(process.argv[2])) { process.stderr.write("authored workload failure"); process.exit(7); } }, 10)`,
markerPath,
finishPath,
],
};
peerEntered.resolve(undefined);
await peerContinue.promise;
await writeFile(
join(runtimePath, "reply.json"),
JSON.stringify({ ok: true, value: decodeHarCapture(text, []) }),
);
return { command: process.execPath, arguments: ["-e", ""] };
},
},
],
process.env,
async () => {
const runtime = await PrivateRuntimeRoot.create({
prefix: "capture-private-runtime-",
});
if (roots++ !== 0) return runtime;
targetRuntime = runtime.path;
return {
path: runtime.path,
close: async () => {
targetRootCloses++;
if (denyRoot) throw new Error("controlled root close refusal");
await runtime.close();
},
};
},
);
onTestFinished(async () => {
denied = false;
denyRoot = false;
peerContinue.resolve(undefined);
await targetOwner?.stop();
await decoder.close();
vi.restoreAllMocks();
});
const input = inspectWebNetworkCaptureInputSchema.parse({
capture_path: capturePath,
format: "har",
sensitive_values: ["capture-private-runtime"],
});
const target = decoder.inspect(input, { signal: controller.signal });
await vi.waitFor(
async () => {
targetPid = JSON.parse(await readFile(markerPath, "utf8"));
},
{ timeout: 5000 },
);
const peer = decoder.inspect(input);
await peerEntered.promise;
if (outcome === "cancelled") controller.abort(reason);
else await writeFile(finishPath, "finish workload");
const failed = await target;
if (failed.ok) throw new Error("cancelled decoder must fail");
expect(targetRootCloses).toBe(0);
expectOwnedDecoderPrimary(failed.error, outcome, reason);
expect(failed.error.partialObservation).toMatchObject({
kind: "file-snapshot-hash",
path: capturePath,
bytes: Buffer.byteLength(text),
sha256: expect.stringMatching(/^[a-f0-9]{64}$/),
});
expect(projectAnalysisError(failed.error).code).toBe("cleanup_incomplete");
expect(JSON.stringify(projectAnalysisError(failed.error))).not.toContain(
"capture-private-runtime",
);
if (targetRuntime === undefined) throw new Error("runtime missing");
await expect(access(targetRuntime)).resolves.toBeUndefined();
expect(targetOwner?.launch.process.exitCode).toBe(
outcome === "cancelled" ? null : 7,
);
const closing = decoder.close().then(
() => undefined,
(cause) => cause,
);
peerContinue.resolve(undefined);
expect((await peer).ok).toBe(true);
expect(await closing).toBeInstanceOf(Error);
expect(targetRootCloses).toBe(0);
denied = false;
denyRoot = true;
await expect(decoder.close()).rejects.toBeInstanceOf(Error);
expect(targetRootCloses).toBe(1);
const releasedStops = targetStops;
denyRoot = false;
await decoder.close();
expect(targetStops).toBe(releasedStops);
await expect(access(targetRuntime)).rejects.toMatchObject({
code: "ENOENT",
});
expect(projectAnalysisError(failed.error).code).not.toBe(
"cleanup_incomplete",
);
expectOwnedDecoderPrimary(failed.error, outcome, reason);
expect(projectAnalysisError(failed.error).code).toBe(
outcome === "cancelled" ? "cancelled" : "execution_failure",
);
},
15000,
);
it("applies a non-default capture deadline and releases its runtime", async () => {
const root = await mkdtemp(join(tmpdir(), "rea-capture-deadline-"));
onTestFinished(() => rm(root, { recursive: true, force: true }));
@@ -859,7 +1033,11 @@ it.each([
"no completed capture Evidence",
);
expect(projected.remediation.action).not.toContain("rea doctor");
expect(result.error.partialObservation).toBeUndefined();
expect(result.error.partialObservation).toMatchObject({
kind: "file-snapshot-hash",
path,
sha256: expect.stringMatching(/^[a-f0-9]{64}$/),
});
} else if (tag === "ProviderAdapterError") {
expect(projected).toMatchObject({
details: {
@@ -1111,7 +1289,12 @@ it.each([
: "unreadable_output",
);
if (reply !== "capture")
expect(result.error.partialObservation).toBeUndefined();
expect(result.error.partialObservation).toMatchObject({
kind: "file-snapshot-hash",
path,
bytes: Buffer.byteLength(text),
sha256: expect.stringMatching(/^[a-f0-9]{64}$/),
});
else
expect(result.error.partialObservation).toMatchObject({
artifact: { path },
@@ -0,0 +1,263 @@
import { createHash } from "node:crypto";
import type { FileHandle } from "node:fs/promises";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, expect, it, vi } from "vitest";
const fault = vi.hoisted(() => ({
path: "",
blocked: true,
handles: [] as FileHandle[],
}));
vi.mock("node:fs/promises", async (importOriginal) => {
const filesystem = await importOriginal<typeof import("node:fs/promises")>();
return {
...filesystem,
open: async (...args: Parameters<typeof filesystem.open>) => {
const handle = await filesystem.open(...args);
if (String(args[0]) === fault.path) {
fault.handles.push(handle);
const close = handle.close.bind(handle);
handle.close = async () => {
if (fault.blocked)
throw new Error(
`controlled selected capture close failure: ${fault.path}`,
);
await close();
};
}
return handle;
},
};
});
import { HistoricalCaptureDecoder } from "../../src/browser/history/HistoricalCaptureDecoder.js";
import { HAR_CAPTURE_PROVIDER_IDENTITY } from "../../src/browser/history/CaptureRelease.js";
import { WebNetworkCaptureService } from "../../src/application/WebNetworkCaptureService.js";
import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js";
import { fileSnapshotPartialObservationSchema } from "../../src/domain/fileSnapshotObservation.js";
import { analysisErrorProjectionSchema } from "../../src/contracts/errorSchemas.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { historicalHar } from "../fixtures/historicalHar.js";
import { readStableArtifact } from "../../src/artifacts/readStableArtifact.js";
import { ArtifactOperationError } from "../../src/domain/artifactOperationError.js";
import { ProviderCleanupError } from "../../src/domain/providerCleanupError.js";
import { analysisErrorWithCleanupFailure } from "../../src/domain/analysisErrorCleanup.js";
import { err } from "../../src/domain/result.js";
import { deliverCliAnalysisEvidence } from "../../src/cli/analysisEvidenceDelivery.js";
import { redactExplicitFailure } from "../../src/domain/explicitSensitiveFailure.js";
const services: WebNetworkCaptureService[] = [];
afterEach(async () => {
fault.blocked = false;
for (const service of services.splice(0)) await service.close();
expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true);
fault.handles = [];
fault.path = "";
});
it.each(["path", "sha256"] as const)(
"excludes an explicitly sensitive selected capture %s from public snapshot facts",
async (sensitiveField) => {
const root = await createTestTempDirectory(
"rea-capture-snapshot-redaction-",
);
const path = join(root, "private-capture-marker.har");
const bytes = Buffer.from(JSON.stringify(historicalHar()));
const sha256 = createHash("sha256").update(bytes).digest("hex");
await writeFile(path, bytes);
fault.path = path;
fault.blocked = true;
const command = vi.fn(async () => {
throw new Error("Selected acquisition failure must precede decoding");
});
const service = new WebNetworkCaptureService(
new HistoricalCaptureDecoder(
[{ format: "har", identity: HAR_CAPTURE_PROVIDER_IDENTITY, command }],
process.env,
),
);
services.push(service);
const literal =
sensitiveField === "path" ? "private-capture-marker" : sha256;
const result = await service.inspect({
capture_path: path,
format: "har",
sensitive_values: [literal],
});
if (result.ok)
throw new Error("Expected selected descriptor close failure");
const projected = analysisErrorProjectionSchema.parse(
projectAnalysisError(result.error),
);
expect(projected.code).toBe("cleanup_incomplete");
expect(command).not.toHaveBeenCalled();
expect(fault.handles).toHaveLength(1);
expect(fault.handles[0]!.fd).toBeGreaterThanOrEqual(0);
expect(JSON.stringify(projected)).not.toContain(literal);
const partial = fileSnapshotPartialObservationSchema.parse(
projected.details?.partial_observation,
);
expect(partial).toMatchObject({ bytes: bytes.length });
expect(partial).toMatchObject(
sensitiveField === "path" ? { sha256 } : { path },
);
fault.blocked = false;
await service.close();
expect(result.error.cleanupIncomplete).toBe(false);
expect(JSON.stringify(projectAnalysisError(result.error))).not.toContain(
literal,
);
},
);
it("redacts repeated selected-file close failures at final CLI delivery and retains retry ownership", async () => {
const root = await createTestTempDirectory("rea-capture-cli-redaction-");
const path = join(root, "private-cli-capture.har");
const bytes = Buffer.from(JSON.stringify(historicalHar()));
const sha256 = createHash("sha256").update(bytes).digest("hex");
await writeFile(path, bytes);
fault.path = path;
fault.blocked = true;
const decoder = new HistoricalCaptureDecoder(
[
{
format: "har",
identity: HAR_CAPTURE_PROVIDER_IDENTITY,
command: async () => {
throw new Error("Acquisition must precede decoding");
},
},
],
process.env,
);
const service = new WebNetworkCaptureService(decoder);
services.push(service);
const values = ["private-cli-capture"];
const output = analysisErrorProjectionSchema.parse(
await deliverCliAnalysisEvidence({
run: () =>
service.inspect({
capture_path: path,
format: "har",
sensitive_values: values,
}),
service,
providerId: "web-network-capture",
operation: "inspect_web_network_capture",
signal: new AbortController().signal,
detail: "complete",
evidenceOutput: undefined,
redactFailure: (error) => redactExplicitFailure(error, values),
}),
);
expect(output.code).toBe("cleanup_incomplete");
expect(output.details).toMatchObject({
execution_failure: "execution_failure",
partial_observation: {
kind: "file-snapshot-hash",
path: null,
sha256,
bytes: bytes.length,
},
diagnostics: {
primary_error: { code: "cleanup_incomplete" },
cleanup_error: { code: "cleanup_incomplete" },
},
});
expect(JSON.stringify(output)).not.toContain(values[0]!);
expect(fault.handles).toHaveLength(1);
expect(fault.handles[0]!.fd).toBeGreaterThanOrEqual(0);
await expect(service.close()).rejects.toThrow();
fault.blocked = false;
await expect(service.close()).resolves.toBeUndefined();
expect(fault.handles[0]!.fd).toBe(-1);
});
it.each(["captured", "over-capacity"] as const)(
"preserves %s read facts through unchanged-error and cleanup-primary service redaction",
async (status) => {
const root = await createTestTempDirectory("rea-read-snapshot-redaction-");
const path = join(root, "private-read-marker.bin");
await writeFile(path, "physical snapshot bytes");
const snapshot = await readStableArtifact(path, 1024);
const capture =
status === "captured"
? { status, bytes: snapshot.bytes.length }
: {
status,
source_bytes_at_least: snapshot.bytes.length,
maximum_bytes: 1,
};
const partial = fileSnapshotPartialObservationSchema.parse({
kind: "file-snapshot-read",
path,
admitted_sha256: snapshot.sha256,
capture,
});
const primary = new ArtifactOperationError(
"inspect_artifact",
"io",
undefined,
undefined,
{ partialObservation: partial },
);
const service = new WebNetworkCaptureService({
inspect: async () => err(primary),
});
const input = {
capture_path: path,
format: "har",
sensitive_values: ["private-read-marker", snapshot.sha256],
};
const result = await service.inspect(input);
if (result.ok) throw new Error("Expected retained read failure");
// This error type is intentionally returned unchanged by diagnostic redaction.
expect(result.error).toBe(primary);
const expected = {
kind: "file-snapshot-read",
path: null,
admitted_sha256: null,
capture,
};
expect(primary.partialObservation).toEqual(expected);
const repeated = await service.inspect(input);
if (repeated.ok) throw new Error("Expected repeated retained read failure");
expect(repeated.error.partialObservation).toEqual(expected);
// Recreate the unsanitized source under cleanup to exercise recursive ownership.
const nested = new ArtifactOperationError(
"inspect_artifact",
"io",
undefined,
undefined,
{ partialObservation: partial },
);
const cleanup = new ProviderCleanupError("fixture", [path], {});
const composed = analysisErrorWithCleanupFailure(nested, cleanup);
composed.partialObservation = undefined;
const nestedService = new WebNetworkCaptureService({
inspect: async () => err(composed),
});
const nestedResult = await nestedService.inspect(input);
if (nestedResult.ok)
throw new Error("Expected nested retained read failure");
const projected = analysisErrorProjectionSchema.parse(
projectAnalysisError(nestedResult.error),
);
expect(
fileSnapshotPartialObservationSchema.parse(
nestedResult.error.cleanupPrimary?.partialObservation,
),
).toEqual(expected);
expect(projected.details?.diagnostics).toMatchObject({
primary_error: { details: { partial_observation: expected } },
});
expect(JSON.stringify(projected)).not.toContain("private-read-marker");
expect(JSON.stringify(projected)).not.toContain(snapshot.sha256);
composed.resolveCleanup();
const finalized = projectAnalysisError(nestedResult.error);
expect(finalized.details?.partial_observation).toEqual(expected);
},
);