From 0e578d43c8663be7dcb0f7f1da8dae7d44481853 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:25:08 +0800 Subject: [PATCH 01/31] refactor(native): retire legacy provider output wrappers --- src/domain/native/nativeDocument.ts | 11 +++-------- src/domain/native/nativeObservation.ts | 11 +++-------- src/domain/native/nativeProviderOutput.test.ts | 10 ++++++++-- src/domain/native/nativeProviderOutput.ts | 16 ---------------- src/domain/native/nativeSegments.ts | 11 +++-------- 5 files changed, 17 insertions(+), 42 deletions(-) diff --git a/src/domain/native/nativeDocument.ts b/src/domain/native/nativeDocument.ts index ddfe059052..54919ad9e2 100644 --- a/src/domain/native/nativeDocument.ts +++ b/src/domain/native/nativeDocument.ts @@ -2,19 +2,14 @@ import { z } from "zod"; import { ok, type Result } from "../result.js"; import { AnalysisOutputError } from "../analysisErrorCore.js"; import type { JsonValue } from "../jsonValue.js"; -import { - unwrapNativeProperty, - invalidNativeOutput, -} from "./nativeProviderOutput.js"; +import { invalidNativeOutput } from "./nativeProviderOutput.js"; -/** Parse direct or wrapped provider document names. */ +/** Parse the provider document-name array. */ export const parseDocuments = ( value: JsonValue, operation = "list_documents", ): Result => { - const parsed = z - .array(z.string()) - .safeParse(unwrapNativeProperty(value, "documents")); + const parsed = z.array(z.string()).safeParse(value); return parsed.success ? ok(parsed.data) : invalidNativeOutput(operation, "document list", parsed.error); diff --git a/src/domain/native/nativeObservation.ts b/src/domain/native/nativeObservation.ts index f8b170c342..4b8c614727 100644 --- a/src/domain/native/nativeObservation.ts +++ b/src/domain/native/nativeObservation.ts @@ -1,7 +1,4 @@ -import { - unwrapNativeProperty, - invalidNativeOutput, -} from "./nativeProviderOutput.js"; +import { invalidNativeOutput } from "./nativeProviderOutput.js"; import { z } from "zod"; import { procedureIdentitySchema } from "./procedureInfo.js"; import { AnalysisOutputError } from "../analysisErrorCore.js"; @@ -79,7 +76,7 @@ export const isNativeObservationOperation = ( ): operation is NativeObservationOperation => NATIVE_OBSERVATION_OPERATIONS.some((supported) => supported === operation); -/** Parse callee/caller strings from direct or wrapped provider results. */ +/** Parse the provider callee/caller address array. */ export const parseRelatedAddresses = ( value: JsonValue, relation: "callees" | "callers", @@ -87,9 +84,7 @@ export const parseRelatedAddresses = ( ? "procedure_callees" : "procedure_callers", ): Result => { - const parsed = z - .array(z.string()) - .safeParse(unwrapNativeProperty(value, relation)); + const parsed = z.array(z.string()).safeParse(value); return parsed.success ? ok(parsed.data) : invalidNativeOutput(operation, `${relation} list`, parsed.error); diff --git a/src/domain/native/nativeProviderOutput.test.ts b/src/domain/native/nativeProviderOutput.test.ts index 69ac6b4a6c..daa06b75b7 100644 --- a/src/domain/native/nativeProviderOutput.test.ts +++ b/src/domain/native/nativeProviderOutput.test.ts @@ -31,8 +31,8 @@ describe("native provider boundary values", () => { parseSegments([{ name: "__TEXT", start: "0x1", end: "0x2" }]).ok, ).toBe(false); }); - it("parses direct and wrapped protocol shapes", () => { - expect(parseRelatedAddresses({ callers: ["0x3"] }, "callers")).toEqual({ + it("parses provider arrays", () => { + expect(parseRelatedAddresses(["0x3"], "callers")).toEqual({ ok: true, value: ["0x3"], }); @@ -68,6 +68,12 @@ describe("native provider boundary values", () => { it.each([ ["relations", () => parseRelatedAddresses([1], "callees")], + [ + "wrapped relations", + () => parseRelatedAddresses({ callees: ["0x3"] }, "callees"), + ], + ["wrapped documents", () => parseDocuments({ documents: ["fixture"] })], + ["wrapped segments", () => parseSegments({ segments: [] })], ["segments", () => parseSegments([{ name: 1 }])], ["documents", () => parseDocuments([1])], ])( diff --git a/src/domain/native/nativeProviderOutput.ts b/src/domain/native/nativeProviderOutput.ts index 5dee5058dd..9dc0ba3060 100644 --- a/src/domain/native/nativeProviderOutput.ts +++ b/src/domain/native/nativeProviderOutput.ts @@ -1,22 +1,6 @@ import { z } from "zod"; import { err, type Result } from "../result.js"; import { AnalysisOutputError } from "../analysisErrorCore.js"; -import type { JsonValue } from "../jsonValue.js"; - -export const unwrapNativeProperty = ( - value: JsonValue, - property: string, -): JsonValue => { - if ( - typeof value === "object" && - value !== null && - !Array.isArray(value) && - property in value - ) { - return value[property] ?? null; - } - return value; -}; export const invalidNativeOutput = ( operation: string, diff --git a/src/domain/native/nativeSegments.ts b/src/domain/native/nativeSegments.ts index 04c9a31102..5a34397639 100644 --- a/src/domain/native/nativeSegments.ts +++ b/src/domain/native/nativeSegments.ts @@ -2,10 +2,7 @@ import { z } from "zod"; import { ok, type Result } from "../result.js"; import { AnalysisOutputError } from "../analysisErrorCore.js"; import type { JsonValue } from "../jsonValue.js"; -import { - unwrapNativeProperty, - invalidNativeOutput, -} from "./nativeProviderOutput.js"; +import { invalidNativeOutput } from "./nativeProviderOutput.js"; import { addressDistance } from "./addressCoordinate.js"; export interface SegmentSummary { @@ -29,14 +26,12 @@ const segmentSchema = z.object({ image_base: z.string().min(1).exactOptional(), address_space: z.string().min(1).exactOptional(), }); -/** Preserve reported address context in direct or wrapped provider segments. */ +/** Preserve reported address context in the provider segment array. */ export const parseSegments = ( value: JsonValue, operation = "list_segments", ): Result => { - const parsed = z - .array(segmentSchema) - .safeParse(unwrapNativeProperty(value, "segments")); + const parsed = z.array(segmentSchema).safeParse(value); return parsed.success ? ok(parsed.data) : invalidNativeOutput(operation, "segment list", parsed.error); From 29ebd6740550bf3989129c39c65f17e5c61e3dd2 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:25:23 +0800 Subject: [PATCH 02/31] fix(native): retain cleanup failures across capability fallback --- src/application/AnalysisDocument.ts | 8 +- src/application/CapabilityFallback.ts | 9 ++ .../EnhancedToolSelectorOrigin.test.ts | 40 +++++++++ src/application/EnhancedTools.ts | 24 ++--- src/application/ProcedureAddressResolution.ts | 12 ++- src/application/XrefsAddressResolution.ts | 7 +- .../EnhancedNativeInvestigation.test.ts | 40 +++++++++ .../native/NativeCallRoutes.test.ts | 66 +++++++++++++- src/application/native/NativeCallRoutes.ts | 3 +- .../NativeDispatchMetadataInspection.ts | 3 +- src/application/native/NativeUiActionTrace.ts | 38 ++++++-- .../mcp/nativeUiPartialCallRoutesMcp.test.ts | 89 +++++++++++++++++++ 12 files changed, 299 insertions(+), 40 deletions(-) create mode 100644 src/application/CapabilityFallback.ts diff --git a/src/application/AnalysisDocument.ts b/src/application/AnalysisDocument.ts index 73e7418df2..393d35a9b7 100644 --- a/src/application/AnalysisDocument.ts +++ b/src/application/AnalysisDocument.ts @@ -1,3 +1,4 @@ +import { permitsCapabilityFallback } from "./CapabilityFallback.js"; import type { AnalysisExecution, AnalysisOperationPort, @@ -5,7 +6,6 @@ import type { import { createAnalysisExecutionEvidence } from "../domain/analysisObservation.js"; import { AnalysisCancelledError, - AnalysisCapabilityUnavailableError, AnalysisOutputError, } from "../domain/analysisErrorCore.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; @@ -32,11 +32,7 @@ export const resolveAnalysisDocument = async ( executionOptions(signal), ); if (!current.ok) { - if ( - !(current.error instanceof AnalysisCapabilityUnavailableError) || - current.error.cleanupIncomplete - ) - return current; + if (!permitsCapabilityFallback(current.error)) return current; if (signal?.aborted) return err( new AnalysisCancelledError("current_document", { diff --git a/src/application/CapabilityFallback.ts b/src/application/CapabilityFallback.ts new file mode 100644 index 0000000000..c887710d9d --- /dev/null +++ b/src/application/CapabilityFallback.ts @@ -0,0 +1,9 @@ +import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js"; + +/** Unsupported operations permit alternatives only after owned cleanup completes. */ +export const permitsCapabilityFallback = ( + error: AnalysisError, +): error is AnalysisCapabilityUnavailableError => + error instanceof AnalysisCapabilityUnavailableError && + !error.cleanupIncomplete; diff --git a/src/application/EnhancedToolSelectorOrigin.test.ts b/src/application/EnhancedToolSelectorOrigin.test.ts index 48c21ea034..60af106439 100644 --- a/src/application/EnhancedToolSelectorOrigin.test.ts +++ b/src/application/EnhancedToolSelectorOrigin.test.ts @@ -259,3 +259,43 @@ describe("trace path completion and cancellation", () => { }); }); }); + +it.each([null, "", "0x1000"] as const)( + "validates the observed call-graph root and retains its limitations (%s)", + async (address) => { + const calls: string[] = []; + const tools = new EnhancedTools({ + execute: async (operation) => { + calls.push(operation); + return ok( + createAnalysisExecution( + operation === "procedure_address" ? address : [], + provider, + { + limitations: + operation === "procedure_address" + ? ["Root identity caveat"] + : [], + }, + ), + ); + }, + }); + const result = await tools.execute("get_call_graph", { address: "main" }); + if (address === "0x1000") { + expect(result).toMatchObject({ + ok: true, + value: { + limitations: expect.arrayContaining(["Root identity caveat"]), + }, + }); + expect(calls).toEqual(["procedure_address", "procedure_callees"]); + } else { + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisOutputError", operation: "procedure_address" }, + }); + expect(calls).toEqual(["procedure_address"]); + } + }, +); diff --git a/src/application/EnhancedTools.ts b/src/application/EnhancedTools.ts index a23d66aa20..03affdce84 100644 --- a/src/application/EnhancedTools.ts +++ b/src/application/EnhancedTools.ts @@ -1,3 +1,4 @@ +import { permitsCapabilityFallback } from "./CapabilityFallback.js"; import type { NativeCallGraph } from "../domain/native/nativeCallGraph.js"; import { nativeProcedureInputSchema } from "../domain/native/nativeProcedureInput.js"; import { CALL_RELATIONSHIP_TRAVERSALS } from "./CallRelationshipTraversal.js"; @@ -16,7 +17,6 @@ import { } from "../contracts/enhancedInputs.js"; import { AnalysisCancelledError, - AnalysisCapabilityUnavailableError, AnalysisOutputError, } from "../domain/analysisErrorCore.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; @@ -64,7 +64,10 @@ import { import { resolveAnalysisDocument } from "./AnalysisDocument.js"; import { traceCallPath } from "./CallPathTracing.js"; import { resolveXrefsName } from "./XrefsAddressResolution.js"; -import { resolveProcedureAddress } from "./ProcedureAddressResolution.js"; +import { + parseProcedureAddress, + resolveProcedureAddress, +} from "./ProcedureAddressResolution.js"; import { traceLiteralFeature } from "./EnhancedLiteralTracing.js"; import { projectNativeApiInspection } from "./native/NativeApiInspection.js"; @@ -103,12 +106,6 @@ const isAnalysisCancellation = ( error: AnalysisError, ): error is AnalysisCancelledError => error._tag === "AnalysisCancelledError"; -const permitsCapabilityFallback = ( - error: AnalysisError, -): error is AnalysisCapabilityUnavailableError => - error instanceof AnalysisCapabilityUnavailableError && - !error.cleanupIncomplete; - /** * Composes direct provider operations into reverse-engineering tools. * Direct callers use `execute`; adapters that own validation use @@ -578,14 +575,9 @@ export class EnhancedTools { if (!resolved.ok) return resolved; for (const limitation of resolved.value.limitations) limitations.add(limitation); - const startAddress = resolved.value.result; - if (typeof startAddress !== "string" || startAddress.length === 0) - return err( - new AnalysisOutputError( - "procedure_address", - "provider returned no canonical procedure address", - ), - ); + const parsedAddress = parseProcedureAddress(resolved.value.result); + if (!parsedAddress.ok) return parsedAddress; + const startAddress = parsedAddress.value; const discovered = new Set([startAddress]); const queue: Array<{ address: string; depth: number }> = [ { address: startAddress, depth: 0 }, diff --git a/src/application/ProcedureAddressResolution.ts b/src/application/ProcedureAddressResolution.ts index 7758c292f4..5b81d7a933 100644 --- a/src/application/ProcedureAddressResolution.ts +++ b/src/application/ProcedureAddressResolution.ts @@ -18,12 +18,18 @@ export const resolveProcedureAddress = async ( ): Promise> => { const result = await call("procedure_address", { procedure }, signal); if (!result.ok) return result; - return typeof result.value === "string" && result.value.length > 0 - ? ok(result.value) + return parseProcedureAddress(result.value); +}; + +/** Validate the provider-owned canonical identity without changing its source value. */ +export const parseProcedureAddress = ( + value: JsonValue, +): Result => + typeof value === "string" && value.length > 0 + ? ok(value) : err( new AnalysisOutputError( "procedure_address", "provider returned no canonical procedure address", ), ); -}; diff --git a/src/application/XrefsAddressResolution.ts b/src/application/XrefsAddressResolution.ts index 1fd799eef7..f28cf62506 100644 --- a/src/application/XrefsAddressResolution.ts +++ b/src/application/XrefsAddressResolution.ts @@ -1,7 +1,7 @@ +import { permitsCapabilityFallback } from "./CapabilityFallback.js"; import { addressedValueSchema } from "../domain/native/nativeInventory.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { - AnalysisCapabilityUnavailableError, AnalysisCancelledError, AnalysisInputError, AnalysisOutputError, @@ -48,9 +48,8 @@ export const resolveXrefsName = async ( if (!inventory.ok) { if ( operation === "xrefs" && - inventory.error instanceof AnalysisCapabilityUnavailableError && - inventory.error.operation === "list_names" && - !inventory.error.cleanupIncomplete + permitsCapabilityFallback(inventory.error) && + inventory.error.operation === "list_names" ) return resolveProcedureAddress( async (operation, parameters, signal) => { diff --git a/src/application/native/EnhancedNativeInvestigation.test.ts b/src/application/native/EnhancedNativeInvestigation.test.ts index 0dd818372d..093e8d4825 100644 --- a/src/application/native/EnhancedNativeInvestigation.test.ts +++ b/src/application/native/EnhancedNativeInvestigation.test.ts @@ -1,3 +1,4 @@ +import { AnalysisCleanupLease } from "../../artifacts/AnalysisCleanupLease.js"; // Fake-backed composition coverage; real provider verification lives in // `npm run verify:hopper` and `npm run verify:ghidra`. import { describe, expect, it } from "vitest"; @@ -874,3 +875,42 @@ describe("native UI trace boundaries", () => { }); }); }); + +it("retains both initial cleanup owners until each lease releases", async () => { + const failure = (operation: string, resource: string) => + new AnalysisCapabilityUnavailableError( + provider.id, + operation, + "unavailable", + { cleanup: { reason: "release failed", resources: [resource] } }, + ); + const ui = failure("decode_interface_builder", "ui-owner"); + const metadata = failure( + "inspect_native_dispatch_metadata", + "metadata-owner", + ); + const uiLease = new AnalysisCleanupLease(async () => {}, ["ui-owner"]); + const metadataLease = new AnalysisCleanupLease(async () => {}, [ + "metadata-owner", + ]); + uiLease.bindCleanupError(ui); + metadataLease.bindCleanupError(metadata); + const result = await new EnhancedTools( + testAnalysis({ + decode_interface_builder: () => err(ui), + inspect_native_dispatch_metadata: () => err(metadata), + }), + ).execute("trace_native_ui_action", { action: "0x1000" }); + if (result.ok) throw new Error("Expected cleanup error"); + expect(result.error.cleanupPrimary).toBe(metadata); + expect(result.error.cleanupResources).toEqual( + expect.arrayContaining(["ui-owner", "metadata-owner"]), + ); + await uiLease.release(); + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.cleanupResources).toEqual(["metadata-owner"]); + await metadataLease.release(); + expect(result.error.cleanupIncomplete).toBe(false); + expect(result.error.cleanupResources).toEqual([]); + expect(result.error.cleanupPrimary).toBe(metadata); +}); diff --git a/src/application/native/NativeCallRoutes.test.ts b/src/application/native/NativeCallRoutes.test.ts index 0d6f10b1d6..ae8cf850ca 100644 --- a/src/application/native/NativeCallRoutes.test.ts +++ b/src/application/native/NativeCallRoutes.test.ts @@ -1,6 +1,10 @@ +import { AnalysisCleanupLease } from "../../artifacts/AnalysisCleanupLease.js"; import { describe, expect, it } from "vitest"; import { readNativeCallRoutes } from "./NativeCallRoutes.js"; -import { createAnalysisExecution } from "../AnalysisProvider.js"; +import { + createAnalysisExecution, + type AnalysisOperation, +} from "../AnalysisProvider.js"; import { ghidraReferenceEdge } from "../../domain/ghidraValues.fixture.js"; import { jsonValueSchema } from "../../domain/jsonValue.js"; import { err, ok } from "../../domain/result.js"; @@ -575,3 +579,63 @@ describe("missing unresolved-call observations", () => { expect(calls).toBe(1); }); }); + +it.each([false, true])( + "preserves capability cleanup ownership (incomplete=%s)", + async (incomplete) => { + const failure = new AnalysisCapabilityUnavailableError( + provider.id, + "procedure_references", + "Typed references unavailable", + incomplete + ? { cleanup: { reason: "release failed", resources: ["route-owner"] } } + : undefined, + ); + const owner = { + cleanupResources: incomplete ? ["route-owner"] : ([] as string[]), + }; + failure.bindCleanupResourceOwner(owner); + const lease = new AnalysisCleanupLease(async () => { + owner.cleanupResources = []; + }, owner); + lease.bindCleanupError(failure); + const calls: string[] = []; + const analysis = { + execute: async (operation: AnalysisOperation) => { + calls.push(operation); + return operation === "procedure_references" + ? err(failure) + : ok(createAnalysisExecution(["0x2000"], provider)); + }, + }; + const result = await readNativeCallRoutes(analysis, "0x1000"); + expect(calls).toEqual( + incomplete + ? ["procedure_references"] + : ["procedure_references", "procedure_callees"], + ); + if (incomplete) { + expect(result.outcome).toEqual(err(failure)); + if (result.outcome.ok) throw new Error("Expected retained cleanup error"); + expect(result.outcome.error).toBe(failure); + expect(result.outcome.error.cleanupResources).toEqual(["route-owner"]); + await lease.release(); + expect(result.outcome.error.cleanupResources).toEqual([]); + const recovered = await readNativeCallRoutes(analysis, "0x1000"); + expect(recovered.outcome.ok).toBe(true); + expect(calls).toEqual([ + "procedure_references", + "procedure_references", + "procedure_callees", + ]); + } else { + expect(result.outcome.ok).toBe(true); + expect(result.routes).toContainEqual( + expect.objectContaining({ + address: "0x2000", + relation: "provider_call", + }), + ); + } + }, +); diff --git a/src/application/native/NativeCallRoutes.ts b/src/application/native/NativeCallRoutes.ts index 16d1568adb..835b443660 100644 --- a/src/application/native/NativeCallRoutes.ts +++ b/src/application/native/NativeCallRoutes.ts @@ -1,3 +1,4 @@ +import { permitsCapabilityFallback } from "../CapabilityFallback.js"; import { z } from "zod"; import type { AnalysisOperationPort, @@ -171,7 +172,7 @@ export const readNativeCallRoutes = async ( (parsed.data.reference_kinds_available || references.length > 0) ) return finish(ok(undefined)); - } else if (execution.error._tag !== "AnalysisCapabilityUnavailableError") + } else if (!permitsCapabilityFallback(execution.error)) return finish(err(execution.error)); const callees = await analysis.execute( NATIVE_CALL_ROUTE_SOURCES.callees, diff --git a/src/application/native/NativeDispatchMetadataInspection.ts b/src/application/native/NativeDispatchMetadataInspection.ts index c56f64c89b..82a20a5fda 100644 --- a/src/application/native/NativeDispatchMetadataInspection.ts +++ b/src/application/native/NativeDispatchMetadataInspection.ts @@ -1,3 +1,4 @@ +import { permitsCapabilityFallback } from "../CapabilityFallback.js"; import { nativeInventoryRowsSchemas } from "../../domain/native/nativeInventory.js"; import { nativeDispatchMetadataResultSchema, @@ -59,7 +60,7 @@ export const inspectNativeDispatch = async ( nativeDispatchMetadataResultSchema.parse(binaryMetadata.value.result), ), ); - if (binaryMetadata.error._tag !== "AnalysisCapabilityUnavailableError") + if (!permitsCapabilityFallback(binaryMetadata.error)) return err(binaryMetadata.error); const execution = await analysis.execute( NATIVE_DISPATCH_METADATA_SOURCES.symbols, diff --git a/src/application/native/NativeUiActionTrace.ts b/src/application/native/NativeUiActionTrace.ts index 6da173330f..350c2cd199 100644 --- a/src/application/native/NativeUiActionTrace.ts +++ b/src/application/native/NativeUiActionTrace.ts @@ -1,3 +1,5 @@ +import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; +import { permitsCapabilityFallback } from "../CapabilityFallback.js"; import { z } from "zod"; import type { AnalysisOperationPort } from "../AnalysisProvider.js"; @@ -48,7 +50,31 @@ export const traceNativeUiAction = async ( ), inspectNativeDispatch(analysis, 20_000, { signal }), ]); - if (!uiExecution.ok && uiExecution.error._tag === "AnalysisCancelledError") + if (!uiExecution.ok && !metadataExecution.ok) { + const primary = + uiExecution.error._tag === "AnalysisCancelledError" + ? uiExecution.error + : metadataExecution.error; + const secondary = + primary === uiExecution.error + ? metadataExecution.error + : uiExecution.error; + if (secondary.cleanupIncomplete) + return err( + analysisErrorWithCleanupFailure( + primary, + secondary, + "trace_native_ui_action", + ).bindCleanupDependencies([primary, secondary]), + ); + if (primary.cleanupIncomplete) primary.retainSecondaryFailure(secondary); + return err(primary); + } + if ( + !uiExecution.ok && + (uiExecution.error._tag === "AnalysisCancelledError" || + uiExecution.error.cleanupIncomplete) + ) return err(uiExecution.error); if (!metadataExecution.ok) return err(metadataExecution.error); const metadata = nativeDispatchMetadataResultSchema.parse( @@ -134,12 +160,7 @@ export const traceNativeUiAction = async ( { procedure: input.action }, signal === undefined ? {} : { signal }, ); - if (!native.ok && native.error._tag === "AnalysisCancelledError") - return err(native.error); - if ( - !native.ok && - native.error._tag !== "AnalysisCapabilityUnavailableError" - ) + if (!native.ok && !permitsCapabilityFallback(native.error)) return err(native.error); if (native.ok) { const address = z.string().safeParse(native.value.result); @@ -438,7 +459,8 @@ const expandNativeCalls = async (input: { sourceLimitations.push(...execution.limitations); if ( !execution.outcome.ok && - execution.outcome.error._tag === "AnalysisCancelledError" + (execution.outcome.error._tag === "AnalysisCancelledError" || + execution.outcome.error.cleanupIncomplete) ) return { error: execution.outcome.error }; const unknowns = execution.unknowns; diff --git a/tests/boundary/mcp/nativeUiPartialCallRoutesMcp.test.ts b/tests/boundary/mcp/nativeUiPartialCallRoutesMcp.test.ts index 15e8e16a28..941253bf05 100644 --- a/tests/boundary/mcp/nativeUiPartialCallRoutesMcp.test.ts +++ b/tests/boundary/mcp/nativeUiPartialCallRoutesMcp.test.ts @@ -112,6 +112,10 @@ const noClassifiedReferences = { type Plan = { readonly references?: unknown; + readonly referencesFailure?: AnalysisError; + readonly uiFailure?: AnalysisError; + readonly metadataFailure?: AnalysisError; + readonly calls?: string[]; readonly callees?: readonly string[]; readonly fallbackProvider?: typeof identity; readonly fallbackFailure?: "unavailable" | "output" | "cancelled"; @@ -122,6 +126,22 @@ function publicTrace(plan: Plan, maxNodes = 12, maxEdges = 500) { operation, parameters, ): Promise> => { + plan.calls?.push(operation); + if ( + operation === "decode_interface_builder" && + plan.uiFailure !== undefined + ) + return err(plan.uiFailure); + if ( + operation === "inspect_native_dispatch_metadata" && + plan.metadataFailure !== undefined + ) + return err(plan.metadataFailure); + if ( + operation === "procedure_references" && + plan.referencesFailure !== undefined + ) + return err(plan.referencesFailure); if (operation === "decode_interface_builder") return err( new AnalysisCapabilityUnavailableError( @@ -716,3 +736,72 @@ it.each([ ]); }, ); + +it.each([false, true])( + "keeps unsupported reference fallback conditional on completed cleanup through MCP (incomplete=%s)", + async (incomplete) => { + const calls: string[] = []; + const failure = new AnalysisCapabilityUnavailableError( + identity.id, + "procedure_references", + "Typed references unavailable", + incomplete + ? { cleanup: { reason: "release failed", resources: ["route-owner"] } } + : undefined, + ); + const response = await publicTrace({ referencesFailure: failure, calls }); + if (incomplete) { + expect(calls).not.toContain("procedure_callees"); + expect(parseMcpToolError(response).error).toMatchObject({ + code: "cleanup_incomplete", + details: { resources: ["route-owner"] }, + }); + expect(failure.cleanupResources).toEqual(["route-owner"]); + } else { + expect(response.isError).not.toBe(true); + expect(calls).toContain("procedure_callees"); + expect(traceFrom(response).edges).toContainEqual( + expect.objectContaining({ + to: "native:function:0x3000", + relation: "provider_call", + }), + ); + } + }, +); + +it.each([false, true])( + "retains initial UI cleanup failure with failed metadata peer=%s", + async (failedPeer) => { + const cleanupFailure = (operation: string, resource: string) => + new AnalysisCapabilityUnavailableError( + identity.id, + operation, + "unavailable after cleanup failed", + { cleanup: { reason: "release failed", resources: [resource] } }, + ); + const calls: string[] = []; + const response = await publicTrace({ + calls, + uiFailure: cleanupFailure("decode_interface_builder", "ui-owner"), + ...(failedPeer + ? { + metadataFailure: cleanupFailure( + "inspect_native_dispatch_metadata", + "metadata-owner", + ), + } + : {}), + }); + expect(parseMcpToolError(response).error).toMatchObject({ + code: "cleanup_incomplete", + details: { + resources: expect.arrayContaining( + failedPeer ? ["ui-owner", "metadata-owner"] : ["ui-owner"], + ), + }, + }); + expect(calls).not.toContain("procedure_address"); + expect(calls).not.toContain("list_names"); + }, +); From 87e2cc26d893a89c7f72ac35744634bb0e2dcb7a Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:28:49 +0800 Subject: [PATCH 03/31] fix(environment): align comparison collection identity --- docs/web-network-captures.md | 3 +- .../environment/environmentCompare.test.ts | 63 +++++++++++++++++++ .../environment/environmentCompareMatch.ts | 6 +- 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index 20592f1fdc..c621f877de 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -625,7 +625,8 @@ refused for them, as the manifest is not. Each `GET` tool whose template ends in a parameter is an item tool, and its type's `collection_template` is the template without that parameter; any other -`GET` tool is a list tool of its own template. Empty segments are ignored, so a +`GET` tool is a list tool of its own template. Interior empty segments remain +meaningful: `/api/books` and `/api//books` name distinct collections. A single trailing slash names the same collection. An item tool proves its type's `id_field` when every initial response carries the requested path value in the same field of one entity object (`id_basis: "path-parameter"`). The object is diff --git a/src/application/environment/environmentCompare.test.ts b/src/application/environment/environmentCompare.test.ts index 677b240408..c69917b786 100644 --- a/src/application/environment/environmentCompare.test.ts +++ b/src/application/environment/environmentCompare.test.ts @@ -130,6 +130,26 @@ describe("matchProbeRequest", () => { expect(item?.args).toEqual({ id: "%31" }); }); + it("keeps trailing slashes significant when matching HTTP route templates", () => { + const manifest = { + ...source.manifest, + tools: source.manifest.tools.map((tool) => ({ + ...tool, + path_template: `${tool.path_template}/`, + })), + }; + const request = { + method: "GET", + path: "/api/books/7/", + query: null, + body: undefined, + }; + expect(matchProbeRequest(manifest, request)?.args).toEqual({ id: "7" }); + expect( + matchProbeRequest(manifest, { ...request, path: "/api/books/7" }), + ).toBeUndefined(); + }); + it("keeps every occurrence of a repeated query value", () => { const item = matchProbeRequest(environmentServeSource().manifest, { method: "GET", @@ -176,6 +196,31 @@ describe("collections under different parents", () => { expect(map.env(110, "/api/orgs/2/users")).toBe(20); }); + it("uses state collection identity for root and trailing-slash routes", () => { + const trailing = { + ...nestedUsers, + path_template: `${nestedUsers.path_template}/`, + }; + const segments = ["api", "orgs", "%32", "users", "101", ""]; + expect(collectionScope(trailing, segments)).toBe("/api/orgs/2/users"); + expect(parameterScopes(trailing, segments)).toEqual( + parameterScopes(nestedUsers, segments), + ); + expect( + collectionScope({ ...nestedUsers, path_template: "/", params: [] }), + ).toBe("/"); + expect( + collectionScope({ + ...nestedUsers, + path_template: "/api//books/", + params: [], + }), + ).toBe("/api//books"); + expect( + collectionScope(trailing, ["api", "orgs", "a%2Fb", "users", "101", ""]), + ).toBe("/api/orgs/a%2Fb/users"); + }); + it("shares a child map when the same parent is spelled with one encoding", () => { const encoded = ["api", "orgs", "%32", "users", "101"]; const plain = ["api", "orgs", "2", "users", "101"]; @@ -465,6 +510,24 @@ const compareRows = ( return report.value.probes[0]!; }; +it("learns ids from trailing-slash lists in the collection their item calls name", () => { + const item = (id: number, name: string) => ({ id, name }); + const probe = compareRows( + [ + ["GET", "/api/books", [item(1, "Book")]], + ["GET", "/api/books/1", item(1, "Book")], + ["GET", "/api/authors/", [item(2, "Ann")]], + ["GET", "/api/authors/2/", item(2, "Ann")], + ], + [ + ["GET", "/api/books", [item(101, "Book")]], + ["GET", "/api/authors/", [item(101, "Ann")]], + ["GET", "/api/authors/101/", item(101, "Ann")], + ], + ); + expect(probe.results.map((step) => step.equal)).toEqual([true, true, true]); +}); + describe("ids the comparison used to misread", () => { it.each([ ["", "", true], diff --git a/src/application/environment/environmentCompareMatch.ts b/src/application/environment/environmentCompareMatch.ts index ec7e3ab5e6..ce19b3dbae 100644 --- a/src/application/environment/environmentCompareMatch.ts +++ b/src/application/environment/environmentCompareMatch.ts @@ -59,9 +59,7 @@ const collectionAt = ( segments?: readonly string[], ): string => { const params = new Set(tool.params.map((param) => param.index)); - return `/${tool.path_template - .slice(1) - .split("/") + return `/${segmentsOf(tool.path_template) .slice(0, end) .map((segment, index) => { if (!params.has(index)) return segment; @@ -78,7 +76,7 @@ export const collectionScope = ( tool: EnvironmentManifestTool, segments?: readonly string[], ): string => { - const length = tool.path_template.slice(1).split("/").length; + const length = segmentsOf(tool.path_template).length; return collectionAt( tool, tool.params.some((param) => param.index === length - 1) From af9bc3ab5015da3734e79f16b550b4b28297356a Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:29:57 +0800 Subject: [PATCH 04/31] fix(ci): validate static evidence through receipt owner --- scripts/ci/artifacts.mjs | 34 +++--------- .../filesystem/ciFailureReport.test.ts | 31 +++++++++++ .../filesystem/ciStaticArtifacts.test.ts | 53 +++++++++++++++++++ 3 files changed, 91 insertions(+), 27 deletions(-) diff --git a/scripts/ci/artifacts.mjs b/scripts/ci/artifacts.mjs index ee12e2013f..0745c4835f 100644 --- a/scripts/ci/artifacts.mjs +++ b/scripts/ci/artifacts.mjs @@ -273,7 +273,7 @@ function normalizeLint(report) { .filter((item) => item.severity === "error") .map((item) => { if ( - (item.filename !== undefined && !repoPath(item.filename)) || + (item.filename !== undefined && !validRepoPath(item.filename)) || (item.code !== undefined && typeof item.code !== "string") || typeof item.message !== "string" ) @@ -304,7 +304,7 @@ function normalizeDuplicates(report) { if (exitCode !== 1) return []; return report.duplicates.map((item) => { const paths = [item.firstFile?.name, item.secondFile?.name].sort(); - if (paths.some((path) => !repoPath(path))) + if (paths.some((path) => !validRepoPath(path))) throw new Error("jscpd duplicate paths are malformed"); return { kind: "jscpd", paths }; }); @@ -338,18 +338,6 @@ async function readLimitedBytes(response, limit) { return result; } -function repoPath(value) { - return ( - typeof value === "string" && - value.length > 0 && - !value.startsWith("/") && - !value.includes("\\") && - value - .split("/") - .every((part) => part !== "" && part !== "." && part !== "..") - ); -} - function positiveId(value) { const id = Number(value); if (!Number.isSafeInteger(id) || id < 1) @@ -398,19 +386,11 @@ function sameTaskHashes(diagnostics, left, right) { }); } function validContext(value) { - return ( - value?.version === 1 && - SHA.test(value.sourceSha ?? "") && - value.sourceClean === true && - typeof value.nodeVersion === "string" && - typeof value.npmVersion === "string" && - typeof value.turboVersion === "string" && - typeof value.platform === "string" && - typeof value.architecture === "string" && - value.taskHashes && - typeof value.taskHashes === "object" && - !Array.isArray(value.taskHashes) - ); + try { + return parseStaticTaskContext(value).sourceClean; + } catch { + return false; + } } function sameExecutionContext(left, right) { return [ diff --git a/tests/boundary/filesystem/ciFailureReport.test.ts b/tests/boundary/filesystem/ciFailureReport.test.ts index 0811d89f25..8dd3d1fa47 100644 --- a/tests/boundary/filesystem/ciFailureReport.test.ts +++ b/tests/boundary/filesystem/ciFailureReport.test.ts @@ -120,6 +120,37 @@ it("attributes only an identical structured lint failure with matching task, con }); }); +it.each([ + ...[ + "nodeVersion", + "npmVersion", + "turboVersion", + "platform", + "architecture", + ].map((field) => [field, { [field]: "" }] as const), + ["optional head hash", { prHeadSha: "bad" }], + [ + "unrelated task hash", + { taskHashes: { "lint:built": "a".repeat(16), typecheck: "bad" } }, + ], +] as const)( + "keeps malformed %s provenance unknown on either side", + (_label, invalid) => { + for (const side of ["context", "mainContext"] as const) { + const input = comparison(); + expect( + classifyFailure({ + ...input, + [side]: { ...input[side], ...invalid }, + }), + ).toEqual({ + classification: "unknown", + reason: "static_task_context_missing_or_invalid", + }); + } + }, +); + it.each(["path", "code"] as const)( "keeps applicability unknown when the lint %s is unavailable on both runs", (missing) => { diff --git a/tests/boundary/filesystem/ciStaticArtifacts.test.ts b/tests/boundary/filesystem/ciStaticArtifacts.test.ts index a499fef684..af44d8197c 100644 --- a/tests/boundary/filesystem/ciStaticArtifacts.test.ts +++ b/tests/boundary/filesystem/ciStaticArtifacts.test.ts @@ -94,6 +94,59 @@ it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( }, ); +it("uses the same repository path admission for archived and compared diagnostics", async () => { + const root = await createTestTempDirectory("rea-ci-static-paths-"); + await writeFile( + join(root, "static-task-context.json"), + JSON.stringify({ + version: 1, + sourceSha: "a".repeat(40), + sourceClean: true, + nodeVersion: process.version, + npmVersion: "11.16.0", + turboVersion: "2.11.7", + platform: process.platform, + architecture: process.arch, + taskHashes: { "lint:built": "a".repeat(16) }, + }), + ); + const archive = join(root, "reports.zip"); + for (const path of [ + "src/space name.ts", + "src/中文.ts", + "", + "/absolute.ts", + "../outside.ts", + "src/./file.ts", + "src//file.ts", + "src\\file.ts", + ]) { + await writeFile( + join(root, "oxlint.json"), + JSON.stringify({ + diagnostics: [ + { filename: path, severity: "error", message: "failure" }, + ], + }), + ); + await execute( + "zip", + ["-q", archive, "oxlint.json", "static-task-context.json"], + { + cwd: root, + }, + ); + if (path === "src/space name.ts" || path === "src/中文.ts") { + const evidence = await parseStaticArchive(archive); + expect(diagnosticPaths(evidence.diagnostics[0]!)).toEqual([path]); + } else { + await expect(parseStaticArchive(archive)).rejects.toThrow( + "oxlint error diagnostic is malformed", + ); + } + } +}); + it.each([3.5, 100, 0])( "retains real lint failures when the duplication threshold is %s", async (threshold) => { From 381004b6648c1a6532b3d0ae42d7ea3d52d17306 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:34:22 +0800 Subject: [PATCH 05/31] fix(decompile): preserve provider cleanup through export finalization --- .../decompileDirectory/decompileAll.test.ts | 162 ++++++++++++++++++ .../decompileDirectory/decompileAll.ts | 25 ++- .../boundary/mcp/decompileAllCaveats.test.ts | 51 +++++- 3 files changed, 232 insertions(+), 6 deletions(-) diff --git a/src/application/decompileDirectory/decompileAll.test.ts b/src/application/decompileDirectory/decompileAll.test.ts index 4e7e3da2f6..445a704fa5 100644 --- a/src/application/decompileDirectory/decompileAll.test.ts +++ b/src/application/decompileDirectory/decompileAll.test.ts @@ -9,6 +9,7 @@ import { type AnalysisOperation, type AnalysisOperationPort, } from "../AnalysisProvider.js"; +import { AnalysisCleanupLease } from "../../artifacts/AnalysisCleanupLease.js"; import { EnhancedTools } from "../EnhancedTools.js"; import { DecompileExport } from "./exportTree.js"; import { @@ -24,6 +25,7 @@ import { decompileJournalLineSchema, } from "../../contracts/decompileDirectory.js"; import { createAnalysisProfile } from "../../domain/analysisProfile.js"; +import { createEvidence } from "../../domain/evidence.js"; import { AnalysisCancelledError, AnalysisCapabilityUnavailableError, @@ -1704,3 +1706,163 @@ describe("decompile_all repeated output failures", () => { }); }); }); + +describe("decompile_all provider cleanup ownership", () => { + const directory = useOutputDirectory(); + afterEach(() => vi.restoreAllMocks()); + + it.each([ + ["procedure_info", false], + ["procedure_info", true], + ["procedure_pseudo_code", false], + ["procedure_pseudo_code", true], + ] as const)( + "retains %s cleanup and committed work with caller aborted=%s", + async (operation, abort) => { + const { port } = harness(); + const controller = new AbortController(); + const cause = new Error("provider cleanup stopped"); + const callerReason = new Error("caller stopped"); + const partial = createEvidence(subject, provider, { + operation, + parameters: { procedure: "0x2000" }, + result: + operation === "procedure_info" + ? infoFor("0x2000") + : "int partial(void) {}", + analysisProfile: profile, + confidence: "observed", + }); + const failure = new AnalysisCancelledError(operation, { + cause, + partialObservation: partial, + capturedOutput: { + stdout: "retained provider output", + stderr: "", + truncated: false, + }, + cleanup: { + reason: "descriptor close denied", + resources: ["provider descriptor"], + }, + }); + const lease = new AnalysisCleanupLease( + async () => undefined, + ["provider descriptor"], + ); + lease.bindCleanupError(failure); + const observed: AnalysisOperation[] = []; + const failing: AnalysisOperationPort = { + execute: (selected, parameters, options) => { + observed.push(selected); + if (selected === operation && parameters.procedure === "0x2000") { + if (abort) controller.abort(callerReason); + return Promise.resolve(err(failure)); + } + return port.execute(selected, parameters, options); + }, + }; + const result = await run( + new EnhancedTools(failing), + directory(), + {}, + controller.signal, + ); + expect(result).toEqual(err(failure)); + expect(failure.cause).toBe(cause); + if (abort) { + expect(failure.secondaryFailures).toHaveLength(1); + expect(failure.secondaryFailures[0]).toBeInstanceOf( + AnalysisCancelledError, + ); + expect(failure.secondaryFailures[0]?.cause).toBe(callerReason); + } else expect(failure.secondaryFailures).toHaveLength(0); + expect(failure.partialObservation).toBe(partial); + expect(failure.capturedOutput?.stdout).toBe("retained provider output"); + expect(failure.cleanupIncomplete).toBe(true); + expect(observed.at(-1)).toBe(operation); + const index = decompileDirectoryIndexSchema.parse( + JSON.parse( + await readFile( + join(directory(), DECOMPILE_DIRECTORY_INDEX_NAME), + "utf8", + ), + ), + ); + expect(index.functions).toHaveLength(1); + expect(index.functions[0]).toMatchObject({ + address: "0x1000", + status: "written", + }); + await expect( + readFile(join(directory(), "functions", "0x1000.c"), "utf8"), + ).resolves.toContain(sentinel); + await lease.release(); + expect(failure.cleanupIncomplete).toBe(false); + }, + ); +}); + +describe("decompile_all mixed cleanup ownership", () => { + const directory = useOutputDirectory(); + afterEach(() => vi.restoreAllMocks()); + + it("waits for both provider and journal cleanup owners after mixed finalization failures", async () => { + const { port } = harness(); + const providerFailure = new AnalysisOutputError( + "procedure_info", + "provider cleanup failed", + { + cleanup: { + reason: "provider close denied", + resources: ["provider descriptor"], + }, + }, + ); + const closeFailure = new AnalysisOutputError( + "decompile_all", + "journal cleanup failed", + { + cleanup: { + reason: "journal close denied", + resources: ["journal descriptor"], + }, + }, + ); + const providerLease = new AnalysisCleanupLease( + async () => undefined, + ["provider descriptor"], + ); + const journalLease = new AnalysisCleanupLease( + async () => undefined, + ["journal descriptor"], + ); + providerLease.bindCleanupError(providerFailure); + journalLease.bindCleanupError(closeFailure); + const close = DecompileExport.prototype.close; + vi.spyOn(DecompileExport.prototype, "close").mockImplementation( + async function (this: DecompileExport) { + await close.call(this); + return err(closeFailure); + }, + ); + const failing: AnalysisOperationPort = { + execute: (operation, parameters, options) => + operation === "procedure_info" + ? Promise.resolve(err(providerFailure)) + : port.execute(operation, parameters, options), + }; + const result = await run(new EnhancedTools(failing), directory()); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.cleanupPrimary).toBe(providerFailure); + expect(result.error.cleanupResources).toEqual( + expect.arrayContaining(["provider descriptor", "journal descriptor"]), + ); + await journalLease.release(); + expect(closeFailure.cleanupIncomplete).toBe(false); + expect(result.error.cleanupIncomplete).toBe(true); + await providerLease.release(); + expect(result.error.cleanupIncomplete).toBe(false); + }); +}); diff --git a/src/application/decompileDirectory/decompileAll.ts b/src/application/decompileDirectory/decompileAll.ts index 4bf91ba4b2..d42433638c 100644 --- a/src/application/decompileDirectory/decompileAll.ts +++ b/src/application/decompileDirectory/decompileAll.ts @@ -173,6 +173,7 @@ export const decompileAll = async ( const callerStoppedPublish = !published.ok && published.error instanceof AnalysisCancelledError && + !published.error.cleanupIncomplete && isAborted(signal); const failures = [ ...(!published.ok && !callerStoppedPublish ? [published.error] : []), @@ -181,6 +182,14 @@ export const decompileAll = async ( ]; const primary = failures[0]; if (primary !== undefined) { + if ( + isAborted(signal) && + !( + primary instanceof AnalysisCancelledError && + primary.cause === signal?.reason + ) + ) + primary.retainSecondaryFailure(cancelled(signal)); for (const failure of failures.slice(1)) primary.retainSecondaryFailure(failure); return !closed.ok && @@ -191,7 +200,7 @@ export const decompileAll = async ( primary, closed.error, "decompile_all", - ).bindCleanupDependencies([closed.error]), + ).bindCleanupDependencies([primary, closed.error]), ) : err(primary); } @@ -465,7 +474,7 @@ const publishOne = async (input: { input.signal, input.bodySupport, ); - if (body.kind === "cancelled") + if (body.kind === "error") return { result: err(body.error), bodySupport: input.bodySupport }; if (body.kind === "unavailable") input.state.bodyUnavailable = true; // Caveats of the observations this attempt used. The journal record keeps @@ -489,6 +498,9 @@ const publishOne = async (input: { { procedure: input.procedure.address }, executionOptions(input.signal), ); + // A provider still owns cleanup even if the caller aborted meanwhile. + if (!decompiled.ok && decompiled.error.cleanupIncomplete) + return { result: decompiled, bodySupport: body.support }; if (isAborted(input.signal)) return { result: err(cancelled(input.signal)), bodySupport: body.support }; if (!decompiled.ok) { @@ -613,7 +625,7 @@ const readBody = async ( readonly fact: FunctionBody; readonly support: "unavailable"; } - | { readonly kind: "cancelled"; readonly error: AnalysisCancelledError } + | { readonly kind: "error"; readonly error: AnalysisError } > => { if (support === "unavailable") return { @@ -627,8 +639,11 @@ const readBody = async ( executionOptions(signal), ); if (!observed.ok) { - if (observed.error instanceof AnalysisCancelledError) - return { kind: "cancelled", error: observed.error }; + if ( + observed.error.cleanupIncomplete || + observed.error instanceof AnalysisCancelledError + ) + return { kind: "error", error: observed.error }; if (observed.error instanceof AnalysisCapabilityUnavailableError) return { kind: "unavailable", diff --git a/tests/boundary/mcp/decompileAllCaveats.test.ts b/tests/boundary/mcp/decompileAllCaveats.test.ts index cc3b5987a7..df7e4050a7 100644 --- a/tests/boundary/mcp/decompileAllCaveats.test.ts +++ b/tests/boundary/mcp/decompileAllCaveats.test.ts @@ -9,7 +9,11 @@ import { import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; import { parseEvidence } from "../../../src/domain/evidence.js"; import { err, ok } from "../../../src/domain/result.js"; -import { AnalysisInputError } from "../../../src/domain/analysisErrorCore.js"; +import { + AnalysisOutputError, + AnalysisInputError, +} from "../../../src/domain/analysisErrorCore.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { decompileDirectoryIndexSchema, decompileDirectoryManifestSchema, @@ -144,3 +148,48 @@ it("keeps long-name failures inside the tool window and complete in the index", index.functions.every((record) => record.limitations?.includes(caveat)), ).toBe(true); }); + +it.each(["procedure_info", "procedure_pseudo_code"] as const)( + "reports %s cleanup failure through MCP while retaining completed files", + async (operation) => { + const parent = await createTestTempDirectory("rea-decompile-cleanup-"); + const output = join(parent, "out"); + const failure = new AnalysisOutputError( + operation, + "provider close failed", + { + cleanup: { + reason: "provider close denied", + resources: ["provider descriptor"], + }, + }, + ); + const port: AnalysisOperationPort = { + execute: (selected, parameters, options) => + selected === operation && parameters.procedure === addresses[1] + ? Promise.resolve(err(failure)) + : addressCaveats.execute(selected, parameters, options), + }; + const client = await connect(port); + const result = await client.callTool({ + name: "decompile_all", + arguments: { output_directory: output }, + }); + expect(result.isError).toBe(true); + expect(parseMcpToolError(result).error).toMatchObject({ + code: "cleanup_incomplete", + details: { cleanup: "incomplete" }, + }); + const index = decompileDirectoryIndexSchema.parse( + JSON.parse(await readFile(join(output, "decompile-index.json"), "utf8")), + ); + expect(index.functions).toHaveLength(1); + expect(index.functions[0]).toMatchObject({ + address: addresses[0], + status: "written", + }); + await expect( + readFile(join(output, "functions", `${addresses[0]}.c`), "utf8"), + ).resolves.toContain("int f(void)"); + }, +); From 95d9d51f7298f855d62c19035f4e3d6491fa9f1e Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:34:46 +0800 Subject: [PATCH 06/31] fix(process): retain failures through finalization --- src/application/LinuxHopperPython.test.ts | 65 ++++++++++++++++++++++- src/application/LinuxHopperPython.ts | 16 +++--- src/process/ExecFileOutput.test.ts | 65 +++++++++++++++++++++++ src/process/ExecFileOutput.ts | 12 ++++- 4 files changed, 149 insertions(+), 9 deletions(-) diff --git a/src/application/LinuxHopperPython.test.ts b/src/application/LinuxHopperPython.test.ts index 10b365136d..e6a0011560 100644 --- a/src/application/LinuxHopperPython.test.ts +++ b/src/application/LinuxHopperPython.test.ts @@ -5,7 +5,10 @@ import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; -import { execFileOutput } from "../process/ExecFileOutput.js"; +import { + ExecFileOutputCancelledError, + execFileOutput, +} from "../process/ExecFileOutput.js"; import { inspectLinuxHopperPythonRuntime } from "./LinuxHopperPython.js"; const response = (value: unknown) => { @@ -128,6 +131,50 @@ describe("distribution Python runtime inspection", () => { }); describe("distribution Python process cleanup", () => { + it("retains output failure when cancellation arrives during command cleanup", async () => { + const directory = await createTestTempDirectory("rea-python-failure-stop-"); + const stopping = join(directory, "stopping"); + const release = join(directory, "release"); + const controller = new AbortController(); + const program = ` +const fs = require('node:fs'); +process.on('SIGTERM', () => { + fs.writeFileSync(${JSON.stringify(stopping)}, 'stopping'); + const timer = setInterval(() => { + if (fs.existsSync(${JSON.stringify(release)})) { + clearInterval(timer); + process.exit(0); + } + }, 10); +}); +process.stdout.write(Buffer.alloc(128 * 1024, 120)); +setInterval(() => {}, 1000); +`; + const execution = inspectLinuxHopperPythonRuntime( + process.env, + controller.signal, + (_command, _arguments, options) => + execFileOutput(process.execPath, ["-e", program], options), + ); + try { + await vi.waitFor(() => access(stopping), { + timeout: 5_000, + interval: 10, + }); + controller.abort({ request: "cancel during failed command cleanup" }); + } finally { + await writeFile(release, "release"); + await execution; + } + expect(await execution).toMatchObject({ + ok: false, + error: { + reason: "query_failed", + cause: { code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" }, + }, + }); + }); + it.skipIf(process.platform !== "linux" || !existsSync("/usr/bin/python3"))( "waits for a delayed Python exit before completing cancellation", async () => { @@ -194,6 +241,22 @@ while True: }); describe("distribution Python isolation and cancellation", () => { + it("uses the producer's cancellation reason without requiring an aborted caller", async () => { + const reason = { producer: "interrupted command" }; + const result = await inspectLinuxHopperPythonRuntime( + {}, + undefined, + async () => { + throw new ExecFileOutputCancelledError(reason); + }, + ); + expect(result).toMatchObject({ + ok: false, + error: { reason: "cancelled", cause: reason }, + }); + if (!result.ok) expect(result.error.cause).toBe(reason); + }); + it("preserves cancellation and starts no interpreter for an already cancelled query", async () => { const controller = new AbortController(); const reason = new Error("caller cancellation"); diff --git a/src/application/LinuxHopperPython.ts b/src/application/LinuxHopperPython.ts index e586ca2cbc..f61019ae32 100644 --- a/src/application/LinuxHopperPython.ts +++ b/src/application/LinuxHopperPython.ts @@ -3,7 +3,10 @@ import { posix } from "node:path"; import { z } from "zod"; import { err, ok, type Result } from "../domain/result.js"; -import { execFileOutput } from "../process/ExecFileOutput.js"; +import { + ExecFileOutputCancelledError, + execFileOutput, +} from "../process/ExecFileOutput.js"; const libraryPath = z .string() @@ -76,15 +79,15 @@ export const inspectLinuxHopperPythonRuntime = async ( signal?: AbortSignal, execute: typeof execFileOutput = execFileOutput, ): Promise> => { - const cancelled = () => + const cancelled = (reason: unknown) => err( new LinuxHopperPythonError( "cancelled", "Python runtime inspection was cancelled.", - signal?.reason, + reason, ), ); - if (isAborted(signal)) return cancelled(); + if (isAborted(signal)) return cancelled(signal?.reason); let output: string; try { const result = await execute( @@ -100,7 +103,8 @@ export const inspectLinuxHopperPythonRuntime = async ( ); output = result.stdout; } catch (cause: unknown) { - if (isAborted(signal)) return cancelled(); + if (cause instanceof ExecFileOutputCancelledError) + return cancelled(cause.cause); return err( new LinuxHopperPythonError( "query_failed", @@ -109,7 +113,7 @@ export const inspectLinuxHopperPythonRuntime = async ( ), ); } - if (isAborted(signal)) return cancelled(); + if (isAborted(signal)) return cancelled(signal?.reason); let value: unknown; try { value = JSON.parse(output); diff --git a/src/process/ExecFileOutput.test.ts b/src/process/ExecFileOutput.test.ts index e31ffe62ab..3975990d61 100644 --- a/src/process/ExecFileOutput.test.ts +++ b/src/process/ExecFileOutput.test.ts @@ -686,3 +686,68 @@ it.each([ expect(failure.cause).toBe(reason); }, ); + +it.skipIf(process.platform === "win32").each([ + { code: 7, action: "abort" }, + { code: 0, action: "abort" }, + { code: 7, action: "overflow" }, +] as const)( + "owns root exit $code before inherited-pipe $action", + async ({ code, action }) => { + const directory = await createTestTempDirectory("rea-exec-exit-pipes-"); + const exited = join(directory, "root-exited"); + const release = join(directory, "release"); + const descendant = [ + 'const fs = require("node:fs");', + "const parent = Number(process.argv[1]);", + "const timer = setInterval(() => {", + " try { process.kill(parent, 0); } catch {", + ` fs.writeFileSync(${JSON.stringify(exited)}, "");`, + " }", + ` if (!fs.existsSync(${JSON.stringify(release)})) return;`, + ` process.stdout.write(${JSON.stringify(action === "overflow" ? "x".repeat(64) : "late")});`, + " clearInterval(timer); process.exit(0);", + "}, 10);", + "setTimeout(() => process.exit(0), 5000).unref();", + ].join("\n"); + const program = [ + 'const { spawn } = require("node:child_process");', + `spawn(process.execPath, ["-e", ${JSON.stringify(descendant)}, String(process.pid)], { stdio: "inherit" }).unref();`, + 'process.stderr.write("root diagnostic");', + `process.exit(${code});`, + ].join("\n"); + const controller = new AbortController(); + let settled = false; + const execution = execFileOutput(process.execPath, ["-e", program], { + stopSignal: "SIGTERM", + signal: controller.signal, + maxBuffer: 32, + }) + .catch((cause: unknown) => cause) + .finally(() => { + settled = true; + }); + try { + expect(await waitForFile(exited)).toBe(true); + expect(settled).toBe(false); + if (action === "abort") controller.abort("late abort"); + expect(settled).toBe(false); + await writeFile(release, ""); + const failure = await execution; + expect(failure).toMatchObject( + code === 0 + ? { _tag: "ExecFileOutputCancelledError", cause: "late abort" } + : { code: 7 }, + ); + expect(execFileOutputFailure(failure)).toMatchObject({ + stderr: "root diagnostic", + stdout: action === "overflow" ? "x".repeat(32) : "late", + outputTruncated: action === "overflow", + }); + } finally { + await writeFile(release, ""); + controller.abort(); + await execution; + } + }, +); diff --git a/src/process/ExecFileOutput.ts b/src/process/ExecFileOutput.ts index 0174e6f7ff..d2c2ca43df 100644 --- a/src/process/ExecFileOutput.ts +++ b/src/process/ExecFileOutput.ts @@ -220,6 +220,7 @@ const startStoppableCommand = ( } let stopped: "abort" | "timeout" | "output-limit" | undefined; let processError: Error | null = null; + let rootFailed = false; let outputError: Error | undefined; let deadline: NodeJS.Timeout | undefined; let grace: NodeJS.Timeout | undefined; @@ -233,7 +234,7 @@ const startStoppableCommand = ( }); // The first stop request names the failure; a later one is not resent. const stop = (reason: "abort" | "timeout" | "output-limit"): void => { - if (stopped !== undefined) return; + if (stopped !== undefined || rootFailed || processError !== null) return; stopped = reason; child.kill(stopSignal); grace = setTimeout(() => child.kill("SIGKILL"), STOP_GRACE_MS); @@ -268,6 +269,11 @@ const startStoppableCommand = ( child.on("error", (error: Error) => { processError ??= error; }); + child.on("exit", (code, exitSignal) => { + // A failed root has completed its outcome even while descendants retain + // its pipes. Finalization cancellation cannot replace that failure. + rootFailed = code !== 0 || exitSignal !== null; + }); child.on("close", (code, exitSignal) => { signal?.removeEventListener("abort", onAbort); clearTimeout(deadline); @@ -290,7 +296,9 @@ const startStoppableCommand = ( ? new ExecFileOutputCancelledError(signal.reason) : stopped === "timeout" && timeout !== undefined ? timeoutError(command, timeout, processError) - : (outputError ?? processError); + : rootFailed && stopped === undefined + ? processError + : (outputError ?? processError); if (failure !== null && outputError !== undefined) Reflect.set(failure, "outputTruncated", true); complete(failure, out, diagnostic); From 59a70adefd4de4fb1964d75a9bddbc2130a9a5a7 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:34:55 +0800 Subject: [PATCH 07/31] fix(artifacts): preserve source and coverage facts --- docs/native-investigation.md | 13 +- src/artifacts/DirectoryArtifactReader.ts | 29 ++- .../apple/AppleAssetCatalogAnalysis.test.ts | 71 +++++- .../apple/AppleAssetCatalogAnalysis.ts | 29 ++- .../AppleAssetCatalogResourceCoverage.test.ts | 230 ++++++++++++++++++ .../apple/InterfaceBuilderAnalysis.ts | 104 +++++--- .../filesystem/directoryRootIdentity.test.ts | 113 +++++++++ .../filesystem/interfaceBuilderBundle.test.ts | 8 +- 8 files changed, 542 insertions(+), 55 deletions(-) create mode 100644 src/artifacts/apple/AppleAssetCatalogResourceCoverage.test.ts create mode 100644 tests/boundary/filesystem/directoryRootIdentity.test.ts diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 73d5bc43e9..b9978aee35 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -1074,8 +1074,17 @@ counted as known archive omissions. Recognized symlinked `.nib` entries are not followed; they retain their paths and reader limitations as counted archive omissions. Either gap makes `archive_decode` partial and the graph truncated while preserving decoded documents. Its `examined` count remains actual decode -attempts, and asset-catalog UI joins retain their existing incomplete-graph -qualification. +attempts. `archive_object_decode` separately reports whether archive selection +and decoding examined the object sources; hierarchy-only and connection-only +unknowns do not make that facet partial. Asset-catalog resource-key joins use +this source coverage together with document and object projection coverage. +Incomplete hierarchy or synthetic hierarchy-node limits alone do not qualify +otherwise complete resource-key joins; omitted archives or resource objects do. +Archive omission reasons are counted independently of the bounded path samples. +A UID outside the exact supported range reports `unsupported_uid_exact_range`, +not budget exhaustion. Mixed source failures report `multiple_archive_decode_causes`; +limitations retain omission counts by cause even when individual paths exceed +the reporting allowance. UI dispatch joins use the authored receiver's class independently of its presentation kind (controller, view, control or another object). An unresolved diff --git a/src/artifacts/DirectoryArtifactReader.ts b/src/artifacts/DirectoryArtifactReader.ts index b05795752a..65a36a83d9 100644 --- a/src/artifacts/DirectoryArtifactReader.ts +++ b/src/artifacts/DirectoryArtifactReader.ts @@ -326,6 +326,7 @@ export class DirectoryArtifactReader implements ArtifactReader { signal?: AbortSignal, ): Promise> { abortIfNeeded(signal); + await this.#validateRoot(); if (entry.kind !== "file") throw new ArtifactReaderFailure( "format", @@ -380,6 +381,21 @@ export class DirectoryArtifactReader implements ArtifactReader { } } + async #validateRoot(): Promise { + const root = await this.#resolveRoot(); + await withDirectoryIoContext( + "inspect artifact root", + root.canonicalPath, + () => + assertContainedDirectory( + root.canonicalPath, + root.canonicalPath, + root.identity, + "integrity", + ), + ); + } + /** Borrow an admitted entry for bounded random reads; retain failed cleanup. */ async inspectFile( entry: ArtifactEntry, @@ -404,6 +420,7 @@ export class DirectoryArtifactReader implements ArtifactReader { const [opened, named] = await Promise.all([ file.stat({ bigint: true }), lstat(entry.adapterKey, { bigint: true }), + this.#validateRoot(), ]).catch((validationCause: unknown) => { if (outcome.kind !== "failed") throw validationCause; throw new ArtifactReaderFailure( @@ -460,7 +477,12 @@ export class DirectoryArtifactReader implements ArtifactReader { async open(entry: ArtifactEntry, signal?: AbortSignal): Promise { const owner = await this.#openFile(entry, signal); const source = Readable.from( - mapStableFileRead(owner.handle, entry, signal), + mapStableFileRead( + owner.handle, + entry, + () => this.#validateRoot(), + signal, + ), { objectMode: false }, ); source.once("close", () => { @@ -635,6 +657,7 @@ const failureMessage = (cause: unknown): string => const mapStableFileRead = async function* ( handle: FileHandle, entry: ArtifactEntry, + validateRoot: () => Promise, signal?: AbortSignal, ): AsyncGenerator { let bytes = 0n; @@ -658,6 +681,7 @@ const mapStableFileRead = async function* ( const [opened, current] = await Promise.all([ handle.stat({ bigint: true }), lstat(entry.adapterKey, { bigint: true }), + validateRoot(), ]); abortIfNeeded(signal); if ( @@ -713,6 +737,7 @@ const assertContainedDirectory = async ( root: string, directory: string, rootIdentity: { readonly dev: bigint; readonly ino: bigint } | undefined, + failureReason: "path" | "integrity" = "path", ): Promise => { const canonical = await realpath(directory); const fromRoot = relative(root, canonical); @@ -729,7 +754,7 @@ const assertContainedDirectory = async ( isAbsolute(fromRoot) ) throw new ArtifactReaderFailure( - "path", + failureReason, "Directory traversal escaped or changed its artifact root", ); }; diff --git a/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts b/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts index b7da4abcd1..01cd079cd7 100644 --- a/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts +++ b/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts @@ -225,7 +225,7 @@ it.skipIf(process.platform === "win32")( }, }); expect(result.limitations).toContain( - "Interface Builder resource-key joins may be incomplete because the decoded UI graph was truncated.", + "Interface Builder resource-key joins may be incomplete because the decoded object inventory is incomplete.", ); }, ); @@ -338,7 +338,7 @@ describe("Apple asset catalog and Interface Builder concurrency", () => { return bundle; }; const emptyGraph = { - graph: { nodes: [], truncated: false }, + graph: { nodes: [], truncated: false, coverage: [] }, } as unknown as Awaited>; it("keeps the Interface Builder analysis running while catalogs are inspected", async () => { @@ -690,3 +690,70 @@ it("preserves every own JSON metadata field through catalog inspection and page expect(Object.hasOwn(metadata, "__proto__")).toBe(true); expect(Object.getPrototypeOf(metadata)).toBe(Object.prototype); }); + +it.skipIf(process.platform === "win32")( + "retains an exited assetutil failure when cancellation arrives while inherited pipes drain", + async () => { + const bundle = await createTestTempDirectory("rea-asset-exit-pipes-"); + await writeFile(join(bundle, "Assets.car"), "inert catalog"); + const exited = join(bundle, "root-exited"); + const release = join(bundle, "release"); + const descendant = [ + 'const fs = require("node:fs");', + "const parent = Number(process.argv[1]);", + "const timer = setInterval(() => {", + " try { process.kill(parent, 0); } catch {", + ` fs.writeFileSync(${JSON.stringify(exited)}, "");`, + " }", + ` if (fs.existsSync(${JSON.stringify(release)})) { clearInterval(timer); process.exit(0); }`, + "}, 10);", + "setTimeout(() => process.exit(0), 5000).unref();", + ].join("\n"); + const program = [ + 'const { spawn } = require("node:child_process");', + `const child = spawn(process.execPath, ["-e", ${JSON.stringify(descendant)}, String(process.pid)], { stdio: "inherit" });`, + "child.unref();", + 'process.stderr.write("specific catalog failure\\n");', + "process.exit(7);", + ].join("\n"); + const controller = new AbortController(); + let settled = false; + const analysis = analyzeAppleAssetCatalogs({ + bundlePath: bundle, + targetSha256: "a".repeat(64), + environment: {}, + signal: controller.signal, + runAssetUtil: async (_path, signal) => + ( + await processOutput.execFileOutput( + process.execPath, + ["-e", program], + { + signal, + stopSignal: "SIGTERM", + }, + ) + ).stdout, + }) + .catch((cause: unknown) => cause) + .finally(() => { + settled = true; + }); + try { + await vi.waitFor(() => expect(access(exited)).resolves.toBeUndefined()); + expect(settled).toBe(false); + controller.abort("late cancellation"); + expect(settled).toBe(false); + await writeFile(release, ""); + expect(await analysis).toMatchObject({ + _tag: "AnalysisOutputError", + diagnostics: { catalog_path: "Assets.car" }, + capturedOutput: { stderr: "specific catalog failure\n", exit_code: 7 }, + }); + } finally { + await writeFile(release, ""); + controller.abort(); + await analysis; + } + }, +); diff --git a/src/artifacts/apple/AppleAssetCatalogAnalysis.ts b/src/artifacts/apple/AppleAssetCatalogAnalysis.ts index b8684b68fd..14473e10ec 100644 --- a/src/artifacts/apple/AppleAssetCatalogAnalysis.ts +++ b/src/artifacts/apple/AppleAssetCatalogAnalysis.ts @@ -43,10 +43,6 @@ const ASSETUTIL = "/usr/bin/assetutil"; const MAX_CATALOG_BYTES = 64 * 1024 * 1024; const MAX_TOTAL_METADATA_BYTES = 64 * 1024 * 1024; -/** The command owner wraps the exact caller reason; do not replace it with the wrapper. */ -const cancellationCause = (cause: unknown, signal?: AbortSignal): unknown => - cause instanceof ExecFileOutputCancelledError ? cause.cause : signal?.reason; - /** Inspect compiled asset catalog metadata without extracting rendition bytes. */ export const analyzeAppleAssetCatalogs = async (input: { readonly bundlePath: string; @@ -132,6 +128,13 @@ export const analyzeAppleAssetCatalogs = async (input: { const resourceReferences = collectInterfaceBuilderResourceKeys( interfaceBuilder.graph.nodes, ); + const resourceObjectsIncomplete = interfaceBuilder.graph.coverage.some( + ({ facet, status }) => + (facet === "archive_object_decode" || + facet === "documents" || + facet.startsWith("objects:")) && + status !== "complete", + ); const result = projectAppleAssetCatalogPage({ targetSha256: input.targetSha256, ...(input.resourceRoot === undefined @@ -143,7 +146,7 @@ export const analyzeAppleAssetCatalogs = async (input: { resourceKeyCoverage: catalogCoverage.status !== "complete" ? catalogCoverage - : interfaceBuilder.graph.truncated + : resourceObjectsIncomplete ? { status: "partial", reason: "interface_builder_graph_incomplete" } : { status: "complete", reason: null }, offset: page.offset, @@ -168,9 +171,9 @@ export const analyzeAppleAssetCatalogs = async (input: { `Asset catalog entries that are not regular files were not inspected, so resource keys reported as unmatched may resolve there: ${uninspected.join(", ")}`, ]), "Exact resource-key joins use only recognized image or asset key fields on decoded Interface Builder resource objects; other archive resource references are not inferred.", - ...(interfaceBuilder.graph.truncated + ...(resourceObjectsIncomplete ? [ - "Interface Builder resource-key joins may be incomplete because the decoded UI graph was truncated.", + "Interface Builder resource-key joins may be incomplete because the decoded object inventory is incomplete.", ] : []), ], @@ -317,7 +320,7 @@ const inspectAssetCatalogEntry = async (input: { try { output = await input.runAssetUtil(entry.adapterKey, signal); } catch (cause) { - throw assetUtilFailure(cause, entry, signal); + throw assetUtilFailure(cause, entry); } const after = await lstat(entry.adapterKey, { bigint: true }); if ( @@ -415,17 +418,13 @@ const runAssetUtil = async ( ).stdout; /** Preserve the command owner's failure and captured output before artifact adaptation. */ -const assetUtilFailure = ( - cause: unknown, - entry: ArtifactEntry, - signal?: AbortSignal, -): unknown => { +const assetUtilFailure = (cause: unknown, entry: ArtifactEntry): unknown => { if (cause instanceof AnalysisError) return cause; - if (signal?.aborted || cause instanceof ExecFileOutputCancelledError) + if (cause instanceof ExecFileOutputCancelledError) return new ArtifactReaderFailure( "cancelled", "Asset catalog inspection cancelled", - { cause: cancellationCause(cause, signal) }, + { cause: cause.cause }, ); const output = execFileOutputFailure(cause); const code = diff --git a/src/artifacts/apple/AppleAssetCatalogResourceCoverage.test.ts b/src/artifacts/apple/AppleAssetCatalogResourceCoverage.test.ts new file mode 100644 index 0000000000..e0c61395d5 --- /dev/null +++ b/src/artifacts/apple/AppleAssetCatalogResourceCoverage.test.ts @@ -0,0 +1,230 @@ +import { symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { build } from "plist"; +import { expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; +import { analyzeAppleAssetCatalogs } from "./AppleAssetCatalogAnalysis.js"; +import { analyzeInterfaceBuilderBundle } from "./InterfaceBuilderAnalysis.js"; +import { encodeNibArchiveFixture } from "./NibArchive.fixture.js"; +import { binaryPlistObjectTable } from "../../../tests/fixtures/binaryPlistObjectTable.js"; + +const unsupportedUidArchive = (): Buffer => { + const bytes = Buffer.alloc(50); + bytes[8] = 0x87; + bytes.writeBigUInt64BE(9007199254740993n, 9); + return binaryPlistObjectTable(bytes, [8], 17); +}; + +for (const symlinks of [0, 1, 65]) + it.skipIf(process.platform === "win32" && symlinks > 0)( + `retains exact UID and mixed omission diagnoses with ${symlinks} symlinks`, + async () => { + const bundle = await createTestTempDirectory("rea-resource-uid-range-"); + await writeFile(join(bundle, "Unsupported.nib"), unsupportedUidArchive()); + await writeFile( + join(bundle, "Valid.nib"), + build({ + "com.apple.ibtool.document.objects": { + image: { customClass: "NSImage", NSImageName: "Toolbar" }, + }, + }), + ); + for (let index = 0; index < symlinks; index += 1) + await symlink("Valid.nib", join(bundle, `Alias-${index}.nib`)); + await writeFile(join(bundle, "Assets.car"), "inert catalog"); + const report = await analyzeInterfaceBuilderBundle({ + bundlePath: bundle, + targetSha256: "a".repeat(64), + }); + for (const facet of ["archive_decode", "archive_object_decode"]) + expect(report.graph.coverage).toContainEqual({ + facet, + status: "partial", + reason: + symlinks === 0 + ? "unsupported_uid_exact_range" + : "multiple_archive_decode_causes", + examined: 2, + omitted: 1 + symlinks, + }); + expect(report.limitations).toContain( + "1 Interface Builder archive(s) omitted because unsupported uid exact range.", + ); + if (symlinks > 0) + expect(report.limitations).toContain( + `${symlinks} Interface Builder archive(s) omitted because symlink not followed.`, + ); + if (symlinks < 64) + expect(report.limitations).toContain( + "Unsupported.nib: omitted because unsupported uid exact range.", + ); + else + expect(report.limitations).toContain( + "2 additional omitted archive path(s) and their reasons were not retained; reporting is bounded to 64 paths.", + ); + const result = await analyzeAppleAssetCatalogs({ + bundlePath: bundle, + targetSha256: "a".repeat(64), + environment: {}, + analyzeInterfaceBuilder: async () => report, + runAssetUtil: async () => JSON.stringify([{ Name: "Toolbar" }]), + }); + expect(result.resource_key_coverage.status).toBe( + symlinks === 0 ? "partial" : "unknown", + ); + expect(result.resource_key_matches).toMatchObject([ + { key: "Toolbar", status: "matched" }, + ]); + }, + ); + +it.each([ + "hierarchy", + "connections", + "registry", + "embedded-objects", + "invalid-archive", + "object-limit", +] as const)( + "qualifies resource-key coverage by object inventory rather than %s graph state", + async (mode) => { + const bundle = await createTestTempDirectory("rea-resource-coverage-"); + const archive = encodeNibArchiveFixture({ + classes: [ + "NSIBObjectData", + "NSImage", + "NSString", + "UIStoryboardShowSegueTemplate", + "UINib", + "NSArray", + ], + objects: [ + { + classIndex: 0, + values: { + NSRoot: { ref: 1 }, + ...(mode === "registry" + ? { NSObjectsKeys: { ref: 3 }, NSObjectsValues: { ref: 4 } } + : {}), + }, + }, + { classIndex: 1, values: { NSImageName: { ref: 2 } } }, + { classIndex: 2, values: { "NS.bytes": "Toolbar" } }, + ...(mode === "connections" || mode === "invalid-archive" + ? [{ classIndex: 3, values: {} }] + : []), + ...(mode === "registry" + ? [ + { classIndex: 5, values: { "NS.objects": { ref: 1 } } }, + { classIndex: 5, values: { "NS.objects": { ref: 2 } } }, + ] + : []), + ...(mode === "embedded-objects" + ? [ + { + classIndex: 4, + values: { + archiveData: { + data: Buffer.from("unexamined nested archive"), + }, + }, + }, + ] + : []), + ], + }); + await writeFile(join(bundle, "Panel.nib"), archive); + if (mode === "invalid-archive") + await writeFile(join(bundle, "Invalid.nib"), "invalid nib"); + await writeFile(join(bundle, "Assets.car"), "inert catalog"); + const interfaceBuilder = await analyzeInterfaceBuilderBundle({ + bundlePath: bundle, + targetSha256: "a".repeat(64), + ...(mode === "object-limit" ? { limits: { max_objects: 1 } } : {}), + }); + expect(interfaceBuilder.graph.truncated).toBe(true); + expect(interfaceBuilder.graph.coverage).toContainEqual( + expect.objectContaining({ + facet: "archive_object_decode", + status: + mode === "embedded-objects" || mode === "invalid-archive" + ? "partial" + : "complete", + }), + ); + const result = await analyzeAppleAssetCatalogs({ + bundlePath: bundle, + targetSha256: "a".repeat(64), + environment: {}, + analyzeInterfaceBuilder: async () => interfaceBuilder, + runAssetUtil: async () => JSON.stringify([{ Name: "Toolbar" }]), + }); + expect(result.resource_key_coverage.status).toBe( + mode === "hierarchy" || mode === "connections" || mode === "registry" + ? "complete" + : "partial", + ); + const incomplete = result.resource_key_coverage.status === "partial"; + expect( + result.limitations.some((limitation) => + limitation.includes("resource-key joins may be incomplete"), + ), + ).toBe(incomplete); + if (mode === "invalid-archive") + expect(interfaceBuilder.graph.coverage).toContainEqual( + expect.objectContaining({ + facet: "archive_object_decode", + reason: "one_or_more_archives_invalid", + }), + ); + if (mode === "registry" || mode === "connections") + expect(interfaceBuilder.graph.coverage).toContainEqual( + expect.objectContaining({ facet: "archive_decode", status: "partial" }), + ); + if (mode !== "object-limit") + expect(result.resource_key_matches).toMatchObject([ + { key: "Toolbar", status: "matched" }, + ]); + }, +); + +it("keeps resource coverage complete when only synthetic hierarchy nodes exceed graph capacity", async () => { + const bundle = await createTestTempDirectory( + "rea-resource-hierarchy-capacity-", + ); + await writeFile( + join(bundle, "Panel.nib"), + build({ + "com.apple.ibtool.document.objects": { + image: { customClass: "NSImage", NSImageName: "Toolbar" }, + }, + "com.apple.ibtool.document.connections": {}, + "com.apple.ibtool.document.hierarchy": [{ objectID: "unlisted-view" }], + "com.apple.ibtool.document.classes": {}, + }), + ); + await writeFile(join(bundle, "Assets.car"), "inert catalog"); + const interfaceBuilder = await analyzeInterfaceBuilderBundle({ + bundlePath: bundle, + targetSha256: "a".repeat(64), + limits: { max_objects: 2 }, + }); + expect(interfaceBuilder.graph.coverage).toContainEqual( + expect.objectContaining({ facet: "graph_nodes", status: "partial" }), + ); + expect(interfaceBuilder.graph.coverage).toContainEqual( + expect.objectContaining({ facet: "objects:Panel.nib", status: "complete" }), + ); + const result = await analyzeAppleAssetCatalogs({ + bundlePath: bundle, + targetSha256: "a".repeat(64), + environment: {}, + analyzeInterfaceBuilder: async () => interfaceBuilder, + runAssetUtil: async () => JSON.stringify([{ Name: "Toolbar" }]), + }); + expect(result.resource_key_coverage.status).toBe("complete"); + expect(result.resource_key_matches).toMatchObject([ + { key: "Toolbar", status: "matched" }, + ]); +}); diff --git a/src/artifacts/apple/InterfaceBuilderAnalysis.ts b/src/artifacts/apple/InterfaceBuilderAnalysis.ts index 852dd850c3..ce5d3fec36 100644 --- a/src/artifacts/apple/InterfaceBuilderAnalysis.ts +++ b/src/artifacts/apple/InterfaceBuilderAnalysis.ts @@ -101,6 +101,11 @@ const decodeInterfaceBuilderBundle = async ( const documents: InterfaceBuilderDocumentInput[] = []; const invalid: string[] = []; const omittedArchives: OmittedArchive[] = []; + const omissionCounts = new Map(); + const recordOmission = (reason: string, count = 1): void => { + if (count > 0) + omissionCounts.set(reason, (omissionCounts.get(reason) ?? 0) + count); + }; let unreportedOmittedArchives = 0; const incompleteHierarchies = new Map(); const unexaminedSources = new Map(); @@ -120,7 +125,7 @@ const decodeInterfaceBuilderBundle = async ( let outcome: { kind: "completed" } | { kind: "failed"; cause: unknown } = { kind: "completed", }; - const recordOmittedArchive = ( + const retainOmittedArchive = ( path: string, reason: string, limitations: readonly string[] = [], @@ -129,6 +134,11 @@ const decodeInterfaceBuilderBundle = async ( omittedArchives.push({ path, reason, limitations }); else unreportedOmittedArchives += 1; }; + const recordOmittedArchive = (path: string, reason: string): void => { + omitted += 1; + recordOmission(reason); + retainOmittedArchive(path, reason); + }; try { const selection = await selectInterfaceBuilderCandidates( reader, @@ -137,10 +147,12 @@ const decodeInterfaceBuilderBundle = async ( traversal, ); omitted = selection.omitted; + recordOmission("max_documents", selection.documentLimitOmissions); + recordOmission("symlink_not_followed", selection.symlinkOmissions); for (const entry of selection.omittedSamples) - recordOmittedArchive(entry.path, "max_documents"); + retainOmittedArchive(entry.path, "max_documents"); for (const entry of selection.symlinkSamples) - recordOmittedArchive( + retainOmittedArchive( entry.path, "symlink_not_followed", entry.limitations, @@ -156,7 +168,6 @@ const decodeInterfaceBuilderBundle = async ( entry.declaredSize !== null && entry.declaredSize > remainingInputBytes ) { - omitted += 1; recordOmittedArchive(entry.path, "aggregate_decode_budget_exhausted"); continue; } @@ -262,12 +273,10 @@ const decodeInterfaceBuilderBundle = async ( if (cause instanceof InterfaceBuilderDecodeBudgetExceeded) { // A document rejected while decoding, e.g. beyond the shared depth // limit, retains no representation; later documents keep its share. - omitted += 1; recordOmittedArchive(entry.path, cause.reason); continue; } if (cause instanceof UnsupportedBinaryPlistUidError) { - omitted += 1; recordOmittedArchive(entry.path, "unsupported_uid_exact_range"); continue; } @@ -308,6 +317,7 @@ const decodeInterfaceBuilderBundle = async ( invalid, omittedArchives, unreportedOmittedArchives, + omissionCounts, attempted, omitted, traversal: traversal.snapshot(), @@ -325,6 +335,8 @@ const selectInterfaceBuilderCandidates = async ( readonly omittedSamples: readonly ArtifactEntry[]; readonly symlinkSamples: readonly ArtifactEntry[]; readonly omitted: number; + readonly documentLimitOmissions: number; + readonly symlinkOmissions: number; readonly unreportedOmitted: number; }> => { const capacity = maximumDocuments + MAX_REPORTED_OMITTED_ARCHIVES; @@ -366,6 +378,8 @@ const selectInterfaceBuilderCandidates = async ( omittedSamples, symlinkSamples, omitted: Math.max(0, total - maximumDocuments) + symlinks, + documentLimitOmissions: Math.max(0, total - maximumDocuments), + symlinkOmissions: symlinks, unreportedOmitted: Math.max(0, total - maximumDocuments - omittedSamples.length) + symlinks - @@ -409,6 +423,7 @@ const finalizeHierarchyCoverage = ( invalid: readonly string[]; omittedArchives: readonly OmittedArchive[]; unreportedOmittedArchives: number; + omissionCounts: ReadonlyMap; attempted: number; omitted: number; traversal: ArtifactReaderTraversal; @@ -427,11 +442,35 @@ const finalizeHierarchyCoverage = ( omittedArchives, unreportedOmittedArchives, } = counts; - const archiveDecodePartial = - invalid.length > 0 || - unexaminedSources.size > 0 || - counts.omitted > 0 || - counts.traversal.status === "unknown"; + const sourceReasons = new Set( + [...counts.omissionCounts.keys()].map((reason) => + reason === "max_documents" + ? "document_limit_reached" + : reason === "symlink_not_followed" + ? "archive_symlink_not_followed" + : reason, + ), + ); + if (invalid.length > 0) sourceReasons.add("one_or_more_archives_invalid"); + if (counts.traversal.status === "unknown") + sourceReasons.add("source_traversal_unknown"); + const sourceDecodeReason = + sourceReasons.size > 1 + ? "multiple_archive_decode_causes" + : (sourceReasons.values().next().value ?? null); + const archiveDecodeReason = + sourceDecodeReason ?? + (unexaminedSources.size > 0 + ? "serialized_archive_content_unexamined" + : null); + const archiveObjectDecodeReason = + sourceDecodeReason ?? + ([...unexaminedSources.values()].some((sources) => + sources.some((source) => source.facets.includes("objects")), + ) + ? "serialized_archive_content_unexamined" + : null); + const archiveDecodePartial = archiveDecodeReason !== null; return { ...result, documents: result.documents.map((document) => ({ @@ -479,28 +518,21 @@ const finalizeHierarchyCoverage = ( }), { facet: "archive_decode", - status: archiveDecodePartial - ? ("partial" as const) - : ("complete" as const), - reason: omittedArchives.some( - ({ reason }) => reason === "aggregate_decode_budget_exhausted", - ) - ? "aggregate_decode_budget_exhausted" - : omittedArchives.some(({ reason }) => reason === "max_documents") - ? "document_limit_reached" - : omittedArchives.some( - ({ reason }) => reason === "symlink_not_followed", - ) - ? "archive_symlink_not_followed" - : omittedArchives.length > 0 - ? "archive_decode_budget_exhausted" - : invalid.length > 0 - ? "one_or_more_archives_invalid" - : unexaminedSources.size > 0 - ? "serialized_archive_content_unexamined" - : counts.traversal.status === "unknown" - ? "source_traversal_unknown" - : null, + status: + archiveDecodeReason === null + ? ("complete" as const) + : ("partial" as const), + reason: archiveDecodeReason, + examined: counts.attempted, + omitted: counts.omitted, + }, + { + facet: "archive_object_decode", + status: + archiveObjectDecodeReason === null + ? ("complete" as const) + : ("partial" as const), + reason: archiveObjectDecodeReason, examined: counts.attempted, omitted: counts.omitted, }, @@ -548,6 +580,12 @@ const finalizeHierarchyCoverage = ( ...[...negativeZeroReals].map( ([path, count]) => `${path}: ${negativeZeroRealLimitation(count)}`, ), + ...[...counts.omissionCounts] + .sort(([left], [right]) => left.localeCompare(right)) + .map( + ([reason, count]) => + `${count} Interface Builder archive(s) omitted because ${reason.replaceAll("_", " ")}.`, + ), ...omittedArchives.flatMap(({ path, reason, limitations }) => [ `${path}: omitted because ${reason.replaceAll("_", " ")}.`, ...limitations.map((limitation) => `${path}: ${limitation}`), diff --git a/tests/boundary/filesystem/directoryRootIdentity.test.ts b/tests/boundary/filesystem/directoryRootIdentity.test.ts new file mode 100644 index 0000000000..de1555def2 --- /dev/null +++ b/tests/boundary/filesystem/directoryRootIdentity.test.ts @@ -0,0 +1,113 @@ +import { mkdir, rename, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; + +import { DirectoryArtifactReader } from "../../../src/artifacts/DirectoryArtifactReader.js"; +import { DesktopApplicationReader } from "../../../src/artifacts/DesktopApplicationReader.js"; +import { DesktopApplicationService } from "../../../src/application/artifacts/DesktopApplicationService.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { desktopApplicationSchema } from "../../../src/domain/desktopApplication.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it.each(["before-open", "during-inspection", "during-stream"] as const)( + "rejects replacement of the admitted physical root %s even when file identity survives", + async (stage) => { + const root = await createTestTempDirectory("rea-directory-root-identity-"); + const original = join(root, "original"); + await mkdir(original); + await writeFile(join(original, "sample.txt"), "original content"); + const reader = new DirectoryArtifactReader(original); + const entries = []; + for await (const entry of reader.entries()) entries.push(entry); + const entry = entries[0]; + if (entry === undefined) throw new Error("Expected sample file"); + const replaceRoot = async () => { + const moved = join(root, "moved"); + await rename(original, moved); + await symlink( + moved, + original, + process.platform === "win32" ? "junction" : "dir", + ); + }; + try { + if (stage === "before-open") { + await replaceRoot(); + await expect(reader.open(entry)).rejects.toMatchObject({ + reason: "integrity", + }); + } else if (stage === "during-inspection") { + await expect( + reader.inspectFile(entry, async (file) => { + const bytes = await file.readFile(); + await replaceRoot(); + return bytes; + }), + ).rejects.toMatchObject({ reason: "integrity" }); + } else { + const stream = await reader.open(entry); + await replaceRoot(); + await expect( + (async () => { + for await (const _chunk of stream) { + /* consume the admitted file */ + } + })(), + ).rejects.toMatchObject({ reason: "integrity" }); + } + } finally { + await reader.close(); + } + }, +); + +it("discards unverified desktop byte facts when the admitted root changes during inspection", async () => { + const root = await createTestTempDirectory("rea-desktop-root-identity-"); + const original = join(root, "original"); + const moved = join(root, "moved"); + await mkdir(original); + await writeFile(join(original, "sample.exe"), Buffer.from("MZ")); + const service = new DesktopApplicationService( + new DesktopApplicationReader((path) => { + const reader = new DirectoryArtifactReader(path); + const inspect = reader.inspectFile.bind(reader); + reader.inspectFile = (entry, read, signal) => + inspect( + entry, + async (file) => { + const value = await read(file); + await rename(original, moved); + await symlink( + moved, + original, + process.platform === "win32" ? "junction" : "dir", + ); + return value; + }, + signal, + ); + return reader; + }), + ); + try { + const result = await service.inspect({ path: original }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected root identity failure"); + const report = desktopApplicationSchema.parse( + parseEvidence(result.error.partialObservation).normalized_result, + ); + expect(report.root.path).toBe(original); + expect(report.files).toHaveLength(1); + expect(report.files[0]).toMatchObject({ + path: "sample.exe", + read_status: "unavailable", + ranges: [], + }); + expect(report.files[0]?.facts.every((fact) => fact.offset === null)).toBe( + true, + ); + expect(report.coverage.status).toBe("partial"); + } finally { + await service.close(); + } +}); diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index 844ce0411b..8aacf6f065 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -225,11 +225,17 @@ describe("compiled Interface Builder traversal coverage", () => { expect.objectContaining({ facet: "archive_decode", status: "partial", - reason: "document_limit_reached", + reason: "multiple_archive_decode_causes", examined: 1, omitted: 126, }), ); + expect(result.limitations).toContain( + "61 Interface Builder archive(s) omitted because max documents.", + ); + expect(result.limitations).toContain( + "65 Interface Builder archive(s) omitted because symlink not followed.", + ); const reasons = result.limitations.filter((value) => value.includes(": omitted because "), ); From e80fb638be5fcff7194455feb358dc193cbc81d7 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:35:04 +0800 Subject: [PATCH 08/31] fix(web): retain source-map declaration provenance --- docs/web-source-location.md | 5 + src/browser/WebSourceMapFetcher.ts | 5 +- src/domain/webBundleAnalysis.ts | 2 + .../browser/webSourceMapFetcher.test.ts | 114 ++++++++++++++++++ 4 files changed, 124 insertions(+), 2 deletions(-) diff --git a/docs/web-source-location.md b/docs/web-source-location.md index 41b793ddf6..8e24bf533a 100644 --- a/docs/web-source-location.md +++ b/docs/web-source-location.md @@ -121,6 +121,11 @@ preceding browser mappings when a nested section starts with a gap or has no child leaves. Raw map text, original source identities and the dedicated decoder profile are preserved. +Browser `original_module_edges` carry a `declaration_index` joining each edge to +its `original_sources` declaration, including repeated source URLs across indexed +sections. Imports are deduplicated within each declaration. Older observations +may omit the index; their edge declaration identity remains unknown. + Browser collection accepts up to 64 MiB of map responses per inspection and preflights indexed sections, sources, names, generated rows and mapping segments against a 262144-record decode budget before upstream flattening. Indexed maps diff --git a/src/browser/WebSourceMapFetcher.ts b/src/browser/WebSourceMapFetcher.ts index ebd70a4b29..b5264265a2 100644 --- a/src/browser/WebSourceMapFetcher.ts +++ b/src/browser/WebSourceMapFetcher.ts @@ -868,7 +868,6 @@ const originalModuleEdges = ( ) => void, ): OriginalModuleEdges => { const edges: ParsedSourceMapItem["original_module_edges"] = []; - const seen = new Set(); const incomplete: string[] = []; for (const source of sources) { check(); @@ -892,6 +891,7 @@ const originalModuleEdges = ( // A recovered program still yields the imports it did parse, but nodes // after an unrecoverable point are missing, so the edges are a subset. if (parsed.errors.length > 0) incomplete.push(sourceName); + const seen = new Set(); let unboundRequires: ReadonlySet | undefined; traverseJavaScriptAst(parsed, { enter: (node) => { @@ -920,10 +920,11 @@ const originalModuleEdges = ( ) return; } - const key = `${sourceName}\0${kind}\0${specifier}`; + const key = `${kind}\0${specifier}`; if (seen.has(key)) return; seen.add(key); const edge = { + declaration_index: source.declaration_index, from_source: sourceName, kind, specifier, diff --git a/src/domain/webBundleAnalysis.ts b/src/domain/webBundleAnalysis.ts index 9a76a38fe5..1b370857ad 100644 --- a/src/domain/webBundleAnalysis.ts +++ b/src/domain/webBundleAnalysis.ts @@ -254,6 +254,8 @@ const originalSourceSchema = z.object({ artifact: webTextArtifactSchema.nullable(), }); const originalModuleEdgeSchema = z.object({ + // Historical observations omit this link to original_sources declarations. + declaration_index: z.int().min(0).optional(), from_source: z.string(), kind: z.enum(["static_import", "dynamic_import", "require"]), specifier: z.string(), diff --git a/tests/boundary/browser/webSourceMapFetcher.test.ts b/tests/boundary/browser/webSourceMapFetcher.test.ts index 33f5b83e39..e933933711 100644 --- a/tests/boundary/browser/webSourceMapFetcher.test.ts +++ b/tests/boundary/browser/webSourceMapFetcher.test.ts @@ -31,6 +31,120 @@ const expectInvalidIncludedSourceMaps = (result: WebSourceMaps): void => { expectInvalidSourceMaps({ ...result, status: "unavailable" }); }; +it.each(["regular", "indexed"])( + "retains declaration identity for duplicate source URLs in HTTP %s maps", + async (format) => { + const contents = [ + null, + 'import "./shared.js"; import "./shared.js"; import "./first.js";', + 'import "./shared.js"; import "./second.js";', + ]; + const leaf = (sourcesContent: (string | null)[]) => ({ + version: 3, + sources: sourcesContent.map(() => "same.ts"), + sourcesContent, + names: [], + mappings: "", + }); + const map = + format === "regular" + ? leaf(contents) + : { + version: 3, + sections: contents.map((content, line) => ({ + offset: { line, column: 0 }, + map: leaf([content]), + })), + }; + const server = createServer((_incoming, response) => { + response.setHeader("content-type", "application/source-map+json"); + response.end(JSON.stringify(map)); + }); + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (address === null || typeof address === "string") + throw new TypeError("Expected a TCP listener address"); + const localOrigin = `http://127.0.0.1:${String(address.port)}`; + const url = `${localOrigin}/app.js.map`; + const result = await fetchWebSourceMapsOk( + [{ ...request, fetchUrl: url, declaredUrl: url }], + input({ allowed_origins: [localOrigin] }), + ); + const item = result.items[0]; + if (item?.status !== "included") throw new Error("map not included"); + expect(item.limitation).toBeNull(); + expect( + item.original_sources.map((source) => ({ + declaration_index: source.declaration_index, + source_index: source.source_index, + source: source.source, + text: source.artifact?.text ?? null, + })), + ).toEqual( + contents.map((text, declaration_index) => ({ + declaration_index, + source_index: format === "regular" ? declaration_index : 0, + source: `${localOrigin}/same.ts`, + text, + })), + ); + expect(item.original_module_edges).toEqual( + [ + [1, "./shared.js"], + [1, "./first.js"], + [2, "./shared.js"], + [2, "./second.js"], + ].map(([declaration_index, specifier]) => ({ + declaration_index, + from_source: `${localOrigin}/same.ts`, + kind: "static_import", + specifier, + resolved_source: new URL(String(specifier), url).href, + })), + ); + expect(webSourceMapsSchema.parse(result)).toEqual(result); + const historical = { + ...result, + items: [ + { + ...item, + original_module_edges: item.original_module_edges.map( + ({ declaration_index: _index, ...edge }) => edge, + ), + }, + ], + }; + expect(webSourceMapsSchema.parse(historical)).toEqual(historical); + expectInvalidSourceMaps({ + ...result, + items: [ + { + ...item, + original_module_edges: [ + { + ...item.original_module_edges[0], + declaration_index: -1, + }, + ], + }, + ], + }); + } finally { + server.closeAllConnections(); + if (server.listening) + await new Promise((resolve, reject) => { + server.close((error) => + error === undefined ? resolve() : reject(error), + ); + }); + } + }, +); + it.each(["regular", "indexed"])( "collects an HTTP %s source map with omitted names without accepting invalid names", async (format) => { From 6a9911ea77f5556b684b03de2c6747f62e9a935b Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:40:40 +0800 Subject: [PATCH 09/31] fix(session): track retained bundle cleanup dependencies --- src/application/binary/BinarySession.ts | 10 ++- .../BinarySessionBundleResources.test.ts | 64 +++++++++++++++++- .../binary/BundleResourceSession.test.ts | 67 ++++++++++++++++++- .../binary/BundleResourceSession.ts | 16 +++-- 4 files changed, 145 insertions(+), 12 deletions(-) diff --git a/src/application/binary/BinarySession.ts b/src/application/binary/BinarySession.ts index f4cb2f5feb..c95a816a72 100644 --- a/src/application/binary/BinarySession.ts +++ b/src/application/binary/BinarySession.ts @@ -939,14 +939,20 @@ export class BinarySession ); if (!closed.ok) { retained.push(session); + const previous = failure; failure = - failure === undefined + previous === undefined ? closed.error : analysisErrorWithCleanupFailure( - failure, + previous, closed.error, "close_binary", ); + if ( + previous?.cleanupIncomplete === true && + closed.error.cleanupIncomplete + ) + failure.bindCleanupDependencies([previous, closed.error]); } } this.#pendingBundleResourceCleanup = retained; diff --git a/src/application/binary/BinarySessionBundleResources.test.ts b/src/application/binary/BinarySessionBundleResources.test.ts index 376fa51ee0..0592b52f6b 100644 --- a/src/application/binary/BinarySessionBundleResources.test.ts +++ b/src/application/binary/BinarySessionBundleResources.test.ts @@ -16,12 +16,12 @@ import { SessionProviderRouter } from "./SessionProviderRouter.js"; import { bundleResourceRootSchema } from "../../domain/apple/bundleResourceRoot.js"; import { AnalysisSnapshotMismatchError } from "../../domain/evidenceErrors.js"; import type { AnalysisSnapshot } from "../../domain/analysisSnapshot.js"; +import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; -import { err } from "../../domain/result.js"; +import { err, ok } from "../../domain/result.js"; import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js"; import { createEvidence } from "../../domain/evidence.js"; import { createAnalysisProfile } from "../../domain/analysisProfile.js"; -import { ok } from "../../domain/result.js"; import { createTestProviderRouter } from "../../../tests/fixtures/binarySession.js"; const identity: ProviderIdentity = { @@ -797,3 +797,63 @@ it("cancels a binary switch during resource retirement and retains null-subject expect(await session.close()).toMatchObject({ ok: true }); expect(resourceCloseAttempts).toBe(2); }); + +it("tracks independent pending resource owners after a failed session replacement", async () => { + const rootPath = await mkdtemp( + join(tmpdir(), "rea-resource-pending-owners-"), + ); + temporaryDirectories.push(rootPath); + await writeFile(join(rootPath, "Info.plist"), ""); + const failures = [ + new ProviderCleanupError(identity.id, ["previous-reader"], {}), + new ProviderCleanupError(identity.id, ["candidate-reader"], {}), + ]; + const released = [false, false]; + let created = 0; + const session = new BinarySession( + SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []), + new InvestigationRecords(), + { + createClient: () => { + const index = created++; + const failure = failures[index]; + if (failure === undefined) + throw new Error("Unexpected resource client"); + return { + capabilities: [], + client: { + execute: async () => ok(createAnalysisExecution(null, identity)), + close: async () => { + if (!released[index]) return err(failure); + failure.resolveCleanup(); + return ok(null); + }, + }, + }; + }, + }, + ); + expect(await session.openBundleResources(rootPath)).toMatchObject({ + ok: true, + }); + expect(await session.openBundleResources(rootPath)).toMatchObject({ + ok: false, + }); + const first = await session.close(); + if (first.ok) + throw new Error("Expected both resource owners to remain pending"); + expect(new Set(first.error.cleanupResources)).toEqual( + new Set(["previous-reader", "candidate-reader"]), + ); + + released[0] = true; + expect(await session.close()).toMatchObject({ ok: false }); + expect(first.error.cleanupIncomplete).toBe(true); + expect(first.error.cleanupResources).toEqual(["candidate-reader"]); + + released[1] = true; + expect(await session.close()).toMatchObject({ ok: true }); + expect(first.error.cleanupIncomplete).toBe(false); + expect(first.error.cleanupResources).toEqual([]); + expect(await session.status()).toMatchObject({ cleanup_pending: false }); +}); diff --git a/src/application/binary/BundleResourceSession.test.ts b/src/application/binary/BundleResourceSession.test.ts index 4f51ee5096..4579bc5f58 100644 --- a/src/application/binary/BundleResourceSession.test.ts +++ b/src/application/binary/BundleResourceSession.test.ts @@ -6,8 +6,9 @@ import { type AnalysisClient, type ProviderIdentity, } from "../AnalysisProvider.js"; +import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; -import { err } from "../../domain/result.js"; +import { err, ok } from "../../domain/result.js"; const identity: ProviderIdentity = { id: "bundle-resource-close-fixture", @@ -104,3 +105,67 @@ it("reports the first mixed child failure before awaiting the other owner and re expect(artifactCloseAttempts).toBe(2); expect(nativeCloseAttempts).toBe(2); }); + +it("updates retained cleanup facts as the two child owners release independently", async () => { + const artifactFailure = new ProviderCleanupError( + identity.id, + ["artifact-reader"], + {}, + ); + const nativeFailure = new ProviderCleanupError( + identity.id, + ["native-reader"], + {}, + ); + let releaseArtifact = false; + let releaseNative = false; + const client = ( + failure: ProviderCleanupError, + released: () => boolean, + ): AnalysisClient => ({ + execute: async () => ok(createAnalysisExecution(null, identity)), + close: async () => { + if (!released()) return err(failure); + failure.resolveCleanup(); + return ok(null); + }, + }); + const artifact = client(artifactFailure, () => releaseArtifact); + const native = client(nativeFailure, () => releaseNative); + const combined = combineBundleResourceClients(artifact, native); + const first = await combined.close(); + if (first.ok) throw new Error("Expected both child owners to remain open"); + expect(new Set(first.error.cleanupResources)).toEqual( + new Set(["artifact-reader", "native-reader"]), + ); + + releaseArtifact = true; + expect(await artifact.close()).toMatchObject({ ok: true }); + expect(first.error.cleanupIncomplete).toBe(true); + expect(first.error.cleanupResources).toEqual(["native-reader"]); + + releaseNative = true; + expect(await combined.close()).toMatchObject({ ok: true }); + expect(first.error.cleanupIncomplete).toBe(false); + expect(first.error.cleanupResources).toEqual([]); + expect(new Set(first.error.cleanup?.resources)).toEqual( + new Set(["artifact-reader", "native-reader"]), + ); +}); + +it("keeps cleanup unknown when a child close failure has no cleanup lifecycle", async () => { + const owned = new ProviderCleanupError(identity.id, ["artifact-reader"], {}); + const client = ( + failure: AnalysisInputError | ProviderCleanupError, + ): AnalysisClient => ({ + execute: async () => ok(createAnalysisExecution(null, identity)), + close: async () => err(failure), + }); + const closed = await combineBundleResourceClients( + client(owned), + client(new AnalysisInputError("close_binary")), + ).close(); + if (closed.ok) throw new Error("Expected child close failures"); + owned.resolveCleanup(); + expect(closed.error.cleanupIncomplete).toBe(true); +}); diff --git a/src/application/binary/BundleResourceSession.ts b/src/application/binary/BundleResourceSession.ts index a511238312..a0371f6e98 100644 --- a/src/application/binary/BundleResourceSession.ts +++ b/src/application/binary/BundleResourceSession.ts @@ -70,14 +70,16 @@ export const combineBundleResourceClients = ( closeAnalysisClient(artifactClient, "bundle-resources", options), closeAnalysisClient(nativeClient, "bundle-resources", options), ]); - if (!artifact.ok && !native.ok) - return err( - analysisErrorWithCleanupFailure( - artifact.error, - native.error, - "close_binary", - ), + if (!artifact.ok && !native.ok) { + const failure = analysisErrorWithCleanupFailure( + artifact.error, + native.error, + "close_binary", ); + if (artifact.error.cleanupIncomplete && native.error.cleanupIncomplete) + failure.bindCleanupDependencies([artifact.error, native.error]); + return err(failure); + } if (!artifact.ok) return artifact; if (!native.ok) return native; return ok(null); From c74059d89fdf0872b7a02fdb6c8f1f04a754a8ba Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:40:45 +0800 Subject: [PATCH 10/31] fix(javascript): preserve unknown Next package ownership --- .../JavaScriptArtifactReconstruction.ts | 3 +- .../JavaScriptNextAppContext.test.ts | 108 ++++++++++++++++-- .../javascript/JavaScriptNextAppContext.ts | 48 +++++++- .../javascriptNextRouteContext.test.ts | 79 +++++++++++++ 4 files changed, 221 insertions(+), 17 deletions(-) diff --git a/src/application/javascript/JavaScriptArtifactReconstruction.ts b/src/application/javascript/JavaScriptArtifactReconstruction.ts index 8e3bf7ecc7..17e03aeee2 100644 --- a/src/application/javascript/JavaScriptArtifactReconstruction.ts +++ b/src/application/javascript/JavaScriptArtifactReconstruction.ts @@ -305,9 +305,10 @@ export const reconstructJavaScriptArtifact = async ( .map(({ logical_path }) => logical_path), ); const nextAppContext = createJavaScriptNextAppContext( - files.files, + files, analysis.jsonDocuments, directoryPaths, + snapshot.coverage, ); const completed = { route_inventory: await completeJavaScriptAnalysisSteps( diff --git a/src/application/javascript/JavaScriptNextAppContext.test.ts b/src/application/javascript/JavaScriptNextAppContext.test.ts index 6bf719c752..c0136bf6a4 100644 --- a/src/application/javascript/JavaScriptNextAppContext.test.ts +++ b/src/application/javascript/JavaScriptNextAppContext.test.ts @@ -4,6 +4,18 @@ import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptA import { JavaScriptJsonDocuments } from "./JavaScriptJsonDocuments.js"; import { createJavaScriptNextAppContext } from "./JavaScriptNextAppContext.js"; +const contextFor = ( + files: readonly JavaScriptArtifactFile[], + documents: JavaScriptJsonDocuments, + directories: ReadonlySet, +) => + createJavaScriptNextAppContext( + { files, unavailable_relevant_files: [] }, + documents, + directories, + { scope: "container-entries", status: "complete" }, + ); + const file = (path: string, text = ""): JavaScriptArtifactFile => ({ path, container_sha256: "a".repeat(64), @@ -23,7 +35,7 @@ it("keeps package applicability and its root cache scoped to the source containe }; const page = file("app/page.js"); const foreignPage = { ...page, container_sha256: foreign.container_sha256 }; - const context = createJavaScriptNextAppContext( + const context = contextFor( [manifest, foreign, page, foreignPage], new JavaScriptJsonDocuments(), new Set(["app"]), @@ -49,7 +61,7 @@ it("uses the nearest declared Next package and root directory precedence", () => file("node_modules/ordinary/package.json", '{"name":"ordinary"}'), file("node_modules/ordinary/app/page.js"), ]; - const context = createJavaScriptNextAppContext( + const context = contextFor( files, new JavaScriptJsonDocuments(), new Set([ @@ -76,7 +88,7 @@ it("uses the nearest declared Next package and root directory precedence", () => it("keeps default compiled output under its declared package and unrelated chunk-like paths unknown", () => { const manifest = file("package.json", '{"dependencies":{"next":"16.4.2"}}'); const chunk = file(".next/static/chunks/app/build/page-abc.js"); - const context = createJavaScriptNextAppContext( + const context = contextFor( [manifest, chunk], new JavaScriptJsonDocuments(), new Set(), @@ -86,11 +98,7 @@ it("keeps default compiled output under its declared package and unrelated chunk directory: ".next/static/chunks/app", }); expect( - createJavaScriptNextAppContext( - [chunk], - new JavaScriptJsonDocuments(), - new Set(), - )(chunk), + contextFor([chunk], new JavaScriptJsonDocuments(), new Set())(chunk), ).toMatchObject({ kind: "unknown" }); }); @@ -101,17 +109,97 @@ it("supports src/app only when app is not inventoried and retains missing framew ); const page = file("src/app/control/page.js"); expect( - createJavaScriptNextAppContext( + contextFor( [manifest, page], new JavaScriptJsonDocuments(), new Set(["src/app"]), )(page), ).toMatchObject({ kind: "app-directory", directory: "src/app" }); expect( - createJavaScriptNextAppContext( + contextFor( [page], new JavaScriptJsonDocuments(), new Set(["src/app"]), )(page), ).toMatchObject({ kind: "unknown", package: null }); }); + +it("does not infer absent manifests or higher-priority directories from incomplete traversal", () => { + const manifest = file("package.json", '{"dependencies":{"next":"16.4.2"}}'); + const page = file("src/app/control/page.js"); + const files = { files: [manifest, page], unavailable_relevant_files: [] }; + const context = createJavaScriptNextAppContext( + files, + new JavaScriptJsonDocuments(), + new Set(["src/app"]), + { scope: "container-entries", status: "unknown" }, + ); + expect(context(page)).toMatchObject({ + kind: "unknown", + package: null, + reason: expect.stringContaining("src/app/control/package.json"), + }); + const rootPage = file("page.js"); + expect( + createJavaScriptNextAppContext( + files, + new JavaScriptJsonDocuments(), + new Set(["src/app"]), + { scope: "container-entries", status: "unknown" }, + )(rootPage), + ).toMatchObject({ + kind: "unknown", + package: { path: "package.json" }, + reason: expect.stringContaining("higher-priority app directory"), + }); +}); + +it("retains the nearest unavailable manifest's evidence instead of borrowing an ancestor's Next declaration", () => { + const parent = file("package.json", '{"dependencies":{"next":"16.4.2"}}'); + const page = file("app/widget/app/page.js"); + const unavailable = { + path: "app/widget/package.json", + container_sha256: parent.container_sha256, + sha256: null, + bytes: 42, + inventory_artifact_id: null, + inventory_occurrence_id: "unreadable-nested-package", + kind: "package-json" as const, + limitations: ["The unpacked package companion is missing."], + }; + const context = createJavaScriptNextAppContext( + { files: [parent, page], unavailable_relevant_files: [unavailable] }, + new JavaScriptJsonDocuments(), + new Set(["app", "app/widget/app"]), + { scope: "container-entries", status: "complete" }, + ); + expect(context(page)).toMatchObject({ + kind: "unknown", + package: null, + reason: expect.stringContaining(unavailable.path), + }); + const result = context(page); + if (result.kind !== "unknown") + throw new Error("Expected unknown package ownership"); + expect(result.reason).toContain(unavailable.inventory_occurrence_id); + expect(result.reason).toContain(unavailable.limitations[0]); + expect(context(file("app/page.js"))).toMatchObject({ + kind: "app-directory", + package: { path: parent.path }, + }); + const foreign = createJavaScriptNextAppContext( + { + files: [parent, page], + unavailable_relevant_files: [ + { ...unavailable, container_sha256: "c".repeat(64) }, + ], + }, + new JavaScriptJsonDocuments(), + new Set(["app", "app/widget/app"]), + { scope: "container-entries", status: "complete" }, + ); + expect(foreign(page)).toMatchObject({ + kind: "app-directory", + package: { path: parent.path }, + }); +}); diff --git a/src/application/javascript/JavaScriptNextAppContext.ts b/src/application/javascript/JavaScriptNextAppContext.ts index 99cbc383fd..70c7528361 100644 --- a/src/application/javascript/JavaScriptNextAppContext.ts +++ b/src/application/javascript/JavaScriptNextAppContext.ts @@ -1,20 +1,36 @@ import { posix } from "node:path"; -import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; +import type { ArtifactInventoryCoverage } from "../../domain/artifactGraph.js"; +import type { + JavaScriptArtifactFileSet, + JavaScriptArtifactFile, +} from "../../domain/javascript/javascriptArtifactFiles.js"; import type { NextAppSourceContext } from "../../domain/javascript/javascriptRoutePaths.js"; import { compositeKey } from "../../domain/unicodeCodePointOrder.js"; import type { JavaScriptJsonDocuments } from "./JavaScriptJsonDocuments.js"; /** Package declarations and inventoried directories own source-router applicability. */ export const createJavaScriptNextAppContext = ( - files: readonly JavaScriptArtifactFile[], + fileSet: Pick< + JavaScriptArtifactFileSet, + "files" | "unavailable_relevant_files" + >, documents: JavaScriptJsonDocuments, directories: ReadonlySet, + coverage: ArtifactInventoryCoverage, ): (( file: Pick, ) => NextAppSourceContext) => { const packages = new Map( - files + fileSet.files + .filter(({ kind }) => kind === "package-json") + .map((file) => [ + compositeKey([file.container_sha256, posix.dirname(file.path)]), + file, + ]), + ); + const unavailablePackages = new Map( + fileSet.unavailable_relevant_files .filter(({ kind }) => kind === "package-json") .map((file) => [ compositeKey([file.container_sha256, posix.dirname(file.path)]), @@ -28,9 +44,15 @@ export const createJavaScriptNextAppContext = ( ; directory = posix.dirname(directory) ) { - const manifest = packages.get( - compositeKey([file.container_sha256, directory]), - ); + const key = compositeKey([file.container_sha256, directory]); + const unavailable = unavailablePackages.get(key); + if (unavailable !== undefined) + return { + kind: "unknown", + package: null, + reason: `The nearest owning package manifest ${unavailable.path}${unavailable.inventory_occurrence_id === undefined ? "" : ` (inventory occurrence ${unavailable.inventory_occurrence_id})`} is unavailable: ${unavailable.limitations?.join("; ") || "Inventoried package bytes are unavailable."}`, + }; + const manifest = packages.get(key); if (manifest !== undefined) { const identity = { path: manifest.path, @@ -53,6 +75,14 @@ export const createJavaScriptNextAppContext = ( directory: compiled, package: identity, }; + // Selecting src/app requires proving that the higher-priority app is absent. + if (coverage.status !== "complete" && !directories.has(`${prefix}app`)) + return { + kind: "unknown", + package: identity, + reason: + "Incomplete artifact traversal does not establish whether the owning package has a higher-priority app directory.", + }; let root = roots.get(manifest); if (root === undefined) { root = @@ -65,6 +95,12 @@ export const createJavaScriptNextAppContext = ( ? { kind: "app-directory", directory: root, package: identity } : { kind: "outside-app-directory", package: identity }; } + if (coverage.status !== "complete") + return { + kind: "unknown", + package: null, + reason: `Incomplete artifact traversal does not establish whether ${directory === "." ? "" : `${directory}/`}package.json owns this source.`, + }; if (directory === "." || directory === "/") break; } return { diff --git a/tests/acceptance/applications/javascriptNextRouteContext.test.ts b/tests/acceptance/applications/javascriptNextRouteContext.test.ts index 667dcef244..4788845f1d 100644 --- a/tests/acceptance/applications/javascriptNextRouteContext.test.ts +++ b/tests/acceptance/applications/javascriptNextRouteContext.test.ts @@ -1,3 +1,7 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createPackageWithOptions } from "@electron/asar"; + import { expect } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -68,3 +72,78 @@ cliTest.for(["CLI", "stdio MCP"] as const)( ); }, ); + +cliTest.for(["CLI", "stdio MCP"] as const)( + "keeps an unreadable nearest Next package unknown and recovers after restoration through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-next-unavailable-package-"); + const source = join(root, "source"); + const input = join(root, "input"); + const archive = join(input, "app.asar"); + const manifest = JSON.stringify({ dependencies: { next: "16.4.2" } }); + const nestedManifest = "app/widget/package.json"; + await writeFixtureFiles(source, { + "package.json": manifest, + "app/page.js": "export default function Page(){return null;}", + [nestedManifest]: manifest, + "app/widget/app/page.js": "export default function Page(){return null;}", + }); + await mkdir(input); + // ASAR matches unpack globs against absolute source filenames. + await createPackageWithOptions(source, archive, { + unpack: `**/${nestedManifest}`, + }); + const companion = join(`${archive}.unpacked`, nestedManifest); + await rm(companion); + const missing = await analyzeJavaScriptDirectory(surface, cli, input); + const routes = missing.route_inventory; + const parent = routes?.sources.find( + ({ key }) => key === "app.asar/app/page.js", + ); + expect(parent?.next_app_context).toMatchObject({ + kind: "app-directory", + package: { path: "app.asar/package.json" }, + }); + const nested = routes?.sources.find( + ({ key }) => key === "app.asar/app/widget/app/page.js", + ); + expect(nested?.next_app_context).toMatchObject({ + kind: "unknown", + package: null, + reason: expect.stringContaining(nestedManifest), + }); + expect(routes?.unknowns).toContainEqual( + expect.objectContaining({ + source_id: nested?.id, + kind: "interpretation", + reason: expect.stringContaining("unavailable"), + }), + ); + expect(routes?.routes.map(({ path }) => path)).toEqual(["/"]); + expect(routes?.routes[0]?.source_count).toBe(1); + + await writeFile(companion, manifest); + const restored = await analyzeJavaScriptDirectory(surface, cli, input); + const recovered = restored.route_inventory?.sources.find( + ({ key }) => key === nested?.key, + ); + expect(recovered?.next_app_context).toMatchObject({ + kind: "app-directory", + directory: "app.asar/app/widget/app", + package: { path: `app.asar/${nestedManifest}` }, + }); + expect(recovered?.id).not.toBe(nested?.id); + expect( + restored.route_inventory?.routes.map(({ path, source_count }) => ({ + path, + source_count, + })), + ).toEqual([{ path: "/", source_count: 2 }]); + expect( + restored.route_inventory?.unknowns.some( + ({ source_id, kind }) => + source_id === recovered?.id && kind === "interpretation", + ), + ).toBe(false); + }, +); From 046c4ce353fabe9eccf3ec43a5f9fc29027db05e Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:40:55 +0800 Subject: [PATCH 11/31] fix(javascript): preserve native export facts in comparisons --- .../javascriptApplicationVersionKeys.ts | 24 +--- .../javascriptNativeExportFacts.test.ts | 112 ++++++++++++++++++ .../javascript/javascriptNativeExportFacts.ts | 72 +++++++++++ .../javascript/javascriptNativeHandoff.ts | 25 ++-- .../javascriptApplicationWorkflows.test.ts | 102 +++++++++++++++- 5 files changed, 295 insertions(+), 40 deletions(-) create mode 100644 src/domain/javascript/javascriptNativeExportFacts.test.ts create mode 100644 src/domain/javascript/javascriptNativeExportFacts.ts diff --git a/src/domain/javascript/javascriptApplicationVersionKeys.ts b/src/domain/javascript/javascriptApplicationVersionKeys.ts index 5d8cd379f7..634b81b2cc 100644 --- a/src/domain/javascript/javascriptApplicationVersionKeys.ts +++ b/src/domain/javascript/javascriptApplicationVersionKeys.ts @@ -1,3 +1,4 @@ +import { nativeExportCorrespondenceKey } from "./javascriptNativeExportFacts.js"; import { canonicalJson } from "../comparisonSemantics.js"; import { compareUnicodeCodePoints, @@ -271,21 +272,7 @@ const semanticValue = ( exactString(properties.api_key) ?? nonEmptyString(properties.api_name) ); - if (node.kind === "native-export") - return ( - joined( - properties.specifier, - compositeKey([ - stringList(properties.requested_members), - typeof properties.namespace_access === "boolean" - ? properties.namespace_access - : null, - typeof properties.dynamic_member_access === "boolean" - ? properties.dynamic_member_access - : null, - ]), - ) ?? firstLabel(node) - ); + if (node.kind === "native-export") return nativeExportCorrespondenceKey(node); if ( [ "electron-main", @@ -334,13 +321,6 @@ const nonEmptyString = (value: unknown): string | null => const exactString = (value: unknown): string | null => typeof value === "string" ? value : null; -const stringList = (value: unknown): string[] => - Array.isArray(value) - ? value - .filter((item): item is string => typeof item === "string") - .sort(compareUnicodeCodePoints) - : []; - const sortedNodes = (nodes: Iterable): ApplicationNode[] => [...nodes].sort((left, right) => compareUnicodeCodePoints(left.node_id, right.node_id), diff --git a/src/domain/javascript/javascriptNativeExportFacts.test.ts b/src/domain/javascript/javascriptNativeExportFacts.test.ts new file mode 100644 index 0000000000..4914905815 --- /dev/null +++ b/src/domain/javascript/javascriptNativeExportFacts.test.ts @@ -0,0 +1,112 @@ +import { expect, it } from "vitest"; +import { createJavaScriptApplicationNode } from "./javascriptApplicationGraph.js"; +import { artifactEvidence } from "./javascriptApplicationGraph.fixture.js"; +import { + nativeExportCorrespondenceKey, + nativeExportObservationFacts, +} from "./javascriptNativeExportFacts.js"; +import type { JsonValue } from "../jsonValue.js"; + +const node = ( + observations: readonly { + label: string; + properties: Record; + }[], +) => + createJavaScriptApplicationNode({ + kind: "native-export", + identity: { + strategy: "artifact-local-key", + stability: "artifact-version", + artifact_sha256: "a".repeat(64), + namespace: "native-export-fixture", + key: "addon.node", + }, + observations: observations.map((observation) => ({ + ...observation, + evidence: artifactEvidence("a".repeat(64), "main.js"), + })), + }); + +it("preserves legacy labels and members while distinguishing omitted and empty requests", () => { + const legacy = node([ + { + label: "open", + properties: { specifier: "./addon.node", members: ["open"] }, + }, + ]); + const empty = node([ + { + label: "open", + properties: { specifier: "./addon.node", requested_members: [] }, + }, + ]); + expect(nativeExportObservationFacts(legacy.observations[0]!)).toMatchObject({ + requestedPresent: false, + members: ["open"], + label: "open", + }); + expect(nativeExportObservationFacts(empty.observations[0]!)).toMatchObject({ + requestedPresent: true, + requested: [], + label: null, + }); + expect(nativeExportCorrespondenceKey(legacy)).not.toEqual( + nativeExportCorrespondenceKey(empty), + ); +}); + +it("considers later conflicting observations and retains malformed values without inventing identity", () => { + const first = { + label: "summary", + properties: { specifier: "./addon.node", requested_members: ["open"] }, + }; + const second = { + label: "summary", + properties: { specifier: "./addon.node", requested_members: ["close"] }, + }; + expect(nativeExportCorrespondenceKey(node([first]))).not.toBeNull(); + expect(nativeExportCorrespondenceKey(node([first, second]))).toBeNull(); + const malformed = node([ + { + label: "legacy", + properties: { + specifier: "./addon.node", + requested_members: ["open", 42], + }, + }, + ]); + const before = JSON.stringify(malformed); + expect( + nativeExportObservationFacts(malformed.observations[0]!), + ).toMatchObject({ valid: false, requested: ["open"], label: null }); + expect(nativeExportCorrespondenceKey(malformed)).toBeNull(); + expect(JSON.stringify(malformed)).toBe(before); +}); + +it("preserves label-only fallback without treating display labels as structured members", () => { + for (const requested_members of [["open"], ["close"], []]) + expect( + nativeExportCorrespondenceKey( + node([{ label: "same summary", properties: { requested_members } }]), + ), + ).toBeNull(); + expect( + nativeExportCorrespondenceKey(node([{ label: "open", properties: {} }])), + ).not.toBeNull(); + const first = node([ + { + label: "summary one", + properties: { specifier: "./addon.node", requested_members: ["open"] }, + }, + ]); + const second = node([ + { + label: "summary two", + properties: { specifier: "./addon.node", requested_members: ["open"] }, + }, + ]); + expect(nativeExportCorrespondenceKey(first)).toEqual( + nativeExportCorrespondenceKey(second), + ); +}); diff --git a/src/domain/javascript/javascriptNativeExportFacts.ts b/src/domain/javascript/javascriptNativeExportFacts.ts new file mode 100644 index 0000000000..1e756cb79d --- /dev/null +++ b/src/domain/javascript/javascriptNativeExportFacts.ts @@ -0,0 +1,72 @@ +import { + compositeKey, + compareUnicodeCodePoints, +} from "../unicodeCodePointOrder.js"; +import type { ApplicationNode } from "./javascriptApplicationGraphSchemas.js"; + +type Observation = ApplicationNode["observations"][number]; + +/** Read member facts without changing historical observations or their identifiers. */ +export const nativeExportObservationFacts = (observation: Observation) => { + const requested = observation.properties.requested_members; + const members = observation.properties.members; + const strings = (value: unknown): string[] => + Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") + : []; + const requestedPresent = Object.hasOwn( + observation.properties, + "requested_members", + ); + const membersPresent = Object.hasOwn(observation.properties, "members"); + const validList = (value: unknown): boolean => + Array.isArray(value) && value.every((item) => typeof item === "string"); + return { + requestedPresent, + membersPresent, + valid: + (!requestedPresent || validList(requested)) && + (!membersPresent || validList(members)), + requested: strings(requested), + members: strings(members), + // Labels were the historical fallback, but current labels summarize names + // or namespace access and must not become requested native symbols. + label: Array.isArray(requested) ? null : observation.label, + }; +}; + +/** Correspondence requires every observation to describe the same binding facts. */ +export const nativeExportCorrespondenceKey = ( + node: ApplicationNode, +): string | null => { + const keys = node.observations.map((observation) => { + const facts = nativeExportObservationFacts(observation); + if (!facts.valid) return null; + const specifier = observation.properties.specifier; + if (typeof specifier !== "string" || specifier === "") + return observation.label === null || + facts.requestedPresent || + facts.membersPresent + ? null + : compositeKey(["label", observation.label]); + const flag = (name: string) => { + const value = observation.properties[name]; + return typeof value === "boolean" ? value : null; + }; + return compositeKey([ + specifier, + facts.requestedPresent + ? ["requested", [...facts.requested].sort(compareUnicodeCodePoints)] + : ["requested-unknown"], + facts.membersPresent + ? ["members", [...facts.members].sort(compareUnicodeCodePoints)] + : ["members-unknown"], + facts.label, + flag("namespace_access"), + flag("dynamic_member_access"), + ]); + }); + if (keys.some((key) => key === null)) return null; + const unique = new Set(keys); + return unique.size === 1 ? keys[0]! : null; +}; diff --git a/src/domain/javascript/javascriptNativeHandoff.ts b/src/domain/javascript/javascriptNativeHandoff.ts index 2635b20088..075ca71062 100644 --- a/src/domain/javascript/javascriptNativeHandoff.ts +++ b/src/domain/javascript/javascriptNativeHandoff.ts @@ -7,6 +7,8 @@ import type { import type { JavaScriptApplicationGraph } from "./javascriptApplicationGraph.js"; import type { ApplicationFeatureTraceResult } from "./javascriptFeatureTraceSchemas.js"; +import { nativeExportObservationFacts } from "./javascriptNativeExportFacts.js"; + type NativeHandoff = ApplicationFeatureTraceResult["native_handoffs"][number]; /** Build provider-neutral addon targets and link exact-subject native Evidence. */ @@ -102,25 +104,18 @@ const nativeExportNodes = ( const requestedExports = (nodes: readonly ApplicationNode[]): string[] => { const values = nodes.flatMap((node) => - node.observations.flatMap((observation) => [ - // Display labels can summarize several names or namespace access. - // Only legacy observations without structured member facts need them. - ...(observation.label === null || - Array.isArray(observation.properties.requested_members) - ? [] - : [observation.label]), - ...stringArray(observation.properties.requested_members), - ...stringArray(observation.properties.members), - ]), + node.observations.flatMap((observation) => { + const facts = nativeExportObservationFacts(observation); + return [ + ...(facts.label === null ? [] : [facts.label]), + ...facts.requested, + ...facts.members, + ]; + }), ); return [...new Set(values)].sort(compareUnicodeCodePoints); }; -const stringArray = (value: unknown): string[] => - Array.isArray(value) - ? value.filter((item): item is string => typeof item === "string") - : []; - const uniqueProviders = ( evidence: readonly Evidence[], ): NativeHandoff["providers"] => diff --git a/tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts b/tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts index 3a442708bd..db8129fffa 100644 --- a/tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts +++ b/tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts @@ -1,5 +1,5 @@ -import { rm } from "node:fs/promises"; -import { relative, resolve } from "node:path"; +import { rm, writeFile } from "node:fs/promises"; +import { join, relative, resolve } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -23,7 +23,11 @@ import { import { javascriptRuntimeReconciliationResultSchema } from "../../../src/domain/javascript/javascriptRuntimeReconciliationSchemas.js"; import { electronPageInspectionSchema } from "../../../src/domain/javascript/electronObservation.js"; import { compareJavaScriptApplicationVersions } from "../../../src/domain/javascript/javascriptApplicationVersionComparison.js"; -import { createJavaScriptApplicationGraph } from "../../../src/domain/javascript/javascriptApplicationGraph.js"; +import { createJavaScriptSemanticGraph } from "../../../src/domain/javascript/javascriptSemanticGraph.js"; +import { + createJavaScriptApplicationNode, + createJavaScriptApplicationGraph, +} from "../../../src/domain/javascript/javascriptApplicationGraph.js"; import { traceApplicationFeature } from "../../../src/domain/javascript/javascriptFeatureTrace.js"; import { applicationFeatureTraceResultSchema } from "../../../src/domain/javascript/javascriptFeatureTraceSchemas.js"; import { @@ -218,6 +222,98 @@ describe("JavaScript application comparison workflows", () => { }); }); +describe("historical native comparison projections", () => { + it("keeps historical native member omission distinct through authenticated comparison", async () => { + const root = await createTestTempDirectory( + "rea-historical-native-members-", + ); + temporary.push(root); + await writeFile( + join(root, "main.js"), + 'const { open } = require("./addon.node"); open();', + ); + await writeFile(join(root, "addon.node"), "inert fixture; never executed"); + const source = await analyzeFixture(root); + const original = javascriptApplicationAnalysisResultSchema.parse( + source.normalized_result, + ); + const binding = original.graph.nodes.find( + ({ kind }) => kind === "native-export", + ); + if (binding === undefined || source.subject === null) + throw new Error("Missing fixture binding"); + const historical = (name: string, explicitEmpty = false) => { + const artifact = binding.observations[0]!.evidence.artifact; + if (!artifact.available) + throw new Error("Missing binding source artifact"); + const node = createJavaScriptApplicationNode({ + kind: binding.kind, + identity: { + strategy: "artifact-local-key", + stability: "artifact-version", + artifact_sha256: artifact.sha256, + namespace: "historical-native-binding-fixture", + key: `${name}-${String(explicitEmpty)}`, + }, + observations: binding.observations.map((observation) => { + const { requested_members: _requested, ...properties } = + observation.properties; + return { + label: name, + properties: { + ...properties, + ...(explicitEmpty + ? { requested_members: [] } + : { members: [name] }), + }, + evidence: observation.evidence, + }; + }), + }); + const { graph_id: _graphId, ...graphContent } = original.graph; + const graph = createJavaScriptApplicationGraph({ + ...graphContent, + nodes: [...original.graph.nodes, node], + }); + const { graph_id: _semanticId, ...semanticContent } = + original.semantic_graph; + const semantic_graph = createJavaScriptSemanticGraph({ + ...semanticContent, + application_graph_id: graph.graph_id, + }); + return createEvidence( + { + path: source.subject!.local_path, + sha256: source.subject!.digest.sha256, + format: source.subject!.format, + }, + source.provider, + { + predicateType: source.predicate_type, + operation: source.operation, + parameters: source.parameters, + authority: source.authority, + confidence: source.confidence, + result: { ...original, graph, semantic_graph }, + }, + ); + }; + const left = historical("open"); + const before = JSON.stringify(left); + for (const right of [historical("close"), historical("open", true)]) { + const compared = compareApplicationVersionsEvidence({ left, right }); + if (!compared.ok) throw compared.error; + const bindings = compared.value.result.items.filter( + ({ node_kind, match }) => + node_kind === "native-export" && match.status === "unmatched", + ); + expect(bindings).toHaveLength(2); + expect(compared.value.sources).toEqual([left, right]); + } + expect(JSON.stringify(left)).toBe(before); + }); +}); + describe("complete comparison projections", () => { it("matches rechunked modules by exact source and minified modules by structural fingerprint", async () => { const root = await createTestTempDirectory("rea-application-versions-"); From 84a54b61833c62f741c58e635d36e9dbfd8b8e45 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:43:28 +0800 Subject: [PATCH 12/31] fix(asar): preserve header filename identity --- src/artifacts/AsarArtifactReader.ts | 30 ++++----- src/artifacts/AsarHeader.test.ts | 39 ++++++++++++ src/artifacts/AsarHeader.ts | 36 +++++++++++ src/artifacts/AsarHeaderIdentity.test.ts | 80 ++++++++++++++++++++++++ 4 files changed, 170 insertions(+), 15 deletions(-) create mode 100644 src/artifacts/AsarHeaderIdentity.test.ts diff --git a/src/artifacts/AsarArtifactReader.ts b/src/artifacts/AsarArtifactReader.ts index a0e5a6a8d0..7f7d923b48 100644 --- a/src/artifacts/AsarArtifactReader.ts +++ b/src/artifacts/AsarArtifactReader.ts @@ -17,8 +17,6 @@ import { isAbsolute, join, relative, sep } from "node:path"; import { Readable, Transform } from "node:stream"; import { pipeline } from "node:stream/promises"; -import { getRawHeader, uncache } from "@electron/asar"; - import { ArtifactReaderFailure, copyArtifactEntry, @@ -27,7 +25,7 @@ import { type ArtifactReader, } from "./ArtifactReader.js"; import { closeAsarHandle, readValidatedAsarEntry } from "./AsarEntryStream.js"; -import { admitAsarHeader } from "./AsarHeader.js"; +import { admitAsarHeader, readAsarHeader } from "./AsarHeader.js"; import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; import type { FileHandleChunkStream } from "../filesystem/readFileHandleChunks.js"; import { readArtifactFileChunks } from "./readArtifactFileChunks.js"; @@ -39,7 +37,7 @@ import { import { readAsarListing, type AsarListedEntry } from "./AsarListing.js"; /** - * Official Electron ASAR adapter with range-streamed member reads. + * Electron ASAR reader with strict header decoding and range-streamed member reads. * * An entry marked `unpacked` is metadata, not an integrity exemption: Electron * stores its bytes beside the archive in `.unpacked`, and callers must @@ -73,10 +71,7 @@ export class AsarArtifactReader implements ArtifactReader { this.#archiveSize = admitted.archiveBytes; this.#headerSize = admitted.headerBytes; assertAsarReadActive(signal); - const ordered = await readAsarListing( - getRawHeader(snapshotPath).header, - signal, - ); + const ordered = await readAsarListing(admitted.header, signal); const archiveMetadata = await lstat(snapshotPath); if (!archiveMetadata.isFile() || archiveMetadata.isSymbolicLink()) throw new ArtifactReaderFailure( @@ -128,17 +123,23 @@ export class AsarArtifactReader implements ArtifactReader { let admission: | { readonly ok: true; - readonly value: Awaited>; + readonly value: Awaited> & { + readonly header: unknown; + }; } | { readonly ok: false; readonly cause: unknown }; try { + const extents = await admitAsarHeader( + headerHandle, + signal, + this.options.headerAdmission, + ); admission = { ok: true, - value: await admitAsarHeader( - headerHandle, - signal, - this.options.headerAdmission, - ), + value: { + ...extents, + header: await readAsarHeader(headerHandle, extents, signal), + }, }; } catch (cause: unknown) { admission = { ok: false, cause }; @@ -388,7 +389,6 @@ export class AsarArtifactReader implements ArtifactReader { } } if (root === undefined || this.#snapshotRoot === undefined) { - if (this.#snapshot !== undefined) uncache(this.#snapshot.path); this.#snapshot = undefined; this.#resetArchiveState(); this.#entries.clear(); diff --git a/src/artifacts/AsarHeader.test.ts b/src/artifacts/AsarHeader.test.ts index 3cf4f776ee..1bfaea23d0 100644 --- a/src/artifacts/AsarHeader.test.ts +++ b/src/artifacts/AsarHeader.test.ts @@ -1,8 +1,10 @@ +import type { FileHandle } from "node:fs/promises"; import type { Stats } from "node:fs"; import { expect, it, vi } from "vitest"; import { admitAsarHeader, + readAsarHeader, createAsarHeaderSignatureProbe, } from "./AsarHeader.js"; import { asarHeaderBytes } from "../../tests/fixtures/asarHeader.js"; @@ -78,3 +80,40 @@ it("rejects a signature whose declared header exceeds the observed archive", () reason: "archive-too-short", }); }); + +it("decodes admitted JSON across short reads without consuming padding", async () => { + const archive = asarHeaderBytes('{"files":{"�":{}}}', { + padding: Buffer.from([0xff]), + }); + const handle = { + stat: async () => ({ isFile: () => true, size: archive.length }) as Stats, + read: async ( + target: Buffer, + offset: number, + length: number, + position: number, + ) => { + const bytesRead = Math.min(2, length, archive.length - position); + archive.copy(target, offset, position, position + bytesRead); + return { bytesRead, buffer: target }; + }, + } as unknown as Parameters[0]; + const extents = await admitAsarHeader(handle); + await expect(readAsarHeader(handle, extents)).resolves.toEqual({ + files: { "�": {} }, + }); + await expect( + readAsarHeader( + { + read: (async (target: Buffer) => ({ + bytesRead: 0, + buffer: target, + })) as FileHandle["read"], + }, + extents, + ), + ).rejects.toMatchObject({ + reason: "format", + message: "Truncated ASAR JSON header", + }); +}); diff --git a/src/artifacts/AsarHeader.ts b/src/artifacts/AsarHeader.ts index 7260a6420c..f4f5b5ae7d 100644 --- a/src/artifacts/AsarHeader.ts +++ b/src/artifacts/AsarHeader.ts @@ -422,3 +422,39 @@ export const admitAsarHeader = async ( export const createAsarHeaderSignatureProbe = ( admission: AsarHeaderAdmission = DEFAULT_ASAR_HEADER_ADMISSION, ): AsarHeaderSignatureProbe => new AsarHeaderSignatureProbeImpl(admission); + +/** Decode only admitted JSON bytes from the same owned descriptor as its extents. */ +export const readAsarHeader = async ( + handle: Pick, + extents: AsarHeaderExtents, + signal?: AbortSignal, +): Promise => { + const bytes = Buffer.alloc(extents.jsonBytes); + let offset = 0; + while (offset < bytes.length) { + assertArtifactReadActive(signal); + const read = await handle.read( + bytes, + offset, + bytes.length - offset, + 16 + offset, + ); + assertArtifactReadActive(signal); + if (read.bytesRead === 0) + throw new ArtifactReaderFailure("format", "Truncated ASAR JSON header"); + offset += read.bytesRead; + } + let json: string; + try { + json = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( + bytes, + ); + } catch (cause: unknown) { + throw new ArtifactReaderFailure( + "format", + "ASAR JSON header is not valid UTF-8", + { cause }, + ); + } + return JSON.parse(json) as unknown; +}; diff --git a/src/artifacts/AsarHeaderIdentity.test.ts b/src/artifacts/AsarHeaderIdentity.test.ts new file mode 100644 index 0000000000..3ef42c1fe6 --- /dev/null +++ b/src/artifacts/AsarHeaderIdentity.test.ts @@ -0,0 +1,80 @@ +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { buffer } from "node:stream/consumers"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, it } from "vitest"; + +import { AsarArtifactReader } from "./AsarArtifactReader.js"; +import { asarHeaderBytes } from "../../tests/fixtures/asarHeader.js"; + +it("rejects malformed UTF-8 instead of inventing a replacement-character member identity", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-asar-identity-")); + const path = join(root, "app.asar"); + const reader = new AsarArtifactReader(path); + const bytes = asarHeaderBytes('{"files":{"x":{"size":0,"offset":"0"}}}'); + bytes[bytes.indexOf(Buffer.from('"x"')) + 1] = 0xff; + await writeFile(path, bytes); + try { + await expect(collectEntries(reader)).rejects.toMatchObject({ + reason: "format", + message: expect.stringContaining("UTF-8"), + }); + } finally { + await reader.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +it.each(["�", "é", "日本語", "\uFEFFname"])( + "preserves valid UTF-8 member identity %j and reads its bytes through padded framing", + async (name) => { + const root = await mkdtemp(join(tmpdir(), "rea-asar-identity-")); + const path = join(root, "app.asar"); + const reader = new AsarArtifactReader(path); + const content = Buffer.from("member bytes"); + await writeFile( + path, + Buffer.concat([ + asarHeaderBytes( + ` \t\r\n${JSON.stringify({ files: { [name]: { size: content.length, offset: "0" } } })} `, + { padding: Buffer.from([0xff, 0xfe]) }, + ), + content, + ]), + ); + try { + const entries = await collectEntries(reader); + expect(entries).toHaveLength(1); + expect(entries[0]?.path).toBe(name); + const output = await reader.open(entries[0]!); + expect((await buffer(output)).equals(content)).toBe(true); + } finally { + await reader.close(); + await rm(root, { recursive: true, force: true }); + } + }, +); + +it.each(['{"files":', '\uFEFF{"files":{}}'])( + "rejects invalid JSON framing %j as format failure", + async (json) => { + const root = await mkdtemp(join(tmpdir(), "rea-asar-identity-")); + const path = join(root, "app.asar"); + const reader = new AsarArtifactReader(path); + await writeFile(path, asarHeaderBytes(json)); + try { + await expect(collectEntries(reader)).rejects.toMatchObject({ + reason: "format", + }); + } finally { + await reader.close(); + await rm(root, { recursive: true, force: true }); + } + }, +); + +async function collectEntries(reader: AsarArtifactReader) { + const entries = []; + for await (const entry of reader.entries()) entries.push(entry); + return entries; +} From ecd22c42ac55287bef0f3e70561773cd4b6ba74c Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:43:36 +0800 Subject: [PATCH 13/31] fix(firmware): retain completed command failures --- src/firmware/FirmwareCommand.test.ts | 102 +++++++++++++++++++++++++++ src/firmware/FirmwareCommand.ts | 61 ++++++++-------- 2 files changed, 134 insertions(+), 29 deletions(-) diff --git a/src/firmware/FirmwareCommand.test.ts b/src/firmware/FirmwareCommand.test.ts index 773da4cb56..06dcae1ad1 100644 --- a/src/firmware/FirmwareCommand.test.ts +++ b/src/firmware/FirmwareCommand.test.ts @@ -1,3 +1,4 @@ +import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js"; import { EventEmitter } from "node:events"; import { chmod, mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -17,6 +18,7 @@ import { import { spawnOwnedProviderProcess, type ProviderProcessHandle, + type ProviderProcessSupervisor, type OwnedProviderProcessSpawnOptions, } from "../process/ProviderProcess.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; @@ -335,3 +337,103 @@ test.each([ expect(launcher).toHaveBeenCalledOnce(); }, ); + +test.each([ + { exitCode: 0, cleanupFails: false }, + { exitCode: 7, cleanupFails: false }, + { exitCode: 7, cleanupFails: true }, +])( + "preserves completed exit $exitCode through cancellation during real owned cleanup (fails: $cleanupFails)", + async ({ exitCode, cleanupFails }) => { + const root = await createTestTempDirectory("rea-firmware-finalization-"); + const caller = new AbortController(); + const reason = { caller: "cancel during final cleanup" }; + let cleanupCalls = 0; + const retained: ProviderProcessSupervisor[] = []; + const failure: unknown = await runFirmwareCommand({ + engine: "binwalk", + operation: "inspect_firmware_regions", + command: process.execPath, + limiter: process.execPath, + fileSizeCeiling: Number.MAX_SAFE_INTEGER, + args: [], + cwd: root, + environment: process.env, + signal: caller.signal, + deadline: { + phase: "analysis", + signal: new AbortController().signal, + timeoutMs: 30_000, + expiresAtMs: performance.now() + 30_000, + }, + launcher: async (options) => { + const owned = await spawnOwnedProviderProcess({ + ...options, + command: process.execPath, + arguments: [ + "-e", + `process.stdout.write("scan evidence\\n"); process.stderr.write("producer diagnostics\\n"); process.exitCode = ${exitCode}`, + ], + }); + return { + ...owned, + cleanup: async () => { + cleanupCalls += 1; + expect(owned.process.exitCode).toBe(exitCode); + caller.abort(reason); + const cleaned = await cleanupOwnedProcessGroup(owned.ownership); + if (cleanupFails && cleanupCalls === 1) + return { + cleaned: false as const, + reason: "controlled cleanup refusal after real release", + }; + return cleaned; + }, + }; + }, + retainCleanup: (owner) => { + retained.push(owner); + }, + }).catch((cause: unknown) => cause); + expect(cleanupCalls).toBe(1); + expect(failure).toBeInstanceOf(AnalysisError); + if (!(failure instanceof AnalysisError)) + throw new Error("Expected typed failure"); + if (exitCode === 0) { + expect(failure).toMatchObject({ + _tag: "AnalysisCancelledError", + cause: reason, + }); + } else if (cleanupFails) { + expect(retained).toHaveLength(1); + expect(await retained[0]!.stop()).toMatchObject({ + status: "verified-cleanup", + }); + expect(cleanupCalls).toBe(2); + retained[0]!.dispose(); + expect(failure).toMatchObject({ + diagnostics: { + previous_error: { + code: "execution_failure", + details: { diagnostics: { exit_code: 7 } }, + }, + }, + capturedOutput: { + stdout: "scan evidence\n", + stderr: "producer diagnostics\n", + exit_code: 7, + }, + }); + } else { + expect(failure).toMatchObject({ + _tag: "ProviderAdapterError", + diagnostics: { + exit_code: 7, + stdout: "scan evidence\n", + stderr: "producer diagnostics\n", + }, + }); + expect(projectAnalysisError(failure).code).toBe("execution_failure"); + } + }, +); diff --git a/src/firmware/FirmwareCommand.ts b/src/firmware/FirmwareCommand.ts index e60729b8d5..d9a2f86c67 100644 --- a/src/firmware/FirmwareCommand.ts +++ b/src/firmware/FirmwareCommand.ts @@ -288,6 +288,38 @@ export const runFirmwareCommand = async (context: { } checkDiagnostics(); checkInterruption(); + // Physical completion belongs to this observation, before awaited cleanup. + // A later cancellation must not replace an already completed tool failure. + const snapshot = supervisor.snapshot(); + if ( + snapshot.exitCode !== 0 && + !( + context.acceptReportedExtractionFailure === true && + snapshot.exitCode === 1 + ) + ) { + const signalLimit = firmwareSignalLimitError( + operation, + snapshot.signal, + fileSizeBytes, + fileSizeAdjustable, + capturedFirmwareOutput(snapshot), + ); + if (signalLimit !== undefined) throw signalLimit; + throw new ProviderAdapterError(engine, operation, { + diagnostics: { + reason: + "Tool failed; inspect exit status and stderr for format, dependency, sandbox or resource-limit failures", + exit_code: snapshot.exitCode ?? null, + signal: snapshot.signal ?? null, + stdout: snapshot.stdout.text, + stderr: snapshot.stderr.text, + stdout_bytes: snapshot.stdout.bytes, + stderr_bytes: snapshot.stderr.bytes, + }, + remediationAction: `Inspect exit status and stderr for format, dependency, sandbox, or resource-limit failures. ${firmwareProcessLimitRecovery(fileSizeBytes, fileSizeAdjustable)} A format or dependency failure is not repaired by changing analysis_timeout_ms.`, + }); + } } catch (cause: unknown) { failure = firmwareFailure(cause, context); } @@ -320,35 +352,6 @@ export const runFirmwareCommand = async (context: { ); } if (failure !== undefined) throw failure; - if ( - snapshot.exitCode !== 0 && - !( - context.acceptReportedExtractionFailure === true && - snapshot.exitCode === 1 - ) - ) { - const signalLimit = firmwareSignalLimitError( - operation, - snapshot.signal, - fileSizeBytes, - fileSizeAdjustable, - capturedFirmwareOutput(snapshot), - ); - if (signalLimit !== undefined) throw signalLimit; - throw new ProviderAdapterError(engine, operation, { - diagnostics: { - reason: - "Tool failed; inspect exit status and stderr for format, dependency, sandbox or resource-limit failures", - exit_code: snapshot.exitCode ?? null, - signal: snapshot.signal ?? null, - stdout: snapshot.stdout.text, - stderr: snapshot.stderr.text, - stdout_bytes: snapshot.stdout.bytes, - stderr_bytes: snapshot.stderr.bytes, - }, - remediationAction: `Inspect exit status and stderr for format, dependency, sandbox, or resource-limit failures. ${firmwareProcessLimitRecovery(fileSizeBytes, fileSizeAdjustable)} A format or dependency failure is not repaired by changing analysis_timeout_ms.`, - }); - } return { stdout: snapshot.stdout, stderr: snapshot.stderr, From 52613403978e4b949d62fd14d426f3c2a075df42 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:43:43 +0800 Subject: [PATCH 14/31] fix(evidence): retain analysis view environment --- .../AnalysisViewEnvironment.test.ts | 66 +++++++++++++++++++ .../analysisView/AnalysisViewService.ts | 1 + 2 files changed, 67 insertions(+) create mode 100644 src/application/analysisView/AnalysisViewEnvironment.test.ts diff --git a/src/application/analysisView/AnalysisViewEnvironment.test.ts b/src/application/analysisView/AnalysisViewEnvironment.test.ts new file mode 100644 index 0000000000..109f656aea --- /dev/null +++ b/src/application/analysisView/AnalysisViewEnvironment.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { scenarioCaptureEvidence } from "../../../tests/fixtures/browserScenarioCapture.js"; +import { + createImmutableEvidenceBundle, + parseEvidenceBundle, + serializeEvidenceBundle, +} from "../../domain/evidenceBundle.js"; +import { EvidenceLedger } from "../investigation/EvidenceLedger.js"; +import { + inspectAnalysisView, + summarizeRetainedAnalysis, +} from "./AnalysisViewService.js"; + +describe("analysis view execution environment provenance", () => { + it("preserves controlled replay environment through projection, retention, and portable export", async () => { + const parent = scenarioCaptureEvidence(); + expect(parent.environment).not.toBeNull(); + const ledger = new EvidenceLedger(); + const summary = summarizeRetainedAnalysis(parent, { + recordEvidence: (evidence) => ledger.record(evidence), + evidenceById: (id) => ledger.forAnalysis(id), + }); + if (!summary.ok) throw summary.error; + const manifest = await inspectAnalysisView( + { + source: { kind: "retained-evidence", evidence_id: parent.evidence_id }, + view: { kind: "environment-manifest" }, + }, + (id) => ledger.forAnalysis(id), + ); + if (!manifest.ok) throw manifest.error; + for (const view of [summary.value, manifest.value]) { + expect(view.environment).toEqual(parent.environment); + expect(view.authority).toBe("controlled-replay"); + expect(view.evidence_links).toEqual([parent.evidence_id]); + expect(ledger.record(view).ok).toBe(true); + } + const bundle = parseEvidenceBundle( + JSON.parse( + serializeEvidenceBundle( + createImmutableEvidenceBundle([ + parent, + summary.value, + manifest.value, + ]), + ), + ), + ); + expect(bundle.environments).toEqual([parent.environment]); + for (const record of bundle.records) + expect(record.environment).toEqual(parent.environment); + }); + + it("keeps an unknown source environment unknown", async () => { + const { analysisViewCaptureEvidence } = + await import("../../../tests/fixtures/analysisView.js"); + const parent = analysisViewCaptureEvidence(); + expect(parent.environment).toBeNull(); + const projected = await inspectAnalysisView({ + source: { kind: "inline", evidence: parent }, + view: { kind: "summary" }, + }); + if (!projected.ok) throw projected.error; + expect(projected.value.environment).toBeNull(); + }); +}); diff --git a/src/application/analysisView/AnalysisViewService.ts b/src/application/analysisView/AnalysisViewService.ts index 3c69a9d632..076cb4b25e 100644 --- a/src/application/analysisView/AnalysisViewService.ts +++ b/src/application/analysisView/AnalysisViewService.ts @@ -400,6 +400,7 @@ const createAnalysisViewEvidence = ( ? "inferred" : "derived", authority: parent.authority, + environment: parent.environment, limitations: result.limitations, ...((nativeFunctionView !== undefined || nativeObservationView) && parent.relationship_coverage !== null From d1202c539d9a2985a55a24a4a8f91fb951e21558 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:43:52 +0800 Subject: [PATCH 15/31] fix(web): preserve Next route filename patterns --- .../javascriptRouteInventory.test.ts | 84 +++++++++++++++++-- src/domain/javascript/javascriptRoutePaths.ts | 14 +++- ...javascriptNextRouteSegmentIdentity.test.ts | 2 + 3 files changed, 87 insertions(+), 13 deletions(-) diff --git a/src/domain/javascript/javascriptRouteInventory.test.ts b/src/domain/javascript/javascriptRouteInventory.test.ts index b4fd09c20a..028a5282a1 100644 --- a/src/domain/javascript/javascriptRouteInventory.test.ts +++ b/src/domain/javascript/javascriptRouteInventory.test.ts @@ -39,15 +39,81 @@ const build = (inputs: JavaScriptRouteInventoryInput[], bytes?: number) => completeSemanticSteps(buildJavaScriptRouteInventorySteps(inputs, bytes)), ); -it("does not promote page-shaped paths without source-router applicability", () => { - const unknown = source("app/page.js", ""); - delete (unknown as { next_app_context?: unknown }).next_app_context; - const result = build([unknown]); - expect(result.routes).toEqual([]); - expect(result.unknowns).toMatchObject([ - { kind: "interpretation", reason: expect.stringContaining("not measured") }, - ]); -}); +it.each([ + ["hello world", "hello%20world"], + ["team?admin", "team%3Fadmin"], + ["team#admin", "team%23admin"], +] as const)( + "preserves Next filename pattern %s and its source identity", + (segment, encoded) => { + const url = `https://example.test/_next/static/chunks/app/${encoded}/page-abc.js`; + const result = build([ + source(`app/${segment}/page.js`, ""), + source("captured.js", "", url), + source( + "runtime.js", + `globalThis.TURBOPACK=[];const page=${JSON.stringify(url)};`, + ), + ]); + expect(result.routes).toMatchObject([ + { path: `/${segment}`, source_count: 3, occurrence_count: 3 }, + ]); + expect(result.routes).toHaveLength(1); + expect(result.occurrences.map(({ source_id }) => source_id).sort()).toEqual( + result.sources.map(({ id }) => id).sort(), + ); + expect(result.sources).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: `app/${segment}/page.js`, url: null }), + expect.objectContaining({ path: null, url }), + ]), + ); + expect(result.unknowns).toEqual([]); + expect(result.coverage).toEqual({ + scope: "provided-code-sources", + source_parsing: "complete", + candidate_enumeration: "complete", + application_routes: "unknown", + }); + }, +); + +it.each(["%00", "%09", "%0A", "%7F"])( + "retains an interpretation gap for a Next filename containing %s", + (encoded) => { + const url = `https://example.test/_next/static/chunks/app/hello${encoded}world/page-abc.js`; + const result = build([source("captured.js", "", url)]); + expect(result.routes).toEqual([]); + expect(result.unknowns).toMatchObject([ + { + source_id: result.sources[0]!.id, + kind: "interpretation", + reason: expect.stringContaining("control character"), + }, + ]); + expect(result.sources[0]!.url).toBe(url); + expect(result.coverage.candidate_enumeration).toBe("partial"); + }, +); + +it.each(["app/page.js", "app/hello\nworld/page.js"])( + "does not promote page-shaped path %j without source-router applicability", + (path) => { + const unknown = source(path, ""); + delete (unknown as { next_app_context?: unknown }).next_app_context; + const result = build([unknown]); + expect(result.routes).toEqual([]); + expect(result.unknowns).toMatchObject([ + { + source_id: result.sources[0]!.id, + kind: "interpretation", + reason: expect.stringContaining("not measured"), + }, + ]); + expect(result.sources[0]!.path).toBe(path); + expect(result.coverage.candidate_enumeration).toBe("partial"); + }, +); it("binds source identity to the package evidence establishing router applicability", () => { const first = source("app/page.js", ""); diff --git a/src/domain/javascript/javascriptRoutePaths.ts b/src/domain/javascript/javascriptRoutePaths.ts index ecfe8fd571..5aad7623c8 100644 --- a/src/domain/javascript/javascriptRoutePaths.ts +++ b/src/domain/javascript/javascriptRoutePaths.ts @@ -105,7 +105,7 @@ export const classifyNextAppRoutePath = ( } if (relative === undefined) { if ( - /(?:^|\/)app\/(?:.*\/)?page(?:\.[cm]?[jt]sx?|-[^/?#]+\.js|)(?:[?#].*)?$/u.test( + /(?:^|\/)app\/(?:[\s\S]*\/)?page(?:\.[cm]?[jt]sx?|-[^/?#]+\.js|)(?:[?#].*)?$/u.test( value, ) ) @@ -137,7 +137,7 @@ export const classifyNextAppRoutePath = ( } } const match = - /^(?:(.*?)\/)?page(?:-([^/?#]+)\.js|\.(?:[cm]?jsx?|tsx?)|)([?#].*)?$/u.exec( + /^(?:([\s\S]*?)\/)?page(?:-([^/?#]+)\.js|\.(?:[cm]?jsx?|tsx?)|)([?#].*)?$/u.exec( relative, ); if ( @@ -148,6 +148,11 @@ export const classifyNextAppRoutePath = ( (match[3] !== undefined && (urlPath || context.kind === "source-path")) ) return; + if (/[\u0000-\u001f\u007f]/u.test(relative)) + return { + kind: "unknown", + reason: "A Next page filename contains a control character.", + }; const sourceSegments = (match[1] ?? "").split("/"); // A private folder excludes the whole page, including intercepted ancestors. // Keep filesystem segment spelling: only Next's explicit %5F escape is @@ -178,8 +183,9 @@ export const classifyNextAppRoutePath = ( : segment, ); } - const path = routePath(`/${segments.filter(Boolean).join("/")}`); - return path === undefined ? undefined : { kind: "known", path }; + // These are filename-derived patterns, not navigation URLs. Preserve spaces + // and literal delimiters rather than applying URL syntax validation. + return { kind: "known", path: `/${segments.filter(Boolean).join("/")}` }; }; export const nextScriptBasePath = (source: string): string | undefined => { diff --git a/tests/acceptance/applications/javascriptNextRouteSegmentIdentity.test.ts b/tests/acceptance/applications/javascriptNextRouteSegmentIdentity.test.ts index e3587c4a38..a27f2c9c09 100644 --- a/tests/acceptance/applications/javascriptNextRouteSegmentIdentity.test.ts +++ b/tests/acceptance/applications/javascriptNextRouteSegmentIdentity.test.ts @@ -20,6 +20,7 @@ cliTest( "app/team%2Fadmin/page.js": page("EncodedSlash"), "app/%5Fpublic/page.js": page("EscapedUnderscore"), "app/blog/%5fdraft/page.js": page("LowercaseEscape"), + "app/hello world/page.js": page("Space"), }); const response = await cli.run({ arguments: ["analyze-javascript-application", root, "--format", "json"], @@ -40,6 +41,7 @@ cliTest( { path: "/_public", source_count: 1 }, { path: "/a", source_count: 1 }, { path: "/blog/%5fdraft", source_count: 1 }, + { path: "/hello world", source_count: 1 }, { path: "/team%2Fadmin", source_count: 1 }, ]); expect(analysis.route_inventory?.coverage).toMatchObject({ From 11b025ef9776dd23057e505549154fea22a8a5ea Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:45:39 +0800 Subject: [PATCH 16/31] fix(session): retain cleanup ownership during failed opens --- src/application/binary/BinarySession.ts | 50 ++- .../BinarySessionBundleResources.test.ts | 344 +++++++++++++----- 2 files changed, 287 insertions(+), 107 deletions(-) diff --git a/src/application/binary/BinarySession.ts b/src/application/binary/BinarySession.ts index c95a816a72..6186a9afe7 100644 --- a/src/application/binary/BinarySession.ts +++ b/src/application/binary/BinarySession.ts @@ -93,6 +93,18 @@ interface CleanupFailureTracker { readonly onFailure: (failure: AnalysisError) => void; } +/** Compose live cleanup facts without treating an unowned rejection as completion. */ +const sessionCleanupFailure = ( + primary: AnalysisError, + cleanup: AnalysisError, + operation: string, +): AnalysisError => { + const failure = analysisErrorWithCleanupFailure(primary, cleanup, operation); + return cleanup.cleanupIncomplete + ? failure.bindCleanupDependencies([primary, cleanup]) + : failure; +}; + const trackCleanupFailures = ( signal: AbortSignal | undefined, operation: string, @@ -120,20 +132,16 @@ const trackCleanupFailures = ( }); cleanupFailure = failure; } else { - cleanupFailure = analysisErrorWithCleanupFailure( - cleanupFailure, - failure, - operation, - ); + // Both operands are cleanup observations here. An earlier rejection + // without a live lifecycle must not disappear when a later owner resolves. + cleanupFailure = cleanupFailure.cleanupIncomplete + ? sessionCleanupFailure(cleanupFailure, failure, operation) + : analysisErrorWithCleanupFailure(cleanupFailure, failure, operation); } error = cancellation === undefined ? cleanupFailure - : analysisErrorWithCleanupFailure( - cancellation, - cleanupFailure, - operation, - ); + : sessionCleanupFailure(cancellation, cleanupFailure, operation); }, }; }; @@ -270,7 +278,7 @@ export class BinarySession return err( closed.ok ? started.error - : analysisErrorWithCleanupFailure( + : sessionCleanupFailure( started.error, closed.error, "open_bundle_resources", @@ -286,7 +294,7 @@ export class BinarySession return err( closed.ok ? cancelled - : analysisErrorWithCleanupFailure( + : sessionCleanupFailure( cancelled, closed.error, "open_bundle_resources", @@ -333,7 +341,7 @@ export class BinarySession cleanupFailures.error ?? (candidateClosed.ok ? cancelled - : analysisErrorWithCleanupFailure( + : sessionCleanupFailure( cancelled, candidateClosed.error, "open_bundle_resources", @@ -1012,11 +1020,7 @@ export class BinarySession onCleanupFailure === undefined ? {} : { onCleanupFailure }, ); if (!closed.ok) - return analysisErrorWithCleanupFailure( - primary, - closed.error, - "open_binary", - ); + return sessionCleanupFailure(primary, closed.error, "open_binary"); return this.#restoreAfterFailedOpen(previous, primary); } @@ -1027,7 +1031,7 @@ export class BinarySession const restored = await this.#restore(previous); return restored.ok ? primary - : analysisErrorWithCleanupFailure(primary, restored.error, "open_binary"); + : sessionCleanupFailure(primary, restored.error, "open_binary"); } /** Describe the active binary session. */ @@ -1468,13 +1472,7 @@ export class BinarySession const closed = await this.#retireClient(run); return closed.ok ? ok(null) - : err( - analysisErrorWithCleanupFailure( - started.error, - closed.error, - "open_binary", - ), - ); + : err(sessionCleanupFailure(started.error, closed.error, "open_binary")); } async #waitForTransition( diff --git a/src/application/binary/BinarySessionBundleResources.test.ts b/src/application/binary/BinarySessionBundleResources.test.ts index 0592b52f6b..b7f3552ecd 100644 --- a/src/application/binary/BinarySessionBundleResources.test.ts +++ b/src/application/binary/BinarySessionBundleResources.test.ts @@ -251,90 +251,101 @@ it("cancels a resource switch during prior-client retirement and closes its cand expect(await session.close()).toMatchObject({ ok: true }); }); -it("keeps cancellation primary when resource retirement fails and retries the owner", async () => { - const rootPath = await mkdtemp( - join(tmpdir(), "rea-resource-cancel-close-failure-"), - ); - temporaryDirectories.push(rootPath); - await writeFile(join(rootPath, "Info.plist"), ""); - let closeStarted!: () => void; - const closing = new Promise((resolve) => { - closeStarted = resolve; - }); - let releaseClose!: () => void; - const closeGate = new Promise((resolve) => { - releaseClose = resolve; - }); - let created = 0; - let oldCloseAttempts = 0; - let candidateCloseAttempts = 0; - const session = new BinarySession( - SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []), - new InvestigationRecords(), - { - createClient: () => { - created += 1; - const candidate = created > 1; - return { - client: { - execute: async () => ({ - ok: true, - value: createAnalysisExecution(null, identity), - }), - close: async () => { - if (candidate) { - candidateCloseAttempts += 1; +it.each([false, true])( + "keeps cancellation primary through resource retirement retry (live cleanup: %s)", + async (liveCleanup) => { + const rootPath = await mkdtemp( + join(tmpdir(), "rea-resource-cancel-close-failure-"), + ); + temporaryDirectories.push(rootPath); + await writeFile(join(rootPath, "Info.plist"), ""); + let closeStarted!: () => void; + const closing = new Promise((resolve) => { + closeStarted = resolve; + }); + let releaseClose!: () => void; + const closeGate = new Promise((resolve) => { + releaseClose = resolve; + }); + let created = 0; + let oldCloseAttempts = 0; + const cleanupFailure = liveCleanup + ? new ProviderCleanupError(identity.id, ["previous-reader"], { + reason: "previous reader remains open", + }) + : new ProviderAdapterError(identity.id, "close_binary"); + let candidateCloseAttempts = 0; + const session = new BinarySession( + SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []), + new InvestigationRecords(), + { + createClient: () => { + created += 1; + const candidate = created > 1; + return { + client: { + execute: async () => ({ + ok: true, + value: createAnalysisExecution(null, identity), + }), + close: async () => { + if (candidate) { + candidateCloseAttempts += 1; + return { ok: true, value: null }; + } + oldCloseAttempts += 1; + if (oldCloseAttempts === 1) { + closeStarted(); + await closeGate; + } + if (oldCloseAttempts < 3) return err(cleanupFailure); + if (liveCleanup) cleanupFailure.resolveCleanup(); return { ok: true, value: null }; - } - oldCloseAttempts += 1; - if (oldCloseAttempts === 1) { - closeStarted(); - await closeGate; - } - return oldCloseAttempts < 3 - ? err(new ProviderAdapterError(identity.id, "close_binary")) - : { ok: true, value: null }; + }, }, - }, - capabilities: [], - }; + capabilities: [], + }; + }, }, - }, - ); + ); - expect((await session.openBundleResources(rootPath)).ok).toBe(true); - const controller = new AbortController(); - const reason = { phase: "resource-close-failed" }; - const opening = session.openBundleResources(rootPath, { - signal: controller.signal, - }); - await Promise.race([ - closing, - new Promise((_, reject) => - setTimeout( - () => reject(new Error("resource close did not start")), - 1_000, + expect((await session.openBundleResources(rootPath)).ok).toBe(true); + const controller = new AbortController(); + const reason = { phase: "resource-close-failed" }; + const opening = session.openBundleResources(rootPath, { + signal: controller.signal, + }); + await Promise.race([ + closing, + new Promise((_, reject) => + setTimeout( + () => reject(new Error("resource close did not start")), + 1_000, + ), ), - ), - ]); - controller.abort(reason); - releaseClose(); + ]); + controller.abort(reason); + releaseClose(); - const result = await opening; - expect(result.ok).toBe(false); - if (result.ok) throw new Error("Expected failed resource switch"); - expect(result.error.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError); - expect(result.error.cleanupPrimary?.cause).toBe(reason); - expect(result.error.cleanupIncomplete).toBe(true); - expect(candidateCloseAttempts).toBe(1); - expect(oldCloseAttempts).toBe(2); - expect(await session.status()).toMatchObject({ - open: false, - cleanup_pending: true, - }); - expect(await session.close()).toMatchObject({ ok: true }); - expect(oldCloseAttempts).toBe(3); -}); + const result = await opening; + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected failed resource switch"); + expect(result.error.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cleanupPrimary?.cause).toBe(reason); + expect(result.error.cleanupIncomplete).toBe(true); + expect(candidateCloseAttempts).toBe(1); + expect(oldCloseAttempts).toBe(2); + expect(await session.status()).toMatchObject({ + open: false, + cleanup_pending: true, + }); + expect(await session.close()).toMatchObject({ ok: true }); + expect(oldCloseAttempts).toBe(3); + expect(result.error.cleanupIncomplete).toBe(!liveCleanup); + if (liveCleanup) expect(result.error.cleanupResources).toEqual([]); + expect(result.error.cleanupPrimary?.cause).toBe(reason); + }, +); it("keeps an earlier resource close failure primary when a later cleanup is cancelled", async () => { const rootPath = await mkdtemp( @@ -836,9 +847,11 @@ it("tracks independent pending resource owners after a failed session replacemen expect(await session.openBundleResources(rootPath)).toMatchObject({ ok: true, }); - expect(await session.openBundleResources(rootPath)).toMatchObject({ - ok: false, - }); + const replacement = await session.openBundleResources(rootPath); + if (replacement.ok) throw new Error("Expected failed resource replacement"); + expect(new Set(replacement.error.cleanupResources)).toEqual( + new Set(["previous-reader", "candidate-reader"]), + ); const first = await session.close(); if (first.ok) throw new Error("Expected both resource owners to remain pending"); @@ -850,10 +863,179 @@ it("tracks independent pending resource owners after a failed session replacemen expect(await session.close()).toMatchObject({ ok: false }); expect(first.error.cleanupIncomplete).toBe(true); expect(first.error.cleanupResources).toEqual(["candidate-reader"]); + expect(replacement.error.cleanupIncomplete).toBe(true); + expect(replacement.error.cleanupResources).toEqual(["candidate-reader"]); released[1] = true; expect(await session.close()).toMatchObject({ ok: true }); expect(first.error.cleanupIncomplete).toBe(false); expect(first.error.cleanupResources).toEqual([]); + expect(replacement.error.cleanupIncomplete).toBe(false); + expect(replacement.error.cleanupResources).toEqual([]); expect(await session.status()).toMatchObject({ cleanup_pending: false }); }); + +it.each([false, true])( + "retains failed-open primary through cleanup retry (live cleanup: %s)", + async (liveCleanup) => { + const primary = new ProviderAdapterError(binaryIdentity.id, "health", { + userMessage: "candidate health failed", + }); + const cleanup = liveCleanup + ? new ProviderCleanupError( + binaryIdentity.id, + ["candidate-reader"], + {}, + { + cleanup: { + reason: "candidate reader remains open", + resources: ["candidate-reader"], + }, + }, + ) + : new ProviderAdapterError(binaryIdentity.id, "close_binary"); + let attempts = 0; + const session = new BinarySession( + createTestProviderRouter(() => ({ + execute: async () => err(primary), + close: async () => { + attempts += 1; + if (attempts === 1) return err(cleanup); + if (liveCleanup) cleanup.resolveCleanup(); + return ok(null); + }, + })), + new InvestigationRecords(), + ); + const opened = await session.open(process.execPath); + if (opened.ok) throw new Error("Expected failed candidate health"); + expect(opened.error.cleanupPrimary).toBe(primary); + expect(opened.error.cleanupIncomplete).toBe(true); + if (liveCleanup) + expect(opened.error.cleanup?.reason).toBe( + "candidate reader remains open", + ); + expect(await session.close()).toMatchObject({ ok: true }); + expect(attempts).toBe(2); + expect(opened.error.cleanupIncomplete).toBe(!liveCleanup); + if (liveCleanup) expect(opened.error.cleanupResources).toEqual([]); + expect(opened.error.cleanupPrimary).toBe(primary); + }, +); + +it.each([0, 1])( + "retains unknown cleanup completion alongside a retryable owner (unknown owner: %s)", + async (unknownOwner) => { + const rootPath = await mkdtemp( + join(tmpdir(), "rea-resource-mixed-cleanup-"), + ); + temporaryDirectories.push(rootPath); + await writeFile(join(rootPath, "Info.plist"), ""); + const ownedFailure = new ProviderCleanupError( + identity.id, + ["owned-reader"], + {}, + ); + const unknownFailure = new ProviderAdapterError( + identity.id, + "close_binary", + { + userMessage: "cleanup completion unknown", + }, + ); + let created = 0; + let released = false; + const session = new BinarySession( + SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []), + new InvestigationRecords(), + { + createClient: () => { + const owner = created++; + return { + capabilities: [], + client: { + execute: async () => ok(createAnalysisExecution(null, identity)), + close: async () => { + if (!released) + return err( + owner === unknownOwner ? unknownFailure : ownedFailure, + ); + if (owner !== unknownOwner) ownedFailure.resolveCleanup(); + return ok(null); + }, + }, + }; + }, + }, + ); + expect(await session.openBundleResources(rootPath)).toMatchObject({ + ok: true, + }); + const replacement = await session.openBundleResources(rootPath); + if (replacement.ok) throw new Error("Expected failed resource replacement"); + expect(replacement.error.cleanupIncomplete).toBe(true); + released = true; + expect(await session.close()).toMatchObject({ ok: true }); + expect(ownedFailure.cleanupIncomplete).toBe(false); + expect(replacement.error.cleanupIncomplete).toBe(true); + expect(await session.status()).toMatchObject({ cleanup_pending: false }); + }, +); + +it.each(["health", "cancellation"] as const)( + "updates early resource-open %s cleanup after its candidate retries", + async (failureStage) => { + const rootPath = await mkdtemp( + join(tmpdir(), "rea-resource-early-cleanup-"), + ); + temporaryDirectories.push(rootPath); + await writeFile(join(rootPath, "Info.plist"), ""); + const primary = new ProviderAdapterError(identity.id, "health"); + const cleanup = new ProviderCleanupError( + identity.id, + ["candidate-reader"], + {}, + ); + const controller = new AbortController(); + const reason = { stage: "candidate-health" }; + let closeAttempts = 0; + const session = new BinarySession( + SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []), + new InvestigationRecords(), + { + createClient: () => ({ + capabilities: [], + client: { + execute: async () => { + if (failureStage === "health") return err(primary); + controller.abort(reason); + return ok(createAnalysisExecution(null, identity)); + }, + close: async () => { + if (++closeAttempts === 1) return err(cleanup); + cleanup.resolveCleanup(); + return ok(null); + }, + }, + }), + }, + ); + const opened = await session.openBundleResources(rootPath, { + signal: controller.signal, + }); + if (opened.ok) throw new Error("Expected early resource open failure"); + expect(opened.error.cleanupIncomplete).toBe(true); + if (failureStage === "health") + expect(opened.error.cleanupPrimary).toBe(primary); + else { + expect(opened.error.cleanupPrimary).toBeInstanceOf( + AnalysisCancelledError, + ); + expect(opened.error.cleanupPrimary?.cause).toBe(reason); + } + expect(await session.close()).toMatchObject({ ok: true }); + expect(closeAttempts).toBe(2); + expect(opened.error.cleanupIncomplete).toBe(false); + expect(opened.error.cleanupResources).toEqual([]); + }, +); From 25a08ed6393464744bd70750cdcba66d81943618 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:45:45 +0800 Subject: [PATCH 17/31] refactor(ida): reuse bounded registration reader --- src/ida/IdaConfiguration.test.ts | 26 +++++++++++++++++++++++++- src/ida/IdaConfiguration.ts | 29 +++++++++++++---------------- 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/src/ida/IdaConfiguration.test.ts b/src/ida/IdaConfiguration.test.ts index 46d6af4c61..da624fee0c 100644 --- a/src/ida/IdaConfiguration.test.ts +++ b/src/ida/IdaConfiguration.test.ts @@ -1,4 +1,4 @@ -import { rm, writeFile } from "node:fs/promises"; +import { appendFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createIdaTarget } from "../../tests/fixtures/idaMcp.js"; @@ -6,6 +6,7 @@ import { readIdaConfiguration } from "./IdaConfiguration.js"; import { decodeIdaToolResult } from "./IdaMcpConnection.js"; import type { RetainedResourceScope } from "../application/AnalysisProvider.js"; import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; +import * as regularFile from "../filesystem/RegularFile.js"; const resourceScope: RetainedResourceScope = { retain: () => undefined, @@ -77,6 +78,29 @@ describe("IDA registration and MCP result boundaries", () => { }); }); +it("rejects registration growth beyond the byte limit after descriptor admission", async () => { + const { root } = await createIdaTarget(); + roots.push(root); + const path = join(root, "mcp.json"); + await writeFile(path, JSON.stringify({ command: "python" })); + const openRegularFile = regularFile.openRegularFile; + const admission = vi + .spyOn(regularFile, "openRegularFile") + .mockImplementation(async (...args) => { + const source = await openRegularFile(...args); + await appendFile(path, " ".repeat(65_536)); + return source; + }); + try { + expect(await read(path)).toMatchObject({ + ok: false, + error: { message: expect.stringContaining("64 KiB") }, + }); + } finally { + admission.mockRestore(); + } +}); + describe("IDA registration descriptor ownership", () => { it("retains the exact descriptor after close denial and resolves its error only after retry", async () => { const { root } = await createIdaTarget(); diff --git a/src/ida/IdaConfiguration.ts b/src/ida/IdaConfiguration.ts index 8c28fb1aa7..33aa88675c 100644 --- a/src/ida/IdaConfiguration.ts +++ b/src/ida/IdaConfiguration.ts @@ -13,9 +13,10 @@ import { RegularFileCleanupFailure, } from "../filesystem/OwnedRegularFile.js"; import { - assertRegularFileActive, + RegularFileOperationCancelled, NonRegularFileReadError, } from "../filesystem/RegularFile.js"; +import { readBoundedFileBytes } from "../process/BoundedFileBytes.js"; /** Omitted registration `timeoutMs` for each upstream IDA MCP request. */ export const DEFAULT_IDA_REQUEST_TIMEOUT_MS = 300_000; @@ -143,21 +144,17 @@ export const readIdaConfiguration = ( async ({ handle, initial: metadata }) => { if (metadata.size > MAXIMUM_REGISTRATION_BYTES) throw registrationTooLarge(); - const bytes = Buffer.alloc(MAXIMUM_REGISTRATION_BYTES + 1); - let length = 0; - while (length < bytes.length) { - assertRegularFileActive(options.signal); - const { bytesRead } = await handle.read( - bytes, - length, - bytes.length - length, - null, - ); - if (bytesRead === 0) break; - length += bytesRead; - } - if (length > MAXIMUM_REGISTRATION_BYTES) throw registrationTooLarge(); - return bytes.toString("utf8", 0, length); + const bytes = await readBoundedFileBytes( + handle, + MAXIMUM_REGISTRATION_BYTES, + { + signal: options.signal, + cancellationFailure: (reason) => + new RegularFileOperationCancelled(reason), + }, + ); + if (bytes === undefined) throw registrationTooLarge(); + return bytes.toString("utf8"); }, ); const input: unknown = JSON.parse(text); From eb24da4481f90f38eaf34088c721941b79ce6a99 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:53:54 +0800 Subject: [PATCH 18/31] fix(session): retain unknown cleanup across mixed failures --- .../binary/AnalysisProviderRegistry.test.ts | 28 +++++++++ .../binary/AnalysisProviderRegistry.ts | 17 +++--- src/application/binary/BinarySession.ts | 6 +- .../BinarySessionBundleResources.test.ts | 58 +++++++++++++++++++ 4 files changed, 98 insertions(+), 11 deletions(-) diff --git a/src/application/binary/AnalysisProviderRegistry.test.ts b/src/application/binary/AnalysisProviderRegistry.test.ts index 10c440daa0..ef4b44394e 100644 --- a/src/application/binary/AnalysisProviderRegistry.test.ts +++ b/src/application/binary/AnalysisProviderRegistry.test.ts @@ -1132,6 +1132,34 @@ it("joins cancelled readiness finalization and shutdown before admitting another } }); +it.each(["alpha", "beta"])( + "retains unknown owned-resource cleanup when %s has no completion signal", + async (unknownOwner) => { + const unknown = new ProviderAdapterError(unknownOwner, "close_binary", { + userMessage: "cleanup completion unknown", + }); + const tracked = new ProviderCleanupError("rea", ["owned-reader"], {}); + let released = false; + const registry = new AnalysisProviderRegistry( + ["alpha", "beta"].map((id) => ({ + ...candidate(id).provider, + closeOwnedResources: async () => { + if (!released) return err(id === unknownOwner ? unknown : tracked); + if (id !== unknownOwner) tracked.resolveCleanup(); + return ok(null); + }, + })), + ); + const closed = await registry.closeOwnedResources(); + if (closed.ok) throw new Error("Expected incomplete registry close"); + expect(closed.error.cleanupIncomplete).toBe(true); + released = true; + expect(await registry.closeOwnedResources()).toEqual(ok(null)); + expect(tracked.cleanupIncomplete).toBe(false); + expect(closed.error.cleanupIncomplete).toBe(true); + }, +); + it("joins every candidate and retains physical failure with exact mixed cleanup owners", async () => { const alpha = candidate("alpha").provider; const beta = candidate("beta").provider; diff --git a/src/application/binary/AnalysisProviderRegistry.ts b/src/application/binary/AnalysisProviderRegistry.ts index e07ce2820e..0a61b66605 100644 --- a/src/application/binary/AnalysisProviderRegistry.ts +++ b/src/application/binary/AnalysisProviderRegistry.ts @@ -252,16 +252,15 @@ export class AnalysisProviderRegistry { const distinct = [...new Set(failures)]; if (distinct.length === 0) return ok(null); if (distinct.length === 1) return err(distinct[0]!); - return err( - new ProviderCleanupError( - "rea", - [...new Set(distinct.flatMap((failure) => failure.cleanupResources))], - { reason: distinct.map((failure) => failure.message).join("; ") }, - { cause: new AggregateError(distinct) }, - ).bindCleanupDependencies( - distinct.filter((failure) => failure.cleanupIncomplete), - ), + const failure = new ProviderCleanupError( + "rea", + [...new Set(distinct.flatMap((failure) => failure.cleanupResources))], + { reason: distinct.map((failure) => failure.message).join("; ") }, + { cause: new AggregateError(distinct) }, ); + if (distinct.every((failure) => failure.cleanupIncomplete)) + failure.bindCleanupDependencies(distinct); + return err(failure); } /** List the unique deep-operation declarations without probing candidates. */ diff --git a/src/application/binary/BinarySession.ts b/src/application/binary/BinarySession.ts index 6186a9afe7..6627353b5a 100644 --- a/src/application/binary/BinarySession.ts +++ b/src/application/binary/BinarySession.ts @@ -913,7 +913,8 @@ export class BinarySession next, "close_binary", ); - resourceFailure.bindCleanupDependencies([previous, next]); + if (previous.cleanupIncomplete && next.cleanupIncomplete) + resourceFailure.bindCleanupDependencies([previous, next]); } if (!closed.ok && resourceFailure !== undefined) { const combined = analysisErrorWithCleanupFailure( @@ -921,7 +922,8 @@ export class BinarySession resourceFailure, "close_binary", ); - combined.bindCleanupDependencies([closed.error, resourceFailure]); + if (closed.error.cleanupIncomplete && resourceFailure.cleanupIncomplete) + combined.bindCleanupDependencies([closed.error, resourceFailure]); return err(combined); } if (!closed.ok) return closed; diff --git a/src/application/binary/BinarySessionBundleResources.test.ts b/src/application/binary/BinarySessionBundleResources.test.ts index b7f3552ecd..9ea8d52e2d 100644 --- a/src/application/binary/BinarySessionBundleResources.test.ts +++ b/src/application/binary/BinarySessionBundleResources.test.ts @@ -982,6 +982,64 @@ it.each([0, 1])( }, ); +it.each([ + ["binary", "client"], + ["binary", "files"], + ["bundle", "client"], + ["bundle", "files"], +] as const)( + "keeps unknown %s close completion when the %s owner has no cleanup signal", + async (targetKind, unknownOwner) => { + const tracked = new ProviderCleanupError(identity.id, ["owned-reader"], {}); + const unknown = new ProviderAdapterError(identity.id, "close_binary", { + userMessage: "cleanup completion unknown", + }); + let released = false; + let failCleanup = false; + const closeOwner = async (owner: "client" | "files") => { + if (!failCleanup) return ok(null); + if (!released) return err(owner === unknownOwner ? unknown : tracked); + if (owner !== unknownOwner) tracked.resolveCleanup(); + return ok(null); + }; + const client: AnalysisClient = { + execute: async () => ok(createAnalysisExecution(null, binaryIdentity)), + close: () => closeOwner("client"), + }; + // Exercise the public session close composition with independently owned + // file cleanup; the file owner's failure shape is the regression boundary. + class SessionWithFileCleanup extends BinarySession { + override closeFileResources() { + return closeOwner("files"); + } + } + const session = new SessionWithFileCleanup( + createTestProviderRouter(() => client), + new InvestigationRecords(), + { createClient: () => ({ client, capabilities: [] }) }, + ); + if (targetKind === "binary") { + expect(await session.open(process.execPath)).toMatchObject({ ok: true }); + } else { + const rootPath = await mkdtemp(join(tmpdir(), "rea-resource-close-")); + temporaryDirectories.push(rootPath); + await writeFile(join(rootPath, "Info.plist"), ""); + expect(await session.openBundleResources(rootPath)).toMatchObject({ + ok: true, + }); + } + failCleanup = true; + const closed = await session.close(); + if (closed.ok) throw new Error("Expected incomplete close"); + expect(closed.error.cleanupIncomplete).toBe(true); + released = true; + expect(await session.close()).toMatchObject({ ok: true }); + expect(tracked.cleanupIncomplete).toBe(false); + expect(closed.error.cleanupIncomplete).toBe(true); + expect(await session.status()).toMatchObject({ cleanup_pending: false }); + }, +); + it.each(["health", "cancellation"] as const)( "updates early resource-open %s cleanup after its candidate retries", async (failureStage) => { From b8f58fd7ca375f2dff2e5a62b409df976226611d Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:54:01 +0800 Subject: [PATCH 19/31] refactor(mcp): bind summary delivery to tool contracts --- src/contracts/artifactToolContracts.ts | 4 + src/contracts/managed/managedToolContracts.ts | 2 + src/contracts/native/nativeToolContracts.ts | 3 + src/contracts/officialToolContracts.ts | 12 +++ src/contracts/toolContractTypes.ts | 4 + src/server/registerArtifactTools.ts | 21 +---- src/server/registerBinaryDiagnosticsTools.ts | 10 +-- src/server/registerByteEntropyTool.ts | 10 +-- src/server/registerEvidenceTools.ts | 52 ++++++------- src/server/registerManagedTools.ts | 22 ++---- src/server/registerNativeTools.ts | 16 +--- src/server/registerOfficialTools.ts | 22 +----- src/server/registerWebNetworkCaptureTool.ts | 6 +- src/server/retainedSummaryDelivery.ts | 33 ++++---- .../mcp/analysisSnapshotProvenanceMcp.test.ts | 7 -- .../mcp/nativeLoadImageViewsMcp.test.ts | 76 ++++++++++++++++++- 16 files changed, 158 insertions(+), 142 deletions(-) diff --git a/src/contracts/artifactToolContracts.ts b/src/contracts/artifactToolContracts.ts index 6b299aad20..1ffaa007a7 100644 --- a/src/contracts/artifactToolContracts.ts +++ b/src/contracts/artifactToolContracts.ts @@ -58,6 +58,7 @@ const artifact = < name: Name, description: string, inputSchema: Schema, + retainedSummary?: ToolContract["retainedSummary"], ) => { const outputSchema = requireOutputSchema(artifactOutputSchemas, name); return { @@ -66,6 +67,7 @@ const artifact = < description, kind: "artifact-provider", requiresActiveTarget: true, + ...(retainedSummary === undefined ? {} : { retainedSummary }), inputSchema, outputSchema, examples: [ @@ -83,6 +85,7 @@ export const ARTIFACT_TOOL_CONTRACTS = [ "inspect_artifact", "Inspect an active archive or application package, including Node.js SEA carriers, in one call. By default returns the complete content-addressed artifact graph once, as the typed nested inventory Evidence, with root and integrity observations, integrity contradictions, hypotheses, unexplored branches, limitations, and next probes inline. For PCK and large containers prefer detail summary: it retains the complete inspection and returns its derived summary view; read exact paths, batch occurrence/content IDs and selected fields with inspect_analysis_view using parent_evidence_id. This changes delivery, not inventory coverage. Read-only DMG mounting is used automatically on supported macOS hosts. Integrity mismatches fail by default; record-and-continue keeps mismatches explicitly untrusted. It does not extract files; use extract_artifact to materialize regular contents of supported formats. DMG extraction is unavailable; use a caller-managed read-only mount on macOS to access its files.", artifactInspectionInputSchema, + { evidenceParameters: "preserve-detail" }, ), artifact( "extract_artifact", @@ -103,6 +106,7 @@ export const ARTIFACT_TOOL_CONTRACTS = [ "Omitted or complete returns the complete decoded graph Evidence. summary retains that complete Evidence in this session and returns its inspect_analysis_view summary; use normalized_result.parent_evidence_id for interface-builder pages.", ), }), + { evidenceParameters: "omit-detail" }, ), artifact( "inspect_keyed_archive", diff --git a/src/contracts/managed/managedToolContracts.ts b/src/contracts/managed/managedToolContracts.ts index 2ae07e1042..63beee1f3d 100644 --- a/src/contracts/managed/managedToolContracts.ts +++ b/src/contracts/managed/managedToolContracts.ts @@ -60,6 +60,7 @@ const nativeBoundaryOutputSchema = requireOutputSchema( export const MANAGED_TOOL_CONTRACTS = [ { name: "inspect_managed_artifact", + retainedSummary: { evidenceParameters: "omit-detail" }, ...toolContractMetadata("inspect_managed_artifact"), description: 'Inspect an explicit PE/CLI path, or the selected managed target, without loading or executing target code. Omitted or complete detail returns complete metadata inventories with original coverage. Select detail: "summary" on the first call for complete identity, PE/CLI, classification with its evidence, module/MVID, assembly, frameworks, returned-collection counts, coverage/issues and limitations. It retains complete Evidence and returns a distinct linked inspect_analysis_view summary. Page original references/resources/attributes with view kind managed and normalized_result.parent_evidence_id. Counts cover producer-returned rows; partial, unavailable or malformed metadata remains explicit and tokens remain build-local.', @@ -80,6 +81,7 @@ export const MANAGED_TOOL_CONTRACTS = [ }, { name: "inspect_managed_members", + retainedSummary: { evidenceParameters: "omit-detail" }, ...toolContractMetadata("inspect_managed_members"), description: 'Inspect PE/CLI metadata members, signatures, raw CIL hashes, decoded-instruction-tuple hashes, separately reported exception regions, call edges, field-access anchors, and the #US string literals that ldstr loads, without loading or executing target code. Select type_names to decode only the members of those exact declaring types and the literals their bodies load, which keeps large assemblies such as a base class library within the representation budget. Metadata tokens are reported as build-local coordinates bound to the artifact SHA-256 and MVID; the tuple hash does not resolve them or fully commit control flow. Omitted or complete detail returns every collection, including the assembly-wide member_refs and exported_types, which type_names does not narrow. Select detail: "summary" for identity, metadata status, selection with unmatched names, coverage/issues, limitations and per-collection counts; it retains complete Evidence and returns a linked inspect_analysis_view summary. Page original rows with view kind managed-members, normalized_result.parent_evidence_id and collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings.', diff --git a/src/contracts/native/nativeToolContracts.ts b/src/contracts/native/nativeToolContracts.ts index 0fde5e4420..dedfbc8f88 100644 --- a/src/contracts/native/nativeToolContracts.ts +++ b/src/contracts/native/nativeToolContracts.ts @@ -48,6 +48,7 @@ const native = < name: Name, description: string, inputSchema: Schema, + retainedSummary?: ToolContract["retainedSummary"], ) => { const outputSchema = requireOutputSchema(nativeOutputSchemas, name); const strictInputSchema = inputSchema.strict(); @@ -57,6 +58,7 @@ const native = < description, kind: "native-provider", requiresActiveTarget: true, + ...(retainedSummary === undefined ? {} : { retainedSummary }), inputSchema: strictInputSchema, outputSchema, examples: [ @@ -108,6 +110,7 @@ export const NATIVE_TOOL_CONTRACTS = [ ), native_command_timeout_ms: nativeCommandTimeoutField, }), + { evidenceParameters: "omit-detail" }, ), native( "inspect_signature", diff --git a/src/contracts/officialToolContracts.ts b/src/contracts/officialToolContracts.ts index a81bc86bb5..7c4fbde59a 100644 --- a/src/contracts/officialToolContracts.ts +++ b/src/contracts/officialToolContracts.ts @@ -30,6 +30,7 @@ const official = < name: Name, description: string, inputSchema: Schema, + retainedSummary?: ToolContract["retainedSummary"], ) => { const strictInputSchema = inputSchema.strict(); return { @@ -38,6 +39,7 @@ const official = < description, kind: "official-proxy", requiresActiveTarget: true, + ...(retainedSummary === undefined ? {} : { retainedSummary }), inputSchema: strictInputSchema, outputSchema: requireOutputSchema(officialOutputSchemas, name), examples: examplesFor(name, strictInputSchema), @@ -55,6 +57,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [ "inspect_native_load_image", "Inspect the provider's measured source digests, memory mappings, relocations, entry points and entry register context. Independent snapshot verification supports DOS MZ, explicitly selected COM, raw binaries and GameCube DOL; other formats retain their observations with unsupported verification. Overlay and uninitialized coverage remain explicit. Select detail summary for verification state and counts, retaining complete Evidence for inspect_analysis_view kind native-load-image: collection source_files, mappings, relocations, entry_points, entry_context, checks or sections, with indices, fields and offset/limit paging. Relocation bytes and check expected/observed payloads require explicit fields; defaults keep metadata. Null collections are unrecorded or inapplicable. Does not execute or modify the target.", z.strictObject({ detail: analysisDetailSchema }), + { evidenceParameters: "omit-detail" }, ), official( "inspect_native_data_type", @@ -124,11 +127,13 @@ export const OFFICIAL_TOOL_CONTRACTS = [ address: optionalAddress, detail: analysisDetailSchema, }), + { evidenceParameters: "omit-detail" }, ), official( "list_procedures", "List every analyzed procedure as address/value pairs after provider analysis. Ghidra metadata distinguishes thunks and external functions; use returned addresses in later function operations. Select detail summary for counts and retained native-inventory pages; complete remains the default.", z.object({ document, detail: analysisDetailSchema }), + { evidenceParameters: "omit-detail" }, ), official( "list_segments", @@ -143,6 +148,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [ address: optionalAddress, detail: analysisDetailSchema, }), + { evidenceParameters: "omit-detail" }, ), official( "next_address", @@ -158,6 +164,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [ "procedure_address", "Resolve an unambiguous procedure symbol name or provider-normalized address to its canonical entry address. External address spaces remain explicit. Select detail summary for selected facts and a context digest; native-observation and native-context views retain complete source access.", z.object({ procedure, document, detail: analysisDetailSchema }), + { evidenceParameters: "omit-detail" }, ), official( "procedure_assembly", @@ -168,11 +175,13 @@ export const OFFICIAL_TOOL_CONTRACTS = [ "procedure_callees", "Return the provider's resolved direct callees for one procedure identified by symbol or address. Unresolved indirect calls may be absent; use analyze_function and typed references to preserve available edge uncertainty. Ghidra terminal-call assumptions that may cut the analyzed body are reported in Evidence limitations. Select detail summary for selected facts and a context digest; native-observation and native-context views retain complete source access.", z.object({ procedure, document, detail: analysisDetailSchema }), + { evidenceParameters: "omit-detail" }, ), official( "procedure_callers", "Return the provider's resolved direct callers for one procedure identified by symbol or address. Results reflect completed static analysis and may omit unresolved indirect references. Ghidra terminal-call assumptions that may cut the analyzed body are reported in Evidence limitations. Select detail summary for selected facts and a context digest; native-observation and native-context views retain complete source access.", z.object({ procedure, document, detail: analysisDetailSchema }), + { evidenceParameters: "omit-detail" }, ), official( "procedure_info", @@ -205,16 +214,19 @@ export const OFFICIAL_TOOL_CONTRACTS = [ "resolve_containing_procedure", "Resolve an arbitrary address, including an interior instruction or exact external entry, to its provider-analyzed containing procedure. A negative result distinguishes outside segments from not in a procedure and is never guessed from nearby symbols. Select detail summary for selected facts and a context digest; native-observation and native-context views retain complete source access.", z.object({ address, document, detail: analysisDetailSchema }), + { evidenceParameters: "omit-detail" }, ), official( "search_procedures", `Search every analyzed procedure name using literal matching by default or regex when requested. Results are deterministic and complete. ${hopperRegexDeadline} Select detail summary for counts and retained native-inventory pages; complete remains the default.`, z.object({ ...nativeSearchInput, detail: analysisDetailSchema }), + { evidenceParameters: "omit-detail" }, ), official( "search_strings", `Search every provider-reported string object or candidate using literal matching by default or regex when requested. Results are deterministic and complete over that provider inventory, not every text sequence in artifact bytes. Kuna scans mapped sequences of at least five characters, including unterminated fragments, without computing references. Ghidra may omit packed or undefined literals, including Rust strings; an empty result does not prove text is absent. If an address is known, read_bytes and xrefs inspect its bytes and references. Hopper retains typed byte decoding, native display, and explicit decoding unknowns. Adjacent long-literal fragments match independently. ${hopperRegexDeadline} Select detail summary for counts and retained native-inventory pages; complete remains the default.`, z.object({ ...nativeSearchInput, detail: analysisDetailSchema }), + { evidenceParameters: "omit-detail" }, ), official( "set_address_name", diff --git a/src/contracts/toolContractTypes.ts b/src/contracts/toolContractTypes.ts index b329ad88da..023fa81e9c 100644 --- a/src/contracts/toolContractTypes.ts +++ b/src/contracts/toolContractTypes.ts @@ -57,6 +57,10 @@ export interface ToolContract< readonly kind: ToolKind; /** This handler dispatches through the active binary-analysis session. */ readonly requiresActiveTarget?: boolean; + /** Adapter delivery of a retained summary; detail never reaches analysis. */ + readonly retainedSummary?: { + readonly evidenceParameters: "omit-detail" | "preserve-detail"; + }; readonly inputSchema: InputSchema; readonly outputSchema: OutputSchema; readonly effects: ToolEffects; diff --git a/src/server/registerArtifactTools.ts b/src/server/registerArtifactTools.ts index a29497aa4c..80d39739df 100644 --- a/src/server/registerArtifactTools.ts +++ b/src/server/registerArtifactTools.ts @@ -3,22 +3,7 @@ import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import { ARTIFACT_TOOL_CONTRACTS } from "../contracts/artifactToolContracts.js"; import { createArtifactExtractionDestination } from "../application/artifacts/ArtifactExtractionDestination.js"; import { registerEvidenceTools } from "./registerEvidenceTools.js"; -import { - retainedSummaryDelivery, - withoutDeliveryDetail, - type EvidenceToolFamilyOptions, -} from "./retainedSummaryDelivery.js"; - -/** Operations whose MCP delivery may be a retained summary view. */ -const SUMMARY_DELIVERY: ReadonlySet = new Set([ - "inspect_artifact", - "decode_interface_builder", -]); -const executionWithoutDelivery = withoutDeliveryDetail(SUMMARY_DELIVERY); -/** inspect_artifact keeps an explicit detail in its Evidence parameters. */ -const decodeWithoutDelivery = withoutDeliveryDetail( - new Set(["decode_interface_builder"]), -); +import type { EvidenceToolFamilyOptions } from "./retainedSummaryDelivery.js"; /** Register deterministic artifact inventory and safe extraction operations. */ export const registerArtifactTools = ( @@ -27,11 +12,9 @@ export const registerArtifactTools = ( ): void => { registerEvidenceTools(server, ARTIFACT_TOOL_CONTRACTS, { ...options, - ...retainedSummaryDelivery(SUMMARY_DELIVERY, options), parametersForExecution: (operation, parameters) => operation === "extract_artifact" ? { ...parameters, output_root: createArtifactExtractionDestination() } - : executionWithoutDelivery(operation, parameters), - parametersForEvidence: decodeWithoutDelivery, + : parameters, }); }; diff --git a/src/server/registerBinaryDiagnosticsTools.ts b/src/server/registerBinaryDiagnosticsTools.ts index 586411aa33..b97f910b99 100644 --- a/src/server/registerBinaryDiagnosticsTools.ts +++ b/src/server/registerBinaryDiagnosticsTools.ts @@ -35,7 +35,7 @@ export const registerBinaryDiagnosticsTools = ( withAdmittedAnalysis, ); const contract = toolContract("inspect_binary_layout"); - const delivery = retainedSummaryDelivery(new Set([contract.name]), { + const delivery = retainedSummaryDelivery(contract.name, { recordEvidence, evidenceById, }); @@ -68,17 +68,13 @@ export const registerBinaryDiagnosticsTools = ( logger, contract.name, async () => { - const ready = delivery.preflight(contract.name, parameters); + const ready = delivery.preflight(parameters); if (!ready.ok) return ready; const inspected = await service.inspect(request, { signal: context.mcpReq.signal, }); return inspected.ok - ? delivery.projectEvidence( - contract.name, - parameters, - inspected.value, - ) + ? delivery.projectEvidence(parameters, inspected.value) : inspected; }, ); diff --git a/src/server/registerByteEntropyTool.ts b/src/server/registerByteEntropyTool.ts index 012aa3d59d..be22ec9a2f 100644 --- a/src/server/registerByteEntropyTool.ts +++ b/src/server/registerByteEntropyTool.ts @@ -22,7 +22,7 @@ export const registerByteEntropyTool = ( withAdmittedAnalysis: WithAdmittedAnalysis | undefined, ): void => { const contract = toolContract("inspect_byte_entropy"); - const delivery = retainedSummaryDelivery(new Set([contract.name]), { + const delivery = retainedSummaryDelivery(contract.name, { recordEvidence, evidenceById, }); @@ -41,17 +41,13 @@ export const registerByteEntropyTool = ( contract.name, async () => { const parameters = { detail }; - const ready = delivery.preflight(contract.name, parameters); + const ready = delivery.preflight(parameters); if (!ready.ok) return ready; const inspected = await entropyService.inspect(request, { signal: context.mcpReq.signal, }); return inspected.ok - ? delivery.projectEvidence( - contract.name, - parameters, - inspected.value, - ) + ? delivery.projectEvidence(parameters, inspected.value) : inspected; }, ); diff --git a/src/server/registerEvidenceTools.ts b/src/server/registerEvidenceTools.ts index 59f4910b85..85b7caf5a5 100644 --- a/src/server/registerEvidenceTools.ts +++ b/src/server/registerEvidenceTools.ts @@ -1,17 +1,20 @@ import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; +import { + retainedSummaryDelivery, + withoutDeliveryDetail, + type EvidenceToolFamilyOptions, +} from "./retainedSummaryDelivery.js"; import type { AnalysisOperation, AnalysisOperationPort, } from "../application/AnalysisProvider.js"; import type { ToolContract } from "../contracts/toolContractTypes.js"; +import { ok } from "../domain/result.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; -import type { Evidence } from "../domain/evidence.js"; import { createAnalysisExecutionEvidence } from "../domain/analysisObservation.js"; import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js"; -import type { AnalysisError } from "../domain/analysisErrorBase.js"; -import { ok, type Result } from "../domain/result.js"; import type { Logger } from "pino"; import { mcpProgressReporter } from "./mcpProgress.js"; import { logToolExecution } from "./toolLogging.js"; @@ -27,6 +30,7 @@ interface EvidenceToolRegistration { readonly logger: Logger; readonly activeTarget: (() => BinaryTarget | undefined) | undefined; readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined; + readonly evidenceById?: EvidenceToolFamilyOptions["evidenceById"]; readonly withAdmittedAnalysis: WithAdmittedAnalysis; readonly analysisForAdmission?: ( analysis: AnalysisOperationPort, @@ -35,21 +39,6 @@ interface EvidenceToolRegistration { operation: Exclude, parameters: Readonly>, ) => Readonly>; - /** Exclude adapter-only selections, such as delivery detail, from Evidence identity. */ - readonly parametersForEvidence?: ( - operation: Exclude, - parameters: Readonly>, - ) => Readonly>; - /** Refuse an unsatisfiable delivery selection before any analysis runs. */ - readonly preflight?: ( - operation: Exclude, - parameters: Readonly>, - ) => Result; - readonly projectEvidence?: ( - operation: Exclude, - parameters: Readonly>, - evidence: Evidence, - ) => Result; } /** Register provider-backed contracts that return atomic Evidence observations. */ @@ -59,14 +48,17 @@ export const registerEvidenceTools = ( options: EvidenceToolRegistration, ): void => { for (const contract of contracts) { + const delivery = + contract.retainedSummary === undefined + ? undefined + : retainedSummaryDelivery(contract.name, options); server.registerTool( contract.name, toolRegistrationOptions(contract), async (input, context) => { const parameters = jsonObjectSchema.parse(input); - const ready = options.preflight?.(contract.name, parameters); - if (ready !== undefined && !ready.ok) - return server.delivery.toCallToolResult(ready, contract); + const ready = delivery?.preflight(parameters) ?? ok(null); + if (!ready.ok) return server.delivery.toCallToolResult(ready, contract); const admitted = await options.withAdmittedAnalysis( contract.name, context.mcpReq.signal, @@ -76,9 +68,15 @@ export const registerEvidenceTools = ( admittedAnalysis; const progress = mcpProgressReporter(context); await reportOperationStart(progress, contract.name); + const providerParameters = + contract.retainedSummary === undefined + ? parameters + : withoutDeliveryDetail(parameters); const executionParameters = - options.parametersForExecution?.(contract.name, parameters) ?? - parameters; + options.parametersForExecution?.( + contract.name, + providerParameters, + ) ?? providerParameters; const execution = await logToolExecution( options.logger, contract.name, @@ -94,8 +92,9 @@ export const registerEvidenceTools = ( const sourceEvidence = execution.value.sourceEvidence ?? []; const evidence = createAnalysisExecutionEvidence( contract.name, - options.parametersForEvidence?.(contract.name, parameters) ?? - parameters, + contract.retainedSummary?.evidenceParameters === "omit-detail" + ? providerParameters + : parameters, { ...execution.value, subject: @@ -112,8 +111,7 @@ export const registerEvidenceTools = ( if (!sourceRecorded.ok) return server.delivery.toCallToolResult(sourceRecorded, contract); const projected = - options.projectEvidence?.(contract.name, parameters, evidence) ?? - ok(evidence); + delivery?.projectEvidence(parameters, evidence) ?? ok(evidence); if (!projected.ok) return server.delivery.toCallToolResult(projected, contract); const recorded = options.recordEvidence?.(projected.value); diff --git a/src/server/registerManagedTools.ts b/src/server/registerManagedTools.ts index f5929243f8..01bcef8a64 100644 --- a/src/server/registerManagedTools.ts +++ b/src/server/registerManagedTools.ts @@ -1,22 +1,14 @@ import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import type { AnalysisOperationPort } from "../application/AnalysisProvider.js"; -import { MANAGED_TOOL_CONTRACTS } from "../contracts/managed/managedToolContracts.js"; +import { + MANAGED_TOOL_CONTRACTS, + isManagedToolName, +} from "../contracts/managed/managedToolContracts.js"; import { AnalysisInputError } from "../domain/analysisErrorCore.js"; import { err } from "../domain/result.js"; import { registerEvidenceTools } from "./registerEvidenceTools.js"; -import { - retainedSummaryDelivery, - withoutDeliveryDetail, - type EvidenceToolFamilyOptions, -} from "./retainedSummaryDelivery.js"; +import type { EvidenceToolFamilyOptions } from "./retainedSummaryDelivery.js"; import { runManagedProviderExecution } from "../composition/directAnalysis.js"; -import { isManagedToolName } from "../contracts/managed/managedToolContracts.js"; - -/** Operations whose MCP delivery may be a retained summary view. */ -const SUMMARY_DELIVERY: ReadonlySet = new Set([ - "inspect_managed_artifact", - "inspect_managed_members", -]); /** Register execution-free managed PE/CLI inspection. */ export const registerManagedTools = ( @@ -24,7 +16,6 @@ export const registerManagedTools = ( options: EvidenceToolFamilyOptions, ): void => { let selectedManagedPath: string | undefined; - const withoutDelivery = withoutDeliveryDetail(SUMMARY_DELIVERY); const analysisForAdmission = ( analysis: AnalysisOperationPort, ): AnalysisOperationPort => ({ @@ -64,9 +55,6 @@ export const registerManagedTools = ( }); registerEvidenceTools(server, MANAGED_TOOL_CONTRACTS, { ...options, - ...retainedSummaryDelivery(SUMMARY_DELIVERY, options), - parametersForExecution: withoutDelivery, - parametersForEvidence: withoutDelivery, analysisForAdmission, }); }; diff --git a/src/server/registerNativeTools.ts b/src/server/registerNativeTools.ts index 62668daa14..e22c297d50 100644 --- a/src/server/registerNativeTools.ts +++ b/src/server/registerNativeTools.ts @@ -2,24 +2,12 @@ import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import { NATIVE_TOOL_CONTRACTS } from "../contracts/native/nativeToolContracts.js"; import { registerEvidenceTools } from "./registerEvidenceTools.js"; -import { - retainedSummaryDelivery, - withoutDeliveryDetail, - type EvidenceToolFamilyOptions, -} from "./retainedSummaryDelivery.js"; - -const SUMMARY_DELIVERY: ReadonlySet = new Set(["inspect_macho"]); +import type { EvidenceToolFamilyOptions } from "./retainedSummaryDelivery.js"; /** Register macOS native contracts; Mach-O inspection can deliver a retained summary. */ export const registerNativeTools = ( server: EvidenceMcpServer, options: EvidenceToolFamilyOptions, ): void => { - const withoutDelivery = withoutDeliveryDetail(SUMMARY_DELIVERY); - registerEvidenceTools(server, NATIVE_TOOL_CONTRACTS, { - ...options, - ...retainedSummaryDelivery(SUMMARY_DELIVERY, options), - parametersForExecution: withoutDelivery, - parametersForEvidence: withoutDelivery, - }); + registerEvidenceTools(server, NATIVE_TOOL_CONTRACTS, options); }; diff --git a/src/server/registerOfficialTools.ts b/src/server/registerOfficialTools.ts index 1f70a1237d..11e9904c33 100644 --- a/src/server/registerOfficialTools.ts +++ b/src/server/registerOfficialTools.ts @@ -1,30 +1,12 @@ -import { NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS } from "../domain/native/nativeObservation.js"; import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/officialToolContracts.js"; -import { NATIVE_INVENTORY_OPERATIONS } from "../domain/native/nativeInventory.js"; import { registerEvidenceTools } from "./registerEvidenceTools.js"; -import { - retainedSummaryDelivery, - withoutDeliveryDetail, - type EvidenceToolFamilyOptions, -} from "./retainedSummaryDelivery.js"; - -const SUMMARY_OPERATIONS: ReadonlySet = new Set([ - ...NATIVE_INVENTORY_OPERATIONS, - ...NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS, - "inspect_native_load_image", -]); -const withoutDetail = withoutDeliveryDetail(SUMMARY_OPERATIONS); +import type { EvidenceToolFamilyOptions } from "./retainedSummaryDelivery.js"; /** Direct tools share Evidence recording/projection with the other provider-backed tools. */ export const registerOfficialTools = ( server: EvidenceMcpServer, options: EvidenceToolFamilyOptions, ): void => { - registerEvidenceTools(server, OFFICIAL_TOOL_CONTRACTS, { - ...options, - ...retainedSummaryDelivery(SUMMARY_OPERATIONS, options), - parametersForExecution: withoutDetail, - parametersForEvidence: withoutDetail, - }); + registerEvidenceTools(server, OFFICIAL_TOOL_CONTRACTS, options); }; diff --git a/src/server/registerWebNetworkCaptureTool.ts b/src/server/registerWebNetworkCaptureTool.ts index 83d82d6440..27c130dfd9 100644 --- a/src/server/registerWebNetworkCaptureTool.ts +++ b/src/server/registerWebNetworkCaptureTool.ts @@ -23,7 +23,7 @@ export const registerWebNetworkCaptureTool = ( evidenceById?: EvidenceLookup, ): void => { const contract = toolContract("inspect_web_network_capture"); - const delivery = retainedSummaryDelivery(new Set([contract.name]), { + const delivery = retainedSummaryDelivery(contract.name, { recordEvidence, evidenceById, }); @@ -49,11 +49,11 @@ export const registerWebNetworkCaptureTool = ( ); const { detail, ...input } = parsed.data; const parameters = { detail }; - const ready = delivery.preflight(contract.name, parameters); + const ready = delivery.preflight(parameters); if (!ready.ok) return ready; const inspected = await service.inspect(input, options); return inspected.ok - ? delivery.projectEvidence(contract.name, parameters, inspected.value) + ? delivery.projectEvidence(parameters, inspected.value) : inspected; }, logger, diff --git a/src/server/retainedSummaryDelivery.ts b/src/server/retainedSummaryDelivery.ts index 0bc703a7c1..088eb329f0 100644 --- a/src/server/retainedSummaryDelivery.ts +++ b/src/server/retainedSummaryDelivery.ts @@ -23,40 +23,33 @@ export interface EvidenceToolFamilyOptions { type Parameters = Readonly>; /** - * Summary delivery for the named operations: refuse it before analysis when + * Summary delivery for one named operation: refuse it before analysis when * this server retains no Evidence, then return the retained summary view. */ export const retainedSummaryDelivery = ( - operations: ReadonlySet, + operation: string, options: Pick, ) => { const retention = { recordEvidence: options.recordEvidence, evidenceById: options.evidenceById, }; - const selected = (operation: string, parameters: Parameters): boolean => - operations.has(operation) && parameters.detail === "summary"; + const selected = (parameters: Parameters): boolean => + parameters.detail === "summary"; return { - preflight: (operation: string, parameters: Parameters) => - selected(operation, parameters) + preflight: (parameters: Parameters) => + selected(parameters) ? requireAnalysisViewRetention(operation, retention) : ok(null), - projectEvidence: ( - operation: string, - parameters: Parameters, - evidence: Evidence, - ) => - selected(operation, parameters) + projectEvidence: (parameters: Parameters, evidence: Evidence) => + selected(parameters) ? summarizeRetainedAnalysis(evidence, retention) : ok(evidence), }; }; -/** Delivery detail selects a response shape; it never reaches tools or Evidence identity. */ -export const withoutDeliveryDetail = - (operations: ReadonlySet) => - (operation: string, parameters: Parameters): Parameters => { - if (!operations.has(operation)) return parameters; - const { detail: _detail, ...selected } = parameters; - return selected; - }; +/** Delivery detail selects a response shape; it never reaches analysis. */ +export const withoutDeliveryDetail = (parameters: Parameters): Parameters => { + const { detail: _detail, ...selected } = parameters; + return selected; +}; diff --git a/tests/boundary/mcp/analysisSnapshotProvenanceMcp.test.ts b/tests/boundary/mcp/analysisSnapshotProvenanceMcp.test.ts index 365e4ecea6..572088d229 100644 --- a/tests/boundary/mcp/analysisSnapshotProvenanceMcp.test.ts +++ b/tests/boundary/mcp/analysisSnapshotProvenanceMcp.test.ts @@ -16,7 +16,6 @@ import { silentLogger } from "../../../src/logger.js"; import { EvidenceMcpServer } from "../../../src/server/EvidenceMcpServer.js"; import { withAdmittedAnalysis } from "../../../src/server/analysisAdmission.js"; import { registerEvidenceTools } from "../../../src/server/registerEvidenceTools.js"; -import { withoutDeliveryDetail } from "../../../src/server/retainedSummaryDelivery.js"; import { createToolResultDelivery } from "../../../src/server/toolResult.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -152,12 +151,6 @@ for (const missingSource of [false, true]) kind: "session", session, }), - parametersForExecution: withoutDeliveryDetail( - new Set(["inspect_managed_artifact"]), - ), - parametersForEvidence: withoutDeliveryDetail( - new Set(["inspect_managed_artifact"]), - ), }, ); const client = await mcp.connect(server); diff --git a/tests/boundary/mcp/nativeLoadImageViewsMcp.test.ts b/tests/boundary/mcp/nativeLoadImageViewsMcp.test.ts index 998e2cf460..967564de28 100644 --- a/tests/boundary/mcp/nativeLoadImageViewsMcp.test.ts +++ b/tests/boundary/mcp/nativeLoadImageViewsMcp.test.ts @@ -1,4 +1,10 @@ -import { expect, it } from "vitest"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { expect, it, onTestFinished } from "vitest"; +import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js"; +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { ok } from "../../../src/domain/result.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js"; import { @@ -93,3 +99,71 @@ it("preserves raw and DOL verification facts and selected DOL sections through t }); } }, 30_000); + +it("delivers load-image summaries from the complete retained public-tool observation", async () => { + const report = nativeRawLoadImageFixture(); + const inputs: unknown[] = []; + const session = createTestBinarySession(() => ({ + execute: async (operation, parameters) => { + if (operation === "inspect_native_load_image") inputs.push(parameters); + return ok( + createAnalysisExecution(operation === "health" ? null : report, { + id: "fixture", + name: "Fixture", + version: "1", + }), + ); + }, + close: async () => ok(null), + })); + const server = createServer({ kind: "session", session }); + const client = new Client({ name: "load-image-summary", version: "1" }); + onTestFinished(async () => { + await client.close(); + await server.close(); + await session.close(); + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + const opened = await client.callTool({ + name: "open_binary", + arguments: { path: process.execPath }, + }); + expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true); + const contract = toolContract("inspect_native_load_image"); + const completeReply = await client.callTool({ + name: contract.name, + arguments: {}, + }); + expect(completeReply.isError, JSON.stringify(completeReply.content)).not.toBe( + true, + ); + const complete = parseEvidence( + contract.outputSchema.parse(completeReply.structuredContent), + ); + const summaryReply = await client.callTool({ + name: contract.name, + arguments: { detail: "summary" }, + }); + expect(summaryReply.isError, JSON.stringify(summaryReply.content)).not.toBe( + true, + ); + const summary = parseEvidence( + contract.outputSchema.parse(summaryReply.structuredContent), + ); + expect(complete.parameters).toEqual({}); + expect(complete.normalized_result).toEqual(report); + expect(summary.normalized_result).toMatchObject({ + kind: "summary", + parent_operation: contract.name, + parent_evidence_id: complete.evidence_id, + source_parameters: {}, + }); + expect(session.evidenceById(complete.evidence_id)).toEqual(complete); + expect(inputs.length).toBeGreaterThan(0); + expect(inputs).not.toContainEqual( + expect.objectContaining({ detail: expect.anything() }), + ); +}); From 8a47cbbcce538f511609b90b5d40bd00920b3216 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:54:09 +0800 Subject: [PATCH 20/31] fix(artifacts): deduplicate keyed archive selector choices --- src/artifacts/apple/KeyedArchiveReader.ts | 9 +++------ .../keyedArchiveStandaloneSelection.test.ts | 14 +++++++++++++- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/artifacts/apple/KeyedArchiveReader.ts b/src/artifacts/apple/KeyedArchiveReader.ts index 5a2e589751..5de89da089 100644 --- a/src/artifacts/apple/KeyedArchiveReader.ts +++ b/src/artifacts/apple/KeyedArchiveReader.ts @@ -240,18 +240,15 @@ export const inspectStandaloneKeyedArchive = (input: { const selected = keyedArchiveInputSchema.parse(input.parameters); const selectedBasename = basename(input.selectedPath); const physicalBasename = basename(input.path); - if ( - selected.path !== "." && - selected.path !== selectedBasename && - selected.path !== physicalBasename - ) + const basenames = [...new Set([selectedBasename, physicalBasename])]; + if (selected.path !== "." && !basenames.includes(selected.path)) throw new AnalysisInputError("inspect_keyed_archive", undefined, [ { path: ["path"], reason: "invalid_value", message: "For an active plist, path must select that archive (omit path or use its basename).", - expected: [selectedBasename, physicalBasename], + expected: basenames.length === 1 ? selectedBasename : basenames, }, ]); await assertStandaloneArchiveSelection( diff --git a/tests/boundary/filesystem/keyedArchiveStandaloneSelection.test.ts b/tests/boundary/filesystem/keyedArchiveStandaloneSelection.test.ts index 5c29f6019e..628fccfe8c 100644 --- a/tests/boundary/filesystem/keyedArchiveStandaloneSelection.test.ts +++ b/tests/boundary/filesystem/keyedArchiveStandaloneSelection.test.ts @@ -76,7 +76,19 @@ it.skipIf(process.platform === "win32")( await session.execute("inspect_keyed_archive", { path: "unrelated.plist", }), - ).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } }); + ).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisInputError", + issues: [ + { + path: ["path"], + reason: "invalid_value", + expected: [alias.split("/").at(-1), "stored.plist"], + }, + ], + }, + }); } await unlink(aliases[1]!); await symlink(other, aliases[1]!); From 7fa54e2d62bf277ecdd8d75a2f3b60de24344591 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:54:56 +0800 Subject: [PATCH 21/31] fix(process): preserve command completion and input ownership --- src/firmware/FirmwareCommand.test.ts | 162 +++++++++++++++++++++++++++ src/firmware/FirmwareCommand.ts | 81 ++++++++------ src/process/ExecFileOutput.test.ts | 15 +++ src/process/ExecFileOutput.ts | 8 +- 4 files changed, 229 insertions(+), 37 deletions(-) diff --git a/src/firmware/FirmwareCommand.test.ts b/src/firmware/FirmwareCommand.test.ts index 06dcae1ad1..8ab02027fe 100644 --- a/src/firmware/FirmwareCommand.test.ts +++ b/src/firmware/FirmwareCommand.test.ts @@ -437,3 +437,165 @@ test.each([ } }, ); + +// The injected launcher runs real owned Node processes on every POSIX host; +// Linux firmware binaries and prlimit are deliberately outside this lifecycle seam. +test.skipIf(process.platform === "win32").each([ + { + overflow: false, + exitCode: 7, + acceptFailure: false, + cancelBeforeExit: false, + expected: "ProviderAdapterError", + }, + { + overflow: false, + exitCode: 0, + acceptFailure: false, + cancelBeforeExit: false, + expected: "AnalysisCancelledError", + }, + { + overflow: false, + exitCode: 1, + acceptFailure: true, + cancelBeforeExit: false, + expected: "AnalysisCancelledError", + }, + { + overflow: false, + exitCode: 7, + acceptFailure: false, + cancelBeforeExit: true, + expected: "AnalysisCancelledError", + }, + { + overflow: true, + exitCode: 7, + acceptFailure: false, + cancelBeforeExit: false, + expected: "ProviderAdapterError", + }, +])( + "retains root exit $exitCode while a handshaken descendant holds output (accepted: $acceptFailure, earlier cancellation: $cancelBeforeExit, overflow: $overflow)", + async ({ exitCode, acceptFailure, cancelBeforeExit, expected, overflow }) => { + const root = await createTestTempDirectory( + "rea-firmware-inherited-output-", + ); + const caller = new AbortController(); + const reason = { caller: "cancel with inherited output still open" }; + let cleanupCalls = 0; + let closed = false; + let exited = false; + const failure: unknown = await runFirmwareCommand({ + engine: "binwalk", + operation: "inspect_firmware_regions", + command: process.execPath, + limiter: process.execPath, + fileSizeCeiling: Number.MAX_SAFE_INTEGER, + args: [], + cwd: root, + environment: process.env, + signal: caller.signal, + deadline: { + phase: "analysis", + signal: new AbortController().signal, + timeoutMs: 30_000, + expiresAtMs: performance.now() + 30_000, + }, + acceptReportedExtractionFailure: acceptFailure, + launcher: async (options) => { + const exitRelease = join(root, "exit-release"); + const tailRelease = join(root, "tail-release"); + const descendant = ` + const { existsSync } = require("node:fs"); + setInterval(() => {}, 1000); + const tail = setInterval(() => { + if (!existsSync(${JSON.stringify(tailRelease)})) return; + clearInterval(tail); + process.stderr.write(Buffer.alloc(${overflow ? FIRMWARE_LIMITS.diagnosticBytes : 0}, 120), () => { + process.stderr.write("descendant-tail\\n"); + }); + }, 10); + process.send("ready"); + `; + const owned = await spawnOwnedProviderProcess({ + ...options, + command: process.execPath, + arguments: [ + "-e", + ` + const { spawn } = require("node:child_process"); + const child = spawn(process.execPath, ["-e", ${JSON.stringify(descendant)}], { + stdio: ["ignore", "inherit", "inherit", "ipc"], + }); + child.once("message", () => { + process.stderr.write("descendant-ready\\n"); + child.disconnect(); + child.unref(); + if (${cancelBeforeExit}) { + const release = setInterval(() => { + if (!require("node:fs").existsSync(${JSON.stringify(exitRelease)})) return; + clearInterval(release); + process.exitCode = ${exitCode}; + }, 10); + } else process.exitCode = ${exitCode}; + }); + `, + ], + }); + owned.process.once("close", () => { + closed = true; + }); + let diagnostics = ""; + owned.process.stderr?.on("data", (chunk: Buffer) => { + diagnostics += chunk.toString(); + if (cancelBeforeExit && !caller.signal.aborted) { + expect(exited).toBe(false); + caller.abort(reason); + void writeFile(exitRelease, "release"); + } else if ( + !cancelBeforeExit && + diagnostics.includes("descendant-tail") + ) { + expect(exited).toBe(true); + expect(closed).toBe(false); + caller.abort(reason); + } + }); + owned.process.once("exit", () => { + exited = true; + // Release descendant diagnostics only after the supervisor has also + // observed the root's exit, leaving inherited pipes open until cleanup. + queueMicrotask(() => { + void writeFile(tailRelease, "release"); + }); + }); + return { + ...owned, + cleanup: async () => { + cleanupCalls += 1; + return cleanupOwnedProcessGroup(owned.ownership); + }, + }; + }, + retainCleanup: () => { + throw new Error("Real descendant cleanup must complete"); + }, + }).catch((cause: unknown) => cause); + expect(exited).toBe(true); + expect(cleanupCalls).toBe(1); + expect(failure).toMatchObject({ _tag: expected }); + if (expected === "ProviderAdapterError") + expect(failure).toMatchObject({ + diagnostics: { + exit_code: 7, + stderr: overflow + ? expect.stringMatching(/^descendant-ready\nxxx/) + : "descendant-ready\ndescendant-tail\n", + truncated: overflow, + }, + }); + else expect(failure).toMatchObject({ cause: reason }); + }, +); diff --git a/src/firmware/FirmwareCommand.ts b/src/firmware/FirmwareCommand.ts index d9a2f86c67..801cd8b97e 100644 --- a/src/firmware/FirmwareCommand.ts +++ b/src/firmware/FirmwareCommand.ts @@ -236,6 +236,39 @@ export const runFirmwareCommand = async (context: { throw firmwareCancellation(operation, signal.reason, context.deadline); throw cause; }); + let failure: AnalysisError | undefined; + const isFailedExit = (code: number | null | undefined) => + code !== undefined && + code !== 0 && + !(context.acceptReportedExtractionFailure === true && code === 1); + const completedFailure = (snapshot: ProviderProcessSnapshot) => { + if (isFailedExit(snapshot.exitCode)) { + const signalLimit = firmwareSignalLimitError( + operation, + snapshot.signal, + fileSizeBytes, + fileSizeAdjustable, + capturedFirmwareOutput(snapshot), + ); + if (signalLimit !== undefined) return signalLimit; + return new ProviderAdapterError(engine, operation, { + diagnostics: { + reason: + "Tool failed; inspect exit status and stderr for format, dependency, sandbox or resource-limit failures", + exit_code: snapshot.exitCode ?? null, + signal: snapshot.signal ?? null, + stdout: snapshot.stdout.text, + stderr: snapshot.stderr.text, + stdout_bytes: snapshot.stdout.bytes, + stderr_bytes: snapshot.stderr.bytes, + truncated: snapshot.diagnosticTruncated === true, + }, + remediationAction: `Inspect exit status and stderr for format, dependency, sandbox, or resource-limit failures. ${firmwareProcessLimitRecovery(fileSizeBytes, fileSizeAdjustable)} A format or dependency failure is not repaired by changing analysis_timeout_ms.`, + }); + } + return undefined; + }; + let completedFailureObserved = false; let diagnosticTruncated = false; const supervisor = new ProviderProcessSupervisor( { @@ -249,6 +282,17 @@ export const runFirmwareCommand = async (context: { onDiagnostic: (event) => { if (event.type === "output" && event.truncated === true) diagnosticTruncated = true; + // Root exit and inherited-pipe closure are different observations. + // Retain a completed failure before drain or cleanup can be interrupted, + // while cancellation already present at exit keeps its precedence. + if ( + event.type === "exit" && + failure === undefined && + !diagnosticTruncated && + firmwarePhaseInterruption(operation, signal, context.deadline) === + undefined + ) + completedFailureObserved = isFailedExit(event.code); }, }, ); @@ -272,7 +316,6 @@ export const runFirmwareCommand = async (context: { ); } }; - let failure: AnalysisError | undefined; try { while (!(await supervisor.waitForOutputClose(100))) { checkDiagnostics(); @@ -288,46 +331,16 @@ export const runFirmwareCommand = async (context: { } checkDiagnostics(); checkInterruption(); - // Physical completion belongs to this observation, before awaited cleanup. - // A later cancellation must not replace an already completed tool failure. - const snapshot = supervisor.snapshot(); - if ( - snapshot.exitCode !== 0 && - !( - context.acceptReportedExtractionFailure === true && - snapshot.exitCode === 1 - ) - ) { - const signalLimit = firmwareSignalLimitError( - operation, - snapshot.signal, - fileSizeBytes, - fileSizeAdjustable, - capturedFirmwareOutput(snapshot), - ); - if (signalLimit !== undefined) throw signalLimit; - throw new ProviderAdapterError(engine, operation, { - diagnostics: { - reason: - "Tool failed; inspect exit status and stderr for format, dependency, sandbox or resource-limit failures", - exit_code: snapshot.exitCode ?? null, - signal: snapshot.signal ?? null, - stdout: snapshot.stdout.text, - stderr: snapshot.stderr.text, - stdout_bytes: snapshot.stdout.bytes, - stderr_bytes: snapshot.stderr.bytes, - }, - remediationAction: `Inspect exit status and stderr for format, dependency, sandbox, or resource-limit failures. ${firmwareProcessLimitRecovery(fileSizeBytes, fileSizeAdjustable)} A format or dependency failure is not repaired by changing analysis_timeout_ms.`, - }); - } } catch (cause: unknown) { - failure = firmwareFailure(cause, context); + failure ??= firmwareFailure(cause, context); } const stopped = await supervisor.stop(); // Cleanup is an awaited part of this phase. Preserve a prior observed failure, // otherwise retain an interruption that arrived while stopping owned resources. failure ??= firmwarePhaseInterruption(operation, signal, context.deadline); const snapshot = supervisor.snapshot(); + // Capture diagnostics only after owned cleanup has drained the inherited pipes. + if (completedFailureObserved) failure = completedFailure(snapshot); if (stopped.status === "incomplete") { context.retainCleanup(supervisor); const previous = diff --git a/src/process/ExecFileOutput.test.ts b/src/process/ExecFileOutput.test.ts index 3975990d61..14cc0f59b4 100644 --- a/src/process/ExecFileOutput.test.ts +++ b/src/process/ExecFileOutput.test.ts @@ -53,6 +53,21 @@ it.each([undefined, "SIGTERM"] as const)( }, ); +it.each([undefined, "SIGTERM"] as const)( + "closes unused command input with stop signal %s", + async (stopSignal) => { + const result = await execFileOutput( + process.execPath, + [ + "-e", + 'process.stdin.resume(); process.stdin.on("end", () => process.stdout.write("input closed"));', + ], + { timeout: 2_000, ...(stopSignal === undefined ? {} : { stopSignal }) }, + ); + expect(result.stdout).toBe("input closed"); + }, +); + describe("execFileOutput", () => { it("ignores non-Error and incomplete failure metadata", () => { expect(execFileOutputFailure("failure")).toBeUndefined(); diff --git a/src/process/ExecFileOutput.ts b/src/process/ExecFileOutput.ts index d2c2ca43df..86f3920391 100644 --- a/src/process/ExecFileOutput.ts +++ b/src/process/ExecFileOutput.ts @@ -142,7 +142,7 @@ export const execFileFailureCapturedOutput = ( truncated: failure.outputTruncated, }); -/** Run a shell-free command while capturing its complete output bytes. */ +/** Run a shell-free command with no input while capturing its complete output bytes. */ export const execFileBytes = ( command: string, arguments_: readonly string[], @@ -175,7 +175,7 @@ export const execFileBytes = ( resolve({ stdout, stderr }); }; if (stopSignal === undefined) { - execFile( + const child = execFile( command, [...arguments_], { @@ -194,6 +194,8 @@ export const execFileBytes = ( stderr, ), ); + // This promise API exposes no input writer; EOF belongs to the owner. + child.stdin?.end(); return; } startStoppableCommand( @@ -229,7 +231,7 @@ const startStoppableCommand = ( // pipe lifetime of descendants still using caller-owned temporary files. const child = spawn(command, [...arguments_], { ...execOptions, - stdio: "pipe", + stdio: ["ignore", "pipe", "pipe"], killSignal: stopSignal, }); // The first stop request names the failure; a later one is not resent. From 2ad599daceb238347d379f81ca170cf662901a49 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:55:06 +0800 Subject: [PATCH 22/31] fix(snapshot): export only target-bound observations --- docs/cli.md | 5 ++ .../binary/AnalysisSnapshotCache.test.ts | 89 ++++++++++++++++++- .../binary/AnalysisSnapshotCache.ts | 18 ++-- .../binary/BinarySessionLiveness.test.ts | 75 ++++++++++++++++ .../directAnalysisSnapshotReplay.test.ts | 8 +- 5 files changed, 186 insertions(+), 9 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 7d7f2823bb..307fa6f3d3 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -491,6 +491,11 @@ Dispatching a provider metadata write invalidates the active session's snapshot even if its reply fails: the provider may already have applied the write. Close and reopen the target before importing or saving another snapshot. +Saved snapshots include only cache entries whose subject identifies the selected +target. Unbound observations and observations of child artifacts remain available +in the open session and its Evidence bundle; they are excluded from the target's +saved replay entries without changing their subjects. + Snapshots retain eligible target-scoped question histories in full, including mutation Evidence recorded while another target was active. Observations and related questions must still belong to the saved target. If those dependencies diff --git a/src/application/binary/AnalysisSnapshotCache.test.ts b/src/application/binary/AnalysisSnapshotCache.test.ts index dae7084061..93e73104ad 100644 --- a/src/application/binary/AnalysisSnapshotCache.test.ts +++ b/src/application/binary/AnalysisSnapshotCache.test.ts @@ -8,7 +8,10 @@ import { ANALYSIS_SNAPSHOT_TARGET, } from "../../domain/analysisSnapshot.fixture.js"; import { createEvidence } from "../../domain/evidence.js"; -import { createEvidenceBundle } from "../../domain/evidenceBundle.js"; +import { + createEvidenceBundle, + evidenceBundleForTarget, +} from "../../domain/evidenceBundle.js"; import { ok } from "../../domain/result.js"; import { createAnalysisExecution } from "../AnalysisProvider.js"; import { @@ -291,6 +294,90 @@ it.each(["provider", "workflow"] as const)( }, ); +it.each(["provider", "workflow"] as const)( + "exports target-scoped %s rows without changing retained observations", + (kind) => { + const cache = new AnalysisSnapshotCache(); + const ledger = new EvidenceLedger(); + const target = ANALYSIS_SNAPSHOT_TARGET; + const profile = ANALYSIS_SNAPSHOT_PROFILE; + const operation = kind === "provider" ? "procedure_info" : "trace_feature"; + const provider = + kind === "provider" ? ANALYSIS_SNAPSHOT_PROVIDER : REA_WORKFLOW_PROVIDER; + const analysisProfile = + kind === "provider" + ? profile + : workflowAnalysisProfile(profile, operation); + const child = { ...target, path: "/tmp/child", sha256: "b".repeat(64) }; + for (const [index, subject] of [target, null, child].entries()) { + const parameters = { address: `0x${index}000` }; + const result = { observation: index }; + const execution = createAnalysisExecution(result, provider, { + subject, + analysisProfile, + }); + const input = { + target, + profile, + operation, + parameters, + execution: { ...execution, subject, analysisProfile }, + }; + if (kind === "provider") + cache.record({ ...input, operation: "procedure_info" }); + else cache.recordWorkflow(input); + expect( + ledger.record( + createEvidence(subject ?? undefined, provider, { + operation, + parameters, + result, + analysisProfile, + confidence: kind === "provider" ? "observed" : "derived", + }), + ).ok, + ).toBe(true); + } + const before = + kind === "provider" ? cache.entries() : cache.workflowEntries(); + expect(before).toHaveLength(3); + const evidence = ledger.export(); + const exported = cache.export( + target, + profile, + evidenceBundleForTarget(evidence, target.sha256), + ); + if (!exported.ok) throw exported.error; + const snapshot = parseAnalysisSnapshot( + JSON.parse(serializeAnalysisSnapshot(exported.value)), + ); + const saved = + kind === "provider" ? snapshot.entries : snapshot.workflow_entries; + expect(saved).toHaveLength(1); + expect(saved[0]?.execution.subject?.sha256).toBe(target.sha256); + expect(snapshot.evidence_bundle.records).toHaveLength(1); + expect( + kind === "provider" ? cache.entries() : cache.workflowEntries(), + ).toEqual(before); + expect(ledger.export()).toEqual(evidence); + const restored = new AnalysisSnapshotCache(); + expect(restored.stage(snapshot)).toBe(1); + expect( + kind === "provider" ? restored.entries() : restored.workflowEntries(), + ).toEqual(saved); + if (kind === "provider") { + expect( + cache.lookup(target, profile, "procedure_info", { address: "0x1000" }) + ?.subject, + ).toBeNull(); + expect( + cache.lookup(target, profile, "procedure_info", { address: "0x2000" }) + ?.subject?.sha256, + ).toBe(child.sha256); + } + }, +); + it("counts newly retained direct and workflow bindings when staging", () => { const source = new AnalysisSnapshotCache(); const target = ANALYSIS_SNAPSHOT_TARGET; diff --git a/src/application/binary/AnalysisSnapshotCache.ts b/src/application/binary/AnalysisSnapshotCache.ts index 99cdc2f0f4..4e90c3d8e6 100644 --- a/src/application/binary/AnalysisSnapshotCache.ts +++ b/src/application/binary/AnalysisSnapshotCache.ts @@ -291,13 +291,19 @@ export class AnalysisSnapshotCache { try { const snapshotTargetIdentity = snapshotTarget(target); const binding = snapshotBinding(profile); - const entries = [...this.#entries.values()].sort((left, right) => - compareUnicodeCodePoints(left.query_id, right.query_id), - ); - const workflows = [...this.#workflowEntries.values()].sort( - (left, right) => + // In-memory replay can preserve unbound and child-artifact observations. + // A persisted target snapshot can authenticate only rows whose Evidence + // has that target subject; the record owner applies the same scope to its bundle. + const entries = [...this.#entries.values()] + .filter((entry) => entry.execution.subject?.sha256 === target.sha256) + .sort((left, right) => compareUnicodeCodePoints(left.query_id, right.query_id), - ); + ); + const workflows = [...this.#workflowEntries.values()] + .filter((entry) => entry.execution.subject?.sha256 === target.sha256) + .sort((left, right) => + compareUnicodeCodePoints(left.query_id, right.query_id), + ); // Parsing owns the returned JSON and metadata; pre-cloning the same // payloads here only adds another full materialization. return ok( diff --git a/src/application/binary/BinarySessionLiveness.test.ts b/src/application/binary/BinarySessionLiveness.test.ts index c7455328be..72c1c56f28 100644 --- a/src/application/binary/BinarySessionLiveness.test.ts +++ b/src/application/binary/BinarySessionLiveness.test.ts @@ -73,6 +73,81 @@ it("preserves an explicitly unbound provider observation", async () => { } }); +it("keeps unbound observations out of persisted target snapshots", async () => { + const { session, deep } = openSession(); + const directory = await createTestTempDirectory("rea-unbound-snapshot-"); + const path = join(directory, "snapshot.json"); + const parameters = { document: "0", address: "0x1000" }; + try { + expect((await session.open(process.execPath)).ok).toBe(true); + const bound = await session.execute("address_name", parameters); + expect(bound.ok).toBe(true); + const child = deep.created[0]; + if (child === undefined || !bound.ok) + throw new Error("Expected bound read"); + child.execute = () => + Promise.resolve( + ok( + createAnalysisExecution("unbound observation", deepIdentity, { + subject: null, + }), + ), + ); + const execution = await session.execute("address_name", { + ...parameters, + address: "0x2000", + }); + expect(execution.ok).toBe(true); + if (!execution.ok) return; + expect(execution.value.subject).toBeNull(); + expect(session.exportEvidenceBundle().records).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + subject: null, + normalized_result: "unbound observation", + }), + ]), + ); + const snapshot = session.exportAnalysisSnapshot(); + expect(snapshot.ok, snapshot.ok ? undefined : snapshot.error.message).toBe( + true, + ); + if (!snapshot.ok) return; + expect(snapshot.value.entries).toHaveLength(1); + expect(snapshot.value.entries[0]?.parameters).toEqual(parameters); + const unboundReplay = await session.execute("address_name", { + ...parameters, + address: "0x2000", + }); + expect(unboundReplay.ok).toBe(true); + if (unboundReplay.ok) { + expect(unboundReplay.value.subject).toBeNull(); + expect(unboundReplay.value.evidenceId).toBe(execution.value.evidenceId); + } + expect( + (await session.writeAnalysisSnapshot(snapshot.value, path, false)).ok, + ).toBe(true); + expect((await session.close()).ok).toBe(true); + expect( + ( + await session.open(process.execPath, { + loadSnapshot: () => session.readAnalysisSnapshot(path), + }) + ).ok, + ).toBe(true); + const replay = await session.execute("address_name", parameters); + expect(replay.ok).toBe(true); + if (replay.ok) { + expect(replay.value.evidenceId).toBe(bound.value.evidenceId); + expect(replay.value.limitations).toContain( + "Loaded from a local REA analysis snapshot; this call did not re-run the provider.", + ); + } + } finally { + await session.close(); + } +}); + describe("BinarySession open liveness", () => { it("retires a locally blocked owner even when its native health is idle", async () => { const { session, deep } = openSession(); diff --git a/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts b/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts index 13294cfc2a..2d5221fee6 100644 --- a/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts +++ b/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts @@ -213,8 +213,12 @@ const makeProvider = ( }, ], list_documents: ["fixture"], - list_procedures: ["0x1000"], - list_strings: { "0x1000": "fixture" }, + list_procedures: [ + { address: "0x1000", value: "fixture" }, + ] satisfies z.infer, + list_strings: [ + { address: "0x1000", value: "fixture" }, + ] satisfies z.infer, analyze_function: functionDossier, list_names: [ { address: "0x1000", value: "fixture" }, From 68533bc0a3ba6b6b55d5406508c0a59e6f52819b Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:57:37 +0800 Subject: [PATCH 23/31] refactor(evidence): share subject architecture declarations --- src/domain/analysisSnapshot.ts | 31 +++----------- src/domain/evidence.ts | 62 +++------------------------- src/domain/evidenceSubject.test.ts | 66 ++++++++++++++++++++++++++++++ src/domain/evidenceSubject.ts | 60 +++++++++++++++++++++++++++ 4 files changed, 137 insertions(+), 82 deletions(-) create mode 100644 src/domain/evidenceSubject.test.ts create mode 100644 src/domain/evidenceSubject.ts diff --git a/src/domain/analysisSnapshot.ts b/src/domain/analysisSnapshot.ts index 2cb1084a78..6ba37e7d56 100644 --- a/src/domain/analysisSnapshot.ts +++ b/src/domain/analysisSnapshot.ts @@ -3,6 +3,8 @@ import { createHash } from "node:crypto"; import canonicalize from "canonicalize"; import { z } from "zod"; +import { evidenceSubjectDeclarationShapes } from "./evidenceSubject.js"; + import { compareUnicodeCodePoints, compositeKey, @@ -82,33 +84,10 @@ const subjectBaseSchema = z.object({ path: localPathStringSchema, sha256: digestSchema, }); -const subjectFormatSchema = z.enum([ - ...formatSchema.options, - "hopper", - "directory", - "file", - "unknown", - "javascript-bundle", - "entitlements", - "pyinstaller", - "node-sea", -]); -// Snapshot entries retain the same raw-only architecture boundary as targets. const subjectSchema = z.discriminatedUnion("format", [ - subjectBaseSchema.extend({ - format: subjectFormatSchema.exclude(["raw-binary", "gamecube-dol"]), - architecture: architectureSchema - .exclude([...RAW_ONLY_ARCHITECTURES]) - .nullable(), - }), - subjectBaseSchema.extend({ - format: z.literal("raw-binary"), - architecture: architectureSchema.nullable(), - }), - subjectBaseSchema.extend({ - format: z.literal("gamecube-dol"), - architecture: z.literal("powerpc"), - }), + subjectBaseSchema.extend(evidenceSubjectDeclarationShapes[0]), + subjectBaseSchema.extend(evidenceSubjectDeclarationShapes[1]), + subjectBaseSchema.extend(evidenceSubjectDeclarationShapes[2]), ]); const targetIdentitySchema = z.object({ sha256: digestSchema, diff --git a/src/domain/evidence.ts b/src/domain/evidence.ts index 07cf9d47f3..38a42dd7f4 100644 --- a/src/domain/evidence.ts +++ b/src/domain/evidence.ts @@ -1,6 +1,8 @@ import { basename } from "node:path"; import { z } from "zod"; +import { evidenceSubjectDeclarationShapes } from "./evidenceSubject.js"; + import { digestCanonicalValue } from "./canonicalDigest.js"; import { canonicalJsonDigestSteps } from "./canonicalJsonDigestSteps.js"; import { nulFreeStringSchema } from "./localPath.js"; @@ -9,12 +11,7 @@ import { analysisProfileSchema, type AnalysisProfileCommitment, } from "./analysisProfile.js"; -import { - BINARY_ARCHITECTURES, - RAW_ONLY_ARCHITECTURES, - type BinaryArchitecture, - type BinaryTarget, -} from "./binaryTargetTypes.js"; +import type { BinaryArchitecture, BinaryTarget } from "./binaryTargetTypes.js"; import { jsonObjectSchema, jsonValueSchema, @@ -41,62 +38,15 @@ export const providerSchema = z.strictObject({ name: z.string().min(1), version: z.string().nullable(), }); -const subjectFormatSchema = z.enum([ - "hopper", - "analysis-database", - "mach-o", - "elf", - "pe", - "dos-mz", - "dos-com", - "raw-binary", - "gamecube-dol", - "zip", - "ipa", - "apk", - "msix", - "appx", - "hap", - "hsp", - "app-pack", - "asar", - "godot-pck", - "dmg", - "pkg", - "plist", - "javascript", - "source-map", - "directory", - "file", - "unknown", - "mach-o-universal", - "javascript-bundle", - "entitlements", - "pyinstaller", - "node-sea", -]); const subjectBaseSchema = z.strictObject({ name: z.string().min(1), digest: z.strictObject({ sha256: digestSchema }), local_path: nulFreeStringSchema, }); -// The format owns the architecture claim, including nested Evidence subjects. const subjectSchema = z.discriminatedUnion("format", [ - subjectBaseSchema.extend({ - format: subjectFormatSchema.exclude(["raw-binary", "gamecube-dol"]), - architecture: z - .enum(BINARY_ARCHITECTURES) - .exclude([...RAW_ONLY_ARCHITECTURES]) - .nullable(), - }), - subjectBaseSchema.extend({ - format: z.literal("raw-binary"), - architecture: z.enum(BINARY_ARCHITECTURES).nullable(), - }), - subjectBaseSchema.extend({ - format: z.literal("gamecube-dol"), - architecture: z.literal("powerpc"), - }), + subjectBaseSchema.extend(evidenceSubjectDeclarationShapes[0]), + subjectBaseSchema.extend(evidenceSubjectDeclarationShapes[1]), + subjectBaseSchema.extend(evidenceSubjectDeclarationShapes[2]), ]); /** Source location attached to an evidence observation. */ /** Source-location schema shared by evidence-bearing persistence formats. */ diff --git a/src/domain/evidenceSubject.test.ts b/src/domain/evidenceSubject.test.ts new file mode 100644 index 0000000000..9caa3fcfb2 --- /dev/null +++ b/src/domain/evidenceSubject.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; + +import { analysisSnapshotSchema } from "./analysisSnapshot.js"; +import { BINARY_ARCHITECTURES } from "./binaryTargetTypes.js"; +import { evidenceSchema } from "./evidence.js"; +import { evidenceSubjectFormatSchema } from "./evidenceSubject.js"; + +const evidenceSubject = evidenceSchema.shape.subject; +const snapshotSubject = + analysisSnapshotSchema.shape.entries.element.shape.execution.shape.subject; +const digest = "a".repeat(64); + +describe("persisted observation subject declarations", () => { + it("keeps Evidence and cache admission aligned for every format and architecture", () => { + for (const format of evidenceSubjectFormatSchema.options) { + for (const architecture of [ + ...BINARY_ARCHITECTURES, + null, + "unsupported", + ]) { + const declaration = { format, architecture }; + const evidence = evidenceSubject.safeParse({ + ...declaration, + name: "fixture", + local_path: "/tmp/fixture", + digest: { sha256: digest }, + }); + const snapshot = snapshotSubject.safeParse({ + ...declaration, + path: "/tmp/fixture", + sha256: digest, + }); + const allowed = + architecture !== "unsupported" && + (format === "gamecube-dol" + ? architecture === "powerpc" + : format === "raw-binary" || + (architecture !== "6502" && architecture !== "m68k")); + expect(evidence.success, JSON.stringify(declaration)).toBe(allowed); + expect(snapshot.success, JSON.stringify(declaration)).toBe(allowed); + } + } + }); + + it("preserves each persistence envelope's handling of additional fields", () => { + expect( + evidenceSubject.safeParse({ + format: "pe", + architecture: null, + name: "fixture", + local_path: "/tmp/fixture", + digest: { sha256: digest }, + extra: true, + }).success, + ).toBe(false); + expect( + snapshotSubject.parse({ + format: "pe", + architecture: null, + path: "/tmp/fixture", + sha256: digest, + extra: true, + }), + ).not.toHaveProperty("extra"); + }); +}); diff --git a/src/domain/evidenceSubject.ts b/src/domain/evidenceSubject.ts new file mode 100644 index 0000000000..a4bd2fadfb --- /dev/null +++ b/src/domain/evidenceSubject.ts @@ -0,0 +1,60 @@ +import { z } from "zod"; + +import { + BINARY_ARCHITECTURES, + RAW_ONLY_ARCHITECTURES, +} from "./binaryTargetTypes.js"; + +export const evidenceSubjectFormatSchema = z.enum([ + "hopper", + "analysis-database", + "mach-o", + "elf", + "pe", + "dos-mz", + "dos-com", + "raw-binary", + "gamecube-dol", + "zip", + "ipa", + "apk", + "msix", + "appx", + "hap", + "hsp", + "app-pack", + "asar", + "godot-pck", + "dmg", + "pkg", + "plist", + "javascript", + "source-map", + "directory", + "file", + "unknown", + "mach-o-universal", + "javascript-bundle", + "entitlements", + "pyinstaller", + "node-sea", +]); + +/** Format-owned architecture declarations shared by Evidence and persisted executions. */ +export const evidenceSubjectDeclarationShapes = [ + { + format: evidenceSubjectFormatSchema.exclude(["raw-binary", "gamecube-dol"]), + architecture: z + .enum(BINARY_ARCHITECTURES) + .exclude([...RAW_ONLY_ARCHITECTURES]) + .nullable(), + }, + { + format: z.literal("raw-binary"), + architecture: z.enum(BINARY_ARCHITECTURES).nullable(), + }, + { + format: z.literal("gamecube-dol"), + architecture: z.literal("powerpc"), + }, +] as const; From 9222526fe234526edc036459b9e8df922fb72863 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:02:45 +0800 Subject: [PATCH 24/31] fix(electron): retain selected scenario paths in evidence --- .../javascript/ElectronActiveEvidence.ts | 24 +++- .../ElectronActiveObservationService.test.ts | 49 +++++++- .../ElectronActiveObservationService.ts | 3 +- .../javascriptRuntimeReconciliationActive.ts | 20 +++- .../javascriptRuntimeReconciliation.test.ts | 106 +++++++++++------- tests/boundary/mcp/electronMcp.test.ts | 43 ++++++- 6 files changed, 191 insertions(+), 54 deletions(-) diff --git a/src/application/javascript/ElectronActiveEvidence.ts b/src/application/javascript/ElectronActiveEvidence.ts index ebf1569cda..602ffe77b2 100644 --- a/src/application/javascript/ElectronActiveEvidence.ts +++ b/src/application/javascript/ElectronActiveEvidence.ts @@ -1,21 +1,25 @@ import { digestCanonicalValue } from "../../domain/canonicalDigest.js"; import type { ProviderIdentity } from "../AnalysisProvider.js"; -import type { CanonicalElectronActiveObservationInput } from "./ElectronActiveObservationPort.js"; import type { Evidence, EvidenceObservation } from "../../domain/evidence.js"; import { createEvidence } from "../../domain/evidence.js"; -import type { ElectronActiveObservationResult } from "../../domain/javascript/electronActiveObservation.js"; +import type { + ElectronActiveObservationInput, + ElectronActiveObservationResult, +} from "../../domain/javascript/electronActiveObservation.js"; import { jsonObjectSchema } from "../../domain/jsonValue.js"; +import type { CanonicalElectronActiveObservationInput } from "./ElectronActiveObservationPort.js"; /** Commit the selected Electron experiment and its observed result. */ export const createElectronActiveEvidence = ( - input: CanonicalElectronActiveObservationInput, + input: ElectronActiveObservationInput, result: ElectronActiveObservationResult, provider: ProviderIdentity, + resolvedInput?: CanonicalElectronActiveObservationInput, ): Evidence => createEvidence(undefined, provider, { predicateType: "rea.electron-active-scenario", operation: "capture_electron_scenario", - parameters: parameters(input), + parameters: parameters(input, resolvedInput), result, confidence: "observed", authority: "controlled-replay", @@ -32,11 +36,21 @@ export const createElectronActiveEvidence = ( }); const parameters = ( - input: CanonicalElectronActiveObservationInput, + input: ElectronActiveObservationInput, + resolvedInput?: CanonicalElectronActiveObservationInput, ): EvidenceObservation["parameters"] => { const scenario = jsonObjectSchema.parse(input); return { ...scenario, scenario_sha256: digestCanonicalValue(scenario, "Electron scenario"), + ...(resolvedInput === undefined + ? {} + : { + resolved_paths: { + executable_path: resolvedInput.executable_path, + application_path: resolvedInput.application_path, + application_root: resolvedInput.application_root, + }, + }), }; }; diff --git a/src/application/javascript/ElectronActiveObservationService.test.ts b/src/application/javascript/ElectronActiveObservationService.test.ts index 0882db0a39..a54ed55de5 100644 --- a/src/application/javascript/ElectronActiveObservationService.test.ts +++ b/src/application/javascript/ElectronActiveObservationService.test.ts @@ -1,11 +1,13 @@ -import { chmod, mkdir, writeFile } from "node:fs/promises"; +import { chmod, mkdir, realpath, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it, vi } from "vitest"; import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; import { electronActiveObservationInputSchema } from "../../domain/javascript/electronActiveObservation.js"; -import { err } from "../../domain/result.js"; +import { err, ok } from "../../domain/result.js"; +import { digestCanonicalValue } from "../../domain/canonicalDigest.js"; +import { createElectronActiveObservationFixtureResult } from "../../domain/javascript/electronActiveObservation.fixture.js"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; import type { ElectronActiveObservationPort } from "./ElectronActiveObservationPort.js"; import { captureElectronScenario } from "./ElectronActiveObservationService.js"; @@ -188,3 +190,46 @@ it("admits readable selections and hands canonical paths to the provider", async application_root: readable, }); }); + +it.skipIf(process.platform === "win32")( + "records selected scenario paths and omission while executing canonical paths", + async () => { + const { root, readable } = await fixture(); + const alias = join(root, "selected-application"); + await symlink(readable, alias); + const input = electronActiveObservationInputSchema.parse({ + executable_path: process.execPath, + application_path: join(alias, "main.js"), + }); + const canonicalApplication = await realpath(input.application_path); + const capture = vi.fn(() => + Promise.resolve( + ok(createElectronActiveObservationFixtureResult(canonicalApplication)), + ), + ); + const result = await captureElectronScenario( + { identity: () => ({ id: "test", name: "test", version: "1" }), capture }, + input, + ); + expect(capture.mock.calls[0]?.[0]).toMatchObject({ + executable_path: await realpath(process.execPath), + application_path: canonicalApplication, + application_root: await realpath(readable), + }); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + expect(result.value.parameters).toEqual({ + ...input, + scenario_sha256: digestCanonicalValue(input, "Electron scenario"), + resolved_paths: { + executable_path: await realpath(process.execPath), + application_path: canonicalApplication, + application_root: await realpath(readable), + }, + }); + expect(result.value.parameters).not.toHaveProperty("application_root"); + expect(result.value.normalized_result).toMatchObject({ + application: { application_path: canonicalApplication }, + }); + }, +); diff --git a/src/application/javascript/ElectronActiveObservationService.ts b/src/application/javascript/ElectronActiveObservationService.ts index 81ccd3c734..220d846bf4 100644 --- a/src/application/javascript/ElectronActiveObservationService.ts +++ b/src/application/javascript/ElectronActiveObservationService.ts @@ -189,9 +189,10 @@ export const captureElectronScenario = async ( ? { ok: true, value: createElectronActiveEvidence( - canonical.value, + input, captured.value, provider.identity(), + canonical.value, ), } : captured; diff --git a/src/domain/javascript/javascriptRuntimeReconciliationActive.ts b/src/domain/javascript/javascriptRuntimeReconciliationActive.ts index ea8cde18da..8baea0ce6d 100644 --- a/src/domain/javascript/javascriptRuntimeReconciliationActive.ts +++ b/src/domain/javascript/javascriptRuntimeReconciliationActive.ts @@ -44,16 +44,30 @@ export const parseActiveElectronCapture = ( z .object({ application_path: absolutePathSchema, - application_root: absolutePathSchema, + application_root: absolutePathSchema.optional(), + resolved_paths: z + .strictObject({ + executable_path: absolutePathSchema, + application_path: absolutePathSchema, + application_root: absolutePathSchema, + }) + .optional(), }) .passthrough(), evidence.parameters, [...path, "parameters"], ); + // Older captures committed canonical paths directly in parameters. New + // captures keep request spelling separate from the admitted execution paths. + const executionPaths = parameters.resolved_paths ?? parameters; if ( - result.application.application_path !== parameters.application_path || + executionPaths.application_root === undefined || + result.application.application_path !== executionPaths.application_path || + (parameters.resolved_paths !== undefined && + result.application.executable_path !== + parameters.resolved_paths.executable_path) || !isPathWithinRoot( - parameters.application_root, + executionPaths.application_root, result.application.application_path, ) ) diff --git a/tests/boundary/filesystem/javascript-applications/javascriptRuntimeReconciliation.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptRuntimeReconciliation.test.ts index cb0a393695..a1cf7ec974 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptRuntimeReconciliation.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptRuntimeReconciliation.test.ts @@ -1,4 +1,4 @@ -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, realpath, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it } from "vitest"; @@ -21,7 +21,10 @@ import { reconcileJavaScriptRuntimeInputSchema, javascriptRuntimeReconciliationResultSchema, } from "../../../../src/domain/javascript/javascriptRuntimeReconciliationSchemas.js"; -import { electronActiveObservationInputSchema } from "../../../../src/domain/javascript/electronActiveObservation.js"; +import { + electronActiveObservationInputSchema, + type ElectronActiveObservationInput, +} from "../../../../src/domain/javascript/electronActiveObservation.js"; import { createElectronActiveObservationFixtureResult } from "../../../../src/domain/javascript/electronActiveObservation.fixture.js"; import { @@ -354,48 +357,69 @@ it("preserves unknown Electron frame provenance without linking it to an authori ).toBe(false); }); -it("reconciles active Electron as a partial target-only runtime capture", async () => { - const fixture = await applicationFixture(); - const staticEvidence = await analyzeFixture(fixture); - const applicationPath = join(fixture, "main.js"); - const input = { - ...electronActiveObservationInputSchema.parse({ - executable_path: process.execPath, - application_path: applicationPath, +it.each(["historical", "selected-paths"] as const)( + "reconciles %s active Electron as a partial target-only runtime capture", + async (representation) => { + const fixture = await applicationFixture(); + const staticEvidence = await analyzeFixture(fixture); + const applicationPath = join(fixture, "main.js"); + const input = { + ...electronActiveObservationInputSchema.parse({ + executable_path: process.execPath, + application_path: applicationPath, + application_root: fixture, + actions: [], + }), application_root: fixture, - actions: [], - }), - application_root: fixture, - }; - const runtimeEvidence = createElectronActiveEvidence( - input, - createElectronActiveObservationFixtureResult(applicationPath), - { - id: "rea-playwright-electron-active", - name: "REA Playwright active Electron observation provider", - version: "1", - }, - ); + }; + const capture = + createElectronActiveObservationFixtureResult(applicationPath); + let selectedInput: ElectronActiveObservationInput = input; + let resolvedInput: typeof input | undefined; + if (representation === "selected-paths") { + const alias = join(fixture, "selected-main.js"); + await symlink(applicationPath, alias); + selectedInput = electronActiveObservationInputSchema.parse({ + executable_path: process.execPath, + application_path: alias, + }); + resolvedInput = { + ...input, + executable_path: await realpath(process.execPath), + }; + capture.application.executable_path = resolvedInput.executable_path; + } + const runtimeEvidence = createElectronActiveEvidence( + selectedInput, + capture, + { + id: "rea-playwright-electron-active", + name: "REA Playwright active Electron observation provider", + version: "1", + }, + resolvedInput, + ); - const result = reconcileInput({ - static_layers: [{ role: "application", analysis: staticEvidence }], - runtime_observations: [runtimeEvidence], - }); + const result = reconcileInput({ + static_layers: [{ role: "application", analysis: staticEvidence }], + runtime_observations: [runtimeEvidence], + }); - expect(result.runtime_captures).toMatchObject([ - { - kind: "electron-active", - scripts: 0, - frames: 0, - workers: 0, - scripts_complete_within_scope: false, - }, - ]); - expect(result.coverage.status).toBe("partial"); - expect(result.limitations.join(" ")).toMatch( - /incomplete|not reported as executed/u, - ); -}); + expect(result.runtime_captures).toMatchObject([ + { + kind: "electron-active", + scripts: 0, + frames: 0, + workers: 0, + scripts_complete_within_scope: false, + }, + ]); + expect(result.coverage.status).toBe("partial"); + expect(result.limitations.join(" ")).toMatch( + /incomplete|not reported as executed/u, + ); + }, +); it("commits complete Electron actions independently from selected arguments", async () => { const fixture = await applicationFixture(); diff --git a/tests/boundary/mcp/electronMcp.test.ts b/tests/boundary/mcp/electronMcp.test.ts index c9f416fe86..15dd86f751 100644 --- a/tests/boundary/mcp/electronMcp.test.ts +++ b/tests/boundary/mcp/electronMcp.test.ts @@ -1,4 +1,6 @@ import { parseEvidence } from "../../../src/domain/evidence.js"; +import { digestCanonicalValue } from "../../../src/domain/canonicalDigest.js"; +import { electronActiveObservationInputSchema } from "../../../src/domain/javascript/electronActiveObservation.js"; import { ok as resultOk, err } from "../../../src/domain/result.js"; import { mkdir, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { basename, join } from "node:path"; @@ -419,7 +421,8 @@ it("exposes endpoint-scoped Electron discovery and inspection as Evidence", asyn }); }, 20_000); -it("runs active Electron scenarios with selected paths and inferred working directory", async () => { +/** Create the selected-path aliases and retained IPC-gap capture for the public workflow. */ +const activeElectronScenarioFixture = async () => { const root = await createTestTempDirectory("rea-electron-active-mcp-"); temporary.push(root); const applicationPath = join(root, "main.js"); @@ -436,7 +439,6 @@ it("runs active Electron scenarios with selected paths and inferred working dire "rea-electron-working-directory-", ); temporary.push(workingDirectory); - const capturedInputs: unknown[] = []; const activeResult = createElectronActiveObservationFixtureResult(applicationPath); const capturedEvent = activeResult.timeline.events[0]; @@ -459,6 +461,13 @@ it("runs active Electron scenarios with selected paths and inferred working dire activeResult.retention.estimated_retained_bytes = 1_536; activeResult.retention.observed_runtime = 2; activeResult.coverage.observed_event_families.push("ipc-observation-gap"); + return { root, applicationPath, aliasedRoot, workingDirectory, activeResult }; +}; + +it("runs active Electron scenarios with selected paths and inferred working directory", async () => { + const { root, applicationPath, aliasedRoot, workingDirectory, activeResult } = + await activeElectronScenarioFixture(); + const capturedInputs: unknown[] = []; const provider: ElectronActiveObservationPort = { identity: () => ({ id: "test-electron-active", @@ -539,6 +548,24 @@ it("runs active Electron scenarios with selected paths and inferred working dire application_path: applicationPath, application_root: workingDirectory, }); + const capturedEvidence = parseEvidence(captured.structuredContent); + expect(capturedEvidence.parameters).toMatchObject({ + executable_path: process.execPath, + application_path: join(aliasedRoot, "main.js"), + application_root: workingDirectory, + }); + expect(capturedEvidence.normalized_result).toMatchObject({ + application: { application_path: applicationPath }, + }); + const { scenario_sha256, resolved_paths, ...selectedScenario } = + capturedEvidence.parameters; + expect(resolved_paths).toMatchObject({ + application_path: applicationPath, + application_root: workingDirectory, + }); + expect(scenario_sha256).toBe( + digestCanonicalValue(selectedScenario, "Electron scenario"), + ); const inferredRoot = await client.callTool({ name: "capture_electron_scenario", arguments: { @@ -552,6 +579,18 @@ it("runs active Electron scenarios with selected paths and inferred working dire application_path: applicationPath, application_root: root, }); + const inferredEvidence = parseEvidence(inferredRoot.structuredContent); + expect(inferredEvidence.parameters).not.toHaveProperty("application_root"); + expect(inferredEvidence.parameters.scenario_sha256).toBe( + digestCanonicalValue( + electronActiveObservationInputSchema.parse({ + executable_path: process.execPath, + application_path: applicationPath, + actions: [], + }), + "Electron scenario", + ), + ); expect(JSON.stringify(captured.structuredContent)).toContain("submit-secret"); expect(JSON.stringify(captured.structuredContent)).toContain("super-secret"); expect(captured.structuredContent).toMatchObject({ From 8c8a2752c0bcee5c07042a7ee45dba31597641bf Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:02:51 +0800 Subject: [PATCH 25/31] refactor(artifacts): retain admitted PyInstaller cookie facts --- src/artifacts/ArtifactEmbeddedFormatProbes.ts | 19 +-- src/artifacts/PyInstallerArtifactReader.ts | 19 ++- src/artifacts/createArtifactReader.ts | 20 ++- .../extraction/ArtifactExtraction.test.ts | 2 +- .../extraction/ArtifactExtraction.ts | 2 +- src/artifacts/inventory/classify.ts | 11 +- src/artifacts/inventory/reader.ts | 6 +- src/artifacts/pyinstaller/CArchiveLayout.ts | 10 -- .../pyinstaller/PyInstallerAdmission.test.ts | 118 ++++++++++++++++++ .../pyinstaller/PyInstallerAdmission.ts | 9 ++ ...tifactProvider.extractionAdmission.test.ts | 9 +- 11 files changed, 194 insertions(+), 31 deletions(-) create mode 100644 src/artifacts/pyinstaller/PyInstallerAdmission.test.ts create mode 100644 src/artifacts/pyinstaller/PyInstallerAdmission.ts diff --git a/src/artifacts/ArtifactEmbeddedFormatProbes.ts b/src/artifacts/ArtifactEmbeddedFormatProbes.ts index c638b2d9c0..173de825df 100644 --- a/src/artifacts/ArtifactEmbeddedFormatProbes.ts +++ b/src/artifacts/ArtifactEmbeddedFormatProbes.ts @@ -13,14 +13,17 @@ import { locateNodeSea, type NodeSeaLocation, } from "./nodeSea/NodeSeaLocation.js"; -import { containsPyInstallerArchive } from "./pyinstaller/CArchiveLayout.js"; +import { + findPyInstallerCookie, + type PyInstallerCookie, +} from "./pyinstaller/CArchiveLayout.js"; /** Completed observations only; null means examined absence, omission means unexamined. */ export interface ArtifactEmbeddedFormatProbeFacts { readonly sourceState: RegularFileState; readonly godotPck?: GodotPckLocation | null; readonly nodeSea?: NodeSeaLocation | null; - readonly pyInstaller?: boolean; + readonly pyInstaller?: PyInstallerCookie | null; } /** Share format observations only across admissions of the same unchanged regular file. */ @@ -72,17 +75,19 @@ export class ArtifactEmbeddedFormatProbes { return location; } - async pyInstaller(): Promise { + async pyInstaller(): Promise { abortIfNeeded(this.signal); - if (this.#facts.pyInstaller !== undefined) return this.#facts.pyInstaller; - const found = await containsPyInstallerArchive( + if (this.#facts.pyInstaller !== undefined) + return this.#facts.pyInstaller ?? undefined; + const found = await findPyInstallerCookie( this.source.handle, this.source.initial.size, this.signal, ); abortIfNeeded(this.signal); - this.#facts = { ...this.#facts, pyInstaller: found }; - return found; + const cookie = found?.ok === true ? found.value : undefined; + this.#facts = { ...this.#facts, pyInstaller: cookie ?? null }; + return cookie; } completedFacts(): ArtifactEmbeddedFormatProbeFacts { diff --git a/src/artifacts/PyInstallerArtifactReader.ts b/src/artifacts/PyInstallerArtifactReader.ts index c2c15e1f05..aaf74fc3af 100644 --- a/src/artifacts/PyInstallerArtifactReader.ts +++ b/src/artifacts/PyInstallerArtifactReader.ts @@ -1,3 +1,4 @@ +import type { PyInstallerAdmission } from "./pyinstaller/PyInstallerAdmission.js"; import type { Readable } from "node:stream"; import { finished } from "node:stream/promises"; import { setImmediate } from "node:timers/promises"; @@ -110,6 +111,7 @@ interface LoadedArchive { export class PyInstallerArtifactReader implements ArtifactReader { readonly format = "pyinstaller" as const; readonly #source: ArtifactFileSource; + #admission: PyInstallerAdmission | undefined; #closed = false; readonly #scope = new ArtifactResourceScope(); readonly #cleanup = new ArtifactResourceScope(); @@ -124,8 +126,16 @@ export class PyInstallerArtifactReader implements ArtifactReader { readonly #members = new Map(); readonly #openedPyzSizes = new Map(); - constructor(path: string, source?: StableRegularFileDescriptor) { - this.#source = new ArtifactFileSource(path, "PyInstaller", source); + constructor( + path: string, + source?: StableRegularFileDescriptor | PyInstallerAdmission, + ) { + if (source !== undefined && "kind" in source) { + this.#admission = source; + this.#source = new ArtifactFileSource(path, "PyInstaller", source.source); + } else { + this.#source = new ArtifactFileSource(path, "PyInstaller", source); + } this.#cleanup.retain({ kind: "lease", resource: "PyInstaller private PYZ images", @@ -287,6 +297,7 @@ export class PyInstallerArtifactReader implements ArtifactReader { await this.#cleanup.close(); this.#closed = true; this.#loaded = undefined; + this.#admission = undefined; this.#members.clear(); this.#openedPyzSizes.clear(); } @@ -313,7 +324,8 @@ export class PyInstallerArtifactReader implements ArtifactReader { const { handle, initial } = await this.#source.readSelectedPath(); const size = initial.size; const pathDialect = await carrierPathDialect(handle, size, signal); - const cookie = await this.#cookie(handle, size, signal); + const cookie = + this.#admission?.cookie ?? (await this.#cookie(handle, size, signal)); this.#budget = artifactExpansionBudgetBytes(cookie.packageLength); const toc = await this.#toc(handle, cookie, signal); for (const entry of toc) { @@ -339,6 +351,7 @@ export class PyInstallerArtifactReader implements ArtifactReader { if (member.read?.kind === "carchive") this.#charge(member.read.header?.length ?? 0); await this.#source.readSelectedPath(); + this.#admission = undefined; this.#metadata = cookieMetadata(cookie, toc, encrypted); let opaqueImages = 0; for (const image of images.values()) diff --git a/src/artifacts/createArtifactReader.ts b/src/artifacts/createArtifactReader.ts index 96bf9888f7..2645156864 100644 --- a/src/artifacts/createArtifactReader.ts +++ b/src/artifacts/createArtifactReader.ts @@ -1,3 +1,4 @@ +import type { PyInstallerAdmission } from "./pyinstaller/PyInstallerAdmission.js"; import type { NodeSeaAdmission } from "./nodeSea/NodeSeaAdmission.js"; import type { AsarHeaderAdmission } from "../config/asarHeaderAdmission.js"; import { AsarArtifactReader } from "./AsarArtifactReader.js"; @@ -33,6 +34,7 @@ interface ArtifactReaderConstruction { readonly admission?: | StableRegularFileDescriptor | NodeSeaAdmission + | PyInstallerAdmission | undefined; readonly asarHeaderAdmission?: AsarHeaderAdmission | undefined; /** Caller-selected root spelling; only ASAR companion lookup consumes this. */ @@ -75,9 +77,23 @@ export function createArtifactReader( : { selectedPath: options.selectedPath }), }); case "pyinstaller": - return new PyInstallerArtifactReader(path, rootSource); + return new PyInstallerArtifactReader( + path, + admission !== undefined && + "kind" in admission && + admission.kind === "pyinstaller" + ? admission + : rootSource, + ); case "node-sea": - return new NodeSeaArtifactReader(path, admission); + return new NodeSeaArtifactReader( + path, + admission !== undefined && + "kind" in admission && + admission.kind === "pyinstaller" + ? rootSource + : admission, + ); case "godot-pck": return new GodotPckArtifactReader(path, rootSource); case "mach-o-universal": diff --git a/src/artifacts/extraction/ArtifactExtraction.test.ts b/src/artifacts/extraction/ArtifactExtraction.test.ts index 51c39ce3e3..c4250d3b1a 100644 --- a/src/artifacts/extraction/ArtifactExtraction.test.ts +++ b/src/artifacts/extraction/ArtifactExtraction.test.ts @@ -375,7 +375,7 @@ it.each([false, true] as const)( vi.mock("../pyinstaller/CArchiveLayout.js", async (importOriginal) => ({ ...(await importOriginal()), - containsPyInstallerArchive: () => + findPyInstallerCookie: () => Promise.reject(new ArtifactReaderFailure("format", "detection failed")), })); diff --git a/src/artifacts/extraction/ArtifactExtraction.ts b/src/artifacts/extraction/ArtifactExtraction.ts index 047dd9956b..eea3bfbc2a 100644 --- a/src/artifacts/extraction/ArtifactExtraction.ts +++ b/src/artifacts/extraction/ArtifactExtraction.ts @@ -603,7 +603,7 @@ const requireExtractableFormat = async ( (await probes.godotPck()) !== undefined || ((format === "elf" || format === "pe" || format === "mach-o") && ((await probes.nodeSea()) !== undefined || - (await probes.pyInstaller()))); + (await probes.pyInstaller()) !== undefined)); if (pack && input.expectedRootSha256 !== undefined) { const digest = await hashStableRootArtifactHandle( path, diff --git a/src/artifacts/inventory/classify.ts b/src/artifacts/inventory/classify.ts index 55ab3a3171..5b7c8ca33a 100644 --- a/src/artifacts/inventory/classify.ts +++ b/src/artifacts/inventory/classify.ts @@ -1,3 +1,4 @@ +import type { PyInstallerAdmission } from "../pyinstaller/PyInstallerAdmission.js"; import { assertArtifactSelectedSource } from "../ArtifactSelectedSource.js"; import { artifactNameForClassification } from "../../domain/artifactNameSelection.js"; import type { NodeSeaAdmission } from "../nodeSea/NodeSeaAdmission.js"; @@ -51,6 +52,7 @@ type RootInventoryClassification = RootClassification & { readonly readerAdmission: | StableRegularFileDescriptor | NodeSeaAdmission + | PyInstallerAdmission | undefined; }; @@ -202,7 +204,11 @@ const classifyRootFormat = async ( signal?: AbortSignal, asarHeaderAdmission?: AsarHeaderAdmission, selectedPath?: string, -): Promise => { +): Promise< + | ArtifactOccurrence["artifact_format"] + | NodeSeaAdmission + | PyInstallerAdmission +> => { const { handle } = source; const probe = createArtifactClassificationProbe(asarHeaderAdmission); const chunk = Buffer.alloc(64 * 1024); @@ -292,7 +298,8 @@ const classifyRootFormat = async ( } const packaged = await embeddedProbes.pyInstaller(); abortIfNeeded(signal); - if (packaged) return "pyinstaller"; + if (packaged !== undefined) + return { kind: "pyinstaller", source, cookie: packaged }; } return format; }; diff --git a/src/artifacts/inventory/reader.ts b/src/artifacts/inventory/reader.ts index 9cf3cfe18e..a0f15578c2 100644 --- a/src/artifacts/inventory/reader.ts +++ b/src/artifacts/inventory/reader.ts @@ -1,3 +1,4 @@ +import type { PyInstallerAdmission } from "../pyinstaller/PyInstallerAdmission.js"; import type { NodeSeaAdmission } from "../nodeSea/NodeSeaAdmission.js"; import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js"; import type { @@ -19,7 +20,10 @@ export const createReader = ( path: string, format: ArtifactOccurrence["artifact_format"], environment: Readonly, - readerAdmission?: StableRegularFileDescriptor | NodeSeaAdmission, + readerAdmission?: + | StableRegularFileDescriptor + | NodeSeaAdmission + | PyInstallerAdmission, asarHeaderAdmission?: AsarHeaderAdmission, selectedPath?: string, ): ArtifactReader | undefined => { diff --git a/src/artifacts/pyinstaller/CArchiveLayout.ts b/src/artifacts/pyinstaller/CArchiveLayout.ts index b1549d44c9..539e27b7cd 100644 --- a/src/artifacts/pyinstaller/CArchiveLayout.ts +++ b/src/artifacts/pyinstaller/CArchiveLayout.ts @@ -83,16 +83,6 @@ export const isCArchiveDeclaration = (entry: CArchiveEntry): boolean => export const isCArchivePythonModule = (entry: CArchiveEntry): boolean => entry.role === "module" || entry.role === "package"; -/** True when a native executable has a structurally valid CArchive cookie. */ -export const containsPyInstallerArchive = async ( - handle: FileHandle, - fileSize: number, - signal?: AbortSignal, -): Promise => { - const found = await findPyInstallerCookie(handle, fileSize, signal); - return found?.ok === true; -}; - /** Locate a structurally valid cookie. Invalid magic copies are skipped. */ export const findPyInstallerCookie = async ( handle: FileHandle, diff --git a/src/artifacts/pyinstaller/PyInstallerAdmission.test.ts b/src/artifacts/pyinstaller/PyInstallerAdmission.test.ts new file mode 100644 index 0000000000..04d7ec7c10 --- /dev/null +++ b/src/artifacts/pyinstaller/PyInstallerAdmission.test.ts @@ -0,0 +1,118 @@ +import { appendFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it, vi } from "vitest"; +import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; +import { withOwnedRegularFile } from "../../filesystem/OwnedRegularFile.js"; +import { ArtifactEmbeddedFormatProbes } from "../ArtifactEmbeddedFormatProbes.js"; +import { ArtifactResourceScope } from "../ArtifactResourceScope.js"; +import { inventoryArtifact } from "../inventory/ArtifactInventory.js"; +import { PyInstallerArtifactReader } from "../PyInstallerArtifactReader.js"; +import { cArchiveExecutable } from "./CArchive.fixture.js"; +import * as layout from "./CArchiveLayout.js"; + +const fixture = () => + cArchiveExecutable({ + layout: "2.1", + pythonVersion: 314, + pythonLibrary: "libpython3.14.so", + members: [ + { + name: "payload", + typeCode: "x", + payload: Buffer.from("fixture"), + compress: false, + }, + ], + }); + +it("inventories members using the cookie discovered by root classification", async () => { + const root = await createTestTempDirectory( + "rea-pyinstaller-cookie-inventory-", + ); + const path = join(root, "app"); + await writeFile(path, fixture()); + const locate = vi.spyOn(layout, "findPyInstallerCookie"); + const resourceScope = new ArtifactResourceScope(); + try { + const result = await inventoryArtifact(path, { + environment: {}, + resourceScope, + }); + expect(result.manifest.root_format).toBe("pyinstaller"); + expect( + result.occurrences.some( + (entry) => + entry.logical_path === "payload" && entry.hash_status === "verified", + ), + ).toBe(true); + expect(locate).toHaveBeenCalledTimes(1); + } finally { + await resourceScope.close(); + locate.mockRestore(); + } +}); + +it("rejects changed source before consuming an admitted cookie and leaves the borrowed descriptor open", async () => { + const root = await createTestTempDirectory("rea-pyinstaller-cookie-change-"); + const path = join(root, "app"); + await writeFile(path, fixture()); + await withOwnedRegularFile(path, {}, async (source) => { + const cookie = await new ArtifactEmbeddedFormatProbes( + path, + source, + ).pyInstaller(); + if (cookie === undefined) throw new Error("Fixture cookie was not found"); + const reader = new PyInstallerArtifactReader(path, { + kind: "pyinstaller", + source, + cookie, + }); + await appendFile(path, "changed"); + try { + await expect( + (async () => { + for await (const entry of reader.entries()) + throw new Error(`Changed source exposed member ${entry.path}`); + })(), + ).rejects.toMatchObject({ reason: "integrity" }); + } finally { + await reader.close(); + } + await expect(source.handle.stat()).resolves.toMatchObject({ + size: fixture().length + 7, + }); + }); +}); + +it("retains completed cookie facts across unchanged admissions and honors cached cancellation", async () => { + const root = await createTestTempDirectory("rea-pyinstaller-cookie-cache-"); + const path = join(root, "app"); + await writeFile(path, fixture()); + const locate = vi.spyOn(layout, "findPyInstallerCookie"); + try { + await withOwnedRegularFile(path, {}, async (source) => { + const controller = new AbortController(); + const probes = new ArtifactEmbeddedFormatProbes( + path, + source, + controller.signal, + ); + const cookie = await probes.pyInstaller(); + expect(cookie).toMatchObject({ layout: "2.1", pythonVersion: 314 }); + const resumed = new ArtifactEmbeddedFormatProbes( + path, + source, + controller.signal, + probes.completedFacts(), + ); + await expect(resumed.pyInstaller()).resolves.toBe(cookie); + controller.abort(new Error("cancel cached cookie")); + await expect(resumed.pyInstaller()).rejects.toMatchObject({ + reason: "cancelled", + }); + expect(locate).toHaveBeenCalledTimes(1); + }); + } finally { + locate.mockRestore(); + } +}); diff --git a/src/artifacts/pyinstaller/PyInstallerAdmission.ts b/src/artifacts/pyinstaller/PyInstallerAdmission.ts new file mode 100644 index 0000000000..b4f8b4d783 --- /dev/null +++ b/src/artifacts/pyinstaller/PyInstallerAdmission.ts @@ -0,0 +1,9 @@ +import type { StableRegularFileDescriptor } from "../../filesystem/RegularFile.js"; +import type { PyInstallerCookie } from "./CArchiveLayout.js"; + +/** Validated cookie facts borrowed with the descriptor that produced them. */ +export interface PyInstallerAdmission { + readonly kind: "pyinstaller"; + readonly source: StableRegularFileDescriptor; + readonly cookie: PyInstallerCookie; +} diff --git a/tests/boundary/providers/native/artifactProvider.extractionAdmission.test.ts b/tests/boundary/providers/native/artifactProvider.extractionAdmission.test.ts index 45b3636190..6ceda198fb 100644 --- a/tests/boundary/providers/native/artifactProvider.extractionAdmission.test.ts +++ b/tests/boundary/providers/native/artifactProvider.extractionAdmission.test.ts @@ -266,10 +266,7 @@ it.each([ await writeFile(source, bytes()); const pck = vi.spyOn(pckLocation, "locateGodotPck"); const sea = vi.spyOn(seaLocation, "locateNodeSea"); - const pyInstaller = vi.spyOn( - pyInstallerLocation, - "containsPyInstallerArchive", - ); + const pyInstaller = vi.spyOn(pyInstallerLocation, "findPyInstallerCookie"); const hashRoot = rootHash.hashStableRootArtifactHandle; let probesBeforeHash: number[] | undefined; const hash = vi @@ -310,6 +307,10 @@ it.each([ expect(extracted.manifest.root_format).toBe(format); expect(extracted.artifacts.length).toBeGreaterThan(0); expect(probesBeforeHash).toEqual(probes); + // Preflight's cookie also supplies inventory. Extraction opens its + // own reader and establishes that reader's cookie independently. + if (format === "pyinstaller") + expect(pyInstaller).toHaveBeenCalledTimes(2); }, ); } finally { From 69b328eec8904ecd40eb713b09d0cd523951cdc0 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:04:29 +0800 Subject: [PATCH 26/31] test(browser): verify out-of-order response correlation --- tests/boundary/browser/cdpConnection.test.ts | 24 +++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/tests/boundary/browser/cdpConnection.test.ts b/tests/boundary/browser/cdpConnection.test.ts index 2ec08c8e49..f57a1ad702 100644 --- a/tests/boundary/browser/cdpConnection.test.ts +++ b/tests/boundary/browser/cdpConnection.test.ts @@ -107,7 +107,7 @@ describe("CDP connection admission cancellation", () => { describe("CDP connection response correlation", () => { it("correlates concurrent command responses over a real WebSocket", async () => { - const browser = await startFakeCdpBrowser(); + const browser = await startFakeCdpBrowser({ hangOnMethod: "Page.enable" }); browsers.push(browser); const connection = await CdpConnection.connect( browser.browserWebSocketUrl, @@ -124,6 +124,28 @@ describe("CDP connection response correlation", () => { }); browser.emitRawMessage(JSON.stringify({ id: 999 })); await expect(connection.send("Runtime.enable")).resolves.toEqual({}); + const first = connection.send("Page.enable", { selected: "first" }); + const second = connection.send("Page.enable", { selected: "second" }); + const replies = Promise.all([first, second]); + await vi.waitFor(() => expect(browser.commands).toHaveLength(5)); + const firstCommand = browser.commands[3]; + const secondCommand = browser.commands[4]; + if (firstCommand === undefined || secondCommand === undefined) + throw new Error("Fake browser did not receive both pending commands"); + browser.emitRawMessage( + JSON.stringify({ + id: secondCommand.id, + result: { selected: "second" }, + }), + ); + browser.emitRawMessage( + JSON.stringify({ id: firstCommand.id, result: { selected: "first" } }), + ); + await expect(replies).resolves.toEqual([ + { selected: "first" }, + { selected: "second" }, + ]); + await expect(connection.send("Runtime.enable")).resolves.toEqual({}); } finally { await connection.close(); } From 5d25aa4f5213ed75d417e4fe60a18b3b32f2aabe Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:06:23 +0800 Subject: [PATCH 27/31] refactor(artifacts): retain admitted Godot pack location --- src/artifacts/GodotPckArtifactReader.test.ts | 94 +++++++++++++++----- src/artifacts/GodotPckArtifactReader.ts | 33 +++++-- src/artifacts/GodotPckLocation.ts | 8 ++ src/artifacts/createArtifactReader.ts | 17 +++- src/artifacts/inventory/classify.ts | 6 +- src/artifacts/inventory/reader.ts | 4 +- 6 files changed, 130 insertions(+), 32 deletions(-) diff --git a/src/artifacts/GodotPckArtifactReader.test.ts b/src/artifacts/GodotPckArtifactReader.test.ts index 8522682f39..92c56943e4 100644 --- a/src/artifacts/GodotPckArtifactReader.test.ts +++ b/src/artifacts/GodotPckArtifactReader.test.ts @@ -17,6 +17,7 @@ import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory import { openRegularFile } from "../filesystem/RegularFile.js"; import type { ArtifactEntry } from "./ArtifactReader.js"; +import { ArtifactEmbeddedFormatProbes } from "./ArtifactEmbeddedFormatProbes.js"; import { GodotPckArtifactReader } from "./GodotPckArtifactReader.js"; const writePck = async (bytes: Buffer): Promise => { @@ -262,32 +263,83 @@ describe("GodotPckArtifactReader member streams", () => { ); }); - it("reads members from the admitted descriptor after the path is replaced", async () => { - const root = await createTestTempDirectory("rea-godot-borrowed-source-"); - const selected = join(root, "selected"); - await mkdir(selected); - const path = join(selected, "game"); - await writeFile(path, buildGodotPck(3, members)); + it("rejects stale located facts after the admitted carrier changes", async () => { + const path = await writePck(buildGodotPck(3, members)); const descriptor = await openRegularFile(path, { symlinks: "reject" }); - const { handle } = descriptor; try { - // Replace the selected directory without changing the admitted inode's - // metadata. Moving the file itself would change its validated ctime. - await rename(selected, join(root, "original")); - await mkdir(selected); - await writeFile(path, buildGodotPck(3, [{ path: "other.gd", data: "" }])); - const reader = new GodotPckArtifactReader(path, descriptor); - const entries = await list(reader); - expect(entries.map(({ path: member }) => member)).toEqual([ - "scenes/main.gd", - "project.binary", - "data/é.json", - ]); - await reader.close(); + const location = await new ArtifactEmbeddedFormatProbes( + path, + descriptor, + ).godotPck(); + if (location === undefined) throw new Error("expected a pack location"); + const reader = new GodotPckArtifactReader(path, { + kind: "godot-pck", + source: descriptor, + location, + }); + try { + await writeFile(path, Buffer.from("replaced carrier")); + await expect(list(reader)).rejects.toMatchObject({ + reason: "integrity", + }); + } finally { + await reader.close(); + } } finally { - await handle.close(); + await descriptor.handle.close(); } }); + + it.each([false, true])( + "reads located pack facts from the admitted descriptor after path replacement (embedded: %s)", + async (embedded) => { + const root = await createTestTempDirectory("rea-godot-borrowed-source-"); + const selected = join(root, "selected"); + await mkdir(selected); + const path = join(selected, "game"); + const bytes = buildGodotPck(3, members); + await writeFile(path, embedded ? embedGodotPck(bytes) : bytes); + const descriptor = await openRegularFile(path, { symlinks: "reject" }); + const { handle } = descriptor; + try { + const location = await new ArtifactEmbeddedFormatProbes( + path, + descriptor, + ).godotPck(); + if (location === undefined) throw new Error("expected a pack location"); + // Replace the selected directory without changing the admitted inode's + // metadata. Moving the file itself would change its validated ctime. + await rename(selected, join(root, "original")); + await mkdir(selected); + await writeFile( + path, + buildGodotPck(3, [{ path: "other.gd", data: "" }]), + ); + const reader = new GodotPckArtifactReader(path, { + kind: "godot-pck", + source: descriptor, + location, + }); + const cancelled = new AbortController(); + cancelled.abort(); + await expect( + reader.entries(cancelled.signal)[Symbol.asyncIterator]().next(), + ).rejects.toMatchObject({ + reason: "cancelled", + }); + const entries = await list(reader); + expect(reader.formatMetadata()?.embedded).toBe(embedded); + expect(entries.map(({ path: member }) => member)).toEqual([ + "scenes/main.gd", + "project.binary", + "data/é.json", + ]); + await reader.close(); + } finally { + await handle.close(); + } + }, + ); }); describe("Godot PCK logical-byte admission", () => { diff --git a/src/artifacts/GodotPckArtifactReader.ts b/src/artifacts/GodotPckArtifactReader.ts index a807cf556a..2901c4e837 100644 --- a/src/artifacts/GodotPckArtifactReader.ts +++ b/src/artifacts/GodotPckArtifactReader.ts @@ -13,7 +13,12 @@ import { } from "./ArtifactReader.js"; import { ArtifactResourceScope } from "./ArtifactResourceScope.js"; import { assertArtifactReadActive } from "./ArtifactFileOperation.js"; -import { locateGodotPck, readPckBytes } from "./GodotPckLocation.js"; +import { + locateGodotPck, + readPckBytes, + type GodotPckAdmission, + type GodotPckLocation, +} from "./GodotPckLocation.js"; import type { GodotPckMetadata } from "../domain/artifactFormatMetadata.js"; import { jsonValueByteLength } from "../domain/jsonValueByteLength.js"; import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js"; @@ -37,12 +42,23 @@ export class GodotPckArtifactReader implements ArtifactReader { readonly #entries = new Map(); readonly #streams = new Set(); readonly #source: ArtifactFileSource; + readonly #location: GodotPckLocation | undefined; #prepared: Promise | undefined; #header: GodotPckMetadata | undefined; #closing = false; - constructor(path: string, admitted?: StableRegularFileDescriptor) { - this.#source = new ArtifactFileSource(path, "Godot PCK", admitted); + constructor( + path: string, + admitted?: StableRegularFileDescriptor | GodotPckAdmission, + ) { + const admission = + admitted !== undefined && "kind" in admitted ? admitted : undefined; + this.#source = new ArtifactFileSource( + path, + "Godot PCK", + admitted !== undefined && "kind" in admitted ? admitted.source : admitted, + ); + this.#location = admission?.location; } formatMetadata(): GodotPckMetadata | undefined { @@ -174,7 +190,12 @@ export class GodotPckArtifactReader implements ArtifactReader { async #prepare(signal?: AbortSignal): Promise { const { handle, initial } = await this.#source.read(signal); - const header = await readGodotPckHeader(handle, initial.size, signal); + const header = await readGodotPckHeader( + handle, + initial.size, + signal, + this.#location, + ); this.#header = header; if (header.directory_status !== "available") return; const cursor = new PckCursor( @@ -298,8 +319,10 @@ const readGodotPckHeader = async ( handle: FileHandle, size: number, signal?: AbortSignal, + admittedLocation?: GodotPckLocation, ): Promise => { - const location = await locateGodotPck(handle, size, signal); + const location = + admittedLocation ?? (await locateGodotPck(handle, size, signal)); if (location === undefined) throw formatFailure("no standalone header or executable trailer"); const { start, end, embedded } = location; diff --git a/src/artifacts/GodotPckLocation.ts b/src/artifacts/GodotPckLocation.ts index d1e3e4e7a2..2588fb4f0a 100644 --- a/src/artifacts/GodotPckLocation.ts +++ b/src/artifacts/GodotPckLocation.ts @@ -1,3 +1,4 @@ +import type { StableRegularFileDescriptor } from "../filesystem/RegularFile.js"; import type { FileHandle } from "node:fs/promises"; import { hasGodotPckHeader, @@ -13,6 +14,13 @@ export interface GodotPckLocation { readonly embedded: boolean; } +/** Located pack facts borrowed with the descriptor that produced them. */ +export interface GodotPckAdmission { + readonly kind: "godot-pck"; + readonly source: StableRegularFileDescriptor; + readonly location: GodotPckLocation; +} + type DeclaredGodotPckTrailer = Extract< GodotPckTrailerFacts, { readonly state: "declared" } diff --git a/src/artifacts/createArtifactReader.ts b/src/artifacts/createArtifactReader.ts index 2645156864..9f5f302804 100644 --- a/src/artifacts/createArtifactReader.ts +++ b/src/artifacts/createArtifactReader.ts @@ -1,3 +1,4 @@ +import type { GodotPckAdmission } from "./GodotPckLocation.js"; import type { PyInstallerAdmission } from "./pyinstaller/PyInstallerAdmission.js"; import type { NodeSeaAdmission } from "./nodeSea/NodeSeaAdmission.js"; import type { AsarHeaderAdmission } from "../config/asarHeaderAdmission.js"; @@ -35,6 +36,7 @@ interface ArtifactReaderConstruction { | StableRegularFileDescriptor | NodeSeaAdmission | PyInstallerAdmission + | GodotPckAdmission | undefined; readonly asarHeaderAdmission?: AsarHeaderAdmission | undefined; /** Caller-selected root spelling; only ASAR companion lookup consumes this. */ @@ -90,12 +92,19 @@ export function createArtifactReader( path, admission !== undefined && "kind" in admission && - admission.kind === "pyinstaller" - ? rootSource - : admission, + admission.kind === "node-sea" + ? admission + : rootSource, ); case "godot-pck": - return new GodotPckArtifactReader(path, rootSource); + return new GodotPckArtifactReader( + path, + admission !== undefined && + "kind" in admission && + admission.kind === "godot-pck" + ? admission + : rootSource, + ); case "mach-o-universal": return new MachOSliceArtifactReader( path, diff --git a/src/artifacts/inventory/classify.ts b/src/artifacts/inventory/classify.ts index 5b7c8ca33a..6d7cdf89fd 100644 --- a/src/artifacts/inventory/classify.ts +++ b/src/artifacts/inventory/classify.ts @@ -1,3 +1,4 @@ +import type { GodotPckAdmission } from "../GodotPckLocation.js"; import type { PyInstallerAdmission } from "../pyinstaller/PyInstallerAdmission.js"; import { assertArtifactSelectedSource } from "../ArtifactSelectedSource.js"; import { artifactNameForClassification } from "../../domain/artifactNameSelection.js"; @@ -53,6 +54,7 @@ type RootInventoryClassification = RootClassification & { | StableRegularFileDescriptor | NodeSeaAdmission | PyInstallerAdmission + | GodotPckAdmission | undefined; }; @@ -208,6 +210,7 @@ const classifyRootFormat = async ( | ArtifactOccurrence["artifact_format"] | NodeSeaAdmission | PyInstallerAdmission + | GodotPckAdmission > => { const { handle } = source; const probe = createArtifactClassificationProbe(asarHeaderAdmission); @@ -261,7 +264,8 @@ const classifyRootFormat = async ( (await hasDiskImageSignature(archiveBytes, read)) ) return "dmg"; - if (await embeddedProbes.godotPck(evidence.prefix)) return "godot-pck"; + const pck = await embeddedProbes.godotPck(evidence.prefix); + if (pck !== undefined) return { kind: "godot-pck", source, location: pck }; const fatEvidence = await readRootFatSliceHeaders(handle, evidence, signal); const mzWindowsHeaderSignature = await readRootMzWindowsHeaderSignature( handle, diff --git a/src/artifacts/inventory/reader.ts b/src/artifacts/inventory/reader.ts index a0f15578c2..23eed04bb2 100644 --- a/src/artifacts/inventory/reader.ts +++ b/src/artifacts/inventory/reader.ts @@ -1,3 +1,4 @@ +import type { GodotPckAdmission } from "../GodotPckLocation.js"; import type { PyInstallerAdmission } from "../pyinstaller/PyInstallerAdmission.js"; import type { NodeSeaAdmission } from "../nodeSea/NodeSeaAdmission.js"; import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js"; @@ -23,7 +24,8 @@ export const createReader = ( readerAdmission?: | StableRegularFileDescriptor | NodeSeaAdmission - | PyInstallerAdmission, + | PyInstallerAdmission + | GodotPckAdmission, asarHeaderAdmission?: AsarHeaderAdmission, selectedPath?: string, ): ArtifactReader | undefined => { From 5b8e4154836c6e26f97c9cf503879fa12f097669 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:10:48 +0800 Subject: [PATCH 28/31] refactor(snapshot): centralize resource cache eligibility --- .../binary/AnalysisSnapshotCache.test.ts | 20 ++++++++ .../binary/AnalysisSnapshotCache.ts | 10 ++++ src/application/binary/BinarySession.ts | 17 +------ .../analysis-sessions/binarySession.test.ts | 49 +++++++++++++++++++ 4 files changed, 81 insertions(+), 15 deletions(-) diff --git a/src/application/binary/AnalysisSnapshotCache.test.ts b/src/application/binary/AnalysisSnapshotCache.test.ts index 93e73104ad..17fab89578 100644 --- a/src/application/binary/AnalysisSnapshotCache.test.ts +++ b/src/application/binary/AnalysisSnapshotCache.test.ts @@ -716,6 +716,26 @@ describe("analysis snapshot cache policy", () => { effects: { changesPermissions: true }, cacheable: false, }, + { + policy: "snapshot", + operation: "decode_interface_builder", + cacheable: false, + }, + { + policy: "snapshot", + operation: "inspect_asset_catalog", + cacheable: false, + }, + { + policy: "snapshot", + operation: "inspect_keyed_archive", + cacheable: false, + }, + { + policy: "snapshot", + operation: "trace_dylib_resolution", + cacheable: false, + }, { policy: "snapshot", operation: "list_documents", cacheable: false }, { policy: "snapshot", operation: "list_strings", cacheable: false }, { diff --git a/src/application/binary/AnalysisSnapshotCache.ts b/src/application/binary/AnalysisSnapshotCache.ts index 4e90c3d8e6..159139d72e 100644 --- a/src/application/binary/AnalysisSnapshotCache.ts +++ b/src/application/binary/AnalysisSnapshotCache.ts @@ -51,6 +51,15 @@ const RELATIONSHIP_OPERATIONS: ReadonlySet = new Set([ "procedure_callees", ]); +// These reports depend on selected bundle resources or loader context beyond +// the immutable executable identity carried by a binary analysis snapshot. +const RESOURCE_SCOPED_OPERATIONS: ReadonlySet = new Set([ + "decode_interface_builder", + "inspect_asset_catalog", + "inspect_keyed_archive", + "trace_dylib_resolution", +]); + const STATEFUL_OPERATIONS: ReadonlySet = new Set([ "health", "current_address", @@ -85,6 +94,7 @@ export const isSnapshotCacheable = ( parameters: Readonly>, ): descriptor is CapabilityDescriptor => !STATEFUL_OPERATIONS.has(operation) && + !RESOURCE_SCOPED_OPERATIONS.has(operation) && (!DOCUMENT_SCOPED_OPERATIONS.has(operation) || typeof parameters.document === "string") && (!CURSOR_DEFAULT_OPERATIONS.has(operation) || diff --git a/src/application/binary/BinarySession.ts b/src/application/binary/BinarySession.ts index 6627353b5a..7053d3efce 100644 --- a/src/application/binary/BinarySession.ts +++ b/src/application/binary/BinarySession.ts @@ -1224,12 +1224,7 @@ export class BinarySession unboundOperationError: (operation, route) => this.#providerRouter.unboundOperationError(operation, route), lookupSnapshot: (target, profile, operation, parameters) => - operation === "decode_interface_builder" || - operation === "inspect_asset_catalog" || - operation === "inspect_keyed_archive" || - operation === "trace_dylib_resolution" - ? undefined - : this.lookupSnapshot(target, profile, operation, parameters), + this.lookupSnapshot(target, profile, operation, parameters), }); if (!prepared.ok) return prepared; const { active, capability, profile, cacheable, cached } = prepared.value; @@ -1275,15 +1270,7 @@ export class BinarySession ); if (!sourcesRecorded.ok) return sourcesRecorded; } - if ( - execution.ok && - cacheable && - profile !== undefined && - name !== "decode_interface_builder" && - name !== "inspect_asset_catalog" && - name !== "inspect_keyed_archive" && - name !== "trace_dylib_resolution" - ) { + if (execution.ok && cacheable && profile !== undefined) { const evidence = createAnalysisExecutionEvidence( name, arguments_, diff --git a/tests/composition/analysis-sessions/binarySession.test.ts b/tests/composition/analysis-sessions/binarySession.test.ts index b3442bd31e..64b9acac88 100644 --- a/tests/composition/analysis-sessions/binarySession.test.ts +++ b/tests/composition/analysis-sessions/binarySession.test.ts @@ -968,6 +968,55 @@ describe("snapshot switch rollback", () => { }); describe("snapshot cache eligibility", () => { + it.each([ + "decode_interface_builder", + "inspect_asset_catalog", + "inspect_keyed_archive", + "trace_dylib_resolution", + ] as const)( + "reobserves resource-scoped %s without executable snapshot Evidence", + async (operation) => { + const [target] = await createBinarySessionTargets(); + const provider = createCacheProvider([]); + const [baseCapability] = provider.capabilities(); + if (baseCapability === undefined) + throw new Error("Expected fixture capability"); + provider.capabilities = () => [ + { ...baseCapability, operation, cachePolicy: "snapshot" }, + ]; + let observations = 0; + provider.createClient = () => ({ + execute: async (name) => + resultOk( + createAnalysisExecution( + name === "health" ? null : ++observations, + provider.identity(), + ), + ), + close: async () => resultOk(null), + }); + const session = createTestBinarySession(provider); + try { + expect((await session.open(target)).ok).toBe(true); + for (const observation of [1, 2]) { + const result = await session.execute(operation, {}); + expect(result).toMatchObject({ + ok: true, + value: { result: observation }, + }); + if (result.ok) expect(result.value.evidenceId).toBeUndefined(); + } + expect(session.exportEvidenceBundle().records).toEqual([]); + expect(session.exportAnalysisSnapshot()).toMatchObject({ + ok: true, + value: { entries: [] }, + }); + } finally { + await session.close(); + } + }, + ); + it.each([false, true])( "retains source Evidence independently of snapshot eligibility (live=%s)", async (live) => { From ee654e98a6fbde73072fb35d915f46c87486b0ce Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:10:55 +0800 Subject: [PATCH 29/31] refactor(artifacts): retain PyInstaller carrier dialect --- src/artifacts/PyInstallerArtifactReader.ts | 4 +- src/artifacts/inventory/classify.ts | 9 +- .../pyinstaller/PyInstallerAdmission.test.ts | 108 +++++++++++++++++- .../pyinstaller/PyInstallerAdmission.ts | 3 + 4 files changed, 121 insertions(+), 3 deletions(-) diff --git a/src/artifacts/PyInstallerArtifactReader.ts b/src/artifacts/PyInstallerArtifactReader.ts index aaf74fc3af..821bac62c1 100644 --- a/src/artifacts/PyInstallerArtifactReader.ts +++ b/src/artifacts/PyInstallerArtifactReader.ts @@ -323,7 +323,9 @@ export class PyInstallerArtifactReader implements ArtifactReader { await this.#openHandle(signal); const { handle, initial } = await this.#source.readSelectedPath(); const size = initial.size; - const pathDialect = await carrierPathDialect(handle, size, signal); + const pathDialect = + this.#admission?.carrierPathDialect ?? + (await carrierPathDialect(handle, size, signal)); const cookie = this.#admission?.cookie ?? (await this.#cookie(handle, size, signal)); this.#budget = artifactExpansionBudgetBytes(cookie.packageLength); diff --git a/src/artifacts/inventory/classify.ts b/src/artifacts/inventory/classify.ts index 6d7cdf89fd..a48c27aa68 100644 --- a/src/artifacts/inventory/classify.ts +++ b/src/artifacts/inventory/classify.ts @@ -302,8 +302,15 @@ const classifyRootFormat = async ( } const packaged = await embeddedProbes.pyInstaller(); abortIfNeeded(signal); + // A valid cookie also proves enough bytes for a thin Mach-O identity; + // PE and universal Mach-O classification already require stronger proof. if (packaged !== undefined) - return { kind: "pyinstaller", source, cookie: packaged }; + return { + kind: "pyinstaller", + source, + cookie: packaged, + carrierPathDialect: format === "pe" ? "windows" : "posix", + }; } return format; }; diff --git a/src/artifacts/pyinstaller/PyInstallerAdmission.test.ts b/src/artifacts/pyinstaller/PyInstallerAdmission.test.ts index 04d7ec7c10..d5353ab328 100644 --- a/src/artifacts/pyinstaller/PyInstallerAdmission.test.ts +++ b/src/artifacts/pyinstaller/PyInstallerAdmission.test.ts @@ -1,4 +1,4 @@ -import { appendFile, writeFile } from "node:fs/promises"; +import { appendFile, lstat, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it, vi } from "vitest"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; @@ -6,6 +6,7 @@ import { withOwnedRegularFile } from "../../filesystem/OwnedRegularFile.js"; import { ArtifactEmbeddedFormatProbes } from "../ArtifactEmbeddedFormatProbes.js"; import { ArtifactResourceScope } from "../ArtifactResourceScope.js"; import { inventoryArtifact } from "../inventory/ArtifactInventory.js"; +import { classifyAndHashRootForInventory } from "../inventory/classify.js"; import { PyInstallerArtifactReader } from "../PyInstallerArtifactReader.js"; import { cArchiveExecutable } from "./CArchive.fixture.js"; import * as layout from "./CArchiveLayout.js"; @@ -25,6 +26,110 @@ const fixture = () => ], }); +it.each([ + { + carrier: "elf", + offset: undefined, + dialect: "posix", + path: String.raw`pkg\data.bin`, + }, + { carrier: "pe", offset: 64, dialect: "windows", path: "pkg/data.bin" }, + { carrier: "pe", offset: 8190, dialect: "windows", path: "pkg/data.bin" }, +] as const)( + "retains $carrier carrier dialect at offset $offset through member projection", + async ({ carrier, offset, dialect, path: memberPath }) => { + const root = await createTestTempDirectory( + "rea-pyinstaller-carrier-admission-", + ); + const path = join(root, "app"); + const sourceName = String.raw`pkg\data.bin`; + await writeFile( + path, + cArchiveExecutable({ + carrier, + ...(offset === undefined ? {} : { peHeaderOffset: offset }), + layout: "2.1", + pythonVersion: 314, + members: [ + { + name: sourceName, + typeCode: "x", + payload: Buffer.from("fixture"), + compress: false, + }, + ], + }), + ); + const resourceScope = new ArtifactResourceScope(); + try { + const classified = await classifyAndHashRootForInventory( + path, + false, + await lstat(path), + { resourceScope }, + ); + const admission = classified.readerAdmission; + if ( + admission === undefined || + !("kind" in admission) || + admission.kind !== "pyinstaller" + ) + throw new Error("Fixture did not produce a PyInstaller admission"); + expect(admission.carrierPathDialect).toBe(dialect); + const reader = new PyInstallerArtifactReader(path, admission); + try { + if (classified.digest === null) + throw new Error("Missing carrier digest"); + await reader.prepareContainer(classified.digest.sha256); + const entries = []; + for await (const entry of reader.entries()) entries.push(entry); + expect(entries).toEqual([ + expect.objectContaining({ + path: memberPath, + formatMetadata: expect.objectContaining({ + source_name: sourceName, + }), + }), + ]); + } finally { + await reader.close(); + } + } finally { + await resourceScope.close(); + } + }, +); + +it("preserves backslashes when a standalone archive has no recognized carrier", async () => { + const root = await createTestTempDirectory( + "rea-pyinstaller-unknown-carrier-", + ); + const path = join(root, "app"); + const sourceName = String.raw`pkg\data.bin`; + const bytes = cArchiveExecutable({ + layout: "2.1", + pythonVersion: 314, + members: [ + { + name: sourceName, + typeCode: "x", + payload: Buffer.from("fixture"), + compress: false, + }, + ], + }); + bytes.fill(0, 0, 64); + await writeFile(path, bytes); + const reader = new PyInstallerArtifactReader(path); + try { + const entries = []; + for await (const entry of reader.entries()) entries.push(entry); + expect(entries.map((entry) => entry.path)).toEqual([sourceName]); + } finally { + await reader.close(); + } +}); + it("inventories members using the cookie discovered by root classification", async () => { const root = await createTestTempDirectory( "rea-pyinstaller-cookie-inventory-", @@ -66,6 +171,7 @@ it("rejects changed source before consuming an admitted cookie and leaves the bo kind: "pyinstaller", source, cookie, + carrierPathDialect: "posix", }); await appendFile(path, "changed"); try { diff --git a/src/artifacts/pyinstaller/PyInstallerAdmission.ts b/src/artifacts/pyinstaller/PyInstallerAdmission.ts index b4f8b4d783..ad30048ac6 100644 --- a/src/artifacts/pyinstaller/PyInstallerAdmission.ts +++ b/src/artifacts/pyinstaller/PyInstallerAdmission.ts @@ -1,9 +1,12 @@ import type { StableRegularFileDescriptor } from "../../filesystem/RegularFile.js"; import type { PyInstallerCookie } from "./CArchiveLayout.js"; +import type { CarrierPathDialect } from "./CArchiveMembers.js"; /** Validated cookie facts borrowed with the descriptor that produced them. */ export interface PyInstallerAdmission { readonly kind: "pyinstaller"; readonly source: StableRegularFileDescriptor; readonly cookie: PyInstallerCookie; + /** Carrier interpretation retained by the classifier that admitted this source. */ + readonly carrierPathDialect?: CarrierPathDialect; } From 60822e07023d08aac2545bf4353d375bc4f7093c Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:11:08 +0800 Subject: [PATCH 30/31] fix(doctor): preserve Homebrew application paths --- src/application/Doctor.ts | 7 +++- src/application/homebrew.ts | 6 ++- .../doctorSystemHostFilesystem.test.ts | 39 ++++++++++++++++++- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts index 355d24ba58..c2ed4d334c 100644 --- a/src/application/Doctor.ts +++ b/src/application/Doctor.ts @@ -17,7 +17,7 @@ import { } from "../config/parseConfig.js"; import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; -import { join } from "node:path"; +import { appendCallerPath } from "../filesystem/callerPath.js"; import { analysisErrorRemediationAction } from "../domain/analysisErrorPresentation.js"; import { BinaryTargetError } from "../domain/configurationErrors.js"; @@ -708,7 +708,10 @@ export const systemDoctorHost = ( ); return appdir === undefined ? undefined - : join(appdir, "Hopper Disassembler.app/Contents/MacOS/hopper"); + : appendCallerPath( + appdir, + "Hopper Disassembler.app/Contents/MacOS/hopper", + ); }); }, manualHopperPaths: () => diff --git a/src/application/homebrew.ts b/src/application/homebrew.ts index ae87108394..ef1c5d0f92 100644 --- a/src/application/homebrew.ts +++ b/src/application/homebrew.ts @@ -3,6 +3,8 @@ import { join } from "node:path"; import { z } from "zod"; +import { appendCallerPath } from "../filesystem/callerPath.js"; + /** Standard executable names and locations used to discover Homebrew. */ const HOMEBREW_COMMANDS = [ "brew", @@ -73,5 +75,7 @@ export const installedCaskAppdir = async ( config.data.env?.appdir ?? config.data.default?.appdir; if (appdir === undefined) return undefined; - return appdir.startsWith("~/") ? join(home, appdir.slice(2)) : appdir; + return appdir.startsWith("~/") + ? appendCallerPath(home, appdir.slice(2)) + : appdir; }; diff --git a/tests/boundary/filesystem/doctorSystemHostFilesystem.test.ts b/tests/boundary/filesystem/doctorSystemHostFilesystem.test.ts index 201a4a568f..c69db398e8 100644 --- a/tests/boundary/filesystem/doctorSystemHostFilesystem.test.ts +++ b/tests/boundary/filesystem/doctorSystemHostFilesystem.test.ts @@ -1,4 +1,4 @@ -import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { access, mkdir, readFile, symlink, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { expect, it } from "vitest"; @@ -278,6 +278,43 @@ it.each([ }, ); +it.skipIf(process.platform === "win32").each(["absolute", "home"])( + "preserves symlink traversal in the cask appdir (%s)", + async (spelling) => { + const root = await createTestTempDirectory("rea-doctor-cask-path-"); + const home = join(root, "home"); + const caskroom = join(root, "Caskroom"); + const metadata = join(caskroom, "hopper-disassembler", ".metadata"); + const target = join(root, "selected", "nested"); + await mkdir(home, { recursive: true }); + await mkdir(target, { recursive: true }); + await mkdir(metadata, { recursive: true }); + await symlink(target, join(home, "apps")); + const suffix = "Hopper Disassembler.app/Contents/MacOS/hopper"; + const selected = join(root, "selected", suffix); + await mkdir(dirname(selected), { recursive: true }); + await writeFile(selected, "selected Hopper"); + const appdir = spelling === "home" ? "~/apps/.." : `${home}/apps/..`; + await writeFile( + join(metadata, "config.json"), + JSON.stringify({ explicit: { appdir } }), + ); + const host = systemDoctorHost({ + platform: "darwin", + environment: { HOME: home }, + execFileOutput: async () => ({ + stdout: `${caskroom}\n`, + stderr: "", + stdoutBytes: Buffer.byteLength(`${caskroom}\n`), + stderrBytes: 0, + }), + }); + const discovered = await host.brewHopperPath(); + expect(discovered).toBe(`${home}/apps/../${suffix}`); + expect(await readFile(discovered!, "utf8")).toBe("selected Hopper"); + }, +); + it("reports no Homebrew Hopper when the cask has no receipt", async () => { const caskroom = await createTestTempDirectory("rea-doctor-caskroom-"); const calls: (readonly string[])[] = []; From 70c9a7c204607130afe2485f9b868315ea8c137f Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 26 Oct 2026 22:12:28 -0700 Subject: [PATCH 31/31] chore(environment): set aside two hunks next to lines main's saved-values change rewrites Main's #7267 changes the lines beside the blank-member docs and the empty-spelling check in explainUpdate. Setting them aside lets that merge apply cleanly; the commit after it puts them back on top of main's text. Co-Authored-By: Claude Opus 5.5 --- docs/web-network-captures.md | 8 +------- src/domain/analysisView/environmentStateWriteExplain.ts | 8 -------- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index 26aa6ecbaa..913158157d 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -779,13 +779,7 @@ create added stays), every field both rule they rule alike (an echo one showed sorted stays sorted, unless a call stored its strings out of order as sent), and a field neither rules holds one value. Members either call sent are accepted, only those every call sent are required, and an echoed member a call left out takes the value that call stored -(`body.defaults`). A member a create sent empty (`""`, `null`, `false`, `[]` or -`{}`), as a form sends a field left as it was, is not required: a call that -leaves it out is applied as sending that value (`body.blank`), unless a call -that left it out showed a default, which it then keeps. A call may send it as any -recorded call sent it empty, even after a default is learned, and one leaving it -out is applied as the earliest did, as when one create sent `false` and another -`null`. A clock rule widens to `trimmed` only when no held entity or +(`body.defaults`). A clock rule widens to `trimmed` only when no held entity or earlier call showed that clock at a fixed width. A later create that disagrees leaves the plan as it was. A create whose observed reference differs from the entity the plan rebuilds, whether or not it sent the reference member, is diff --git a/src/domain/analysisView/environmentStateWriteExplain.ts b/src/domain/analysisView/environmentStateWriteExplain.ts index 4f854ebc9f..b65f37a770 100644 --- a/src/domain/analysisView/environmentStateWriteExplain.ts +++ b/src/domain/analysisView/environmentStateWriteExplain.ts @@ -493,14 +493,6 @@ export const explainUpdate = ( if (kept !== undefined) rules.push(kept); continue; } - // An empty list or map one answer spells `null` and another `[]` or `{}` is unchanged. - if ( - emptySpellings( - ownMember(evidence.before, field), - ownMember(evidence.after, field), - ) - ) - continue; const rule = changeRule(evidence, field, slugs); if (rule === undefined) unexplained.push(field); else rules.push(rule);