From bbe2bcc979be747698d9ad9ce87af017b92e0f10 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 13 Nov 2026 17:45:16 +0800 Subject: [PATCH] refactor: remove legacy seams and consolidate resource ownership (#10349) --- scripts/verify/rea-numeric-limits.mjs | 47 +++- src/adb/AdbProvider.ts | 12 +- src/apkSignature/ApkSignatureProvider.ts | 6 +- .../AppBundleExecutable.openStep.test.ts | 4 +- src/application/AppBundleExecutable.test.ts | 10 +- src/application/AppBundleExecutable.ts | 4 +- .../AppBundleOpenStepDepth.test.ts | 6 +- src/application/BinaryTargetResolver.ts | 2 +- src/application/CapabilityScope.test.ts | 7 +- .../ClaudeDesktopConfigLocation.test.ts | 21 +- .../ClaudeDesktopConfigLocation.ts | 2 +- src/application/DirectAnalysis.ts | 14 +- src/application/EvidenceBundleCommands.ts | 4 +- src/application/EvidenceBundleFiles.ts | 17 +- .../GooseConfigurationYaml.test.ts | 9 +- src/application/GooseConfigurationYaml.ts | 2 +- src/application/HopperDownload.ts | 4 +- src/application/LinuxHopper.test.ts | 65 ++--- src/application/LinuxHopper.ts | 6 +- .../LinuxHopperPackageRuntime.test.ts | 35 ++- src/application/MacHopperPaths.test.ts | 27 +- src/application/MacHopperPaths.ts | 2 +- .../McpRegistrationCatalog.test.ts | 44 +++- src/application/McpRegistrationCatalog.ts | 2 +- src/application/MsixPackageStorage.ts | 2 +- src/application/SetupCleanupOwner.test.ts | 9 +- src/application/UpdateCommandRunner.ts | 2 +- src/application/UpdateRuntime.ts | 4 +- src/application/WorkflowEvidence.ts | 2 +- .../binary/AnalysisSnapshotCache.ts | 2 +- src/application/binary/BinarySession.ts | 5 - .../BinarySessionBundleResources.test.ts | 8 +- .../binaryDiagnostics/ElfNotesService.ts | 9 +- .../ElfUnwindRangesService.ts | 13 +- .../binaryDiagnostics/KernelModuleService.ts | 16 +- .../NativeInitializersService.ts | 7 +- .../PeCertificatesService.ts | 4 +- .../binaryDiagnostics/PeImportsService.ts | 4 +- .../binaryDiagnostics/PeLoadConfigService.ts | 8 +- .../binaryDiagnostics/PeResourcesService.ts | 4 +- .../WindowsShortcutService.ts | 4 +- .../binaryDiagnostics/artifactRange.ts | 11 + .../desktop/DesktopBuildOutputRead.test.ts | 9 +- .../desktop/DesktopTaskExport.test.ts | 27 +- src/application/desktop/DesktopTaskExport.ts | 2 +- .../environment/environmentBuild.test.ts | 21 +- .../environment/environmentBuild.ts | 2 +- .../environment/environmentBundle.test.ts | 8 +- .../environment/environmentBundle.ts | 19 +- .../environment/environmentQueryText.test.ts | 2 +- .../environment/environmentState.test.ts | 17 +- .../environment/environmentStateDocument.ts | 4 +- .../environment/environmentStateStore.ts | 31 ++- .../environment/environmentTools.test.ts | 3 +- .../environment/environmentTools.ts | 8 +- .../javascript/ElectronBoundaryAnalysis.ts | 2 +- .../JavaScriptArtifactAnalysis.order.test.ts | 55 +++-- .../javascript/JavaScriptArtifactAnalysis.ts | 2 +- ...criptArtifactGraphElectronCoverage.test.ts | 28 +-- .../javascript/JavaScriptNextAppContext.ts | 10 +- .../JavaScriptSemanticGraphBuilder.test.ts | 19 +- .../JavaScriptSemanticGraphBuilder.ts | 38 +-- .../managed/ManagedMemberComparisonService.ts | 4 +- .../NativeDispatchMetadataInspection.ts | 2 +- src/application/native/NativeUiActionTrace.ts | 4 +- .../ownedProcessAvailability.test.ts | 45 ++-- src/application/process/ProcessCli.ts | 2 +- .../process/ProcessScenarioPreparation.ts | 2 +- .../ReleaseExposureService.test.ts | 4 - src/artifacts/AsarArtifactReader.ts | 15 +- src/artifacts/AsarHeaderIdentity.test.ts | 26 ++ src/artifacts/BunStandaloneArtifactReader.ts | 52 ++-- src/artifacts/DecodedRegionArtifactReader.ts | 2 + src/artifacts/DenoStandaloneArtifactReader.ts | 69 +----- src/artifacts/DirectoryArtifactReader.ts | 2 + src/artifacts/FitArtifactReader.ts | 47 +--- src/artifacts/HexRecordArtifactReader.test.ts | 23 ++ src/artifacts/InesArtifactReader.ts | 13 +- src/artifacts/NativeDmgArtifactReader.ts | 12 +- src/artifacts/NativeDmgProtocol.test.ts | 6 + src/artifacts/NtfsMftArtifactReader.test.ts | 16 ++ src/artifacts/NtfsMftArtifactReader.ts | 1 + src/artifacts/OwnedArtifactStream.ts | 67 +++++ src/artifacts/OwnedAsyncArtifactStream.ts | 81 ++++++ src/artifacts/PositionalArtifactReader.ts | 71 ++---- src/artifacts/PyInstallerArtifactReader.ts | 2 + src/artifacts/UImageArtifactReader.ts | 65 +---- .../UnityFsArtifactReader.boundaries.test.ts | 32 +++ src/artifacts/UnityFsArtifactReader.test.ts | 17 ++ src/artifacts/UnityFsArtifactReader.ts | 43 +--- .../WxapkgArtifactReader.boundaries.test.ts | 111 +++++++++ src/artifacts/WxapkgArtifactReader.test.ts | 14 ++ src/artifacts/WxapkgArtifactReader.ts | 43 +--- src/artifacts/ZipArtifactReader.test.ts | 13 +- src/artifacts/ZipArtifactReader.ts | 2 + .../bun/BunStandaloneArtifactReader.test.ts | 58 +++++ .../ArtifactInventoryFinalization.test.ts | 36 +++ src/artifacts/pyinstaller/MarshalSubset.ts | 4 +- .../PyInstallerArtifactReader.test.ts | 35 +++ src/browser/CdpElectronInspection.ts | 10 +- src/browser/CdpElectronProvider.ts | 41 ++-- .../PlaywrightElectronActiveProvider.test.ts | 11 +- src/browser/PlaywrightScenarioRequest.ts | 2 +- .../history/HistoricalCaptureBudgets.test.ts | 2 +- src/captures/TsharkCaptureProvider.ts | 17 +- src/cli/environmentCommands.ts | 2 +- src/cli/reverseEngineeringCommands.ts | 12 +- src/cutter/CutterBridgeClient.ts | 11 +- src/cutter/CutterBridgeService.ts | 2 +- src/cutter/cutterAuthenticatedResponse.ts | 4 +- src/desktop/AgentEnvComputer.test.ts | 56 +++++ src/desktop/AgentEnvComputer.ts | 6 +- src/desktop/BuildComputerController.test.ts | 113 +++++---- src/desktop/BuildComputerController.ts | 8 +- src/desktop/DesktopInspectionSampling.test.ts | 44 ++-- src/desktop/GuestFileAnswer.test.ts | 44 +--- src/desktop/LinuxApplicationInventory.ts | 2 +- src/domain/analysisErrorProjection.test.ts | 5 +- .../EnvironmentStateExtraction.ts | 15 +- .../analysisView/environmentManifestRules.ts | 2 +- .../analysisView/environmentStateIdentity.ts | 14 ++ .../environmentStateWriteChecks.ts | 8 +- .../analysisView/nativeObservationView.ts | 2 +- src/domain/android/androidJniBindings.test.ts | 7 +- .../android/androidNativeDeclarations.ts | 3 - src/domain/artifactComparison.test.ts | 17 +- src/domain/artifactComparison.ts | 2 +- src/domain/artifactExpansionBudget.test.ts | 19 +- src/domain/artifactExpansionBudget.ts | 4 +- src/domain/desktop/stateFormats.ts | 2 +- src/domain/evidenceBundle.test.ts | 6 +- src/domain/evidenceBundle.ts | 4 +- src/domain/evidenceCompletionLedger.ts | 2 +- src/domain/evidenceRecordSummary.ts | 4 +- src/domain/hopperErrors.ts | 2 +- src/domain/native/nativeDeadlines.ts | 2 +- src/domain/propertyListOpenStep.test.ts | 12 +- src/domain/propertyListOpenStep.ts | 2 +- src/domain/propertyListXmlSource.ts | 2 +- src/domain/referenceSourceGraph.test.ts | 7 +- src/domain/referenceSourceGraph.ts | 9 +- src/domain/releaseExposure.test.ts | 25 +- src/domain/releaseExposure.ts | 4 +- src/domain/webNetworkCapture.ts | 2 +- src/domain/windowsNativeArtifact.ts | 2 +- src/domain/xmlDocument.ts | 2 +- src/evm/EvmAdmissionError.ts | 2 +- src/evm/EvmoleFailures.ts | 2 +- src/evm/EvmoleInterfaceProvider.ts | 16 +- src/filesystem/OwnedFileHandle.ts | 2 +- src/flutter/FlutterBuildProvider.ts | 13 +- src/frida/FridaInstrumentationManager.test.ts | 8 +- src/gdb/GdbSessionManager.ts | 13 +- src/ghidra/GhidraDolLoadImage.ts | 21 +- src/ghidra/GhidraLoadImageChecks.ts | 24 ++ src/ghidra/GhidraLoadImageValues.ts | 25 +- src/ghidra/GhidraProviderClient.ts | 2 +- src/ghidra/GhidraRequestQueue.ts | 6 +- src/ghidra/GhidraSocketConnection.ts | 59 +---- src/gpu/CudaInfo.ts | 2 +- src/gpu/GpuElfKernels.ts | 4 +- src/gpu/PtxKernels.ts | 2 +- src/hopper/BridgeLauncher.ts | 2 +- src/hopper/HopperRequestQueue.test.ts | 8 +- src/hopper/HopperRequestQueue.ts | 9 +- src/hopper/HopperResponseStream.ts | 2 +- src/hopper/HopperSocketConnection.ts | 43 +--- .../sourceMaps/SourceMapCommand.test.ts | 35 ++- src/kuna/KunaCommand.ts | 6 +- src/kuna/KunaInstallation.ts | 10 +- src/native/CommandRunner.ts | 6 +- src/native/NativeCallObservation.ts | 4 +- src/native/NativeCommandDeadline.test.ts | 2 - src/native/NativeMachoInspection.ts | 10 +- src/native/NativeMachoOperand.test.ts | 9 +- src/native/NativeMachoSelectedSlice.test.ts | 6 +- src/native/NativeMachoVtoolRefusal.test.ts | 13 +- src/native/metadata/ElfStringTable.test.ts | 3 +- src/native/metadata/ElfStringTable.ts | 4 - src/native/parsers/lipo.test.ts | 47 ++-- src/native/parsers/lipo.ts | 5 +- src/native/pwntools/PwntoolsDecoder.ts | 2 +- src/native/pwntools/PwntoolsFailures.test.ts | 13 +- src/process/ArtifactAnalysisResources.ts | 4 + src/process/DetachedPgpSignature.ts | 2 +- .../JavaScriptWorkerMemoryLimit.test.ts | 6 +- src/process/JavaScriptWorkerMemoryLimit.ts | 2 +- src/process/LocalSocketConnection.test.ts | 97 ++++++++ src/process/LocalSocketConnection.ts | 46 ++++ src/process/ProcessOutputTokenScanner.test.ts | 12 +- src/process/ProcessOutputTokenScanner.ts | 2 +- .../ProcessOwnership.listCancellation.test.ts | 38 ++- src/process/ProcessOwnershipObservation.ts | 7 +- src/providers/OwnedCommandCleanup.ts | 2 +- src/rust/RustBinaryProvider.ts | 2 +- src/server/deliverInvestigationWorkflow.ts | 42 ++++ .../environment/environmentCapacity.test.ts | 31 ++- .../environment/serveEnvironmentState.test.ts | 30 ++- src/server/recordDerivedEvidence.ts | 9 +- src/server/registerFunctionComparisonTool.ts | 35 +-- src/server/registerGdbTools.ts | 4 +- src/server/registerInvestigationTools.ts | 35 +-- src/server/registerProcessComparisonTool.ts | 35 +-- src/server/registerRizinDebugTools.ts | 8 +- src/signals/SigrokRecordingProvider.ts | 17 +- src/sqlite/SqliteDatabaseProvider.ts | 14 +- src/wasm/WabtArtifactProvider.ts | 23 +- src/windows/WindowsPrivateRuntime.test.ts | 6 +- .../artifactComparisonNodeSea.test.ts | 7 +- tests/acceptance/rustBinaryCli.test.ts | 9 +- .../cli/cliJavaScriptResourceClose.test.ts | 9 +- tests/boundary/cli/cutterCommands.test.ts | 63 +---- tests/boundary/cli/flutterCli.test.ts | 95 +------- .../cli/hopperLaunchFailureCli.test.ts | 5 +- .../cli/referenceSourceSummary.test.ts | 4 +- .../evm/evmLimiterAvailability.test.ts | 11 +- .../boundary/filesystem/binaryTarget.test.ts | 29 +-- .../claudeDesktopClientConfiguration.test.ts | 2 +- ...claudeDesktopSkillUninstallWindows.test.ts | 2 +- .../filesystem/dotnetBundleArtifacts.test.ts | 25 ++ .../filesystem/evidenceBundleFiles.test.ts | 6 +- .../filesystem/flutterBuildProvider.test.ts | 184 +++++++------- .../filesystem/inesCancellation.test.ts | 27 ++ tests/boundary/gpu/codeObjects.test.ts | 10 + tests/boundary/mcp/analysisViewMcp.test.ts | 230 +++++++++--------- .../mcp/androidPackageSummaryMcp.test.ts | 78 +++--- tests/boundary/mcp/appleIpsMcp.test.ts | 12 - .../applicationMcpHarnessLifecycle.test.ts | 1 - .../mcp/artifactMcp.inlineInventory.test.ts | 13 +- .../mcp/batchDecompileViewsMcp.test.ts | 28 +-- .../boundary/mcp/binaryDiagnosticsMcp.test.ts | 32 +-- tests/boundary/mcp/browserMcp.test.ts | 25 +- .../mcp/browserSourceCoordinates.test.ts | 38 +-- .../mcp/commandObservationViews.test.ts | 67 +++-- tests/boundary/mcp/dartAotSummaryMcp.test.ts | 38 ++- .../mcp/desktopApplicationMcp.test.ts | 107 ++++---- tests/boundary/mcp/devicetreeMcp.test.ts | 12 +- tests/boundary/mcp/electronFusesMcp.test.ts | 12 +- ...enhancedTools.traceFeatureCoverage.test.ts | 10 +- .../boundary/mcp/environmentBundleMcp.test.ts | 11 +- .../mcp/evidenceLinkInspectionMcp.test.ts | 16 +- .../mcp/evidenceRecordSelectionMcp.test.ts | 24 +- tests/boundary/mcp/evmInterfaceMcp.test.ts | 42 ++-- tests/boundary/mcp/fridaMcp.test.ts | 23 +- .../mcp/ghidraMcpHarnessLifecycle.test.ts | 3 +- tests/boundary/mcp/ghidraNativeApiMcp.test.ts | 4 - tests/boundary/mcp/goBinaryMcp.test.ts | 12 +- tests/boundary/mcp/godotPckMcp.test.ts | 14 +- .../mcp/javascriptFactsViewMcp.test.ts | 21 +- .../mcp/javascriptRecoveryMcp.test.ts | 12 +- .../mcp/managedArtifactSummaryMcp.test.ts | 15 +- .../mcp/managedFieldConstantsMcp.test.ts | 12 +- .../mcp/managedMemberSummaryMcp.test.ts | 15 +- .../nativeInstructionReferencesView.test.ts | 61 ++--- .../mcp/nativeInventoryViewsMcp.test.ts | 47 ++-- .../mcp/nativeMachoSummaryMcp.test.ts | 43 ++-- .../mcp/nativeObservationViewsMcp.test.ts | 16 +- .../mcp/nativeValueFlowAvailability.test.ts | 4 - .../mcp/nativeValueTraceCompleteness.test.ts | 20 +- .../mcp/nativeViewPredicateMcp.test.ts | 7 - tests/boundary/mcp/peCertificatesMcp.test.ts | 12 +- tests/boundary/mcp/peImportsMcp.test.ts | 33 +-- .../mcp/pseudocodeDeliveryMcp.test.ts | 12 +- .../mcp/pyinstallerArtifactMcp.test.ts | 18 +- tests/boundary/mcp/pythonPycMcp.test.ts | 26 +- tests/boundary/mcp/recordedCrashMcp.test.ts | 15 +- .../mcp/sameFunctionObservation.test.ts | 29 ++- tests/boundary/mcp/shutdown.test.ts | 2 +- .../mcp/sourcePublicationContext.test.ts | 7 - tests/boundary/mcp/wasmArtifactMcp.test.ts | 38 ++- tests/boundary/mcp/webBundleViewsMcp.test.ts | 156 ++++++------ tests/boundary/mcp/webModuleTraceMcp.test.ts | 13 +- .../boundary/mcp/webNetworkCaptureMcp.test.ts | 12 +- tests/boundary/mcp/webRuntimeMcp.test.ts | 42 ++-- .../boundary/mcp/webSourceLocationMcp.test.ts | 13 +- tests/boundary/mcp/windowsShortcutMcp.test.ts | 21 +- tests/boundary/pcap/pcapCommand.test.ts | 5 +- .../process/processCaptureProgress.test.ts | 13 +- .../boundary/process/providerProcess.test.ts | 12 +- tests/boundary/process/regexMatcher.test.ts | 2 - .../process/verifierCommandSemantics.test.ts | 1 - .../providers/CutterBridgeClient.test.ts | 136 +++++++---- .../GdbSessionManagerCoverage.test.ts | 14 +- .../cutterBridgeRequestLimit.test.ts | 28 ++- .../native/nativeCallObservation.test.ts | 17 +- .../native/nativeDmgArtifactReader.test.ts | 10 +- .../providers/native/nativeProvider.test.ts | 12 +- .../native/nativeTextRobustness.test.ts | 23 +- .../reverseProviderLaunchErrors.test.ts | 38 ++- .../environmentBundlePackagePin.test.ts | 25 +- tests/evaluation/numericLimitCheck.test.ts | 20 ++ tests/fixtures/advertisedMcpTool.ts | 5 - tests/fixtures/claudeDesktop.ts | 2 + tests/fixtures/flutterCleanup.ts | 20 ++ tests/fixtures/lipo.ts | 8 + .../cutterDescriptorLifetime.test.ts | 32 ++- .../firmwareFileLifetime.test.ts | 7 +- .../process-global/flutterCliCleanup.test.ts | 103 ++++++++ .../hopperDownloadDeadline.test.ts | 8 +- .../process-global/updateMaintenance.test.ts | 6 +- tests/process-global/updateOutput.test.ts | 6 +- 301 files changed, 3148 insertions(+), 2804 deletions(-) create mode 100644 src/application/binaryDiagnostics/artifactRange.ts create mode 100644 src/artifacts/OwnedArtifactStream.ts create mode 100644 src/artifacts/OwnedAsyncArtifactStream.ts create mode 100644 src/domain/analysisView/environmentStateIdentity.ts create mode 100644 src/ghidra/GhidraLoadImageChecks.ts create mode 100644 src/process/LocalSocketConnection.test.ts create mode 100644 src/process/LocalSocketConnection.ts create mode 100644 src/server/deliverInvestigationWorkflow.ts create mode 100644 tests/fixtures/claudeDesktop.ts create mode 100644 tests/fixtures/flutterCleanup.ts create mode 100644 tests/fixtures/lipo.ts create mode 100644 tests/process-global/flutterCliCleanup.test.ts diff --git a/scripts/verify/rea-numeric-limits.mjs b/scripts/verify/rea-numeric-limits.mjs index 8b53ab76fe..8d5e65d59b 100644 --- a/scripts/verify/rea-numeric-limits.mjs +++ b/scripts/verify/rea-numeric-limits.mjs @@ -94,29 +94,36 @@ const stringBindings = (sourceFile, symbols, unresolved) => { ts.forEachChild(node, collect); }; collect(sourceFile); - const potential = (node) => { + const potential = (node, matchesCandidate) => { const expression = unwrapExpression(node); if (exactLimit(literalText(expression)) !== undefined) return true; - if (ts.isIdentifier(expression)) return candidates.has(expression.text); + if (ts.isIdentifier(expression)) return matchesCandidate(expression); if (ts.isPropertyAccessExpression(expression)) return ( exactLimit(expression.name.text) !== undefined || - potential(expression.expression) + potential(expression.expression, matchesCandidate) ); if (ts.isElementAccessExpression(expression)) return ( expression.argumentExpression !== undefined && - potential(expression.argumentExpression) + potential(expression.argumentExpression, matchesCandidate) ); if (ts.isObjectLiteralExpression(expression)) return expression.properties.some( (property) => - ts.isPropertyAssignment(property) && potential(property.initializer), + ts.isPropertyAssignment(property) && + potential(property.initializer, matchesCandidate), ); if (ts.isConditionalExpression(expression)) - return potential(expression.whenTrue) || potential(expression.whenFalse); + return ( + potential(expression.whenTrue, matchesCandidate) || + potential(expression.whenFalse, matchesCandidate) + ); if (ts.isBinaryExpression(expression)) - return potential(expression.left) || potential(expression.right); + return ( + potential(expression.left, matchesCandidate) || + potential(expression.right, matchesCandidate) + ); return false; }; let changed = true; @@ -125,13 +132,34 @@ const stringBindings = (sourceFile, symbols, unresolved) => { for (const declaration of declarations) { if ( !candidates.has(declaration.name.text) && - potential(declaration.initializer) + potential(declaration.initializer, (identifier) => + candidates.has(identifier.text), + ) ) { candidates.add(declaration.name.text); changed = true; } } } + // Names narrow the search cheaply, but only the lexical declaration can + // establish that a particular use is an alias of a limit. + const lexicalCandidates = new Set(); + changed = true; + while (changed) { + changed = false; + for (const declaration of declarations) { + if ( + candidates.has(declaration.name.text) && + !lexicalCandidates.has(declaration) && + potential(declaration.initializer, (identifier) => + lexicalCandidates.has(symbols(identifier)?.valueDeclaration), + ) + ) { + lexicalCandidates.add(declaration); + changed = true; + } + } + } const unknown = (node, reason) => { const path = sourceFile.fileName; const offset = node.getStart(); @@ -152,7 +180,8 @@ const stringBindings = (sourceFile, symbols, unresolved) => { declaration === undefined || !ts.isVariableDeclaration(declaration) || !ts.isVariableDeclarationList(declaration.parent) || - declaration.initializer === undefined + declaration.initializer === undefined || + !lexicalCandidates.has(declaration) ) return undefined; if ((declaration.parent.flags & ts.NodeFlags.Const) === 0) { diff --git a/src/adb/AdbProvider.ts b/src/adb/AdbProvider.ts index 50677b3e1a..5b2a1c2e43 100644 --- a/src/adb/AdbProvider.ts +++ b/src/adb/AdbProvider.ts @@ -84,6 +84,7 @@ import { import { RegularFileOperationCancelled } from "../filesystem/RegularFile.js"; import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; import { createEvidence } from "../domain/evidence.js"; +import { createAnalysisExecutionEvidence } from "../domain/analysisObservation.js"; import { PrivateRuntimeRoot, type PrivateRuntimeRootOptions, @@ -993,15 +994,12 @@ export class AdbProvider { const partialObservation = execution === undefined ? undefined - : createEvidence(execution.subject ?? undefined, execution.provider, { + : createAnalysisExecutionEvidence( operation, parameters, - result: execution.result, - rawResult: execution.rawResult, - limitations: execution.limitations, - locations: execution.locations, - confidence: "observed", - }); + execution, + "observed", + ); return new ProviderCleanupError( ADB_PROVIDER_IDENTITY.id, resources, diff --git a/src/apkSignature/ApkSignatureProvider.ts b/src/apkSignature/ApkSignatureProvider.ts index 66863f93c3..eb30ffcf5d 100644 --- a/src/apkSignature/ApkSignatureProvider.ts +++ b/src/apkSignature/ApkSignatureProvider.ts @@ -415,11 +415,11 @@ export class ApkSignatureProvider implements ApkSignaturePort { const { v1, decoded: v1Decoded, duplicates } = v1Read.value; const v2 = decodeScheme(block.value, APK_BLOCK_ID.signatureV2, "v2"); - if (!v2.ok) return err(v2.error); + if (!v2.ok) return v2; const v3 = decodeScheme(block.value, APK_BLOCK_ID.signatureV3, "v3"); - if (!v3.ok) return err(v3.error); + if (!v3.ok) return v3; const v31 = decodeScheme(block.value, APK_BLOCK_ID.signatureV31, "v3.1"); - if (!v31.ok) return err(v31.error); + if (!v31.ok) return v31; // Account for representation expansion before allocating the report. const projected = projectedOutputBytes( diff --git a/src/application/AppBundleExecutable.openStep.test.ts b/src/application/AppBundleExecutable.openStep.test.ts index cf8e8011a1..4c3f1fa4f2 100644 --- a/src/application/AppBundleExecutable.openStep.test.ts +++ b/src/application/AppBundleExecutable.openStep.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { expect, it } from "vitest"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; -import { resolveAppBundleExecutable } from "./AppBundleExecutable.js"; +import { resolveAppleBundleExecutable } from "./AppBundleExecutable.js"; it.each([ ["no comment", ""], @@ -22,7 +22,7 @@ it.each([ await writeFile(infoPlist, source); await writeFile(executable, "fixture - never executed"); - expect(await resolveAppBundleExecutable(bundle)).toEqual({ + expect(await resolveAppleBundleExecutable(bundle)).toEqual({ ok: true, value: { executable, infoPlist }, }); diff --git a/src/application/AppBundleExecutable.test.ts b/src/application/AppBundleExecutable.test.ts index 2e3a016bf1..eaaff23396 100644 --- a/src/application/AppBundleExecutable.test.ts +++ b/src/application/AppBundleExecutable.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from "vitest"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; import { binaryPlistObjectTable } from "../../tests/fixtures/binaryPlistObjectTable.js"; -import { resolveAppBundleExecutable } from "./AppBundleExecutable.js"; +import { resolveAppleBundleExecutable } from "./AppBundleExecutable.js"; const binaryPlistExecutableWithThreeByteUid = (): Buffer => { const ascii = (value: string): Buffer => { @@ -53,7 +53,7 @@ it.each([ ); await writeFile(executable, "inert fixture; never executed"); - const result = await resolveAppBundleExecutable(bundle); + const result = await resolveAppleBundleExecutable(bundle); expect(result).toEqual({ ok: true, value: { executable, infoPlist: plistPath }, @@ -71,7 +71,7 @@ describe("app bundle Info.plist magic", () => { await writeFile(plistPath, binaryPlistExecutableWithThreeByteUid()); await writeFile(executable, "inert fixture; never executed"); - expect(await resolveAppBundleExecutable(bundle)).toEqual({ + expect(await resolveAppleBundleExecutable(bundle)).toEqual({ ok: true, value: { executable, infoPlist: plistPath }, }); @@ -86,7 +86,7 @@ describe("app bundle Info.plist magic", () => { const plist = Buffer.from("bplist00"); plist[0] = (plist[0] ?? 0) | 0x80; await writeFile(plistPath, plist); - const result = await resolveAppBundleExecutable(bundle); + const result = await resolveAppleBundleExecutable(bundle); expect(result).toMatchObject({ ok: false, error: { @@ -114,7 +114,7 @@ describe("app bundle program directory containment", () => { await writeFile(join(bundle, "Contents", "Info.plist"), plist); let childResolved = false; - const result = await resolveAppBundleExecutable(bundle, { + const result = await resolveAppleBundleExecutable(bundle, { readFile: async () => Buffer.from(plist), realpath: async (path) => { if (path === bundle) return canonicalBundle; diff --git a/src/application/AppBundleExecutable.ts b/src/application/AppBundleExecutable.ts index 815debc13d..9c737fd822 100644 --- a/src/application/AppBundleExecutable.ts +++ b/src/application/AppBundleExecutable.ts @@ -88,7 +88,7 @@ export interface ResolvedAppBundleResourceRoot { } /** Filesystem operations used by the resolver, injectable for boundary tests. */ -export interface AppBundleFileSystem { +interface AppBundleFileSystem { readonly readFile: (path: string) => Promise; readonly realpath: (path: string) => Promise; } @@ -101,7 +101,7 @@ const defaultFileSystem: AppBundleFileSystem = { readFile, realpath }; * a wrapper whose `Wrapper` directory holds the iOS bundle; that bundle is * found by listing `Wrapper`, never by following the `WrappedBundle` link. */ -export const resolveAppBundleExecutable = async ( +const resolveAppBundleExecutable = async ( path: string, fileSystem: AppBundleFileSystem = defaultFileSystem, ): Promise> => { diff --git a/src/application/AppBundleOpenStepDepth.test.ts b/src/application/AppBundleOpenStepDepth.test.ts index 38ff38862c..7775c76d59 100644 --- a/src/application/AppBundleOpenStepDepth.test.ts +++ b/src/application/AppBundleOpenStepDepth.test.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { resolveAppBundleExecutable } from "./AppBundleExecutable.js"; +import { resolveAppleBundleExecutable } from "./AppBundleExecutable.js"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; describe("OpenStep Info.plist nesting during app target resolution", () => { @@ -20,7 +20,7 @@ describe("OpenStep Info.plist nesting during app target resolution", () => { `{ CFBundleExecutable = App; payload = ${"(".repeat(depth)}x${")".repeat(depth)}; }`, ); - const result = await resolveAppBundleExecutable(app); + const result = await resolveAppleBundleExecutable(app); if (depth === 127) { expect(result).toMatchObject({ @@ -52,7 +52,7 @@ describe("OpenStep Info.plist nesting during app target resolution", () => { `{ CFBundleExecutable = App; }${"(".repeat(3000)}x${")".repeat(3000)}`, ); - expect(await resolveAppBundleExecutable(app)).toMatchObject({ + expect(await resolveAppleBundleExecutable(app)).toMatchObject({ ok: true, value: { executable: join(programs, "App") }, }); diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index 3267f9635c..522adcca87 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -208,7 +208,7 @@ export const parseBinaryTarget = async ( throwIfTargetResolutionCancelled(signal); const resolved = await resolveAppBundle(canonical); throwIfTargetResolutionCancelled(signal); - if (!resolved.ok) return err(resolved.error); + if (!resolved.ok) return resolved; const { executable: path, infoPlist } = resolved.value; const bundle = infoPlist === undefined ? {} : { bundleInfoPlist: infoPlist }; diff --git a/src/application/CapabilityScope.test.ts b/src/application/CapabilityScope.test.ts index 4842d8fafe..2ee0da3f22 100644 --- a/src/application/CapabilityScope.test.ts +++ b/src/application/CapabilityScope.test.ts @@ -93,7 +93,12 @@ it("joins concurrent shutdowns and seals ownership before asynchronous cleanup s try { await awaitAcknowledgement(entered, first, "capability cleanup"); second = scope.close(); - expect(second).toBe(first); + let secondSettled = false; + void second.then(() => { + secondSettled = true; + }); + await Promise.resolve(); + expect(secondSettled).toBe(false); expect(close).toHaveBeenCalledTimes(1); expect(() => scope.own("later-owner", {}, async () => undefined)).toThrow( "after host shutdown", diff --git a/src/application/ClaudeDesktopConfigLocation.test.ts b/src/application/ClaudeDesktopConfigLocation.test.ts index 662b3ebef2..d0529a5308 100644 --- a/src/application/ClaudeDesktopConfigLocation.test.ts +++ b/src/application/ClaudeDesktopConfigLocation.test.ts @@ -1,15 +1,14 @@ +import { CLAUDE_DESKTOP_PACKAGE_FAMILY } from "../../tests/fixtures/claudeDesktop.js"; import { basename, dirname, join, sep } from "node:path"; import { describe, expect, it } from "vitest"; import { - CLAUDE_DESKTOP_PACKAGE_FAMILY, resolveWindowsClaudeDesktopLocation, type ClaudeDesktopLocationFileSystem, } from "./ClaudeDesktopConfigLocation.js"; import { packagePrivateLauncherBlocker, - packagePrivateStorageFamily, redirectedWriteBlocker, type MsixPackageView, windowsAppDataRoots, @@ -642,15 +641,19 @@ describe("package-private REA launchers", () => { "rea.mjs", ); - it("names the package that privately stores a path", () => { - expect(packagePrivateStorageFamily(stored, packages)).toBe( - CLAUDE_DESKTOP_PACKAGE_FAMILY, - ); - expect(packagePrivateStorageFamily(launcher, packages)).toBeUndefined(); + it("blocks private cache paths without treating other package paths as private", () => { + const identity = (path: string) => path; expect( - packagePrivateStorageFamily( - join(packages, CLAUDE_DESKTOP_PACKAGE_FAMILY, "LocalState", "x"), + packagePrivateLauncherBlocker([stored], packages, identity), + ).toContain(CLAUDE_DESKTOP_PACKAGE_FAMILY); + expect( + packagePrivateLauncherBlocker([launcher], packages, identity), + ).toBeUndefined(); + expect( + packagePrivateLauncherBlocker( + [join(packages, CLAUDE_DESKTOP_PACKAGE_FAMILY, "LocalState", "x")], packages, + identity, ), ).toBeUndefined(); }); diff --git a/src/application/ClaudeDesktopConfigLocation.ts b/src/application/ClaudeDesktopConfigLocation.ts index fb10e496ff..bbee87fe29 100644 --- a/src/application/ClaudeDesktopConfigLocation.ts +++ b/src/application/ClaudeDesktopConfigLocation.ts @@ -29,7 +29,7 @@ const PROBES_PER_DIRECTORY = 2; * publisher ID Windows derives from `CN="Anthropic, PBC", O="Anthropic, PBC", * L=San Francisco, S=California, C=US, …`. */ -export const CLAUDE_DESKTOP_PACKAGE_FAMILY = "Claude_pzs8sxrjxfjjc"; +const CLAUDE_DESKTOP_PACKAGE_FAMILY = "Claude_pzs8sxrjxfjjc"; /** Observed Claude package names plus Windows' 13-character publisher ID. */ const CLAUDE_PACKAGE_FAMILY = diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index 589d76b9f3..26684e8b51 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -296,7 +296,7 @@ export const runProviderAnalysis = async ( delivery.detail, operationSignal, ); - return result.ok ? ok(result.value) : err(cliError(result.error, path)); + return result.ok ? result : err(cliError(result.error, path)); } const admitted = admitOneShotOperation(tool, arguments_, operationSignal); if (!admitted.ok) return err(cliError(admitted.error, path)); @@ -445,14 +445,14 @@ const runAnalysis = async ( let isDirectory = false; if (BUNDLE_RESOURCE_OPERATIONS.has(tool)) { const inspected = await inspectBundleResourceInput(path); - if (!inspected.ok) return err(inspected.error); + if (!inspected.ok) return inspected; isDirectory = inspected.value; } if (BUNDLE_RESOURCE_OPERATIONS.has(tool) && isDirectory) { const opened = await session.openBundleResources(path, { signal }); - if (!opened.ok) return err(opened.error); + if (!opened.ok) return opened; const execution = await session.execute(tool, arguments_, { signal }); - if (!execution.ok) return err(execution.error); + if (!execution.ok) return execution; return ok( withEvidenceCallerInputPath( createAnalysisExecutionEvidence( @@ -472,7 +472,7 @@ const runAnalysis = async ( signal, ...targetSelection(options), }); - if (!prepared.ok) return err(prepared.error); + if (!prepared.ok) return prepared; const { snapshot } = prepared.value; let resolvedTarget: ResolvedSessionOpen | undefined; if (snapshot !== undefined && prepared.value.target !== undefined) { @@ -484,7 +484,7 @@ const runAnalysis = async ( ? {} : { providerId: options.providerId }), }); - if (!preview.ok) return err(preview.error); + if (!preview.ok) return preview; resolvedTarget = preview.value; const route = preview.value.route; const bindingProfile = @@ -548,7 +548,7 @@ const runAnalysis = async ( resolvedTarget === undefined ? await session.open(path, openOptions) : await session.openResolvedTarget(resolvedTarget, openOptions); - if (!opened.ok) return err(opened.error); + if (!opened.ok) return opened; const evidenceProfile = analysisProfileForEvidence(session, tool); const executed = await executeAnalysisTool({ session, diff --git a/src/application/EvidenceBundleCommands.ts b/src/application/EvidenceBundleCommands.ts index 3f4cc35192..02b2ea2e81 100644 --- a/src/application/EvidenceBundleCommands.ts +++ b/src/application/EvidenceBundleCommands.ts @@ -307,9 +307,7 @@ const projectWrite = ( bundle: EvidenceBundle, written: Result, ): Result => - written.ok - ? ok(evidenceBundleExportReceipt(bundle, written.value)) - : err(written.error); + written.ok ? ok(evidenceBundleExportReceipt(bundle, written.value)) : written; /** Standalone bundle discovery shares the same read owner as import and comparison. */ export const inspectEvidenceBundleCommand = async ( diff --git a/src/application/EvidenceBundleFiles.ts b/src/application/EvidenceBundleFiles.ts index ccb3f350c3..2753b4e689 100644 --- a/src/application/EvidenceBundleFiles.ts +++ b/src/application/EvidenceBundleFiles.ts @@ -31,16 +31,11 @@ import { import { readJsonFile, writeTextParts } from "./JsonFiles.js"; import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; -type EvidenceReadFailure = +type EvidenceBundleFileFailure = | EvidenceFileError | EvidenceIntegrityError | AnalysisResourceConstraintError | AnalysisCancelledError; -type EvidenceWriteFailure = - | EvidenceFileError - | EvidenceIntegrityError - | AnalysisCancelledError - | AnalysisResourceConstraintError; /** Project a published bundle with record and residual-unknown revision counts. */ export const evidenceBundleExportReceipt = ( @@ -59,7 +54,7 @@ export const readEvidenceBundle = ( signal?: AbortSignal, resources?: ArtifactResourceScope, operation = "import_evidence_bundle", -): Promise> => +): Promise> => readBundle(path, false, signal, resources, operation); /** Read an authenticated immutable snapshot for a subsequent session import. */ @@ -68,7 +63,7 @@ export const readImmutableEvidenceBundle = ( signal?: AbortSignal, resources?: ArtifactResourceScope, operation = "import_evidence_bundle", -): Promise> => +): Promise> => readBundle(path, true, signal, resources, operation); /** Join both comparison reads and preserve cleanup owned by either failed input. */ @@ -109,7 +104,7 @@ const readBundle = async ( signal?: AbortSignal, resources?: ArtifactResourceScope, operation = "import_evidence_bundle", -): Promise> => { +): Promise> => { if (signal?.aborted) return err( new AnalysisCancelledError(operation, { @@ -135,7 +130,7 @@ export const authenticateEvidenceBundleValue = ( value: unknown, path: string, options: { readonly operation: string; readonly immutable?: boolean }, -): Result => { +): Result => { const { operation } = options; const headroom = requireAuthenticationHeadroom(value, { operation, @@ -226,7 +221,7 @@ export const writeEvidenceBundle = async ( ): Promise< Result< { readonly path: string; readonly bytes: number }, - EvidenceWriteFailure + EvidenceBundleFileFailure > > => { if (signal?.aborted === true) diff --git a/src/application/GooseConfigurationYaml.test.ts b/src/application/GooseConfigurationYaml.test.ts index 96a9356acc..c12ceb116b 100644 --- a/src/application/GooseConfigurationYaml.test.ts +++ b/src/application/GooseConfigurationYaml.test.ts @@ -4,7 +4,6 @@ import { parseDocument } from "yaml"; import { ConfigurationError } from "../domain/configurationErrors.js"; import { editGooseConfigurationYaml, - GooseConfigurationYamlError, parseGooseConfigurationYaml, stringifyGooseConfigurationYaml, } from "./GooseConfigurationYaml.js"; @@ -111,11 +110,15 @@ describe("Goose YAML 1.2 source facts", () => { } catch (cause: unknown) { failure = cause; } - expect(failure).toBeInstanceOf(GooseConfigurationYamlError); + expect(failure).toBeInstanceOf(ConfigurationError); expect(failure).toMatchObject({ kind: "shadowed_member", path }); + expect(failure).toHaveProperty( + "message", + `Goose YAML contains a duplicate mapping member at ${path.join(".")}`, + ); expect(() => editGooseConfigurationYaml(source, ["extensions", "rea"], extension), - ).toThrow(GooseConfigurationYamlError); + ).toThrow(`duplicate mapping member at ${path.join(".")}`); }, ); diff --git a/src/application/GooseConfigurationYaml.ts b/src/application/GooseConfigurationYaml.ts index 005d5df9e6..8d1ae5cd78 100644 --- a/src/application/GooseConfigurationYaml.ts +++ b/src/application/GooseConfigurationYaml.ts @@ -143,7 +143,7 @@ const gooseYamlOptions = { } as const; /** A duplicate YAML member cannot establish one selected configuration value. */ -export class GooseConfigurationYamlError extends ConfigurationError { +class GooseConfigurationYamlError extends ConfigurationError { readonly kind = "shadowed_member"; constructor( readonly path: readonly string[], diff --git a/src/application/HopperDownload.ts b/src/application/HopperDownload.ts index f92d143a83..f7094c80ba 100644 --- a/src/application/HopperDownload.ts +++ b/src/application/HopperDownload.ts @@ -49,13 +49,13 @@ export type HopperRequestFailure = HopperDownloadFailure & { export const HOPPER_DOWNLOAD_BYTES = 100_000_000; /** Longest wait for the response or its next bytes before a download stalls. */ -export const HOPPER_DOWNLOAD_STALL_MS = 30_000; +const HOPPER_DOWNLOAD_STALL_MS = 30_000; /** * Overall bound for one download. A slow connection that keeps delivering * bytes is bounded here rather than by a deadline sized for fast networks. */ -export const HOPPER_DOWNLOAD_TIMEOUT_MS = 15 * 60_000; +const HOPPER_DOWNLOAD_TIMEOUT_MS = 15 * 60_000; /** Small release feed and key requests must not use the package bounds. */ export const HOPPER_METADATA_TIMEOUT_MS = 10_000; diff --git a/src/application/LinuxHopper.test.ts b/src/application/LinuxHopper.test.ts index 9c00fbad3d..a7b56f99a2 100644 --- a/src/application/LinuxHopper.test.ts +++ b/src/application/LinuxHopper.test.ts @@ -4,9 +4,8 @@ import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { - formatCommandForDisplay, installLinuxHopper, - linuxPackageManagerCommands, + linuxHopperInstallDisclosure, linuxSharedLibrariesAvailable, parseLinuxDistribution, supportsReaHost, @@ -31,19 +30,6 @@ import * as nativeCommands from "../process/ExecFileOutput.js"; import * as pythonRuntime from "./LinuxHopperPython.js"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; -describe("Linux package-manager command display", () => { - it("preserves argument boundaries for paths with spaces and quotes", () => { - expect( - formatCommandForDisplay("pkexec", [ - "pacman", - "-U", - "/tmp/rea test/hopper.arch", - "apostrophe'path", - ]), - ).toBe('pkexec pacman -U "/tmp/rea test/hopper.arch" "apostrophe\'path"'); - }); -}); - class RecordingLinuxHost implements LinuxHopperInstallHost { distributionValue: LinuxDistribution | undefined = { id: "ubuntu", @@ -263,47 +249,34 @@ describe("Linux Hopper installation", () => { ] as const)( "selects the %s native package manager and dependencies", (family, executable, packages) => { - expect( - linuxPackageManagerCommands(family, "/tmp/Hopper package", true), - ).toContainEqual( - expect.objectContaining({ - executable, - args: expect.arrayContaining([...packages]), - }), + const root = linuxHopperInstallDisclosure(family, true).commands; + const nonRoot = linuxHopperInstallDisclosure(family, false).commands; + expect(root.some((command) => command.startsWith(`${executable} `))).toBe( + true, ); + for (const dependency of packages) + expect( + root.some((command) => command.split(" ").includes(dependency)), + ).toBe(true); expect( - linuxPackageManagerCommands(family, "/tmp/Hopper package", false), - ).toContainEqual( - expect.objectContaining({ - executable: "pkexec", - args: expect.arrayContaining([executable, "/tmp/Hopper package"]), - }), - ); + nonRoot.some( + (command) => + command.startsWith(`pkexec ${executable} `) && + command.includes(`""`), + ), + ).toBe(true); }, ); it.each([true, false])( "keeps installed apt dependencies at their versions while Hopper may upgrade (root: %s)", (isRoot) => { - const prefix = isRoot ? [] : ["pkexec"]; + const prefix = isRoot ? "" : "pkexec "; expect( - linuxPackageManagerCommands("deb", "/tmp/Hopper package", isRoot).map( - ({ executable, args }) => [executable, ...args], - ), + linuxHopperInstallDisclosure("deb", isRoot).commands.slice(0, 2), ).toEqual([ - [ - ...prefix, - "apt-get", - "install", - "-y", - "--no-upgrade", - "xvfb", - "xauth", - "python3", - "libx11-6", - "libxtst6", - ], - [...prefix, "apt-get", "install", "-y", "/tmp/Hopper package"], + `${prefix}apt-get install -y --no-upgrade xvfb xauth python3 libx11-6 libxtst6`, + `${prefix}apt-get install -y ""`, ]); }, ); diff --git a/src/application/LinuxHopper.ts b/src/application/LinuxHopper.ts index 34d2007c89..93cd01a84b 100644 --- a/src/application/LinuxHopper.ts +++ b/src/application/LinuxHopper.ts @@ -511,7 +511,7 @@ export const installLinuxHopper = async ( }; /** Canonical launcher path for a legacy user-local Linux Hopper installation. */ -export const linuxHopperLauncherPath = (home: string): string => +const linuxHopperLauncherPath = (home: string): string => appendCallerPath(home, ".local/share/rea/hopper/bin/Hopper", "linux"); /** REA's managed Linux install path, preferred when no explicit launcher is set. */ @@ -796,7 +796,7 @@ const runPackageManagerCommand = async ( }; /** Preserve argument boundaries in a command shown to the user. */ -export const formatCommandForDisplay = ( +const formatCommandForDisplay = ( executable: string, args: readonly string[], ): string => @@ -807,7 +807,7 @@ export const formatCommandForDisplay = ( .join(" "); /** Select the native package manager and authorization boundary without shell evaluation. */ -export const linuxPackageManagerCommands = ( +const linuxPackageManagerCommands = ( family: LinuxPackageFamily, archive: string, isRoot: boolean, diff --git a/src/application/LinuxHopperPackageRuntime.test.ts b/src/application/LinuxHopperPackageRuntime.test.ts index b3cda7929f..992c821df8 100644 --- a/src/application/LinuxHopperPackageRuntime.test.ts +++ b/src/application/LinuxHopperPackageRuntime.test.ts @@ -6,16 +6,11 @@ import { err, ok } from "../domain/result.js"; import * as pythonRuntime from "./LinuxHopperPython.js"; import { linuxHopperInstallDisclosure, - linuxPackageManagerCommands, systemLinuxHopperInstallHost, } from "./LinuxHopper.js"; /** Package transactions that install Hopper before any runtime library repair. */ -const debCommands = linuxPackageManagerCommands( - "deb", - "/tmp/verified-hopper.deb", - false, -).length; +const debCommands = 2; const missingPython = { interpreter: "/usr/bin/python3", @@ -41,6 +36,34 @@ const commandSuccess = { }; describe("Linux Hopper package runtime", () => { + it("preserves package path boundaries in the reported failed command", async () => { + const directory = await createTestTempDirectory("rea-hopper-command-"); + const host = systemLinuxHopperInstallHost( + { XDG_CACHE_HOME: directory }, + async () => undefined, + ); + const archive = "/tmp/rea test/hopper's package.rpm"; + const command = vi + .spyOn(boundedCommands, "execFileBoundedOutput") + .mockResolvedValue({ + exitCode: 1, + stdout: "", + stderr: "fixture failure", + outputTruncated: false, + }); + try { + const result = await host.installPackage("rpm", archive); + expect(command.mock.calls[0]?.[1]).toContain(archive); + expect(result).toMatchObject({ + status: "failed", + failure: { command: expect.stringContaining(`"${archive}"`) }, + }); + } finally { + command.mockRestore(); + await host.close?.(); + } + }); + it.each([ { name: "installs and verifies the matching future Python library", diff --git a/src/application/MacHopperPaths.test.ts b/src/application/MacHopperPaths.test.ts index 15a32dc0aa..83be4a56b6 100644 --- a/src/application/MacHopperPaths.test.ts +++ b/src/application/MacHopperPaths.test.ts @@ -11,7 +11,7 @@ import { import { tmpdir } from "node:os"; import { join, posix, relative } from "node:path"; import { installMacHopperBundle } from "./MacHopper.js"; -import { linuxHopperLauncherPath } from "./LinuxHopper.js"; +import { linuxHopperLauncherCandidates } from "./LinuxHopper.js"; import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js"; import { describe, expect, it } from "vitest"; @@ -19,34 +19,17 @@ import { localMacHopperRenamedLauncherCandidates, macHopperCanonicalLauncherCandidates, macHopperUserApplicationPath, - macHopperRenamedLauncherCandidates, readMacHopperRenamedLauncherCandidates, } from "./MacHopperPaths.js"; describe("macOS Hopper launcher paths", () => { const home = "/fixture/home"; - it("keeps canonical system and user candidates ahead of renamed bundles", () => { + it("keeps canonical system and user candidates in order", () => { expect(macHopperCanonicalLauncherCandidates(home)).toEqual([ "/Applications/Hopper Disassembler.app/Contents/MacOS/hopper", "/fixture/home/Applications/Hopper Disassembler.app/Contents/MacOS/hopper", ]); - expect( - macHopperRenamedLauncherCandidates( - home, - new Map([ - [ - "/Applications", - ["Hopper z.app", "Hopper a.app", "Hopper Disassembler.app"], - ], - ["/fixture/home/Applications", ["Hopper User.app"]], - ]), - ), - ).toEqual([ - "/Applications/Hopper a.app/Contents/MacOS/hopper", - "/Applications/Hopper z.app/Contents/MacOS/hopper", - "/fixture/home/Applications/Hopper User.app/Contents/MacOS/hopper", - ]); }); it.skipIf(process.platform === "win32")( @@ -84,7 +67,7 @@ describe("macOS Hopper launcher paths", () => { expect(macHopperCanonicalLauncherCandidates(home)[1]).toBe( `${home}/Applications/Hopper Disassembler.app/Contents/MacOS/hopper`, ); - expect(linuxHopperLauncherPath(home)).toBe( + expect(linuxHopperLauncherCandidates(home)[1]).toBe( `${home}/.local/share/rea/hopper/bin/Hopper`, ); const source = join(temporary, "source.app"); @@ -154,13 +137,17 @@ describe("macOS Hopper launcher paths", () => { recursive: true, }); await mkdir(join(applications, "Hopper Disassembler.app")); + await mkdir(join(applications, "Hopper Alpha.app")); + await mkdir(join(applications, "Hopper Zeta.app")); await writeFile(join(applications, "Hopper File.app"), ""); const runtime = [...localMacHopperRenamedLauncherCandidates(home)]; expect(runtime.filter((path) => path.startsWith(home))).toEqual([ + posix.join(home, "Applications/Hopper Alpha.app/Contents/MacOS/hopper"), posix.join( home, "Applications/Hopper Preview.app/Contents/MacOS/hopper", ), + posix.join(home, "Applications/Hopper Zeta.app/Contents/MacOS/hopper"), ]); expect(await readMacHopperRenamedLauncherCandidates(home)).toEqual( runtime, diff --git a/src/application/MacHopperPaths.ts b/src/application/MacHopperPaths.ts index e30705cf89..643051812d 100644 --- a/src/application/MacHopperPaths.ts +++ b/src/application/MacHopperPaths.ts @@ -43,7 +43,7 @@ export const macHopperCanonicalLauncherCandidates = ( ]; /** Build deterministic candidates from the discovered app bundle names. */ -export const macHopperRenamedLauncherCandidates = ( +const macHopperRenamedLauncherCandidates = ( homeDirectory: string, bundleNamesByRoot: ReadonlyMap, ): readonly string[] => diff --git a/src/application/McpRegistrationCatalog.test.ts b/src/application/McpRegistrationCatalog.test.ts index 28cc45f7a3..7e116aa0ec 100644 --- a/src/application/McpRegistrationCatalog.test.ts +++ b/src/application/McpRegistrationCatalog.test.ts @@ -5,7 +5,28 @@ import { z } from "zod"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; import { createTestProcessRegistry } from "../../tests/support/process/processFixture.js"; -import { resolveMcpRegistrationClientId } from "./McpRegistrationCatalog.js"; +import { resolveMcpRegistrationClients } from "./McpRegistrationCatalog.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; + +const selectAgent = async (agent: string) => { + const root = await createTestTempDirectory("rea-mcp-alias-"); + const resources = new ArtifactResourceScope(); + try { + return await resolveMcpRegistrationClients( + { + agents: [agent], + global: true, + cwd: root, + home: root, + platform: process.platform, + environment: {}, + }, + resources, + ); + } finally { + await resources.close(); + } +}; it.each([ ["cline-vscode", "cline"], @@ -23,15 +44,22 @@ it.each([ ["kiro", "kiro-cli"], ["mastra", "mastracode"], ["pi-agent", "pi"], -])("retains helper alias %s as %s", (alias, expected) => { - expect(resolveMcpRegistrationClientId(alias.toUpperCase())).toBe(expected); +])("resolves registration alias %s as %s", async (alias, expected) => { + const result = await selectAgent(alias.toUpperCase()); + if (!result.ok) throw result.error; + expect(result.value.map(({ id }) => id)).toEqual([expected]); }); -it("does not treat prototype names or unknown strings as client identities", () => { - expect(resolveMcpRegistrationClientId("__proto__")).toBeUndefined(); - expect(resolveMcpRegistrationClientId("constructor")).toBeUndefined(); - expect(resolveMcpRegistrationClientId("unknown-client")).toBeUndefined(); -}); +it.each(["__proto__", "constructor", "unknown-client"])( + "rejects unknown registration identity %s", + async (agent) => { + const result = await selectAgent(agent); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "ConfigurationError" }, + }); + }, +); const clientSchema = z.object({ id: z.string(), diff --git a/src/application/McpRegistrationCatalog.ts b/src/application/McpRegistrationCatalog.ts index 4ddd82cd27..d1bb301684 100644 --- a/src/application/McpRegistrationCatalog.ts +++ b/src/application/McpRegistrationCatalog.ts @@ -356,7 +356,7 @@ const ALIASES = { const isClientId = (value: string): value is McpRegistrationClientId => Object.hasOwn(MCP_REGISTRATION_CLIENTS, value); -export const resolveMcpRegistrationClientId = ( +const resolveMcpRegistrationClientId = ( input: string, ): McpRegistrationClientId | undefined => { const lower = input.toLowerCase(); diff --git a/src/application/MsixPackageStorage.ts b/src/application/MsixPackageStorage.ts index 9eb3dede08..13dbefba8a 100644 --- a/src/application/MsixPackageStorage.ts +++ b/src/application/MsixPackageStorage.ts @@ -54,7 +54,7 @@ const pathWithin = (path: string, root: string): boolean => { * processes see those files at virtual AppData locations; other processes do * not, and Windows removes them with the package. */ -export const packagePrivateStorageFamily = ( +const packagePrivateStorageFamily = ( canonicalPath: string, packagesDirectory: string, ): string | undefined => { diff --git a/src/application/SetupCleanupOwner.test.ts b/src/application/SetupCleanupOwner.test.ts index 7c665b1f6d..0804406aba 100644 --- a/src/application/SetupCleanupOwner.test.ts +++ b/src/application/SetupCleanupOwner.test.ts @@ -101,9 +101,16 @@ it("starts child finalization synchronously and shares concurrent closes", async ]); const first = owner.close(); expect(calls).toBe(1); - expect(owner.close()).toBe(first); + const second = owner.close(); + let secondSettled = false; + void second.then(() => { + secondSettled = true; + }); + await Promise.resolve(); + expect(secondSettled).toBe(false); complete?.(); expect(await first).toBeUndefined(); + expect(await second).toBeUndefined(); expect(await owner.close()).toBeUndefined(); expect(calls).toBe(1); }); diff --git a/src/application/UpdateCommandRunner.ts b/src/application/UpdateCommandRunner.ts index a273696801..9081b9b834 100644 --- a/src/application/UpdateCommandRunner.ts +++ b/src/application/UpdateCommandRunner.ts @@ -18,7 +18,7 @@ import { UpdateCommandProgress } from "./UpdateCommandProgress.js"; import type { UpdateOutput } from "./Update.js"; import { withoutPackageRunnerIdentity } from "./ClientRegistrationIdentity.js"; -export const UPDATE_COMMAND_OUTPUT_BYTES = 4 * 1024 * 1024; +const UPDATE_COMMAND_OUTPUT_BYTES = 4 * 1024 * 1024; const UPDATE_PROBE_TIMEOUT_MS = 10_000; /** The runner owns whether the process started; callers need not infer it from text. */ diff --git a/src/application/UpdateRuntime.ts b/src/application/UpdateRuntime.ts index 4e04bed385..0bf27d1355 100644 --- a/src/application/UpdateRuntime.ts +++ b/src/application/UpdateRuntime.ts @@ -144,7 +144,7 @@ const INVALID_NPM_RELEASE_METADATA = "Invalid npm release metadata: expected a version string or a single-element version array."; /** bun info arguments for dist-tags.latest; Bun resolves its configured registry. */ -export const BUN_LATEST_VERSION_COMMAND: readonly string[] = [ +const BUN_LATEST_VERSION_COMMAND: readonly string[] = [ "bun", "info", PRODUCT_IDENTITY.packageName, @@ -173,7 +173,7 @@ const npmLatestVersionCommand = ( */ const parseNpmLatestVersion = (metadata: string): Result => { const decoded = safeParseJson(metadata); - if (!decoded.ok) return err(decoded.error); + if (!decoded.ok) return decoded; const version = readNpmLatestVersion(decoded.value); return version === undefined ? err(INVALID_NPM_RELEASE_METADATA) diff --git a/src/application/WorkflowEvidence.ts b/src/application/WorkflowEvidence.ts index 0928d904aa..6d6127f0e8 100644 --- a/src/application/WorkflowEvidence.ts +++ b/src/application/WorkflowEvidence.ts @@ -217,7 +217,7 @@ export const executeComposedWorkflow = async ( }; /** Scope of the exact-address reference lookups composed by trace_feature. */ -export const TRACE_FEATURE_REFERENCE_LIMITATION = +const TRACE_FEATURE_REFERENCE_LIMITATION = "Reference queries cover only each matched start address, not interior bytes of strings and procedures. Empty references and truncated: false do not establish complete reference coverage."; /** Build the shared Evidence record for a successful composed binary workflow. */ diff --git a/src/application/binary/AnalysisSnapshotCache.ts b/src/application/binary/AnalysisSnapshotCache.ts index 560f34d1d5..e89abf080a 100644 --- a/src/application/binary/AnalysisSnapshotCache.ts +++ b/src/application/binary/AnalysisSnapshotCache.ts @@ -173,7 +173,7 @@ export const requiresNativeSourceAdmissionBeforeReplay = ( ): boolean => process.platform === "win32" && providerId === "ghidra"; /** Limitation that marks every observation replayed from a snapshot. */ -export const SNAPSHOT_REPLAY_LIMITATION = +const SNAPSHOT_REPLAY_LIMITATION = "Loaded from a local REA analysis snapshot; this call did not re-run the provider."; /** Bounded in-memory cache for one immutable binary identity. */ diff --git a/src/application/binary/BinarySession.ts b/src/application/binary/BinarySession.ts index aaf7f2a759..e9159e596c 100644 --- a/src/application/binary/BinarySession.ts +++ b/src/application/binary/BinarySession.ts @@ -1399,11 +1399,6 @@ export class BinarySession : structuredClone(this.#active.target); } - activeBundleResourceRoot(): ResolvedAppBundleResourceRoot | undefined { - const root = this.#activeBundleResources?.root; - return root === undefined ? undefined : structuredClone(root); - } - override exportAnalysisSnapshot() { if (this.#activeBundleResources !== undefined) return err(new ResourceSnapshotMismatchError("export")); diff --git a/src/application/binary/BinarySessionBundleResources.test.ts b/src/application/binary/BinarySessionBundleResources.test.ts index d1d89dd7a4..9e21bb8c80 100644 --- a/src/application/binary/BinarySessionBundleResources.test.ts +++ b/src/application/binary/BinarySessionBundleResources.test.ts @@ -145,9 +145,11 @@ it("opens a resource-only bundle without executable identity and keeps snapshot path: opened.value.canonicalPath, caller_path: opened.value.callerPath, }); - expect(session.activeBundleResourceRoot()?.canonicalPath).toBe( - opened.value.canonicalPath, - ); + expect(await session.status()).toMatchObject({ + open: true, + session_kind: "bundle_resources", + resource_root: { canonical_path: opened.value.canonicalPath }, + }); expect( bundleResourceRootSchema.parse({ caller_path: opened.value.callerPath, diff --git a/src/application/binaryDiagnostics/ElfNotesService.ts b/src/application/binaryDiagnostics/ElfNotesService.ts index fae180099f..6ba2fe0705 100644 --- a/src/application/binaryDiagnostics/ElfNotesService.ts +++ b/src/application/binaryDiagnostics/ElfNotesService.ts @@ -1,3 +1,4 @@ +import { insideArtifact } from "./artifactRange.js"; import type { ExecutionOptions } from "../AnalysisProvider.js"; import type { ElfNotesPort } from "./ElfNotesPort.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; @@ -8,7 +9,6 @@ import { elfNotesSchema, type ElfNotes, } from "../../domain/native/elfNotes.js"; -import { BINARY_ARCHITECTURES } from "../../domain/binaryTargetTypes.js"; import { elfArchitecture } from "../../domain/elfFixedHeader.js"; import { err, type Result } from "../../domain/result.js"; import { completeProviderEvidence } from "../ProviderOperationExecution.js"; @@ -54,9 +54,7 @@ export class ElfNotesService { ), ); const value = report.data; - const architecture = BINARY_ARCHITECTURES.find( - (supported) => supported === elfArchitecture(value.machine), - ); + const architecture = elfArchitecture(value.machine); const locations = [ { kind: "artifact-path" as const, path: value.artifact.path }, ...value.tables.map((table) => ({ @@ -93,8 +91,7 @@ const validReport = ( maxEntries: number, maxBytes: number, ): boolean => { - const inside = ({ offset, bytes }: { offset: number; bytes: number }) => - offset <= report.artifact.bytes - bytes; + const inside = insideArtifact(report.artifact.bytes); if ( report.artifact.bytes > maxBytes || report.coverage.examined_entries > maxEntries || diff --git a/src/application/binaryDiagnostics/ElfUnwindRangesService.ts b/src/application/binaryDiagnostics/ElfUnwindRangesService.ts index 21d6eccbd6..588c27975e 100644 --- a/src/application/binaryDiagnostics/ElfUnwindRangesService.ts +++ b/src/application/binaryDiagnostics/ElfUnwindRangesService.ts @@ -1,3 +1,4 @@ +import { insideArtifact } from "./artifactRange.js"; import type { ExecutionOptions } from "../AnalysisProvider.js"; import type { ElfUnwindRangesPort } from "./ElfUnwindRangesPort.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; @@ -12,7 +13,6 @@ import { MAX_INSPECT_ELF_UNWIND_RANGES, type ElfUnwindRanges, } from "../../domain/native/elfUnwindRanges.js"; -import { BINARY_ARCHITECTURES } from "../../domain/binaryTargetTypes.js"; import { elfArchitecture } from "../../domain/elfFixedHeader.js"; import { err, type Result } from "../../domain/result.js"; import { completeProviderEvidence } from "../ProviderOperationExecution.js"; @@ -75,9 +75,7 @@ export class ElfUnwindRangesService { ), ); const value = report.data; - const architecture = BINARY_ARCHITECTURES.find( - (supported) => supported === elfArchitecture(value.machine), - ); + const architecture = elfArchitecture(value.machine); const locations = [ { kind: "artifact-path" as const, path: value.artifact.path }, ...rangesOf(value).map((range) => ({ @@ -109,11 +107,6 @@ export class ElfUnwindRangesService { } } -const inside = - (fileBytes: number) => - (range: Range): boolean => - range.bytes <= fileBytes && range.offset <= fileBytes - range.bytes; - const rangesOf = (report: ElfUnwindRanges): Range[] => { const found: Range[] = []; const add = (range: Range | null): void => { @@ -381,7 +374,7 @@ const validReport = ( report.type_name !== elfObjectTypeName(report.type) || report.page.offset !== offset || report.page.limit !== limit || - !rangesOf(report).every(inside(report.artifact.bytes)) || + !rangesOf(report).every(insideArtifact(report.artifact.bytes)) || !has(report, CFA_LIMITATION) || !has(report, UNTRUSTED_LIMITATION) || (indirectVisible(report) && !has(report, INDIRECT_LIMITATION)) diff --git a/src/application/binaryDiagnostics/KernelModuleService.ts b/src/application/binaryDiagnostics/KernelModuleService.ts index 6c80f7d801..0c822628a3 100644 --- a/src/application/binaryDiagnostics/KernelModuleService.ts +++ b/src/application/binaryDiagnostics/KernelModuleService.ts @@ -1,3 +1,4 @@ +import { insideArtifact } from "./artifactRange.js"; import type { ExecutionOptions } from "../AnalysisProvider.js"; import type { KernelModulePort } from "./KernelModulePort.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; @@ -15,7 +16,6 @@ import { type KernelModule, type InspectKernelModuleInput, } from "../../domain/native/kernelModule.js"; -import { BINARY_ARCHITECTURES } from "../../domain/binaryTargetTypes.js"; import { elfArchitecture } from "../../domain/elfFixedHeader.js"; import { err, type Result } from "../../domain/result.js"; import { completeProviderEvidence } from "../ProviderOperationExecution.js"; @@ -59,11 +59,8 @@ export class KernelModuleService { ), ); const value = report.data; - const machine = value.machine; - const mapped = machine === null ? undefined : elfArchitecture(machine); - const architecture = BINARY_ARCHITECTURES.find( - (supported) => supported === mapped, - ); + const architecture = + value.machine === null ? undefined : elfArchitecture(value.machine); const locations = [ { kind: "artifact-path" as const, path: value.artifact.path }, ...rangesOf(value).map((range) => ({ @@ -95,11 +92,6 @@ export class KernelModuleService { } } -const inside = - (fileBytes: number) => - (range: Range): boolean => - range.bytes <= fileBytes && range.offset <= fileBytes - range.bytes; - const rangesOf = (report: KernelModule): Range[] => { const found: Range[] = []; const add = (range: Range | null | undefined): void => { @@ -290,7 +282,7 @@ const validReport = ( if ( report.artifact.bytes > input.max_file_bytes || report.coverage.examined_entries > input.max_entries || - !rangesOf(report).every(inside(report.artifact.bytes)) + !rangesOf(report).every(insideArtifact(report.artifact.bytes)) ) return false; if (report.applicability === "unsupported") diff --git a/src/application/binaryDiagnostics/NativeInitializersService.ts b/src/application/binaryDiagnostics/NativeInitializersService.ts index e61db3ac0d..af234d3332 100644 --- a/src/application/binaryDiagnostics/NativeInitializersService.ts +++ b/src/application/binaryDiagnostics/NativeInitializersService.ts @@ -1,3 +1,4 @@ +import { insideArtifact } from "./artifactRange.js"; import type { ExecutionOptions } from "../AnalysisProvider.js"; import type { NativeInitializersPort } from "./NativeInitializersPort.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; @@ -106,8 +107,7 @@ const validReport = ( report.architecture !== input.architecture ) return false; - const insideArtifact = (offset: number, bytes: number): boolean => - offset <= report.artifact.bytes - bytes; + const inside = insideArtifact(report.artifact.bytes); // Entry file offsets name the selected image, not another slice in the container. const imageStart = slice?.file_offset ?? 0; const imageEnd = @@ -115,7 +115,8 @@ const validReport = ( const insideImage = (offset: number | null, bytes: number): boolean => offset === null || (offset >= imageStart && bytes <= imageEnd - offset); if ( - (slice !== null && !insideArtifact(slice.file_offset, slice.size)) || + (slice !== null && + !inside({ offset: slice.file_offset, bytes: slice.size })) || report.artifact.bytes > input.max_file_bytes || coverage.offset !== input.offset || coverage.limit !== input.limit || diff --git a/src/application/binaryDiagnostics/PeCertificatesService.ts b/src/application/binaryDiagnostics/PeCertificatesService.ts index 8c0351c3b6..2f3752a559 100644 --- a/src/application/binaryDiagnostics/PeCertificatesService.ts +++ b/src/application/binaryDiagnostics/PeCertificatesService.ts @@ -1,3 +1,4 @@ +import { insideArtifact } from "./artifactRange.js"; import type { ExecutionOptions } from "../AnalysisProvider.js"; import type { PeCertificatesPort } from "./PeCertificatesPort.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; @@ -94,8 +95,7 @@ const validReport = ( maxEntries: number, maxBytes: number, ): boolean => { - const inside = ({ offset, bytes }: { offset: number; bytes: number }) => - offset <= report.artifact.bytes - bytes; + const inside = insideArtifact(report.artifact.bytes); if ( report.artifact.bytes > maxBytes || report.coverage.examined_entries > maxEntries || diff --git a/src/application/binaryDiagnostics/PeImportsService.ts b/src/application/binaryDiagnostics/PeImportsService.ts index 60bf227c6c..c84e188b5d 100644 --- a/src/application/binaryDiagnostics/PeImportsService.ts +++ b/src/application/binaryDiagnostics/PeImportsService.ts @@ -1,3 +1,4 @@ +import { insideArtifact } from "./artifactRange.js"; import type { ExecutionOptions } from "../AnalysisProvider.js"; import type { PeImportsPort } from "./PeImportsPort.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; @@ -101,8 +102,7 @@ const validReport = ( readonly max_file_bytes: number; }, ): boolean => { - const inside = ({ offset, bytes }: { offset: number; bytes: number }) => - offset <= report.artifact.bytes - bytes; + const inside = insideArtifact(report.artifact.bytes); const dllCount = report.imports.length + report.delay_imports.length; if ( report.artifact.bytes > input.max_file_bytes || diff --git a/src/application/binaryDiagnostics/PeLoadConfigService.ts b/src/application/binaryDiagnostics/PeLoadConfigService.ts index 0e18362d5d..3f56621fef 100644 --- a/src/application/binaryDiagnostics/PeLoadConfigService.ts +++ b/src/application/binaryDiagnostics/PeLoadConfigService.ts @@ -1,3 +1,4 @@ +import { insideArtifact } from "./artifactRange.js"; import type { ExecutionOptions } from "../AnalysisProvider.js"; import type { PeLoadConfigPort } from "./PeLoadConfigPort.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; @@ -132,17 +133,16 @@ const validReport = ( ...fileRange(report.load_config?.directory ?? null), ]; const structure = report.load_config?.structure_location; + const inside = insideArtifact(report.artifact.bytes); return ( (structure === undefined || (structure.offset === report.load_config?.directory.file_offset && structure.bytes === report.load_config.size && - structure.offset <= report.artifact.bytes - structure.bytes)) && + inside(structure))) && ranges.every( ({ start, end }) => start <= end && end <= report.artifact.bytes, ) && - (report.tls?.callback_array_locations ?? []).every( - ({ offset, bytes }) => offset <= report.artifact.bytes - bytes, - ) && + (report.tls?.callback_array_locations ?? []).every(inside) && (report.tls?.callbacks ?? []).every( ({ file_offset }) => file_offset === null || file_offset < report.artifact.bytes, diff --git a/src/application/binaryDiagnostics/PeResourcesService.ts b/src/application/binaryDiagnostics/PeResourcesService.ts index 78b58216ca..c0e331519c 100644 --- a/src/application/binaryDiagnostics/PeResourcesService.ts +++ b/src/application/binaryDiagnostics/PeResourcesService.ts @@ -1,3 +1,4 @@ +import { insideArtifact } from "./artifactRange.js"; import type { ExecutionOptions } from "../AnalysisProvider.js"; import type { PeResourcesPort } from "./PeResourcesPort.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; @@ -83,8 +84,7 @@ const validReport = ( maxEntries: number, maxBytes: number, ): boolean => { - const inside = ({ offset, bytes }: { offset: number; bytes: number }) => - offset <= report.artifact.bytes - bytes; + const inside = insideArtifact(report.artifact.bytes); const identityInside = (identity: PeResourceIdentity): boolean => identity.kind === "id" || inside(identity.location); if ( diff --git a/src/application/binaryDiagnostics/WindowsShortcutService.ts b/src/application/binaryDiagnostics/WindowsShortcutService.ts index 44dcd2f620..49a4c31ee2 100644 --- a/src/application/binaryDiagnostics/WindowsShortcutService.ts +++ b/src/application/binaryDiagnostics/WindowsShortcutService.ts @@ -1,3 +1,4 @@ +import { insideArtifact } from "./artifactRange.js"; import type { ExecutionOptions } from "../AnalysisProvider.js"; import type { WindowsShortcutPort } from "./WindowsShortcutPort.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; @@ -121,8 +122,7 @@ const validReport = ( if (complete !== (report.issues.length === 0)) return false; if (complete && report.coverage.failing_offset !== null) return false; if (!complete && report.coverage.reason === null) return false; - const inside = ({ offset, bytes }: { offset: number; bytes: number }) => - offset <= report.artifact.bytes - bytes; + const inside = insideArtifact(report.artifact.bytes); if (!inside(report.header.location)) return false; if (report.id_list !== null && !inside(report.id_list.location)) return false; if (report.link_info !== null && !inside(report.link_info.location)) diff --git a/src/application/binaryDiagnostics/artifactRange.ts b/src/application/binaryDiagnostics/artifactRange.ts new file mode 100644 index 0000000000..1349934b60 --- /dev/null +++ b/src/application/binaryDiagnostics/artifactRange.ts @@ -0,0 +1,11 @@ +/** Check a schema-validated nonnegative byte range against its carrier size. */ +export const insideArtifact = + (fileBytes: number) => + ({ + offset, + bytes, + }: { + readonly offset: number; + readonly bytes: number; + }): boolean => + offset <= fileBytes - bytes; diff --git a/src/application/desktop/DesktopBuildOutputRead.test.ts b/src/application/desktop/DesktopBuildOutputRead.test.ts index 273f0a60bd..8819f9ad72 100644 --- a/src/application/desktop/DesktopBuildOutputRead.test.ts +++ b/src/application/desktop/DesktopBuildOutputRead.test.ts @@ -3,10 +3,7 @@ import { describe, expect, it } from "vitest"; import { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore.js"; import { redactSecrets } from "../../domain/desktop/buildSecrets.js"; import { createEvidence } from "../../domain/evidence.js"; -import { - GUEST_FILE_ANSWER_SOURCE, - guestFileAnswerCeiling, -} from "../../desktop/AgentEnvComputer.js"; +import { guestFileAnswerCeiling } from "../../desktop/AgentEnvComputer.js"; import { readOutput } from "./DesktopBuildCommands.js"; import { endedCapture } from "./DesktopBuildCaptureFailure.js"; import { @@ -38,7 +35,7 @@ describe("readOutput", () => { guest_file_bytes: Number.MAX_SAFE_INTEGER, file_count: 1, answer_ceiling_bytes: guestFileAnswerCeiling(), - ceiling_source: GUEST_FILE_ANSWER_SOURCE, + ceiling_source: "v8.heap_size_limit/4", }); }); @@ -101,7 +98,7 @@ describe("endedCapture", () => { guest_file_bytes: Number.MAX_SAFE_INTEGER, file_count: 1, answer_ceiling_bytes: guestFileAnswerCeiling(), - ceiling_source: GUEST_FILE_ANSWER_SOURCE, + ceiling_source: "v8.heap_size_limit/4", }); }); diff --git a/src/application/desktop/DesktopTaskExport.test.ts b/src/application/desktop/DesktopTaskExport.test.ts index 03cb266ca3..4cda72255e 100644 --- a/src/application/desktop/DesktopTaskExport.test.ts +++ b/src/application/desktop/DesktopTaskExport.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from "vitest"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; import { exportDesktopApplication } from "./DesktopApplicationExport.js"; -import { namesApp, shippedSeedCheckView } from "./DesktopTaskExport.js"; +import { shippedSeedCheckView } from "./DesktopTaskExport.js"; import { draftDesktopApplicationTask } from "./DesktopTaskDraft.js"; import { HOME, @@ -802,17 +802,20 @@ describe("exportDesktopApplication with a task's prompt", () => { expect(naming(named.result.limitations)).toEqual([]); }); - it("does not treat a word that only contains the app's name as naming it", () => { - const dia = { title: "Dia", name: "dia", aptPackage: "dia" }; - expect(namesApp("Update the diagram and save it.", dia)).toBe(false); - expect(namesApp("In Dia, add a box.", dia)).toBe(true); - expect( - namesApp("In Notes, write down milk.", { - title: "Notes", - name: "notes", - aptPackage: "notes", - }), - ).toBe(true); + it("does not treat a word that only contains the app's name as naming it", async () => { + const checks = [ + { id: "milk", kind: "lines_present", file: NOTE, lines: ["milk"] }, + ]; + const exported = await exportWithTasks({ + ...task(checks), + prompt: "Update the notesheet and save it.", + }); + if (exported.result instanceof Error) throw exported.result; + expect(exported.result.limitations).toEqual( + expect.arrayContaining([ + expect.stringMatching(/prompt.*does not name the app/iu), + ]), + ); }); }); diff --git a/src/application/desktop/DesktopTaskExport.ts b/src/application/desktop/DesktopTaskExport.ts index 5014d398df..0c64f6441c 100644 --- a/src/application/desktop/DesktopTaskExport.ts +++ b/src/application/desktop/DesktopTaskExport.ts @@ -490,7 +490,7 @@ const admitScreenTask = async ( }; /** Whether a prompt names the app by its title, environment or package, in any letter case. */ -export const namesApp = ( +const namesApp = ( prompt: string, { title, diff --git a/src/application/environment/environmentBuild.test.ts b/src/application/environment/environmentBuild.test.ts index 6229505826..5cabed56d2 100644 --- a/src/application/environment/environmentBuild.test.ts +++ b/src/application/environment/environmentBuild.test.ts @@ -33,7 +33,6 @@ import { inspectAnalysisViewValidated } from "../analysisView/AnalysisViewServic import { DEFAULT_BUILD_TEMPLATE_RULES, buildEnvironment, - canonicalTemplateRules, classifyBuildCapture, type EnvironmentBuildInput, } from "./environmentBuild.js"; @@ -497,12 +496,20 @@ describe("classifyBuildCapture", () => { }); }); -describe("canonicalTemplateRules", () => { - it("orders rules as the views apply them", () => { - expect( - canonicalTemplateRules(["prefix-variance", "after-uid-literal"]), - ).toEqual(["after-uid-literal", "prefix-variance"]); - }); +it("reports template rules in the order the views apply them", async () => { + const root = await createTestTempDirectory("rea-environment-rule-order-"); + const built = await buildEnvironment( + await buildInput(root, undefined, { + dryRun: true, + directory: undefined, + templateRules: ["prefix-variance", "after-uid-literal"], + }), + ); + if (!built.ok) throw built.error; + expect(built.value.manifest.template_rules).toEqual([ + "after-uid-literal", + "prefix-variance", + ]); }); describe("buildEnvironment credential names", () => { diff --git a/src/application/environment/environmentBuild.ts b/src/application/environment/environmentBuild.ts index 6c53935d4f..29f21d8065 100644 --- a/src/application/environment/environmentBuild.ts +++ b/src/application/environment/environmentBuild.ts @@ -86,7 +86,7 @@ export const classifyBuildCapture = ( }; /** Rules in the views' fixed order, so the report and manifest list them canonically. */ -export const canonicalTemplateRules = ( +const canonicalTemplateRules = ( rules: readonly TemplateRule[], ): TemplateRule[] => TEMPLATE_RULES.filter((rule) => rules.includes(rule)); diff --git a/src/application/environment/environmentBundle.test.ts b/src/application/environment/environmentBundle.test.ts index 0da059f5a1..7a4b4773f6 100644 --- a/src/application/environment/environmentBundle.test.ts +++ b/src/application/environment/environmentBundle.test.ts @@ -14,8 +14,6 @@ import { import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; import { PACKAGE_METADATA } from "../../generatedPackageMetadata.js"; import { - BUNDLE_BASE_IMAGE, - bundleDockerfile, ENVIRONMENT_EXPORT_OPERATION, planEnvironmentBundle, registryPackageSource, @@ -115,7 +113,7 @@ describe("planEnvironmentBundle", () => { it("pins the registry package to this build's exact version in the Dockerfile", () => { const dockerfile = file(plan(), "envs/grafana-example/Dockerfile"); const lines = dockerfile.trimEnd().split("\n"); - expect(lines[0]).toBe(`FROM ${BUNDLE_BASE_IMAGE}`); + expect(lines[0]).toMatch(/^FROM node:\d+\.\d+\.\d+-bookworm-slim$/u); expect(lines).toContain("ENV MCP_HOST=0.0.0.0"); expect(lines).toContain("ENV MCP_PORT=18765"); expect(lines).toContain( @@ -149,7 +147,9 @@ describe("planEnvironmentBundle", () => { "RUN npm install -g --ignore-scripts --no-fund --no-audit /tmp/rea-agents.tgz && rm /tmp/rea-agents.tgz\n", ); expect(dockerfile).not.toContain(`rea-agents@${PACKAGE_METADATA.version}`); - expect(bundleDockerfile(registryPackageSource())).not.toBe(dockerfile); + expect(file(plan(), "envs/grafana-example/Dockerfile")).not.toBe( + dockerfile, + ); }); it("writes a verifier that checks the served tool count and the status the server answers the first parameterless GET tool with", () => { diff --git a/src/application/environment/environmentBundle.ts b/src/application/environment/environmentBundle.ts index f381c2e47c..5bdc028cb8 100644 --- a/src/application/environment/environmentBundle.ts +++ b/src/application/environment/environmentBundle.ts @@ -21,15 +21,15 @@ import type { EnvironmentToolDefinition } from "./environmentTools.js"; export const ENVIRONMENT_EXPORT_OPERATION = "environment-export"; /** The image the exported Dockerfile builds from; the agentenv runner builds it with Docker. */ -export const BUNDLE_BASE_IMAGE = `node:${PACKAGE_METADATA.pinnedNodeVersion}-bookworm-slim`; +const BUNDLE_BASE_IMAGE = `node:${PACKAGE_METADATA.pinnedNodeVersion}-bookworm-slim`; /** The port the agentenv framework maps for an MCP server env; it sets the host but not the port. */ -export const BUNDLE_MCP_PORT = 18765; +const BUNDLE_MCP_PORT = 18765; /** Host names the exported env container is addressed by: the published loopback port and the compose service name. */ -export const BUNDLE_ALLOWED_HOSTS = "127.0.0.1,localhost"; +const BUNDLE_ALLOWED_HOSTS = "127.0.0.1,localhost"; /** Name of the packed CLI inside the env folder when a tarball is exported. */ -export const BUNDLE_TARBALL_NAME = "rea-agents.tgz"; +const BUNDLE_TARBALL_NAME = "rea-agents.tgz"; /** Where the Dockerfile installs the CLI from. */ export type EnvironmentPackageSource = @@ -152,7 +152,7 @@ const READINESS_PROBE_LAYER = "RUN apt-get update && apt-get install -y --no-install-recommends python3 && rm -rf /var/lib/apt/lists/*"; /** The Dockerfile text: pinned base image, readiness probe interpreter, pinned CLI, listener defaults, non-root user. */ -export const bundleDockerfile = ( +const bundleDockerfile = ( source: EnvironmentPackageSource, name?: string, ): string => @@ -196,7 +196,7 @@ const pythonLiteral = (value: string | number | null): string => value === null ? "None" : JSON.stringify(value); /** The verifier script in the shape of the agentenv echo example's `verify(mcp_url)`. */ -export const bundleVerifier = ( +const bundleVerifier = ( plan: Pick, ): string => [ @@ -241,7 +241,7 @@ export const bundleVerifier = ( ].join("\n"); /** The smoke task: deploy the env locally, then run the verifier against it. */ -export const bundleSmokeTask = (name: string): string => +const bundleSmokeTask = (name: string): string => `${JSON.stringify( [ { id: "env", type: "deploy_env", env_id: name, sandbox_type: "local" }, @@ -280,10 +280,7 @@ from their recordings because the state is unavailable }; /** The bundle README: provenance, what the environment serves and does not establish, how to run it, and the rights note. */ -export const bundleReadme = ( - source: EnvironmentSource, - plan: ReadmePlan, -): string => { +const bundleReadme = (source: EnvironmentSource, plan: ReadmePlan): string => { const { manifest } = source; const check = plan.check === null diff --git a/src/application/environment/environmentQueryText.test.ts b/src/application/environment/environmentQueryText.test.ts index 1e52df3078..6f123857ce 100644 --- a/src/application/environment/environmentQueryText.test.ts +++ b/src/application/environment/environmentQueryText.test.ts @@ -28,7 +28,7 @@ describe("query values given as numbers or booleans", () => { body: { maxSize: 3 }, }); const strings = { maxSize: "2", offset: ["0"] }; - expect(withQueryText(tool, strings)).toBe(strings); + expect(withQueryText(tool, strings)).toEqual(strings); }); it("answers a list paged by a number as it answers the same page size given as text", () => { diff --git a/src/application/environment/environmentState.test.ts b/src/application/environment/environmentState.test.ts index 51f47a55cc..44479e8d40 100644 --- a/src/application/environment/environmentState.test.ts +++ b/src/application/environment/environmentState.test.ts @@ -33,10 +33,7 @@ import { STATE_INTAKE_LIMIT_BYTES, parseStateDocument, } from "./environmentStateDocument.js"; -import { - EnvironmentStateStore, - ENVIRONMENT_COLLECTION_HISTORY_LIMIT_BYTES, -} from "./environmentStateStore.js"; +import { EnvironmentStateStore } from "./environmentStateStore.js"; const fixture = environmentStateServeFixture(); const { source, state } = environmentStateServeInputs(fixture); @@ -189,12 +186,12 @@ describe("served collection history", () => { if (refused === null) throw new Error("Expected reported history budget details"); expect(refused).toEqual(run()); - expect(refused).toMatchObject({ - max_collection_history_bytes: ENVIRONMENT_COLLECTION_HISTORY_LIMIT_BYTES, - }); - expect(refused.collection_history_bytes).toBeGreaterThan( - ENVIRONMENT_COLLECTION_HISTORY_LIMIT_BYTES, - ); + const maximum = refused.max_collection_history_bytes; + expect(maximum).toEqual(expect.any(Number)); + if (typeof maximum !== "number") + throw new Error("Expected a history limit"); + expect(maximum).toBeGreaterThan(0); + expect(refused.collection_history_bytes).toBeGreaterThan(maximum); }); it("does not charge a deleted loaded collection again at the history limit", () => { diff --git a/src/application/environment/environmentStateDocument.ts b/src/application/environment/environmentStateDocument.ts index 85febafb2e..6877f97b8a 100644 --- a/src/application/environment/environmentStateDocument.ts +++ b/src/application/environment/environmentStateDocument.ts @@ -1,3 +1,4 @@ +import { environmentEntityKey } from "../../domain/analysisView/environmentStateIdentity.js"; import { types } from "node:util"; import { z } from "zod"; @@ -28,7 +29,6 @@ import { type JsonValue, } from "../../domain/jsonValue.js"; import { err, ok, type Result } from "../../domain/result.js"; -import { compositeKey } from "../../domain/unicodeCodePointOrder.js"; import type { EnvironmentState } from "./environmentState.js"; import { ENVIRONMENT_SERVE_OPERATION } from "./environmentSource.js"; import { REA_STATE_FORMAT } from "./environmentTools.js"; @@ -216,7 +216,7 @@ interface EntityCheck { } const entityKey = (entity: Pick) => - compositeKey([entity.type, entity.collection, entity.id]); + environmentEntityKey(entity.type, entity.collection, entity.id); /** The first problem with an entity's view values: each names an item or list view of its type once and keeps its identity. */ const viewFieldsProblem = ( diff --git a/src/application/environment/environmentStateStore.ts b/src/application/environment/environmentStateStore.ts index b6e9d99a5e..45818277a2 100644 --- a/src/application/environment/environmentStateStore.ts +++ b/src/application/environment/environmentStateStore.ts @@ -1,3 +1,7 @@ +import { + environmentCollectionKey, + environmentEntityKey, +} from "../../domain/analysisView/environmentStateIdentity.js"; import { markedDeleted, type StateEntity, @@ -11,7 +15,6 @@ import { import type { JsonValue } from "../../domain/jsonValue.js"; import { jsonByteLength } from "../../domain/jsonSerialization.js"; import { err, ok, type Result } from "../../domain/result.js"; -import { compositeKey } from "../../domain/unicodeCodePointOrder.js"; import { ENVIRONMENT_SERVE_OPERATION } from "./environmentSource.js"; import type { EnvironmentState } from "./environmentState.js"; import { @@ -22,7 +25,7 @@ import { } from "./environmentStateDocument.js"; /** Maximum logical budget for collection spellings added by served creates since reset/load. */ -export const ENVIRONMENT_COLLECTION_HISTORY_LIMIT_BYTES = 1024 * 1024; +const ENVIRONMENT_COLLECTION_HISTORY_LIMIT_BYTES = 1024 * 1024; const collectionHistoryCost = ( state: EnvironmentState, @@ -259,7 +262,7 @@ export class EnvironmentStateStore { entity: StateEntity, index: number, ): Result { - const key = compositeKey([entity.type, entity.collection, entity.id]); + const key = environmentEntityKey(entity.type, entity.collection, entity.id); const replaced = this.#storedDeleted.get(key); const entities = replaced === undefined @@ -339,7 +342,7 @@ export class EnvironmentStateStore { const replaced = new Map(); for (const entity of entities) { const held = this.#entities.get( - compositeKey([entity.type, entity.collection, entity.id]), + environmentEntityKey(entity.type, entity.collection, entity.id), ); if (held !== undefined) replaced.set(held, entity); } @@ -351,7 +354,7 @@ export class EnvironmentStateStore { const bytes = this.#grown(delta); if (!bytes.ok) return bytes; const key = (entity: StateEntity) => - compositeKey([entity.type, entity.collection, entity.id]); + environmentEntityKey(entity.type, entity.collection, entity.id); const placed = new Set(order.map(key)); let next = 0; this.#install( @@ -377,7 +380,7 @@ export class EnvironmentStateStore { const held = new Set( entities.flatMap((entity) => { const found = this.#entities.get( - compositeKey([entity.type, entity.collection, entity.id]), + environmentEntityKey(entity.type, entity.collection, entity.id), ); return found === undefined ? [] : [found]; }), @@ -400,14 +403,14 @@ export class EnvironmentStateStore { collection: string, id: string, ): StateEntity | undefined { - const key = compositeKey([type, collection, id]); + const key = environmentEntityKey(type, collection, id); return this.#entities.has(key) ? undefined : (this.#storedDeleted.get(key) ?? this.#deleted.get(key)); } #keepDeleted(entity: StateEntity): void { - const key = compositeKey([entity.type, entity.collection, entity.id]); + const key = environmentEntityKey(entity.type, entity.collection, entity.id); const earlier = this.#deleted.get(key); if (earlier !== undefined) { this.#deleted.delete(key); @@ -472,12 +475,14 @@ export class EnvironmentStateStore { collection: string, id: string, ): StateEntity | undefined { - return this.#entities.get(compositeKey([type, collection, id])); + return this.#entities.get(environmentEntityKey(type, collection, id)); } /** One collection's entities in state order. */ inCollection(type: string, collection: string): readonly StateEntity[] { - return this.#collections.get(compositeKey([type, collection])) ?? []; + return ( + this.#collections.get(environmentCollectionKey(type, collection)) ?? [] + ); } /** The collections holding entities of one type, in state order of their first entity. */ @@ -529,7 +534,7 @@ export class EnvironmentStateStore { for (const entity of content.entities) { if (markedDeleted(entity.fields)) { storedDeleted.set( - compositeKey([entity.type, entity.collection, entity.id]), + environmentEntityKey(entity.type, entity.collection, entity.id), entity, ); const named = typeCollections.get(entity.type) ?? []; @@ -538,10 +543,10 @@ export class EnvironmentStateStore { continue; } entities.set( - compositeKey([entity.type, entity.collection, entity.id]), + environmentEntityKey(entity.type, entity.collection, entity.id), entity, ); - const key = compositeKey([entity.type, entity.collection]); + const key = environmentCollectionKey(entity.type, entity.collection); const members = collections.get(key); if (members !== undefined) { members.push(entity); diff --git a/src/application/environment/environmentTools.test.ts b/src/application/environment/environmentTools.test.ts index c1662f9521..344dd80fc5 100644 --- a/src/application/environment/environmentTools.test.ts +++ b/src/application/environment/environmentTools.test.ts @@ -6,7 +6,6 @@ import type { RecordedExchange, } from "./environmentSource.js"; import { - ENVIRONMENT_CATALOG_LIMIT_BYTES, environmentCard, environmentToolArguments, environmentToolDefinitions as prepareDefinitions, @@ -352,7 +351,7 @@ describe("environment tool response summaries", () => { error: { _tag: "AnalysisResourceConstraintError", resource: "capacity", - reportedLimits: { max_catalog_bytes: ENVIRONMENT_CATALOG_LIMIT_BYTES }, + reportedLimits: { max_catalog_bytes: expect.any(Number) }, }, }); const [single] = environmentToolDefinitions( diff --git a/src/application/environment/environmentTools.ts b/src/application/environment/environmentTools.ts index f0446bdf4f..11691c479f 100644 --- a/src/application/environment/environmentTools.ts +++ b/src/application/environment/environmentTools.ts @@ -231,7 +231,7 @@ const toolDescription = ( `${REPLAY_CATALOG_INSTRUCTIONS} ${describeText(tool.method, "method")} ${describeText(tool.path_template, "path")}; ${describeResponses(tool, source.exchanges)}; ${describe?.(tool) ?? answers} [${describeList(tool.record_ordinals, tool.record_ordinals.length)}]; evidence ${source.evidenceId}`; /** Captured argument names longer than this are advertised under a short alias; the manifest keeps the name. */ -export const MAX_ARGUMENT_NAME_CHARS = 64; +const MAX_ARGUMENT_NAME_CHARS = 64; const bodyDescription = (tool: EnvironmentManifestTool): string => { const properties = tool.body_shape?.properties; @@ -377,7 +377,7 @@ function* environmentToolProperties( } /** Serialization reservation calibrated with 1500 fields and eight concurrent SDK discovery requests. */ -export const ENVIRONMENT_CATALOG_LIMIT_BYTES = 1024 * 1024; +const ENVIRONMENT_CATALOG_LIMIT_BYTES = 1024 * 1024; const EMPTY_PROPERTY_BYTES = JSON.stringify({ type: "string", description: "" }).length + 4; const EMPTY_TOOL_BYTES = JSON.stringify({ @@ -530,10 +530,10 @@ export const environmentToolDefinitions = ( export const REA_STATE_FORMAT = "rea-state/v1"; /** The data-plane methods an environment served from a state answers. */ -export const STATE_OPERATIONS = ["data/reset", "data/add", "data/get"] as const; +const STATE_OPERATIONS = ["data/reset", "data/add", "data/get"] as const; /** The intake declaration a state-served card carries: what `data/add` loads and `data/get` returns. */ -export const STATE_INTAKE_EXTENSION = { +const STATE_INTAKE_EXTENSION = { uri: "urn:agentenv:intake/v1", description: `data/add replaces the whole environment state with one ${REA_STATE_FORMAT} document; data/get returns the current state in the same format.`, params: { diff --git a/src/application/javascript/ElectronBoundaryAnalysis.ts b/src/application/javascript/ElectronBoundaryAnalysis.ts index b98bdbaaa9..a60c1f32c9 100644 --- a/src/application/javascript/ElectronBoundaryAnalysis.ts +++ b/src/application/javascript/ElectronBoundaryAnalysis.ts @@ -172,7 +172,7 @@ const electronModuleReferencedBy = ({ }; /** Whether any analyzed source loads an Electron module. */ -export const electronModuleReferenced = ( +const electronModuleReferenced = ( analysis: JavaScriptArtifactAnalysis, ): boolean => analysis.files.some(electronModuleReferencedBy); diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.order.test.ts b/src/application/javascript/JavaScriptArtifactAnalysis.order.test.ts index 3cea7a14ca..d384e44131 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysis.order.test.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysis.order.test.ts @@ -1,8 +1,5 @@ import { expect, it } from "vitest"; -import { - analyzeAndAdoptJavaScriptArtifactFiles, - javaScriptAnalysisOrder, -} from "./JavaScriptArtifactAnalysis.js"; +import { analyzeAndAdoptJavaScriptArtifactFiles } from "./JavaScriptArtifactAnalysis.js"; import { createJavaScriptSemanticGraphProjection } from "./JavaScriptSemanticGraphBuilder.js"; import { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore.js"; import { analyzeJavaScriptStaticSource } from "../../../tests/fixtures/javascriptStaticAnalysis.js"; @@ -37,24 +34,6 @@ const inventory = [ manifest("package.json"), ]; -it("offers application JavaScript before dependency JavaScript", () => { - expect(javaScriptAnalysisOrder(inventory).map(({ path }) => path)).toEqual([ - "node_modules/a/package.json", - "out-global-online/main/index.js", - "out-global-online/preload/index.js", - "package.json", - "node_modules/a/index.js", - "node_modules/b/lib/yaml.js", - ]); - // A package merely named like a dependency directory is not one. - expect( - javaScriptAnalysisOrder([ - source("node_modules.js"), - source("src/node_modules_shim.js"), - ]).map(({ path }) => path), - ).toEqual(["node_modules.js", "src/node_modules_shim.js"]); -}); - it("spends a limited result budget on first-party sources and keeps inventory order", async () => { const javascript = analyzeJavaScriptStaticSource("export const x = 1;"); const analyzed: string[] = []; @@ -107,6 +86,38 @@ it("spends a limited result budget on first-party sources and keeps inventory or expect(outcome.interruption).toBeNull(); }); +it("does not treat files merely named like dependency directories as dependencies", async () => { + const files = [ + source("node_modules/dep/index.js"), + source("node_modules.js"), + source("src/node_modules_shim.js"), + ]; + const analyzed: string[] = []; + const javascript = analyzeJavaScriptStaticSource("export const x = 1;"); + await analyzeAndAdoptJavaScriptArtifactFiles( + { + files, + unavailable_relevant_files: [], + containers: [], + text_bytes_read: 60, + invalid_utf8_files: 0, + }, + { + analyze: async (file) => { + analyzed.push(file.path); + return ok({ javascript, module: null, projection: null }); + }, + }, + createJavaScriptSemanticGraphProjection(), + async () => {}, + ); + expect(analyzed).toEqual([ + "node_modules.js", + "src/node_modules_shim.js", + "node_modules/dep/index.js", + ]); +}); + it("spends capacity in the caller's requested order for explicitly selected sources", async () => { const files = [ source("node_modules/selected/index.js"), diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.ts b/src/application/javascript/JavaScriptArtifactAnalysis.ts index bbed58a4d3..142587a98a 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysis.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysis.ts @@ -329,7 +329,7 @@ const dependencySourcePath = (path: string): boolean => * preloads, and Electron windows are examined. Offer application JavaScript * first and dependency JavaScript last; metadata files keep their position. */ -export const javaScriptAnalysisOrder = ( +const javaScriptAnalysisOrder = ( files: readonly JavaScriptArtifactFile[], ): JavaScriptArtifactFile[] => { const application: JavaScriptArtifactFile[] = []; diff --git a/src/application/javascript/JavaScriptArtifactGraphElectronCoverage.test.ts b/src/application/javascript/JavaScriptArtifactGraphElectronCoverage.test.ts index c3c5f1677c..bfa873624b 100644 --- a/src/application/javascript/JavaScriptArtifactGraphElectronCoverage.test.ts +++ b/src/application/javascript/JavaScriptArtifactGraphElectronCoverage.test.ts @@ -17,7 +17,6 @@ import { javascriptArtifactAnalysisScopes } from "./JavaScriptArtifactAnalysisTy import { completeJavaScriptAnalysisSteps } from "./JavaScriptAnalysisControl.js"; import { buildImmutableJavaScriptArtifactGraphSteps as buildGraph } from "./JavaScriptArtifactGraphBuilder.js"; import { JavaScriptJsonDocuments } from "./JavaScriptJsonDocuments.js"; -import { electronModuleReferenced } from "./ElectronBoundaryAnalysis.js"; const SHA256 = "a".repeat(64); const PATH = "main.js"; @@ -178,25 +177,14 @@ describe("Electron semantic coverage", () => { expect(electronSemantics).toBeUndefined(); }); - it("does not count a type-only Electron import as boundary coverage", () => { - // A `.js` fixture cannot carry `import type`, so the module predicate is - // exercised directly here. - const analysisWith = ( - kind: "require" | "type-import", - ): JavaScriptArtifactAnalysis => - ({ - files: [ - { - file: fileFor(""), - javascript: null, - semantic: { - ir: { moduleLinks: [{ kind, specifier: "electron" }] }, - }, - }, - ], - }) as unknown as JavaScriptArtifactAnalysis; - expect(electronModuleReferenced(analysisWith("require"))).toBe(true); - expect(electronModuleReferenced(analysisWith("type-import"))).toBe(false); + it("does not count a type-only Electron import as boundary coverage", async () => { + const result = await graphFor( + 'import type { BrowserWindow } from "electron";\nexport type Window = BrowserWindow;\n', + undefined, + "main.ts", + ); + expect(result.electronSemantics).toBeUndefined(); + expect(result.limitations).not.toContain(EMPTY_COVERAGE); }); it("matches a supported Electron subpath and ignores an unrelated package", async () => { diff --git a/src/application/javascript/JavaScriptNextAppContext.ts b/src/application/javascript/JavaScriptNextAppContext.ts index 70c7528361..7630dc3772 100644 --- a/src/application/javascript/JavaScriptNextAppContext.ts +++ b/src/application/javascript/JavaScriptNextAppContext.ts @@ -9,6 +9,10 @@ import type { NextAppSourceContext } from "../../domain/javascript/javascriptRou import { compositeKey } from "../../domain/unicodeCodePointOrder.js"; import type { JavaScriptJsonDocuments } from "./JavaScriptJsonDocuments.js"; +/** Identify a package directory within its owning container. */ +const packageDirectoryKey = (container: string, directory: string): string => + compositeKey([container, directory]); + /** Package declarations and inventoried directories own source-router applicability. */ export const createJavaScriptNextAppContext = ( fileSet: Pick< @@ -25,7 +29,7 @@ export const createJavaScriptNextAppContext = ( fileSet.files .filter(({ kind }) => kind === "package-json") .map((file) => [ - compositeKey([file.container_sha256, posix.dirname(file.path)]), + packageDirectoryKey(file.container_sha256, posix.dirname(file.path)), file, ]), ); @@ -33,7 +37,7 @@ export const createJavaScriptNextAppContext = ( fileSet.unavailable_relevant_files .filter(({ kind }) => kind === "package-json") .map((file) => [ - compositeKey([file.container_sha256, posix.dirname(file.path)]), + packageDirectoryKey(file.container_sha256, posix.dirname(file.path)), file, ]), ); @@ -44,7 +48,7 @@ export const createJavaScriptNextAppContext = ( ; directory = posix.dirname(directory) ) { - const key = compositeKey([file.container_sha256, directory]); + const key = packageDirectoryKey(file.container_sha256, directory); const unavailable = unavailablePackages.get(key); if (unavailable !== undefined) return { diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts index 1ff7f2932e..2dda35da5d 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts @@ -1,3 +1,4 @@ +import { JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS } from "../../domain/javascript/javascriptAnalysisResourceControls.js"; import { javaScriptSemanticQueryInputSchema } from "../../domain/javascript/javascriptSemanticQuerySchemas.js"; import { primitiveByteExpansionSource, @@ -9,8 +10,6 @@ import { expect, it } from "vitest"; import { aliasSelectionBudgetSource } from "../../../tests/fixtures/javascriptAliasSelectionBudget.js"; import { - SEMANTIC_GRAPH_FILE_NODE_CEILING, - SEMANTIC_GRAPH_NODE_CEILING, createJavaScriptSemanticGraphProjection, projectJavaScriptSemanticFileSteps, } from "./JavaScriptSemanticGraphBuilder.js"; @@ -972,7 +971,9 @@ it("bounds semantic node projection and reports the ceiling in coverage", () => ).join("\n"); const graph = graphFor(statements); - expect(graph.nodes.length).toBeLessThanOrEqual(SEMANTIC_GRAPH_NODE_CEILING); + expect(graph.nodes.length).toBeLessThanOrEqual( + JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.semanticNodes, + ); expect(graph.coverage.truncated).toBe(true); expect(graph.coverage.status).toBe("partial"); expect(graph.coverage.omitted_nodes).toBeNull(); @@ -1136,9 +1137,13 @@ it("leaves a small application complete and untruncated", () => { it("does not mark a file truncated when it exactly fills its budget", () => { // Each unbound reference projects one expression, plus the module root. const graph = graphFor( - "external;\n".repeat(SEMANTIC_GRAPH_FILE_NODE_CEILING - 1), + "external;\n".repeat( + JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.semanticNodesPerFile - 1, + ), + ); + expect(graph.nodes).toHaveLength( + JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.semanticNodesPerFile, ); - expect(graph.nodes).toHaveLength(SEMANTIC_GRAPH_FILE_NODE_CEILING); expect(graph.coverage).toMatchObject({ truncated: false, omitted_nodes: 0, @@ -1160,7 +1165,9 @@ it("only records node loss when the budget rejects a new identity", () => { expect(state.fileNodeBudget).toBe(0); expect(state.fileNodesDropped).toBe(false); - expect(addSemanticGraphNode(state, semanticNodeFor("first"))).toBe(first); + expect(addSemanticGraphNode(state, semanticNodeFor("first"))).toEqual(first); + expect(state.nodes.size).toBe(2); + expect(state.fileNodeBudget).toBe(0); expect(state.fileNodesDropped).toBe(false); const blocked = addSemanticGraphNode(state, semanticNodeFor("third")); diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.ts index c1e4ade32c..b6bafed798 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.ts @@ -111,36 +111,6 @@ interface FileContext extends SemanticFlowProjectionContext { >; } -/** - * Resource-safety ceiling on retained semantic nodes. - * - * The semantic projection emits one node per AST expression, call site, binding - * and property slot. That is unbounded in input size. A small tree of five files - * already yields on the order of 170,000 nodes, and larger trees yield millions, - * exhausting the heap before the result can be serialized. - * - * This is a real resource-safety constraint, not a presentation preference, so - * the ceiling is applied and reported through the graph's own `coverage` fields - * (`truncated`, `omitted_nodes`, `omitted_relations`, `limits`) rather than - * silently dropping data. - * - * Defaults bound work even when ample heap is available. Callers may raise the - * capacities explicitly; isolated worker heap, bounded record transport and - * parent result admission independently constrain actual retained memory. The - * result uses incremental serialization rather than a single JSON string. - */ -export const SEMANTIC_GRAPH_NODE_CEILING = - JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.semanticNodes; - -/** - * Per-file share of the semantic node budget. - * - * Bound each source file independently so one large bundled library cannot - * consume the whole tree's budget and starve the application's own modules. - */ -export const SEMANTIC_GRAPH_FILE_NODE_CEILING = - JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.semanticNodesPerFile; - /** File-local semantic projection that does not retain consumed source IR. */ export interface JavaScriptSemanticGraphProjection { /** Node capacity offered to the next independently analyzed source file. */ @@ -216,8 +186,12 @@ export const createJavaScriptSemanticGraphProjection = ( readonly fileUnknowns?: number; } = {}, ): JavaScriptSemanticGraphProjection => { - const nodeCeiling = limits.totalNodes ?? SEMANTIC_GRAPH_NODE_CEILING; - const fileNodeCeiling = limits.fileNodes ?? SEMANTIC_GRAPH_FILE_NODE_CEILING; + // Bound total and per-file work so a large dependency cannot starve the graph. + const nodeCeiling = + limits.totalNodes ?? JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.semanticNodes; + const fileNodeCeiling = + limits.fileNodes ?? + JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.semanticNodesPerFile; const unknownCeiling = limits.totalUnknowns ?? JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.semanticUnknowns; diff --git a/src/application/managed/ManagedMemberComparisonService.ts b/src/application/managed/ManagedMemberComparisonService.ts index c337097c20..48c7651332 100644 --- a/src/application/managed/ManagedMemberComparisonService.ts +++ b/src/application/managed/ManagedMemberComparisonService.ts @@ -138,9 +138,9 @@ export const compareManagedMemberPaths = async ( operation, ).bindCleanupDependencies([leftTarget.error, rightTarget.error]), ); - return err(leftTarget.error); + return leftTarget; } - if (!rightTarget.ok) return err(rightTarget.error); + if (!rightTarget.ok) return rightTarget; const inspectTarget = async ( target: BinaryTarget, inputField: "leftPath" | "rightPath", diff --git a/src/application/native/NativeDispatchMetadataInspection.ts b/src/application/native/NativeDispatchMetadataInspection.ts index 42a4cb45d0..b7f38cad61 100644 --- a/src/application/native/NativeDispatchMetadataInspection.ts +++ b/src/application/native/NativeDispatchMetadataInspection.ts @@ -83,7 +83,7 @@ export const inspectNativeDispatch = async ( {}, signal === undefined ? {} : { signal }, ); - if (!execution.ok) return err(execution.error); + if (!execution.ok) return execution; const names = dispatchNameInventorySchema.safeParse(execution.value.result); if (!names.success) return err( diff --git a/src/application/native/NativeUiActionTrace.ts b/src/application/native/NativeUiActionTrace.ts index 0113c26aaa..905686c0b8 100644 --- a/src/application/native/NativeUiActionTrace.ts +++ b/src/application/native/NativeUiActionTrace.ts @@ -77,8 +77,8 @@ export const traceNativeUiAction = async ( (uiExecution.error._tag === "AnalysisCancelledError" || uiExecution.error.cleanupIncomplete) ) - return err(uiExecution.error); - if (!metadataExecution.ok) return err(metadataExecution.error); + return uiExecution; + if (!metadataExecution.ok) return metadataExecution; const metadata = nativeDispatchMetadataResultSchema.parse( metadataExecution.value, ); diff --git a/src/application/ownedProcessAvailability.test.ts b/src/application/ownedProcessAvailability.test.ts index 13a97d8dfb..4ead728557 100644 --- a/src/application/ownedProcessAvailability.test.ts +++ b/src/application/ownedProcessAvailability.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, onTestFinished, vi } from "vitest"; +import * as processOutput from "../process/ExecFileOutput.js"; import { AnalysisCancelledError, @@ -20,15 +21,11 @@ describe("Linux process table prerequisite discovery", () => { "reports %s with its own recovery", async (code) => { const cause = Object.assign(new Error(`ps failed: ${code}`), { code }); - const host = createSystemProcessOwnershipHost( - "linux", - {}, - { - execFileOutput: async () => { - throw cause; - }, - }, - ); + const command = vi + .spyOn(processOutput, "execFileOutput") + .mockRejectedValue(cause); + onTestFinished(() => command.mockRestore()); + const host = createSystemProcessOwnershipHost("linux", {}); let failure: unknown; try { await host.prepare?.(); @@ -58,15 +55,11 @@ describe("Linux process table prerequisite discovery", () => { it("reports a ps probe failure with no error code without blaming missing procps", async () => { const cause = new Error("ps failed without error code"); - const host = createSystemProcessOwnershipHost( - "linux", - {}, - { - execFileOutput: async () => { - throw cause; - }, - }, - ); + const command = vi + .spyOn(processOutput, "execFileOutput") + .mockRejectedValue(cause); + onTestFinished(() => command.mockRestore()); + const host = createSystemProcessOwnershipHost("linux", {}); let failure: unknown; try { await host.prepare?.(); @@ -103,15 +96,11 @@ describe("Linux process table prerequisite discovery", () => { it("does not convert host spawn capacity exhaustion to unavailability", async () => { const cause = Object.assign(new Error("capacity"), { code: "EAGAIN" }); - const host = createSystemProcessOwnershipHost( - "linux", - {}, - { - execFileOutput: async () => { - throw cause; - }, - }, - ); + const command = vi + .spyOn(processOutput, "execFileOutput") + .mockRejectedValue(cause); + onTestFinished(() => command.mockRestore()); + const host = createSystemProcessOwnershipHost("linux", {}); await expect(host.prepare?.()).rejects.toBe(cause); }); }); diff --git a/src/application/process/ProcessCli.ts b/src/application/process/ProcessCli.ts index a50b470b1d..da4a0e7775 100644 --- a/src/application/process/ProcessCli.ts +++ b/src/application/process/ProcessCli.ts @@ -152,7 +152,7 @@ export const captureProcessScenarioFile = async ( ); captureOutcome = captured.ok ? ok(createProcessCaptureEvidence(parsed.data, captured.value)) - : err(captured.error); + : captured; } catch (cause: unknown) { captureOutcome = err(cause); } diff --git a/src/application/process/ProcessScenarioPreparation.ts b/src/application/process/ProcessScenarioPreparation.ts index d2bdb6f9ca..4010896982 100644 --- a/src/application/process/ProcessScenarioPreparation.ts +++ b/src/application/process/ProcessScenarioPreparation.ts @@ -53,7 +53,7 @@ export const prepareProcessScenarioPlan = async ( await resolveProcessScenarioRuntimePaths(scenario, environment, platform); if (aborted()) return cancelled(); const privacy = createProcessSensitiveOutput(scenario); - if (!privacy.ok) return err(privacy.error); + if (!privacy.ok) return privacy; const admitted = await admitFilesystemObservationRoots(resolved, signal); if (!admitted.ok) return aborted() ? cancelled() : admitted; if (aborted()) return cancelled(); diff --git a/src/application/releaseExposure/ReleaseExposureService.test.ts b/src/application/releaseExposure/ReleaseExposureService.test.ts index ac1bbba2dc..4a7b073e1b 100644 --- a/src/application/releaseExposure/ReleaseExposureService.test.ts +++ b/src/application/releaseExposure/ReleaseExposureService.test.ts @@ -19,7 +19,6 @@ import { createEvidence, type Evidence } from "../../domain/evidence.js"; import { RELEASE_EXPOSURE_PROVIDER } from "../../domain/investigationProviders.js"; import { PACKAGED_NATIVE_IMAGES, - RELEASE_EXPOSURE_CHECK_IDS, releaseExposureReportSchema, type ReleaseExposureScan, } from "../../domain/releaseExposure.js"; @@ -189,9 +188,6 @@ describe("ReleaseExposureService", () => { observation: { samples: string[] }; }[]; }; - expect(report.checks.map((check) => check.id)).toEqual([ - ...RELEASE_EXPOSURE_CHECK_IDS, - ]); expect( report.checks.find((check) => check.id === "go.build_paths")?.status, ).toBe("not_applicable"); diff --git a/src/artifacts/AsarArtifactReader.ts b/src/artifacts/AsarArtifactReader.ts index 4dd8bf328c..fed845c12e 100644 --- a/src/artifacts/AsarArtifactReader.ts +++ b/src/artifacts/AsarArtifactReader.ts @@ -57,6 +57,7 @@ export class AsarArtifactReader implements ArtifactReader { #snapshot: { readonly path: string; readonly sha256: string } | undefined; #snapshotRoot: string | undefined; #snapshotOperation: Promise = Promise.resolve(); + #snapshotGeneration = 0; constructor( private readonly path: string, @@ -64,6 +65,7 @@ export class AsarArtifactReader implements ArtifactReader { ) {} async *entries(signal?: AbortSignal): AsyncIterable { + const generation = this.#snapshotGeneration; let paths: readonly AsarListedEntry[]; try { await this.prepareContainer(undefined, signal); @@ -102,7 +104,7 @@ export class AsarArtifactReader implements ArtifactReader { throw asarFailure(this.path, "inventory", cause); } for (const facts of paths) { - assertAsarReadActive(signal); + this.#assertInventoryActive(generation, signal); const { path, kind } = facts; const providerPath = path.split("/").join(sep); const entry: ArtifactEntry = { @@ -130,6 +132,16 @@ export class AsarArtifactReader implements ArtifactReader { }); yield entry; } + this.#assertInventoryActive(generation, signal); + } + + #assertInventoryActive(generation: number, signal?: AbortSignal): void { + assertAsarReadActive(signal); + if (generation !== this.#snapshotGeneration) + throw new ArtifactReaderFailure( + "unavailable", + "ASAR inventory snapshot was closed", + ); } async #admitHeader(snapshotPath: string, signal?: AbortSignal) { @@ -341,6 +353,7 @@ export class AsarArtifactReader implements ArtifactReader { } async close(): Promise { + this.#snapshotGeneration += 1; await this.#withSnapshotOperation(() => this.#closeSnapshot()); } diff --git a/src/artifacts/AsarHeaderIdentity.test.ts b/src/artifacts/AsarHeaderIdentity.test.ts index 3ef42c1fe6..9f28ea159f 100644 --- a/src/artifacts/AsarHeaderIdentity.test.ts +++ b/src/artifacts/AsarHeaderIdentity.test.ts @@ -7,6 +7,32 @@ import { expect, it } from "vitest"; import { AsarArtifactReader } from "./AsarArtifactReader.js"; import { asarHeaderBytes } from "../../tests/fixtures/asarHeader.js"; +it.each(["close", "cancel"] as const)( + "does not complete an ASAR inventory after %s while paused", + async (interruption) => { + const root = await mkdtemp(join(tmpdir(), "rea-asar-paused-")); + const path = join(root, "app.asar"); + await writeFile( + path, + asarHeaderBytes('{"files":{"x":{"size":0,"offset":"0"}}}'), + ); + const reader = new AsarArtifactReader(path); + const controller = new AbortController(); + try { + const entries = reader.entries(controller.signal)[Symbol.asyncIterator](); + expect((await entries.next()).done).toBe(false); + if (interruption === "close") await reader.close(); + else controller.abort(); + await expect(entries.next()).rejects.toMatchObject({ + reason: interruption === "close" ? "unavailable" : "cancelled", + }); + } finally { + await reader.close(); + await rm(root, { recursive: true, force: true }); + } + }, +); + 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"); diff --git a/src/artifacts/BunStandaloneArtifactReader.ts b/src/artifacts/BunStandaloneArtifactReader.ts index 9754854ee9..776c856f4c 100644 --- a/src/artifacts/BunStandaloneArtifactReader.ts +++ b/src/artifacts/BunStandaloneArtifactReader.ts @@ -7,8 +7,7 @@ import { artifactReaderLimitFailure } from "./ArtifactReaderLimits.js"; import type { BunStandaloneAdmission } from "./bun/BunStandaloneAdmission.js"; import type { StableRegularFileDescriptor } from "../filesystem/RegularFile.js"; import { setImmediate } from "node:timers/promises"; -import { Readable, Transform, pipeline } from "node:stream"; -import { finished } from "node:stream/promises"; +import { Readable, Transform } from "node:stream"; import { artifactExpansionBudgetBytes } from "../domain/artifactExpansionBudget.js"; import type { BunStandaloneEntryMetadata, @@ -32,6 +31,7 @@ import { } from "./ArtifactReader.js"; import { ArtifactFileSource } from "./ArtifactFileSource.js"; import { ArtifactResourceScope } from "./ArtifactResourceScope.js"; +import { ownArtifactStream } from "./OwnedArtifactStream.js"; import { bunFormatFailure, bunMetadataBytesLimit, @@ -108,12 +108,14 @@ export class BunStandaloneArtifactReader implements ArtifactReader { ); await this.#source.readSelectedPath(signal); }); + this.#assertOpen(signal); let index = 0; for (const entry of this.#entries.values()) { if (index++ % 256 === 0) await setImmediate(); this.#assertOpen(signal); yield copyArtifactEntry(entry); } + this.#assertOpen(signal); } async open(entry: ArtifactEntry, signal?: AbortSignal): Promise { @@ -129,6 +131,14 @@ export class BunStandaloneArtifactReader implements ArtifactReader { this.#assertOpen(signal); if (stored.byteOffset === null || stored.declaredSize === null) throw bunFormatFailure("member has no physical byte range"); + const cancellationFailure = (reason: unknown): ArtifactReaderFailure => + new ArtifactReaderFailure( + "cancelled", + "Bun standalone read cancelled", + { + cause: reason, + }, + ); const source = stored.declaredSize === 0 ? Readable.from([]) @@ -136,33 +146,16 @@ export class BunStandaloneArtifactReader implements ArtifactReader { start: stored.byteOffset, end: stored.byteOffset + stored.declaredSize - 1, signal, - cancellationFailure: (reason) => - new ArtifactReaderFailure( - "cancelled", - "Bun standalone read cancelled", - { cause: reason }, - ), + cancellationFailure, }); const output = memberStream(stored); - output.on("error", () => undefined); - this.#streams.add(output); - output.once("close", () => this.#streams.delete(output)); - void this.#scope - .run( - () => - new Promise((resolve) => { - pipeline(source, output, () => { - resolve(); - }); - }), - ) - .catch((cause: unknown) => - output.destroy( - cause instanceof Error - ? cause - : new Error("Bun standalone stream ownership failed", { cause }), - ), - ); + ownArtifactStream(source, output, { + scope: this.#scope, + streams: this.#streams, + signal, + cancellationFailure, + label: "Bun standalone", + }); return output; }); } @@ -177,11 +170,6 @@ export class BunStandaloneArtifactReader implements ArtifactReader { ), ); await this.#scope.close(); - await Promise.all( - [...this.#streams].map((stream) => - finished(stream).catch(() => undefined), - ), - ); await this.#source.close(); this.#entries.clear(); this.#header = undefined; diff --git a/src/artifacts/DecodedRegionArtifactReader.ts b/src/artifacts/DecodedRegionArtifactReader.ts index fd600cab19..f3406908c5 100644 --- a/src/artifacts/DecodedRegionArtifactReader.ts +++ b/src/artifacts/DecodedRegionArtifactReader.ts @@ -118,6 +118,7 @@ export class DecodedRegionArtifactReader implements ArtifactReader { ); await this.#source.read(signal); }); + this.#assertOpen(signal); const coverage = this.#described?.coverage; if (coverage !== undefined) options?.onCoverage?.({ @@ -131,6 +132,7 @@ export class DecodedRegionArtifactReader implements ArtifactReader { this.#assertOpen(signal); yield copyArtifactEntry(entry); } + this.#assertOpen(signal); } async open(entry: ArtifactEntry, signal?: AbortSignal): Promise { diff --git a/src/artifacts/DenoStandaloneArtifactReader.ts b/src/artifacts/DenoStandaloneArtifactReader.ts index 2e40a76abe..8f7a79b5b5 100644 --- a/src/artifacts/DenoStandaloneArtifactReader.ts +++ b/src/artifacts/DenoStandaloneArtifactReader.ts @@ -1,5 +1,5 @@ import { ArtifactPreparation } from "./ArtifactPreparation.js"; -import { Readable, Transform, pipeline } from "node:stream"; +import { Readable, Transform } from "node:stream"; import { setImmediate } from "node:timers/promises"; import type { StableRegularFileDescriptor } from "../filesystem/RegularFile.js"; @@ -27,6 +27,7 @@ import { type ArtifactTraversalOptions, } from "./ArtifactReader.js"; import { ArtifactResourceScope } from "./ArtifactResourceScope.js"; +import { ownArtifactStream } from "./OwnedArtifactStream.js"; import type { DenoStandaloneAdmission } from "./deno/DenoStandaloneAdmission.js"; import { denoFormatFailure, @@ -146,6 +147,10 @@ export class DenoStandaloneArtifactReader implements ArtifactReader { this.#assertOpen(signal); if (stored.byteOffset === null || stored.declaredSize === null) throw denoFormatFailure("member has no physical byte range"); + const cancellationFailure = (reason: unknown): ArtifactReaderFailure => + new ArtifactReaderFailure("cancelled", "Deno compile read cancelled", { + cause: reason, + }); const source = stored.declaredSize === 0 ? Readable.from([]) @@ -153,64 +158,16 @@ export class DenoStandaloneArtifactReader implements ArtifactReader { start: stored.byteOffset, end: stored.byteOffset + stored.declaredSize - 1, signal, - cancellationFailure: (reason) => - new ArtifactReaderFailure( - "cancelled", - "Deno compile read cancelled", - { - cause: reason, - }, - ), + cancellationFailure, }); const output = this.#memberStream(stored, signal); - output.on("error", () => undefined); - this.#streams.add(output); - let producerComplete = false; - const onAbort = (): void => { - // Active I/O and final validation own failure precedence. Buffered - // readable bytes remain owned after production finishes. - if (!producerComplete || output.destroyed) return; - output.destroy( - new ArtifactReaderFailure( - "cancelled", - "Deno compile read cancelled", - { - cause: signal?.reason, - }, - ), - ); - }; - const retired = new Promise((resolve) => { - output.once("close", () => { - this.#streams.delete(output); - signal?.removeEventListener("abort", onAbort); - resolve(); - }); + ownArtifactStream(source, output, { + scope: this.#scope, + streams: this.#streams, + signal, + cancellationFailure, + label: "Deno compile", }); - signal?.addEventListener("abort", onAbort, { once: true }); - if (signal?.aborted) onAbort(); - void this.#scope - .run(async () => { - // Writable completion can leave unread bytes buffered. Join both - // producer work and retirement before releasing the carrier. - await Promise.all([ - new Promise((resolve) => { - pipeline(source, output, () => { - producerComplete = true; - if (signal?.aborted) onAbort(); - resolve(); - }); - }), - retired, - ]); - }) - .catch((cause: unknown) => - output.destroy( - cause instanceof Error - ? cause - : new Error("Deno compile stream ownership failed", { cause }), - ), - ); return output; }); } diff --git a/src/artifacts/DirectoryArtifactReader.ts b/src/artifacts/DirectoryArtifactReader.ts index ad855a4f0b..00e14ce33d 100644 --- a/src/artifacts/DirectoryArtifactReader.ts +++ b/src/artifacts/DirectoryArtifactReader.ts @@ -169,6 +169,7 @@ export class DirectoryArtifactReader implements ArtifactReader { assertContainedDirectory(root, directory, admittedRoot.identity), ); } + abortIfNeeded(signal); if (unrepresentableNameCount > 0) options?.onCoverage?.({ status: "unknown", @@ -254,6 +255,7 @@ export class DirectoryArtifactReader implements ArtifactReader { }; try { for (;;) { + abortIfNeeded(signal); await withDirectoryIoContext("inspect directory", directory, () => assertContainedDirectory(root, directory, admittedRoot.identity), ); diff --git a/src/artifacts/FitArtifactReader.ts b/src/artifacts/FitArtifactReader.ts index 2130ad7e61..37cd34b15f 100644 --- a/src/artifacts/FitArtifactReader.ts +++ b/src/artifacts/FitArtifactReader.ts @@ -1,5 +1,5 @@ import { ArtifactPreparation } from "./ArtifactPreparation.js"; -import { Readable, Transform, pipeline } from "node:stream"; +import { Readable, Transform } from "node:stream"; import { setImmediate } from "node:timers/promises"; import type { FitMetadata } from "../domain/artifactFormatMetadata.js"; @@ -14,6 +14,7 @@ import { type ArtifactTraversalOptions, } from "./ArtifactReader.js"; import { ArtifactResourceScope } from "./ArtifactResourceScope.js"; +import { ownArtifactStream } from "./OwnedArtifactStream.js"; import { assertArtifactReadActive } from "./ArtifactFileOperation.js"; import { ArtifactFileSource } from "./ArtifactFileSource.js"; import { @@ -115,45 +116,13 @@ export class FitArtifactReader implements ArtifactReader { cancellationFailure, }); const output = this.#imageStream(stored, signal); - output.on("error", () => undefined); - this.#streams.add(output); - let producerComplete = false; - const onAbort = (): void => { - // Active I/O and final validation own failure precedence. Buffered - // readable bytes still belong to the caller after they finish. - if (!producerComplete || output.destroyed) return; - output.destroy(cancellationFailure(signal?.reason)); - }; - const retired = new Promise((resolve) => { - output.once("close", () => { - this.#streams.delete(output); - signal?.removeEventListener("abort", onAbort); - resolve(); - }); + ownArtifactStream(source, output, { + scope: this.#scope, + streams: this.#streams, + signal, + cancellationFailure, + label: "FIT", }); - signal?.addEventListener("abort", onAbort, { once: true }); - if (signal?.aborted) onAbort(); - void this.#scope - .run(async () => { - // Retain both producer work and the unconsumed readable stream. - await Promise.all([ - new Promise((resolve) => - pipeline(source, output, () => { - producerComplete = true; - if (signal?.aborted) onAbort(); - resolve(); - }), - ), - retired, - ]); - }) - .catch((cause: unknown) => - output.destroy( - cause instanceof Error - ? cause - : new Error("FIT stream ownership failed", { cause }), - ), - ); return output; }); } diff --git a/src/artifacts/HexRecordArtifactReader.test.ts b/src/artifacts/HexRecordArtifactReader.test.ts index 037c296547..8d1136ea07 100644 --- a/src/artifacts/HexRecordArtifactReader.test.ts +++ b/src/artifacts/HexRecordArtifactReader.test.ts @@ -27,6 +27,29 @@ const writeImage = async (name: string, text: string): Promise => { return path; }; +it.each(["close", "cancel"] as const)( + "does not complete a decoded-region inventory after %s while paused", + async (interruption) => { + const path = await writeImage( + "image.hex", + `${intelHexRecord(0, 0, [1])}\n:00000001FF\n`, + ); + const reader = new HexRecordArtifactReader(path, "intel-hex"); + const controller = new AbortController(); + try { + const entries = reader.entries(controller.signal)[Symbol.asyncIterator](); + expect((await entries.next()).done).toBe(false); + if (interruption === "close") await reader.close(); + else controller.abort(); + await expect(entries.next()).rejects.toMatchObject({ + reason: interruption === "close" ? "unavailable" : "cancelled", + }); + } finally { + await reader.close(); + } + }, +); + it("reports unknown coverage and termination facts as limitations", async () => { const truncated = await scanCanonicalArtifactInventory( await writeImage("truncated.s19", SREC_S19.replace("S9030000FC\n", "")), diff --git a/src/artifacts/InesArtifactReader.ts b/src/artifacts/InesArtifactReader.ts index 1be8e79cdf..3ff48f8562 100644 --- a/src/artifacts/InesArtifactReader.ts +++ b/src/artifacts/InesArtifactReader.ts @@ -57,7 +57,7 @@ export class InesArtifactReader implements ArtifactReader { this.#layout = parsed.value; this.#entries.clear(); for (const region of parsed.value.regions) { - abortIfNeeded(signal); + this.#assertOpen(signal); const entry: ArtifactEntry = { path: region.path, kind: "file", @@ -74,6 +74,7 @@ export class InesArtifactReader implements ArtifactReader { this.#entries.set(entry.adapterKey, copyArtifactEntry(entry)); yield entry; } + this.#assertOpen(signal); } async open(entry: ArtifactEntry, signal?: AbortSignal): Promise { @@ -166,9 +167,7 @@ export class InesArtifactReader implements ArtifactReader { } async #verifySource(signal?: AbortSignal): Promise { - abortIfNeeded(signal); - if (this.#closed) - throw new ArtifactReaderFailure("unavailable", "iNES reader is closed"); + this.#assertOpen(signal); try { await assertStableRegularFileIdentity( this.source.handle, @@ -188,4 +187,10 @@ export class InesArtifactReader implements ArtifactReader { // State validation is asynchronous even after the final byte was consumed. abortIfNeeded(signal); } + + #assertOpen(signal?: AbortSignal): void { + abortIfNeeded(signal); + if (this.#closed) + throw new ArtifactReaderFailure("unavailable", "iNES reader is closed"); + } } diff --git a/src/artifacts/NativeDmgArtifactReader.ts b/src/artifacts/NativeDmgArtifactReader.ts index 90a1d01305..235b5a795b 100644 --- a/src/artifacts/NativeDmgArtifactReader.ts +++ b/src/artifacts/NativeDmgArtifactReader.ts @@ -213,9 +213,17 @@ export class NativeDmgArtifactReader implements ArtifactReader { [Symbol.asyncIterator](); try { for (;;) { - if (this.#closed) return; + if (this.#closed) + throw new ArtifactReaderFailure( + "unavailable", + "DMG reader is closed", + ); const next = await this.#trackDirectoryOperation(() => entries.next()); - if (this.#closed) return; + if (this.#closed) + throw new ArtifactReaderFailure( + "unavailable", + "DMG reader is closed", + ); if (next.done) return; yield { ...next.value, path: `${prefix}/${next.value.path}` }; } diff --git a/src/artifacts/NativeDmgProtocol.test.ts b/src/artifacts/NativeDmgProtocol.test.ts index f7bbb9e1e5..7e9106a734 100644 --- a/src/artifacts/NativeDmgProtocol.test.ts +++ b/src/artifacts/NativeDmgProtocol.test.ts @@ -81,6 +81,12 @@ it.skipIf(process.platform === "win32").each([false, true])( paths.push(entry.path); expect(paths).toContain("image.dmg/Volume/readable.txt"); expect(coverage).toEqual(unknownCoverage ? [producerCoverage] : []); + const paused = reader.entries()[Symbol.asyncIterator](); + expect((await paused.next()).done).toBe(false); + await reader.close(); + await expect(paused.next()).rejects.toMatchObject({ + reason: "unavailable", + }); } finally { try { await reader.close(); diff --git a/src/artifacts/NtfsMftArtifactReader.test.ts b/src/artifacts/NtfsMftArtifactReader.test.ts index 3fec45e728..6dc2438c90 100644 --- a/src/artifacts/NtfsMftArtifactReader.test.ts +++ b/src/artifacts/NtfsMftArtifactReader.test.ts @@ -225,6 +225,22 @@ it("retries a shared preparation cancelled by another traversal", async () => { } }); +it("reports cancellation during MFT coverage", async () => { + const path = await writeMft("$MFT", recordBytes({})); + const reader = new NtfsMftArtifactReader(path); + const controller = new AbortController(); + try { + const entries = reader + .entries(controller.signal, { + onCoverage: () => controller.abort(), + }) + [Symbol.asyncIterator](); + await expect(entries.next()).rejects.toMatchObject({ reason: "cancelled" }); + } finally { + await reader.close(); + } +}); + it("revalidates the source before reusing a completed header inventory", async () => { const path = await writeMft("$MFT", recordBytes({})); const reader = new NtfsMftArtifactReader(path); diff --git a/src/artifacts/NtfsMftArtifactReader.ts b/src/artifacts/NtfsMftArtifactReader.ts index 4f54fa3982..0989f9e421 100644 --- a/src/artifacts/NtfsMftArtifactReader.ts +++ b/src/artifacts/NtfsMftArtifactReader.ts @@ -74,6 +74,7 @@ export class NtfsMftArtifactReader implements ArtifactReader { // An MFT declares no members, so the root occurrence is the whole // inventory; the coverage callback reports what its parse did not establish. options?.onCoverage?.(coverageOf(this.#limitations)); + this.#assertOpen(signal); } async open(entry: ArtifactEntry, _signal?: AbortSignal): Promise { diff --git a/src/artifacts/OwnedArtifactStream.ts b/src/artifacts/OwnedArtifactStream.ts new file mode 100644 index 0000000000..1f1ebce415 --- /dev/null +++ b/src/artifacts/OwnedArtifactStream.ts @@ -0,0 +1,67 @@ +import { pipeline, type Readable, type Transform } from "node:stream"; + +import type { ArtifactResourceScope } from "./ArtifactResourceScope.js"; + +/** Keep the carrier and buffered output owned through production and retirement. */ +export const ownArtifactStream = ( + source: Readable, + output: Transform, + { + scope, + streams, + signal, + cancellationFailure, + label, + }: { + readonly scope: ArtifactResourceScope; + readonly streams: Set; + readonly signal: AbortSignal | undefined; + readonly cancellationFailure: (reason: unknown) => Error; + readonly label: string; + }, +): void => { + output.on("error", () => undefined); + streams.add(output); + let producerComplete = false; + const onAbort = (): void => { + // Active I/O and validation own failure precedence; cancellation also + // retires buffered output after production completes. + if (!producerComplete || output.destroyed) return; + output.destroy(cancellationFailure(signal?.reason)); + }; + const retired = new Promise((resolve) => { + output.once("close", () => { + streams.delete(output); + signal?.removeEventListener("abort", onAbort); + resolve(); + }); + }); + signal?.addEventListener("abort", onAbort, { once: true }); + if (signal?.aborted) onAbort(); + output.once("finish", () => { + // A successful writable finish follows the transform's final validation. + // Pipeline retirement may follow later while readable bytes remain buffered. + producerComplete = true; + if (signal?.aborted) onAbort(); + }); + void scope + .run(async () => { + await Promise.all([ + new Promise((resolve) => { + pipeline(source, output, () => { + producerComplete = true; + if (signal?.aborted) onAbort(); + resolve(); + }); + }), + retired, + ]); + }) + .catch((cause: unknown) => + output.destroy( + cause instanceof Error + ? cause + : new Error(`${label} stream ownership failed`, { cause }), + ), + ); +}; diff --git a/src/artifacts/OwnedAsyncArtifactStream.ts b/src/artifacts/OwnedAsyncArtifactStream.ts new file mode 100644 index 0000000000..16f0b3675e --- /dev/null +++ b/src/artifacts/OwnedAsyncArtifactStream.ts @@ -0,0 +1,81 @@ +import { Readable } from "node:stream"; + +import { ArtifactReaderFailure } from "./ArtifactReader.js"; +import { artifactReadCancelledBy } from "./ArtifactFileOperation.js"; +import type { ArtifactResourceScope } from "./ArtifactResourceScope.js"; + +/** Retain a member iterator, including pending reads, until its stream closes. */ +export const ownAsyncArtifactStream = ( + chunks: AsyncIterable, + controller: AbortController, + { + scope, + streams, + signal, + label, + highWaterMark, + }: { + readonly scope: ArtifactResourceScope; + readonly streams: Map; + readonly signal: AbortSignal | undefined; + readonly label: string; + readonly highWaterMark: number; + }, +): Readable => { + let pending = false; + const cancelled = (): ArtifactReaderFailure => + new ArtifactReaderFailure("cancelled", `${label} member read cancelled`, { + cause: signal?.reason, + }); + const tracked = async function* (): AsyncGenerator { + const iterator = chunks[Symbol.asyncIterator](); + try { + while (true) { + pending = true; + let next: IteratorResult; + try { + next = await iterator.next(); + } catch (cause) { + if (artifactReadCancelledBy(cause, signal)) throw cancelled(); + throw cause; + } finally { + pending = false; + } + if (signal?.aborted) throw cancelled(); + if (next.done) return; + yield next.value; + } + } finally { + await iterator.return?.(); + } + }; + const output = Readable.from(tracked(), { objectMode: false, highWaterMark }); + const onAbort = (): void => { + controller.abort(signal?.reason); + // An outstanding iterator read owns its failure; a successful read will + // observe cancellation before yielding its next chunk. + if (!pending) output.destroy(cancelled()); + }; + output.on("error", () => undefined); + streams.set(output, controller); + const completion = new Promise((resolve) => { + output.once("close", () => { + signal?.removeEventListener("abort", onAbort); + streams.delete(output); + resolve(); + }); + }); + void scope + .run(() => completion) + .catch((cause: unknown) => { + controller.abort(cause); + output.destroy( + cause instanceof Error + ? cause + : new Error(`${label} stream ownership failed`, { cause }), + ); + }); + signal?.addEventListener("abort", onAbort, { once: true }); + if (signal?.aborted === true) onAbort(); + return output; +}; diff --git a/src/artifacts/PositionalArtifactReader.ts b/src/artifacts/PositionalArtifactReader.ts index 72b0931095..2e01b8ab29 100644 --- a/src/artifacts/PositionalArtifactReader.ts +++ b/src/artifacts/PositionalArtifactReader.ts @@ -1,5 +1,5 @@ import { ArtifactPreparation } from "./ArtifactPreparation.js"; -import { Readable, Transform, pipeline } from "node:stream"; +import { Readable, Transform } from "node:stream"; import { setImmediate } from "node:timers/promises"; import { @@ -12,6 +12,7 @@ import { type ArtifactTraversalOptions, } from "./ArtifactReader.js"; import { ArtifactResourceScope } from "./ArtifactResourceScope.js"; +import { ownArtifactStream } from "./OwnedArtifactStream.js"; import { assertArtifactReadActive } from "./ArtifactFileOperation.js"; import { ArtifactFileSource } from "./ArtifactFileSource.js"; import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js"; @@ -116,6 +117,14 @@ export abstract class PositionalArtifactReader implements ArtifactReader { ); const { handle } = await this.#source.read(signal); this.assertOpen(signal); + const cancellationFailure = (reason: unknown): ArtifactReaderFailure => + new ArtifactReaderFailure( + "cancelled", + `${this.#label} read cancelled`, + { + cause: reason, + }, + ); const source = stored.declaredSize === 0 ? Readable.from([]) @@ -123,63 +132,17 @@ export abstract class PositionalArtifactReader implements ArtifactReader { start: stored.byteOffset, end: stored.byteOffset + stored.declaredSize - 1, signal, - cancellationFailure: (reason) => - new ArtifactReaderFailure( - "cancelled", - `${this.#label} read cancelled`, - { cause: reason }, - ), + cancellationFailure, }); const output = this.#entryStream(stored, signal); // Retirement can destroy the stream before its consumer listens. - output.on("error", () => undefined); - this.#streams.add(output); - let producerComplete = false; - const onAbort = (): void => { - // Preserve active read/validation errors; after production, the caller - // still owns buffered bytes and cancellation must retire that result. - if (!producerComplete || output.destroyed) return; - output.destroy( - new ArtifactReaderFailure( - "cancelled", - `${this.#label} read cancelled`, - { cause: signal?.reason }, - ), - ); - }; - // Writable completion can leave unread bytes buffered on the readable - // side, so the stream stays owned until that side closes too. - const retired = new Promise((resolve) => { - output.once("close", () => { - this.#streams.delete(output); - signal?.removeEventListener("abort", onAbort); - resolve(); - }); + ownArtifactStream(source, output, { + scope: this.#scope, + streams: this.#streams, + signal, + cancellationFailure, + label: this.#label, }); - signal?.addEventListener("abort", onAbort, { once: true }); - if (signal?.aborted) onAbort(); - void this.#scope - .run(async () => { - await Promise.all([ - new Promise((resolve) => { - pipeline(source, output, () => { - producerComplete = true; - if (signal?.aborted) onAbort(); - resolve(); - }); - }), - retired, - ]); - }) - .catch((cause: unknown) => - output.destroy( - cause instanceof Error - ? cause - : new Error(`${this.#label} stream ownership failed`, { - cause, - }), - ), - ); return output; }); } diff --git a/src/artifacts/PyInstallerArtifactReader.ts b/src/artifacts/PyInstallerArtifactReader.ts index f6eab57113..3298164dcd 100644 --- a/src/artifacts/PyInstallerArtifactReader.ts +++ b/src/artifacts/PyInstallerArtifactReader.ts @@ -180,6 +180,7 @@ export class PyInstallerArtifactReader implements ArtifactReader { this.#members.clear(); return value; }); + this.#signal(signal); options?.onCoverage?.(loaded.traversal); for (const member of loaded.members) { abortIfNeeded(active); @@ -187,6 +188,7 @@ export class PyInstallerArtifactReader implements ArtifactReader { this.#members.set(sealed.entry.adapterKey, sealed); yield sealed.entry; } + this.#signal(signal); } async open(entry: ArtifactEntry, signal?: AbortSignal): Promise { diff --git a/src/artifacts/UImageArtifactReader.ts b/src/artifacts/UImageArtifactReader.ts index 05c9af9347..a8fc6a38a7 100644 --- a/src/artifacts/UImageArtifactReader.ts +++ b/src/artifacts/UImageArtifactReader.ts @@ -1,5 +1,5 @@ import { ArtifactPreparation } from "./ArtifactPreparation.js"; -import { Readable, Transform, pipeline } from "node:stream"; +import { Readable, Transform } from "node:stream"; import { crc32 } from "node:zlib"; import { readArtifactFileBytes } from "./ArtifactFileRange.js"; import { setImmediate } from "node:timers/promises"; @@ -19,6 +19,7 @@ import { type ArtifactTraversalOptions, } from "./ArtifactReader.js"; import { ArtifactResourceScope } from "./ArtifactResourceScope.js"; +import { ownArtifactStream } from "./OwnedArtifactStream.js"; import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js"; import type { StableRegularFileDescriptor } from "../filesystem/RegularFile.js"; import { @@ -114,6 +115,10 @@ export class UImageArtifactReader implements ArtifactReader { ); const { handle } = await this.#source.read(signal); this.#assertOpen(signal); + const cancellationFailure = (reason: unknown): ArtifactReaderFailure => + new ArtifactReaderFailure("cancelled", "uImage read cancelled", { + cause: reason, + }); const source = stored.declaredSize === 0 ? Readable.from([]) @@ -121,60 +126,16 @@ export class UImageArtifactReader implements ArtifactReader { start: stored.byteOffset, end: stored.byteOffset + stored.declaredSize - 1, signal, - cancellationFailure: (reason) => - new ArtifactReaderFailure( - "cancelled", - "uImage read cancelled", - { - cause: reason, - }, - ), + cancellationFailure, }); const output = this.#memberStream(stored, signal); - output.on("error", () => undefined); - this.#streams.add(output); - let producerComplete = false; - const onAbort = (): void => { - // Active reads/validation own failure precedence. After they complete, - // cancellation must still retire any unconsumed readable bytes. - if (!producerComplete || output.destroyed) return; - output.destroy( - new ArtifactReaderFailure("cancelled", "uImage read cancelled", { - cause: signal?.reason, - }), - ); - }; - const retired = new Promise((resolve) => { - output.once("close", () => { - this.#streams.delete(output); - signal?.removeEventListener("abort", onAbort); - resolve(); - }); + ownArtifactStream(source, output, { + scope: this.#scope, + streams: this.#streams, + signal, + cancellationFailure, + label: "uImage", }); - signal?.addEventListener("abort", onAbort, { once: true }); - if (signal?.aborted) onAbort(); - void this.#scope - .run(async () => { - // Writable completion can leave readable bytes buffered. Retain both - // producer I/O and the caller's stream until consumption or closure. - await Promise.all([ - new Promise((resolve) => - pipeline(source, output, () => { - producerComplete = true; - if (signal?.aborted) onAbort(); - resolve(); - }), - ), - retired, - ]); - }) - .catch((cause: unknown) => - output.destroy( - cause instanceof Error - ? cause - : new Error("uImage stream ownership failed", { cause }), - ), - ); return output; }); } diff --git a/src/artifacts/UnityFsArtifactReader.boundaries.test.ts b/src/artifacts/UnityFsArtifactReader.boundaries.test.ts index 672d7f3336..0268cb4128 100644 --- a/src/artifacts/UnityFsArtifactReader.boundaries.test.ts +++ b/src/artifacts/UnityFsArtifactReader.boundaries.test.ts @@ -335,6 +335,38 @@ it.each([1, 2])( ); describe("UnityFS stream ownership and immutable cache", () => { + it("preserves a pending read failure when cancellation overlaps it", async () => { + const path = await fixture(buildUnityFs([{ path: "x", data: "bytes" }])); + const admitted = await openRegularFile(path, { symlinks: "reject" }); + const reader = new UnityFsArtifactReader(path, admitted); + const reached = createDeferred(); + const release = createDeferred(); + const failure = new Error("member read failed"); + const controller = new AbortController(); + const [entry] = await entries(reader); + const reads = vi + .spyOn(admitted.handle, "read") + .mockImplementation((async () => { + reached.resolve(); + await release.promise; + throw failure; + }) as typeof admitted.handle.read); + let consumed: Promise | undefined; + try { + consumed = buffer(await reader.open(entry!, controller.signal)); + await reached.promise; + controller.abort(new Error("caller cancelled")); + release.resolve(); + await expect(consumed).rejects.toBe(failure); + } finally { + release.resolve(); + await consumed?.catch(() => undefined); + reads.mockRestore(); + await reader.close(); + await admitted.handle.close(); + } + }); + it("retains descriptor ownership after cleanup fails and permits retry", async () => { const reader = new UnityFsArtifactReader( await fixture(buildUnityFs([{ path: "x", data: "bytes" }])), diff --git a/src/artifacts/UnityFsArtifactReader.test.ts b/src/artifacts/UnityFsArtifactReader.test.ts index 4f992c7f47..55dfee3037 100644 --- a/src/artifacts/UnityFsArtifactReader.test.ts +++ b/src/artifacts/UnityFsArtifactReader.test.ts @@ -198,6 +198,23 @@ describe("UnityFS payloads and malformed declarations", () => { }); describe("UnityFS unavailable and malformed directories", () => { + it("reports cancellation during unavailable-directory coverage", async () => { + await withReader( + buildUnityFs(members, { flags: 0x1400 | 0x43 }), + async (reader) => { + const controller = new AbortController(); + const entries = reader + .entries(controller.signal, { + onCoverage: () => controller.abort(), + }) + [Symbol.asyncIterator](); + await expect(entries.next()).rejects.toMatchObject({ + reason: "cancelled", + }); + }, + ); + }); + it("reports an encrypted directory as unknown coverage without entries", async () => { await withReader( buildUnityFs(members, { flags: 0x1400 | 0x43 }), diff --git a/src/artifacts/UnityFsArtifactReader.ts b/src/artifacts/UnityFsArtifactReader.ts index e25069ab45..5d6ef1c023 100644 --- a/src/artifacts/UnityFsArtifactReader.ts +++ b/src/artifacts/UnityFsArtifactReader.ts @@ -13,6 +13,7 @@ import { type ArtifactTraversalOptions, } from "./ArtifactReader.js"; import { ArtifactResourceScope } from "./ArtifactResourceScope.js"; +import { ownAsyncArtifactStream } from "./OwnedAsyncArtifactStream.js"; import { assertArtifactReadActive } from "./ArtifactFileOperation.js"; import { readArtifactFileBytes } from "./ArtifactFileRange.js"; import type { @@ -108,6 +109,7 @@ export class UnityFsArtifactReader implements ArtifactReader { : "UnityFS blocks-and-directory table uses an unsupported compression; member identities remain unknown.", ], }); + this.#assertOpen(signal); return; } let index = 0; @@ -153,46 +155,17 @@ export class UnityFsArtifactReader implements ArtifactReader { { boundary: "capacity" }, ); const controller = new AbortController(); - const output = Readable.from( + const output = ownAsyncArtifactStream( this.#memberChunks(handle, state, controller.signal), + controller, { - objectMode: false, + scope: this.#scope, + streams: this.#streams, + signal, + label: "UnityFS", highWaterMark: CHUNK_BYTES, }, ); - const onAbort = (): void => { - controller.abort(signal?.reason); - output.destroy( - new ArtifactReaderFailure( - "cancelled", - "UnityFS member read cancelled", - { cause: signal?.reason }, - ), - ); - }; - output.on("error", () => undefined); - this.#streams.set(output, controller); - const completion = new Promise((resolve) => { - output.once("close", () => { - signal?.removeEventListener("abort", onAbort); - this.#streams.delete(output); - resolve(); - }); - }); - // Readable.from waits for its async iterator to retire before close. Keep - // that whole lifetime owned, including a suspended positional read/decoder. - void this.#scope - .run(() => completion) - .catch((cause: unknown) => { - controller.abort(cause); - output.destroy( - cause instanceof Error - ? cause - : new Error("UnityFS stream ownership failed", { cause }), - ); - }); - signal?.addEventListener("abort", onAbort, { once: true }); - if (signal?.aborted === true) onAbort(); return output; }); } diff --git a/src/artifacts/WxapkgArtifactReader.boundaries.test.ts b/src/artifacts/WxapkgArtifactReader.boundaries.test.ts index 62750f463c..b580f0469a 100644 --- a/src/artifacts/WxapkgArtifactReader.boundaries.test.ts +++ b/src/artifacts/WxapkgArtifactReader.boundaries.test.ts @@ -187,6 +187,117 @@ describe("wxapkg allocation and malformed-input boundaries", () => { }); }); +describe("wxapkg pending read cancellation", () => { + it("retains final source verification failure during cancellation", async () => { + const reader = new WxapkgArtifactReader( + await fixture(buildWxapkg([{ path: "x", data: "bytes" }])), + ); + const [entry] = await entries(reader); + const reached = createDeferred(); + const release = createDeferred(); + const failure = new Error("final source verification failed"); + const controller = new AbortController(); + const read = ArtifactFileSource.prototype.read; + let count = 0; + const observer = vi + .spyOn(ArtifactFileSource.prototype, "read") + .mockImplementation(async function (this: ArtifactFileSource, signal) { + if (++count === 2) { + reached.resolve(); + await release.promise; + throw failure; + } + return read.call(this, signal); + }); + let consumed: Promise | undefined; + try { + consumed = buffer(await reader.open(entry!, controller.signal)); + await awaitAcknowledgement( + reached.promise, + consumed, + "final verification", + ); + controller.abort(new Error("caller cancelled")); + release.resolve(); + await expect(consumed).rejects.toBe(failure); + } finally { + release.resolve(); + await consumed?.catch(() => undefined); + observer.mockRestore(); + await reader.close(); + } + }); + + it("retains the member cancellation outcome after a pending read succeeds", async () => { + const path = await fixture(buildWxapkg([{ path: "x", data: "bytes" }])); + const admitted = await openRegularFile(path, { symlinks: "reject" }); + const reader = new WxapkgArtifactReader(path, admitted); + const reached = createDeferred(); + const release = createDeferred(); + const controller = new AbortController(); + const reason = new Error("caller cancelled"); + const nativeRead = admitted.handle.read.bind(admitted.handle); + const [entry] = await entries(reader); + const reads = vi.spyOn(admitted.handle, "read").mockImplementation((async ( + ...args: unknown[] + ) => { + reached.resolve(); + await release.promise; + return Reflect.apply(nativeRead, admitted.handle, args); + }) as typeof admitted.handle.read); + let consumed: Promise | undefined; + try { + consumed = buffer(await reader.open(entry!, controller.signal)); + await awaitAcknowledgement(reached.promise, consumed, "member read"); + controller.abort(reason); + release.resolve(); + await expect(consumed).rejects.toMatchObject({ + reason: "cancelled", + message: "wxapkg member read cancelled", + cause: reason, + }); + } finally { + release.resolve(); + await consumed?.catch(() => undefined); + reads.mockRestore(); + await reader.close(); + await admitted.handle.close(); + } + }); + + it("preserves a pending read failure when cancellation overlaps it", async () => { + const path = await fixture(buildWxapkg([{ path: "x", data: "bytes" }])); + const admitted = await openRegularFile(path, { symlinks: "reject" }); + const reader = new WxapkgArtifactReader(path, admitted); + const reached = createDeferred(); + const release = createDeferred(); + const failure = new Error("member read failed"); + const controller = new AbortController(); + const [entry] = await entries(reader); + const reads = vi + .spyOn(admitted.handle, "read") + .mockImplementation((async () => { + reached.resolve(); + await release.promise; + throw failure; + }) as typeof admitted.handle.read); + let consumed: Promise | undefined; + try { + consumed = buffer(await reader.open(entry!, controller.signal)); + await awaitAcknowledgement(reached.promise, consumed, "member read"); + controller.abort(new Error("caller cancelled")); + release.resolve(); + await expect(consumed).rejects.toBe(failure); + } finally { + release.resolve(); + await consumed?.catch(() => undefined); + reads.mockRestore(); + await reader.close(); + await admitted.handle.close(); + } + }); +}); + describe("wxapkg stream ownership", () => { it("retains descriptor ownership after cleanup fails and permits retry", async () => { const reader = new WxapkgArtifactReader( diff --git a/src/artifacts/WxapkgArtifactReader.test.ts b/src/artifacts/WxapkgArtifactReader.test.ts index 55dc883f73..0f50222f08 100644 --- a/src/artifacts/WxapkgArtifactReader.test.ts +++ b/src/artifacts/WxapkgArtifactReader.test.ts @@ -176,6 +176,20 @@ describe("WxapkgArtifactReader", () => { }); }); }); + + it("reports cancellation during encrypted-directory coverage", async () => { + await withReader(wxapkgEncryptedBytes(), async (reader) => { + const controller = new AbortController(); + const entries = reader + .entries(controller.signal, { + onCoverage: () => controller.abort(), + }) + [Symbol.asyncIterator](); + await expect(entries.next()).rejects.toMatchObject({ + reason: "cancelled", + }); + }); + }); }); describe("WxapkgArtifactReader malformed inputs", () => { diff --git a/src/artifacts/WxapkgArtifactReader.ts b/src/artifacts/WxapkgArtifactReader.ts index 4bc11a737f..7aeb15c8d5 100644 --- a/src/artifacts/WxapkgArtifactReader.ts +++ b/src/artifacts/WxapkgArtifactReader.ts @@ -13,6 +13,7 @@ import { type ArtifactTraversalOptions, } from "./ArtifactReader.js"; import { ArtifactResourceScope } from "./ArtifactResourceScope.js"; +import { ownAsyncArtifactStream } from "./OwnedAsyncArtifactStream.js"; import { assertArtifactReadActive } from "./ArtifactFileOperation.js"; import { readArtifactFileBytes } from "./ArtifactFileRange.js"; import type { @@ -97,6 +98,7 @@ export class WxapkgArtifactReader implements ArtifactReader { "wxapkg package is V1MMWX-encrypted; member identities and contents remain unknown.", ], }); + this.#assertOpen(signal); return; } let index = 0; @@ -131,46 +133,17 @@ export class WxapkgArtifactReader implements ArtifactReader { { boundary: "capacity" }, ); const controller = new AbortController(); - const output = Readable.from( + const output = ownAsyncArtifactStream( this.#memberChunks(handle, state, controller.signal), + controller, { - objectMode: false, + scope: this.#scope, + streams: this.#streams, + signal, + label: "wxapkg", highWaterMark: CHUNK_BYTES, }, ); - const onAbort = (): void => { - controller.abort(signal?.reason); - output.destroy( - new ArtifactReaderFailure( - "cancelled", - "wxapkg member read cancelled", - { cause: signal?.reason }, - ), - ); - }; - output.on("error", () => undefined); - this.#streams.set(output, controller); - const completion = new Promise((resolve) => { - output.once("close", () => { - signal?.removeEventListener("abort", onAbort); - this.#streams.delete(output); - resolve(); - }); - }); - // Readable.from waits for its async iterator to retire before close. Keep - // that whole lifetime owned, including a suspended positional read. - void this.#scope - .run(() => completion) - .catch((cause: unknown) => { - controller.abort(cause); - output.destroy( - cause instanceof Error - ? cause - : new Error("wxapkg stream ownership failed", { cause }), - ); - }); - signal?.addEventListener("abort", onAbort, { once: true }); - if (signal?.aborted === true) onAbort(); return output; }); } diff --git a/src/artifacts/ZipArtifactReader.test.ts b/src/artifacts/ZipArtifactReader.test.ts index 523edb25ad..6895828b65 100644 --- a/src/artifacts/ZipArtifactReader.test.ts +++ b/src/artifacts/ZipArtifactReader.test.ts @@ -26,12 +26,14 @@ import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory import { ZipArtifactReader } from "./ZipArtifactReader.js"; import { openRegularFile } from "../filesystem/RegularFile.js"; -import { MINIMUM_ARTIFACT_EXPANDED_BYTES } from "../domain/artifactExpansionBudget.js"; import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; import { hashReadable } from "./ArtifactHash.js"; import { translateArtifactFailure } from "./ArtifactOperationFailure.js"; + import { analysisErrorUserMessage } from "../domain/analysisErrorPresentation.js"; +const MINIMUM_ARTIFACT_EXPANDED_BYTES = 256 * 1024 * 1024; + /** Small real ZIP carriers with forged sizes; metadata-only controls, never decompressed. */ const writeZipWithDeclaredSize = async ( directory: string, @@ -754,7 +756,11 @@ describe("ZipArtifactReader producer ownership", () => { for await (const _chunk of stream) break; } const closing = reader.close(); - expect(reader.close()).toBe(closing); + const secondClose = reader.close(); + let secondClosed = false; + void secondClose.then(() => { + secondClosed = true; + }); await held.reached; expect(stream.destroyed).toBe(true); expect(fileClose).not.toHaveBeenCalled(); @@ -764,8 +770,9 @@ describe("ZipArtifactReader producer ownership", () => { }); await Promise.resolve(); expect(closed).toBe(false); + expect(secondClosed).toBe(false); held.release(); - await closing; + await Promise.all([closing, secondClose]); expect(fileClose).toHaveBeenCalledTimes(1); await reader.close(); expect(fileClose).toHaveBeenCalledTimes(1); diff --git a/src/artifacts/ZipArtifactReader.ts b/src/artifacts/ZipArtifactReader.ts index 791da08573..b786adbb94 100644 --- a/src/artifacts/ZipArtifactReader.ts +++ b/src/artifacts/ZipArtifactReader.ts @@ -246,7 +246,9 @@ export class ZipArtifactReader implements ArtifactReader { }); yield metadata; } + this.#assertOpen(signal); await this.#source.verify(); + this.#assertOpen(signal); } finally { this.#enumerations.delete(enumeration); } diff --git a/src/artifacts/bun/BunStandaloneArtifactReader.test.ts b/src/artifacts/bun/BunStandaloneArtifactReader.test.ts index 1914901cd0..b7ea0895d2 100644 --- a/src/artifacts/bun/BunStandaloneArtifactReader.test.ts +++ b/src/artifacts/bun/BunStandaloneArtifactReader.test.ts @@ -1,4 +1,5 @@ import { open, writeFile, type FileHandle } from "node:fs/promises"; +import { once } from "node:events"; import { createHash } from "node:crypto"; import { join } from "node:path"; import { buffer } from "node:stream/consumers"; @@ -17,6 +18,34 @@ import { elfAppendedBunPayload, } from "./BunStandalone.fixture.js"; +it.each(["close", "cancel"] as const)( + "does not report a complete inventory after %s while iteration is paused", + async (interruption) => { + const path = join(await createTestTempDirectory("rea-bun-paused-"), "app"); + await writeFile( + path, + elfAppendedBunPayload( + bunModuleGraphPayload({ + modules: [{ name: "main.js", contents: Buffer.from("body") }], + }), + ), + ); + const reader = new BunStandaloneArtifactReader(path); + const controller = new AbortController(); + try { + const entries = reader.entries(controller.signal)[Symbol.asyncIterator](); + expect((await entries.next()).done).toBe(false); + if (interruption === "close") await reader.close(); + else controller.abort(); + await expect(entries.next()).rejects.toMatchObject({ + reason: interruption === "close" ? "unavailable" : "cancelled", + }); + } finally { + await reader.close(); + } + }, +); + it("keeps duplicate and unsafe names distinct while extracting exact bytes", async () => { const path = join(await createTestTempDirectory("rea-bun-reader-"), "app"); await writeFile( @@ -130,6 +159,35 @@ async function collect(values: AsyncIterable): Promise { return result; } +it("cancels buffered member bytes after production finishes", async () => { + const path = join(await createTestTempDirectory("rea-bun-buffered-"), "app"); + await writeFile( + path, + elfAppendedBunPayload( + bunModuleGraphPayload({ + modules: [{ name: "main.js", contents: Buffer.from("buffered") }], + }), + ), + ); + const reader = new BunStandaloneArtifactReader(path); + const controller = new AbortController(); + const reason = new Error("cancel buffered Bun member"); + try { + const [entry] = await collect(reader.entries()); + if (entry === undefined) throw new Error("Expected Bun member"); + const stream = await reader.open(entry, controller.signal); + await once(stream, "finish"); + expect(stream.readableLength).toBeGreaterThan(0); + controller.abort(reason); + await expect(buffer(stream)).rejects.toMatchObject({ + reason: "cancelled", + cause: reason, + }); + } finally { + await reader.close(); + } +}); + it("allows an uncancelled inventory after a cancelled graph preparation", async () => { const path = join( await createTestTempDirectory("rea-bun-cancel-retry-"), diff --git a/src/artifacts/inventory/ArtifactInventoryFinalization.test.ts b/src/artifacts/inventory/ArtifactInventoryFinalization.test.ts index 6f5da4a287..4a33afa234 100644 --- a/src/artifacts/inventory/ArtifactInventoryFinalization.test.ts +++ b/src/artifacts/inventory/ArtifactInventoryFinalization.test.ts @@ -8,6 +8,7 @@ import { ArtifactProvider } from "../ArtifactProvider.js"; import { ArtifactReaderFailure } from "../ArtifactReader.js"; import { ArtifactResourceScope } from "../ArtifactResourceScope.js"; import { ZipArtifactReader } from "../ZipArtifactReader.js"; +import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js"; import { OwnedFileHandle } from "../../filesystem/OwnedFileHandle.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; import { @@ -51,6 +52,41 @@ const zipFixture = async () => { return { path, sha256: createHash("sha256").update(bytes).digest("hex") }; }; +it("does not complete a directory inventory after cancellation at the last entry", async () => { + const root = await createTestTempDirectory("rea-directory-paused-"); + await writeFile(join(root, "member"), "data"); + const reader = new DirectoryArtifactReader(root); + const controller = new AbortController(); + try { + const entries = reader.entries(controller.signal)[Symbol.asyncIterator](); + expect((await entries.next()).done).toBe(false); + controller.abort(); + await expect(entries.next()).rejects.toMatchObject({ reason: "cancelled" }); + } finally { + await reader.close(); + } +}); + +it.each(["close", "cancel"] as const)( + "does not complete a ZIP inventory after %s at the last entry", + async (interruption) => { + const { path } = await zipFixture(); + const reader = new ZipArtifactReader(path, "zip"); + const controller = new AbortController(); + try { + const entries = reader.entries(controller.signal)[Symbol.asyncIterator](); + expect((await entries.next()).done).toBe(false); + if (interruption === "close") await reader.close(); + else controller.abort(); + await expect(entries.next()).rejects.toMatchObject({ + reason: interruption === "close" ? "unavailable" : "cancelled", + }); + } finally { + await reader.close(); + } + }, +); + it.each([ { transfer: false, readerFails: false, rootFails: false, cancelAt: "reader" }, { transfer: false, readerFails: false, rootFails: false, cancelAt: "root" }, diff --git a/src/artifacts/pyinstaller/MarshalSubset.ts b/src/artifacts/pyinstaller/MarshalSubset.ts index ad3584eef1..864ae7799f 100644 --- a/src/artifacts/pyinstaller/MarshalSubset.ts +++ b/src/artifacts/pyinstaller/MarshalSubset.ts @@ -192,9 +192,7 @@ const readByteString = ( ): Result => { const length = readInt32(cursor); if (!length.ok) return length; - const bytes = readBytes(cursor, length.value); - if (!bytes.ok) return bytes; - return ok(bytes.value); + return readBytes(cursor, length.value); }; const readLong = ( diff --git a/src/artifacts/pyinstaller/PyInstallerArtifactReader.test.ts b/src/artifacts/pyinstaller/PyInstallerArtifactReader.test.ts index f73b3584c5..796e8f465d 100644 --- a/src/artifacts/pyinstaller/PyInstallerArtifactReader.test.ts +++ b/src/artifacts/pyinstaller/PyInstallerArtifactReader.test.ts @@ -30,6 +30,41 @@ const sha256 = (bytes: Buffer): string => const PYTHON_313_MAGIC = Buffer.from("f30d0d0a", "hex"); +it.each(["close", "cancel"] as const)( + "does not complete a PyInstaller inventory after %s while paused", + async (interruption) => { + const path = join(await createTestTempDirectory("rea-pyi-paused-"), "app"); + await writeFile( + path, + cArchiveExecutable({ + layout: "2.1", + pythonVersion: 311, + members: [ + { + name: "data", + typeCode: "x", + payload: Buffer.from("x"), + compress: false, + }, + ], + }), + ); + const reader = new PyInstallerArtifactReader(path); + const controller = new AbortController(); + try { + const entries = reader.entries(controller.signal)[Symbol.asyncIterator](); + expect((await entries.next()).done).toBe(false); + if (interruption === "close") await reader.close(); + else controller.abort(); + await expect(entries.next()).rejects.toMatchObject({ + reason: interruption === "close" ? "unavailable" : "cancelled", + }); + } finally { + await reader.close(); + } + }, +); + const derived = (payload: Buffer): Buffer => Buffer.concat([reconstructedPycHeader(PYTHON_39_MAGIC), payload]); diff --git a/src/browser/CdpElectronInspection.ts b/src/browser/CdpElectronInspection.ts index d4e3bb7223..168bc83dd1 100644 --- a/src/browser/CdpElectronInspection.ts +++ b/src/browser/CdpElectronInspection.ts @@ -244,6 +244,10 @@ const frameInventoryIdentity = ( .sort(compareUnicodeCodePoints), ); +/** Key a frame generation consistently for both authorization and capture. */ +const frameGenerationKey = (frameId: string, generation: number): string => + compositeKey([frameId, generation]); + const authorizeFrameGenerations = ( read: ElectronFrameRead, state: ElectronInspectionState, @@ -262,13 +266,13 @@ const authorizeFrameGenerations = ( const generation = read.generations.get(frame.frame_id) ?? 0; if (unproven.has(frame.frame_id)) continue; state.authorizedFrameGenerations.add( - compositeKey([frame.frame_id, generation]), + frameGenerationKey(frame.frame_id, generation), ); } for (const id of read.excludedFrameIds) if (!unproven.has(id)) state.excludedFrameGenerations.add( - compositeKey([id, read.generations.get(id) ?? 0]), + frameGenerationKey(id, read.generations.get(id) ?? 0), ); }; @@ -583,7 +587,7 @@ const captureElectronContent = async ( frameScopeForScript: (script) => { const generation = state.scriptGenerations.get(script); if (script.frameId !== null && generation !== undefined) { - const key = compositeKey([script.frameId, generation]); + const key = frameGenerationKey(script.frameId, generation); if (state.excludedFrameGenerations.has(key)) return { state: "excluded" }; if (state.authorizedFrameGenerations.has(key)) diff --git a/src/browser/CdpElectronProvider.ts b/src/browser/CdpElectronProvider.ts index 1dc918975d..bd9d96737a 100644 --- a/src/browser/CdpElectronProvider.ts +++ b/src/browser/CdpElectronProvider.ts @@ -214,24 +214,25 @@ export class CdpElectronProvider implements ElectronObservationPort { }), ); } catch (cause: unknown) { + const completedPrefix = { + browser_version: discovery?.version ?? null, + examined_targets: examined, + discovered_targets: discovery?.targets.length ?? null, + allowed_targets: allowed, + excluded: { + unsupported_url: unsupportedUrl, + non_page: nonPage, + }, + unconnectable_targets: unconnectable, + coverage: + "partial: target authorization stopped; remaining targets are unknown", + }; const fileFailure = mapElectronFileAuthorizationFailure( cause, CDP_ELECTRON_PROVIDER_IDENTITY.id, "list_electron_targets", { - completed_prefix: { - browser_version: discovery?.version ?? null, - examined_targets: examined, - discovered_targets: discovery?.targets.length ?? null, - allowed_targets: allowed, - excluded: { - unsupported_url: unsupportedUrl, - non_page: nonPage, - }, - unconnectable_targets: unconnectable, - coverage: - "partial: target authorization stopped; remaining targets are unknown", - }, + completed_prefix: completedPrefix, }, ); if (fileFailure !== undefined) return err(fileFailure); @@ -240,19 +241,7 @@ export class CdpElectronProvider implements ElectronObservationPort { ? new AnalysisCancelledError("list_electron_targets", { cause: options.signal.reason, diagnostics: { - completed_prefix: { - browser_version: discovery?.version ?? null, - examined_targets: examined, - discovered_targets: discovery?.targets.length ?? null, - allowed_targets: allowed, - excluded: { - unsupported_url: unsupportedUrl, - non_page: nonPage, - }, - unconnectable_targets: unconnectable, - coverage: - "partial: target authorization stopped; remaining targets are unknown", - }, + completed_prefix: completedPrefix, }, }) : providerError(cause, "list_electron_targets"), diff --git a/src/browser/PlaywrightElectronActiveProvider.test.ts b/src/browser/PlaywrightElectronActiveProvider.test.ts index 4d576cdc4d..90e4a07f5a 100644 --- a/src/browser/PlaywrightElectronActiveProvider.test.ts +++ b/src/browser/PlaywrightElectronActiveProvider.test.ts @@ -8,6 +8,7 @@ import { AnalysisOutputError } from "../domain/analysisErrorCore.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js"; import { ExecFileOutputCancelledError } from "../process/ExecFileOutput.js"; +import * as processOutput from "../process/ExecFileOutput.js"; import { cleanupElectronProcesses } from "./ElectronProcessCleanup.js"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { writeFile } from "node:fs/promises"; @@ -183,15 +184,13 @@ it("preserves an independent AbortError from actual ownership baseline inspectio "independent inspector failure", "AbortError", ); + vi.spyOn(processOutput, "execFileOutput").mockImplementation(async () => { + controller.abort(reason); + throw physical; + }); const host = ownershipObservation.createSystemProcessOwnershipHost( "linux", {}, - { - execFileOutput: async () => { - controller.abort(reason); - throw physical; - }, - }, ); const prepare = vi .spyOn(ownershipObservation, "prepareProcessOwnershipInspection") diff --git a/src/browser/PlaywrightScenarioRequest.ts b/src/browser/PlaywrightScenarioRequest.ts index 6a2c909e31..8c53335b1e 100644 --- a/src/browser/PlaywrightScenarioRequest.ts +++ b/src/browser/PlaywrightScenarioRequest.ts @@ -38,7 +38,7 @@ type PageResponse = | { readonly error: string }; /** A request step that could not be sent or did not answer as expected. */ -export class ScenarioRequestError extends Error { +class ScenarioRequestError extends Error { override readonly name = "ScenarioRequestError"; } diff --git a/src/browser/history/HistoricalCaptureBudgets.test.ts b/src/browser/history/HistoricalCaptureBudgets.test.ts index b5c382176b..f0ca1b87ef 100644 --- a/src/browser/history/HistoricalCaptureBudgets.test.ts +++ b/src/browser/history/HistoricalCaptureBudgets.test.ts @@ -10,9 +10,9 @@ import { historicalHar } from "../../../tests/fixtures/historicalHar.js"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; import { jsonValueValidationIssue } from "../../domain/jsonValue.js"; -import { MAX_WEB_NETWORK_CAPTURE_NESTING_DEPTH } from "../../domain/webNetworkCapture.js"; const require = createRequire(import.meta.url); +const MAX_WEB_NETWORK_CAPTURE_NESTING_DEPTH = 996; /** Exercise the real private decoder protocol and parsers from the source checkout. */ const service = () => diff --git a/src/captures/TsharkCaptureProvider.ts b/src/captures/TsharkCaptureProvider.ts index 7fc4f98232..cde6285ba7 100644 --- a/src/captures/TsharkCaptureProvider.ts +++ b/src/captures/TsharkCaptureProvider.ts @@ -31,6 +31,7 @@ import { import type { PacketCapturePort } from "../application/captures/PacketCaptureService.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { + ARTIFACT_PRODUCER_LIMITATION, ArtifactAnalysisResources, artifactCommandOutput, type ArtifactAnalysisRuntime, @@ -51,7 +52,7 @@ import { AnalysisOutputError, AnalysisResourceConstraintError, } from "../domain/analysisErrorCore.js"; -import { createEvidence } from "../domain/evidence.js"; +import { createAnalysisExecutionEvidence } from "../domain/analysisObservation.js"; import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { @@ -427,7 +428,7 @@ export class TsharkCaptureProvider implements PacketCapturePort { "Packet limits count input packets before display filtering. Filtered results do not establish whole-capture or device traffic absence. Two-pass reassembly cannot recover bytes missing from the recording or beyond the read limit.", "Repeated field values remain ordered string arrays; raw packet bytes are included only when explicitly selected as a supported field. Export index/score metadata are omitted. All stderr, including warnings on exit zero, is retained.", "A private empty personal configuration is used. Installed libraries, global configuration and dissector plugins are trusted and not fully fingerprinted. File-only analysis and disabled name resolution do not constitute an OS sandbox.", - "Linux prlimit enforces the reported per-process address-space, per-file and CPU limits before execution with no core dumps, preserving tighter inherited soft and hard limits. Input, command deadline and retained output budgets are reported in limits; producer_limits can change their requested defaults on the next call without restart. Output overflow fails. Parser and transport bounds remain independent. These are per-process/per-file limits, not aggregate memory or filesystem quotas; caller-supplied tools and the limiter are trusted.", + ARTIFACT_PRODUCER_LIMITATION, ], }), ); @@ -455,14 +456,12 @@ export class TsharkCaptureProvider implements PacketCapturePort { return this.#resources.finish( result, (execution) => - createEvidence(execution.subject ?? undefined, execution.provider, { + createAnalysisExecutionEvidence( operation, - parameters: input, - result: execution.result, - confidence: "observed", - locations: execution.locations, - limitations: execution.limitations, - }), + input, + execution, + "observed", + ), operation, options?.signal, ); diff --git a/src/cli/environmentCommands.ts b/src/cli/environmentCommands.ts index 68159b4e45..7594c08684 100644 --- a/src/cli/environmentCommands.ts +++ b/src/cli/environmentCommands.ts @@ -376,7 +376,7 @@ const inspectBuildHar = async ( inspected.value, ENVIRONMENT_BUILD_OPERATION, ); - return file.ok ? ok(file.value) : err(cliError(file.error)); + return file.ok ? file : err(cliError(file.error)); }; /** Read the capture once, then build or plan the bundle from it. */ diff --git a/src/cli/reverseEngineeringCommands.ts b/src/cli/reverseEngineeringCommands.ts index 186660ab45..cd8d89260d 100644 --- a/src/cli/reverseEngineeringCommands.ts +++ b/src/cli/reverseEngineeringCommands.ts @@ -373,16 +373,14 @@ export const registerReverseEngineeringCommands = ( const started = await manager.start(signal, { inferiorTty: options.inferiorTty, }); - if (!started.ok) return err(started.error); + if (!started.ok) return started; sessionId = started.value.session_id; const executed = await manager.execute( sessionId, args.command, signal, ); - return executed.ok - ? ok(executed.value.evidence) - : err(executed.error); + return executed.ok ? ok(executed.value.evidence) : executed; }, service: { close: () => @@ -445,16 +443,14 @@ export const registerReverseEngineeringCommands = ( }, signal, ); - if (!started.ok) return err(started.error); + if (!started.ok) return started; sessionId = started.value.session_id; const executed = await manager.execute( sessionId, args.command, signal, ); - return executed.ok - ? ok(executed.value.evidence) - : err(executed.error); + return executed.ok ? ok(executed.value.evidence) : executed; }, { close: () => diff --git a/src/cutter/CutterBridgeClient.ts b/src/cutter/CutterBridgeClient.ts index d2e0dc5451..cef5949892 100644 --- a/src/cutter/CutterBridgeClient.ts +++ b/src/cutter/CutterBridgeClient.ts @@ -222,7 +222,7 @@ const mapDiscovery = async ( export interface CutterDiscoveryOptions { readonly signal?: AbortSignal; } -export type CutterBridgeExecutionState = +type CutterBridgeExecutionState = | "not_started" | "complete" | "failed" @@ -252,7 +252,7 @@ class CutterBridgeRequestFailure extends Error { } } -export interface WindowsCutterDescriptorReader { +interface WindowsCutterDescriptorReader { verifyDirectory(directory: string): boolean; readDescriptor(directory: string, name: string, maxBytes: number): Buffer; } @@ -795,13 +795,6 @@ export class CutterBridgeClient { return descriptor; } - async listDescriptors(): Promise< - readonly z.infer[] - > { - const loaded = await this.#readDescriptors("list_cutter_sessions"); - return loaded.descriptors; - } - async #readDescriptors( operation: "list_cutter_sessions" | "cutter_command", signal?: AbortSignal, diff --git a/src/cutter/CutterBridgeService.ts b/src/cutter/CutterBridgeService.ts index 987d4b4b16..bde3c2d74f 100644 --- a/src/cutter/CutterBridgeService.ts +++ b/src/cutter/CutterBridgeService.ts @@ -163,7 +163,7 @@ export class CutterBridgeService { ); const input = parsed.data; const cleanup = await this.retryCleanup(); - if (!cleanup.ok) return err(cleanup.error); + if (!cleanup.ok) return cleanup; try { const output = await this.client.execute({ sessionId: input.session_id, diff --git a/src/cutter/cutterAuthenticatedResponse.ts b/src/cutter/cutterAuthenticatedResponse.ts index ce57b9c503..c8b6dc8483 100644 --- a/src/cutter/cutterAuthenticatedResponse.ts +++ b/src/cutter/cutterAuthenticatedResponse.ts @@ -40,7 +40,7 @@ type Escape = "none" | "slash" | "hex" | "low-slash" | "low-u" | "low-hex"; const invalid = (): Error => new Error("invalid authenticated response"); -export interface DecodedCutterResponse { +interface DecodedCutterResponse { readonly response: string; readonly proof: string; } @@ -262,7 +262,7 @@ export class CutterAuthenticatedResponseDecoder { } } -export type CutterInboundFrame = +type CutterInboundFrame = | { readonly kind: "challenge"; readonly value: unknown } | { readonly kind: "response"; diff --git a/src/desktop/AgentEnvComputer.test.ts b/src/desktop/AgentEnvComputer.test.ts index 9d79fcef21..a828e3a877 100644 --- a/src/desktop/AgentEnvComputer.test.ts +++ b/src/desktop/AgentEnvComputer.test.ts @@ -6,6 +6,62 @@ import { StructuredContentMcpClient } from "../providers/StructuredContentMcpCli import { AgentEnvComputer } from "./AgentEnvComputer.js"; import type { ComputerHelper } from "./AgentEnvComputerHelper.js"; +it("refuses a home-file answer larger than the reported size", async () => { + const connect = vi + .spyOn(StructuredContentMcpClient.prototype, "connect") + .mockResolvedValue(undefined); + const close = vi + .spyOn(StructuredContentMcpClient.prototype, "close") + .mockResolvedValue(undefined); + const call = vi + .spyOn(StructuredContentMcpClient.prototype, "callTool") + .mockResolvedValue({ content: [] }); + const fetch = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(Response.json({ ready: true })) + .mockResolvedValueOnce( + Response.json({ + result: { + parts: [ + { + kind: "file", + file: { bytes: Buffer.from("larger").toString("base64") }, + metadata: { + "urn:agentenv:data-objects/v1": { path: "out.json" }, + }, + }, + ], + }, + }), + ); + try { + const { computer } = await AgentEnvComputer.connect( + { + handshake: async () => ({ + sandbox_id: "controlled-sandbox", + base_url: "http://127.0.0.1:1", + mcp_url: "http://127.0.0.1:1/mcp", + control_token: null, + }), + release: async () => ({ verified: true, teardown: "agentenv" }), + } as unknown as ComputerHelper, + new AbortController().signal, + ); + try { + await expect(computer.homeFiles(["out.json"], 1)).rejects.toMatchObject({ + reportedLimits: { guest_file_bytes: 1, decoded_file_bytes: 6 }, + }); + } finally { + await computer.close(); + } + } finally { + fetch.mockRestore(); + call.mockRestore(); + close.mockRestore(); + connect.mockRestore(); + } +}); + it.each(["http", "invalid_json", "readiness"] as const)( "redacts the complete control token in %s failure diagnostics", async (failure) => { diff --git a/src/desktop/AgentEnvComputer.ts b/src/desktop/AgentEnvComputer.ts index ccf5231eff..af747b679b 100644 --- a/src/desktop/AgentEnvComputer.ts +++ b/src/desktop/AgentEnvComputer.ts @@ -38,13 +38,13 @@ export const guestFileAnswerBytes = (bytes: number, files: number): number => /** The most REA reads into memory for one answer of unknown size: a quarter of the V8 heap, as for recordings. */ const heapQuarter = () => Math.floor(getHeapStatistics().heap_size_limit / 4); /** Where the data/get answer ceiling comes from. The guest's reported size does not change it. */ -export const GUEST_FILE_ANSWER_SOURCE = "v8.heap_size_limit/4"; +const GUEST_FILE_ANSWER_SOURCE = "v8.heap_size_limit/4"; /** * Copies of one answer held together while it is parsed: the raw chunks, the * concatenated buffer, and the text. The decoded files replace the text * afterward and are smaller than the base64 body. */ -export const GUEST_FILE_ANSWER_COPIES = 3; +const GUEST_FILE_ANSWER_COPIES = 3; /** The host ceiling for one data/get answer, including base64 expansion and each file's envelope. */ export const guestFileAnswerCeiling = (): number => heapQuarter(); @@ -105,7 +105,7 @@ export interface GuestFilePart { * The decoded files, or a refusal when they contain more bytes than the * computer reported. An underreported size must not admit the larger body. */ -export const retainGuestFiles = ( +const retainGuestFiles = ( parts: readonly GuestFilePart[], reportedBytes: number, files: number, diff --git a/src/desktop/BuildComputerController.test.ts b/src/desktop/BuildComputerController.test.ts index 017a297e83..024536de23 100644 --- a/src/desktop/BuildComputerController.test.ts +++ b/src/desktop/BuildComputerController.test.ts @@ -4,21 +4,20 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; +import { getHeapStatistics } from "node:v8"; import { describe, expect, it } from "vitest"; import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js"; import { guestFileAnswerBytes } from "./AgentEnvComputer.js"; import { - batchListedFiles, - controllerArgumentPieces, fetchHomeFolder, LINUX_ARGUMENT_VECTOR_BYTES, - LINUX_SINGLE_ARGUMENT_BYTES, runBuildController, } from "./BuildComputerController.js"; const MIB = 1024 * 1024; +const EXPECTED_LINUX_SINGLE_ARGUMENT_BYTES = 32 * 4096 - 1; const sha256 = (bytes: Buffer) => createHash("sha256").update(bytes).digest("hex"); @@ -58,6 +57,38 @@ const computer = (files: ReadonlyMap, steps: string[]) => { }; describe("fetchHomeFolder", () => { + it("splits tiny files before their answer envelopes exceed the live budget", async () => { + const workingBytes = Math.floor(getHeapStatistics().heap_size_limit / 12); + const count = Math.floor(workingBytes / (64 * 1024)) + 2; + const files = new Map( + Array.from({ length: count }, (_, index) => [ + `.rea-build/b/file-${String(index)}`, + Buffer.from("x"), + ]), + ); + const base = computer(files, []); + const batches: { count: number; bytes: number }[] = []; + const observed = { + ...base, + homeFiles: (paths: readonly string[], bytes: number) => { + if (paths[0]?.endsWith(".listing.json") !== true) + batches.push({ count: paths.length, bytes }); + return base.homeFiles(paths, bytes); + }, + }; + const result = await fetchHomeFolder(observed, ".rea-build/b", { + file: () => Promise.resolve(), + }); + expect(result).toEqual({ files: count, bytes: count }); + expect(batches.length).toBeGreaterThan(1); + expect( + batches.every( + ({ count, bytes }) => + guestFileAnswerBytes(bytes, count) <= workingBytes, + ), + ).toBe(true); + }); + it("hands over each checked batch before asking for the next, so one batch at a time is held", async () => { const files = new Map([ [".rea-build/b/a", Buffer.alloc(5 * MIB, 1)], @@ -103,22 +134,6 @@ describe("fetchHomeFolder", () => { }); }); -describe("batchListedFiles", () => { - it("starts another batch before each file's envelope pushes the answer over the budget", () => { - const files = [1, 2, 3].map((index) => ({ - path: `f${String(index)}`, - size: 10, - })); - const budget = guestFileAnswerBytes(20, 2); - expect( - batchListedFiles(files, { - rawBytes: 8 * 1024 * 1024, - answerBytes: budget, - }).map((batch) => batch.files.length), - ).toEqual([2, 1]); - }); -}); - describe("controller arguments", () => { it("splits a full start request so each argument fits Linux and the pieces join back", async () => { const launchArguments = Array.from({ length: 32 }, () => "a".repeat(4096)); @@ -160,10 +175,10 @@ describe("controller arguments", () => { ]); for (const argument of argv) expect(Buffer.byteLength(argument)).toBeLessThanOrEqual( - LINUX_SINGLE_ARGUMENT_BYTES, + EXPECTED_LINUX_SINGLE_ARGUMENT_BYTES, ); expect(Buffer.byteLength(JSON.stringify(payload))).toBeGreaterThan( - LINUX_SINGLE_ARGUMENT_BYTES, + EXPECTED_LINUX_SINGLE_ARGUMENT_BYTES, ); expect(JSON.parse(argv.slice(5).join(""))).toEqual(payload); }); @@ -204,13 +219,37 @@ describe("controller arguments", () => { }); it("keeps a character split on a UTF-16 pair whole and joins the controller's real entry point", async () => { - const text = `${"a".repeat(LINUX_SINGLE_ARGUMENT_BYTES - 1)}\u{1F600}rest`; - const pieces = controllerArgumentPieces(text); - expect(pieces.join("")).toBe(text); - for (const piece of pieces) + const text = `${"a".repeat(EXPECTED_LINUX_SINGLE_ARGUMENT_BYTES - 12)}\u{1F600}rest`; + const input = { text }; + let argv: readonly string[] = []; + await runBuildController( + { + exec: (next) => { + argv = next; + return Promise.resolve({ + exit_code: 0, + stdout: "{}\n", + stderr: "", + truncated: false, + timed_out: false, + }); + }, + homeFiles: () => Promise.resolve(new Map()), + redacted: (value) => value, + }, + "start", + input, + { timeoutS: 1 }, + ); + const pieces = argv.slice(5); + expect(JSON.parse(pieces.join(""))).toEqual(input); + expect(pieces.length).toBeGreaterThan(1); + for (const piece of pieces) { expect(Buffer.byteLength(piece)).toBeLessThanOrEqual( - LINUX_SINGLE_ARGUMENT_BYTES, + EXPECTED_LINUX_SINGLE_ARGUMENT_BYTES, ); + expect(Buffer.from(piece, "utf8").toString("utf8") === piece).toBe(true); + } const root = await mkdtemp(join(tmpdir(), "rea-controller-args-")); try { const output = join(root, "listing.json"); @@ -235,25 +274,3 @@ describe("controller arguments", () => { } }); }); - -describe("batchListedFiles", () => { - it("splits many tiny files before their envelopes pass the answer budget", () => { - const files = Array.from({ length: 3000 }, (_, index) => ({ - path: `home/file-${String(index)}`, - size: 1, - })); - const answerBytes = guestFileAnswerBytes(1, 1); - const batches = batchListedFiles(files, { - rawBytes: 8 * MIB, - answerBytes, - }); - - expect(batches.length).toBe(files.length); - expect( - batches.every( - (batch) => - guestFileAnswerBytes(batch.bytes, batch.files.length) <= answerBytes, - ), - ).toBe(true); - }); -}); diff --git a/src/desktop/BuildComputerController.ts b/src/desktop/BuildComputerController.ts index 7ab7f9ae00..ca538ba2fe 100644 --- a/src/desktop/BuildComputerController.ts +++ b/src/desktop/BuildComputerController.ts @@ -25,7 +25,7 @@ export const BUILD_REA_TIMEOUT_S = 1800; * Linux `MAX_ARG_STRLEN` on a 4 KiB page, excluding the NUL the kernel counts. * One argument past this fails with E2BIG before the process starts. */ -export const LINUX_SINGLE_ARGUMENT_BYTES = 32 * 4096 - 1; +const LINUX_SINGLE_ARGUMENT_BYTES = 32 * 4096 - 1; /** * A usual Linux `ARG_MAX`, leaving room for the environment the kernel counts * with the arguments. A larger vector fails with E2BIG before the process starts. @@ -43,7 +43,7 @@ const isLowSurrogate = (text: string, index: number): boolean => { }; /** Pieces of `text` that each fit in one Linux argument and join back to it. */ -export const controllerArgumentPieces = (text: string): readonly string[] => { +const controllerArgumentPieces = (text: string): readonly string[] => { const pieces: string[] = []; let start = 0; while (start < text.length) { @@ -277,7 +277,7 @@ const listFolder = async ( }; /** One listed file and the answer budget a batch must fit. */ -export interface ListedBatchFile { +interface ListedBatchFile { readonly path: string; readonly size: number; } @@ -287,7 +287,7 @@ export interface ListedBatchFile { * `rawBytes` or its base64 answer, including each file's envelope, passes * `answerBytes`. */ -export const batchListedFiles = ( +const batchListedFiles = ( files: readonly File[], limits: { readonly rawBytes: number; readonly answerBytes: number }, ): { files: File[]; bytes: number }[] => { diff --git a/src/desktop/DesktopInspectionSampling.test.ts b/src/desktop/DesktopInspectionSampling.test.ts index 63ef2dc7d3..ce1e83607b 100644 --- a/src/desktop/DesktopInspectionSampling.test.ts +++ b/src/desktop/DesktopInspectionSampling.test.ts @@ -3,6 +3,7 @@ import { writeFile } from "node:fs/promises"; import * as files from "node:fs/promises"; import { join } from "node:path"; import { expect, it, onTestFinished, vi } from "vitest"; +import * as processOutput from "../process/ExecFileOutput.js"; vi.mock("node:fs/promises", async (original) => { const actual = await original(); @@ -260,30 +261,25 @@ const samplingSession = async (route: Route) => { sourceSettled = true; sampling = false; }); - const native = createSystemProcessOwnershipHost( - "linux", - {}, - { - execFileOutput: async () => { - if (sourceSettled && retained.pending().length > 0) { - freshBeforeRetry += 1; - throw new Error("fresh lookup before sampling owner retry"); - } - if (sampling && route !== "runId") { - if (route === "cancelledListing" || retained.pending().length > 0) { - nativeCancellations += 1; - if (route === "cancelledListing") sampling = false; - controller.abort(reason); - throw new ExecFileOutputCancelledError(reason); - } - return commandOutput( - "42 1 42 1000 S recording-child\n43 1 42 1000 S recording-peer\n", - ); - } - return commandOutput(""); - }, - }, - ); + vi.spyOn(processOutput, "execFileOutput").mockImplementation(async () => { + if (sourceSettled && retained.pending().length > 0) { + freshBeforeRetry += 1; + throw new Error("fresh lookup before sampling owner retry"); + } + if (sampling && route !== "runId") { + if (route === "cancelledListing" || retained.pending().length > 0) { + nativeCancellations += 1; + if (route === "cancelledListing") sampling = false; + controller.abort(reason); + throw new ExecFileOutputCancelledError(reason); + } + return commandOutput( + "42 1 42 1000 S recording-child\n43 1 42 1000 S recording-peer\n", + ); + } + return commandOutput(""); + }); + const native = createSystemProcessOwnershipHost("linux", {}); rejectUnexpectedNativeOperations(); vi.spyOn(systemProcessOwnershipHost, "listProcesses").mockImplementation( async (signal) => { diff --git a/src/desktop/GuestFileAnswer.test.ts b/src/desktop/GuestFileAnswer.test.ts index 6b4b0b2ba2..0f34db72c5 100644 --- a/src/desktop/GuestFileAnswer.test.ts +++ b/src/desktop/GuestFileAnswer.test.ts @@ -4,12 +4,9 @@ import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js" import { readHomeFiles } from "./ComputerHomeFiles.js"; import { AgentEnvComputer, - GUEST_FILE_ANSWER_COPIES, - GUEST_FILE_ANSWER_SOURCE, guestFileAnswerBytes, guestFileAnswerRefusal, guestFileAnswerWorkingBytes, - retainGuestFiles, } from "./AgentEnvComputer.js"; describe("guest file encoding", () => { @@ -119,20 +116,14 @@ describe("guest file answers", () => { ); it("counts base64, the envelope, and the copies held while the answer is parsed", () => { - expect(guestFileAnswerBytes(3, 1)).toBeGreaterThan(3); - expect(guestFileAnswerBytes(3, 2)).toBeGreaterThan( - guestFileAnswerBytes(3, 1), - ); - const wire = guestFileAnswerBytes(3, 1); + expect(guestFileAnswerBytes(3, 1)).toBe(65_540); + expect(guestFileAnswerBytes(3, 2)).toBe(131_076); + const wire = 65_540; expect(guestFileAnswerRefusal(3, 1, wire)).toBeInstanceOf( AnalysisResourceConstraintError, ); - expect( - guestFileAnswerRefusal(3, 1, wire * GUEST_FILE_ANSWER_COPIES), - ).toBeUndefined(); - expect(guestFileAnswerWorkingBytes(wire * GUEST_FILE_ANSWER_COPIES)).toBe( - wire, - ); + expect(guestFileAnswerRefusal(3, 1, wire * 3)).toBeUndefined(); + expect(guestFileAnswerWorkingBytes(wire * 3)).toBe(wire); }); it("refuses a reported size that expands past the working budget before naming a fetch", () => { @@ -144,13 +135,13 @@ describe("guest file answers", () => { expect(refusal.reportedLimits).toEqual({ guest_file_bytes: reported, file_count: 2, - answer_bytes: guestFileAnswerBytes(reported, 2), + answer_bytes: 135_072, answer_ceiling_bytes: ceiling, - answer_working_bytes: guestFileAnswerWorkingBytes(ceiling), - answer_copies: GUEST_FILE_ANSWER_COPIES, - ceiling_source: GUEST_FILE_ANSWER_SOURCE, + answer_working_bytes: 333, + answer_copies: 3, + ceiling_source: "v8.heap_size_limit/4", }); - expect(refusal.remediationAction).toContain(GUEST_FILE_ANSWER_SOURCE); + expect(refusal.remediationAction).toContain("v8.heap_size_limit/4"); expect(refusal.remediationAction).toContain("--max-old-space-size"); expect(refusal.remediationAction).toContain("Restart REA"); expect(guestFileAnswerBytes(reported, 2)).toBeGreaterThan( @@ -158,21 +149,6 @@ describe("guest file answers", () => { ); }); - it("does not keep file bytes the computer reported as smaller", () => { - const retained = retainGuestFiles( - [{ path: "out.json", bytes: Buffer.from("larger") }], - 1, - 1, - 1_000_000, - ); - expect(retained.ok).toBe(false); - if (retained.ok) return; - expect(retained.error.reportedLimits).toMatchObject({ - guest_file_bytes: 1, - decoded_file_bytes: Buffer.from("larger").length, - }); - }); - it("does not ask the computer for a home file past that same ceiling", async () => { const failure = await AgentEnvComputer.prototype.homeFiles .call({}, ["out.json"], Number.MAX_SAFE_INTEGER) diff --git a/src/desktop/LinuxApplicationInventory.ts b/src/desktop/LinuxApplicationInventory.ts index 7439eaea0c..8d3a8d73eb 100644 --- a/src/desktop/LinuxApplicationInventory.ts +++ b/src/desktop/LinuxApplicationInventory.ts @@ -92,7 +92,7 @@ const LINUX_INVENTORY_HOST: LinuxInventoryHost = { type Package = DesktopApplicationInventory["package"]; /** Directories searched for desktop entries, most specific first. */ -export const desktopEntryDirectories = ( +const desktopEntryDirectories = ( environment: LinuxApplicationRequest["environment"], home: string, ): readonly string[] => { diff --git a/src/domain/analysisErrorProjection.test.ts b/src/domain/analysisErrorProjection.test.ts index 1c80094466..c9f45c78fa 100644 --- a/src/domain/analysisErrorProjection.test.ts +++ b/src/domain/analysisErrorProjection.test.ts @@ -16,7 +16,6 @@ import { import { BrowserObservationError } from "./browserObservationError.js"; import { EvidenceFileError, EvidenceIntegrityError } from "./evidenceErrors.js"; import { - HOPPER_STARTUP_TIMEOUT_CONTINUING_REMEDIATION, HOPPER_STARTUP_TIMEOUT_REMEDIATION, HopperProcessError, HopperRemoteError, @@ -1570,8 +1569,8 @@ describe("analysis error projection: caller lifetime", () => { ), }, ); - expect(projectAnalysisError(error).remediation.action).toBe( - HOPPER_STARTUP_TIMEOUT_CONTINUING_REMEDIATION, + expect(projectAnalysisError(error).remediation.action).toMatch( + /update_analysis_settings.*hopper\.startup_timeout_ms.*without restarting REA/u, ); const invocation = projectAnalysisError(error, "invocation"); expect(invocation.remediation.action).toBe( diff --git a/src/domain/analysisView/EnvironmentStateExtraction.ts b/src/domain/analysisView/EnvironmentStateExtraction.ts index b95be24419..2bfc300e05 100644 --- a/src/domain/analysisView/EnvironmentStateExtraction.ts +++ b/src/domain/analysisView/EnvironmentStateExtraction.ts @@ -1,10 +1,8 @@ import { StateRecordChronology } from "./StateRecordChronology.js"; import { jsonByteLength } from "../jsonSerialization.js"; import type { JsonValue } from "../jsonValue.js"; -import { - compareUnicodeCodePoints as compare, - compositeKey, -} from "../unicodeCodePointOrder.js"; +import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js"; +import { environmentEntityKey } from "./environmentStateIdentity.js"; import { ownMember, apiBaseLengths, @@ -95,9 +93,6 @@ interface ObservedMembership { capacity: number; } -const entityKey = (type: string, collection: string, id: string): string => - compositeKey([type, collection, id]); - const requestLine = (record: StateRecordFacts): string => record.path + (record.query ?? ""); @@ -772,7 +767,7 @@ export class EnvironmentStateExtraction { const id = scalarText(ownMember(object, field)); if (id === undefined) return; const collection = collectionPathOf(record, plan.item); - const key = entityKey(plan.type, collection, id); + const key = environmentEntityKey(plan.type, collection, id); const position = this.#chronology.positions.get(record.ordinal)!; let entity = this.#entities.get(key); if (entity === undefined) { @@ -823,7 +818,7 @@ export class EnvironmentStateExtraction { compareObservedEntities, )) this.#merged.set( - entityKey(entity.type, entity.collection, entity.id), + environmentEntityKey(entity.type, entity.collection, entity.id), mergeObservedEntity(entity), ); } @@ -841,7 +836,7 @@ export class EnvironmentStateExtraction { withBody: (ordinal, use) => this.#withBody(ordinal, use), retain: (bytes) => this.#retain(bytes), entity: (type, collection, id) => - this.#merged.get(entityKey(type, collection, id)), + this.#merged.get(environmentEntityKey(type, collection, id)), inCollection: (type, collection) => this.#inCollection(type, collection), heldFields: (type) => { const fields = new Set(); diff --git a/src/domain/analysisView/environmentManifestRules.ts b/src/domain/analysisView/environmentManifestRules.ts index 1bdf605df2..2ded89e506 100644 --- a/src/domain/analysisView/environmentManifestRules.ts +++ b/src/domain/analysisView/environmentManifestRules.ts @@ -118,7 +118,7 @@ const earliest = (groups: readonly ToolGroup[]): ToolGroup => /** Merge groups around `shape`; literal siblings of its templated positions become observed values. */ const mergeGroups = ( groups: readonly ToolGroup[], - shape = earliest(groups), + shape: ToolGroup, ): ToolGroup => { if (groups.length === 1) return groups[0]!; const primary = earliest(groups); diff --git a/src/domain/analysisView/environmentStateIdentity.ts b/src/domain/analysisView/environmentStateIdentity.ts new file mode 100644 index 0000000000..4d04c0d07f --- /dev/null +++ b/src/domain/analysisView/environmentStateIdentity.ts @@ -0,0 +1,14 @@ +import { compositeKey } from "../unicodeCodePointOrder.js"; + +/** Key an entity by its type, collection, and caller-supplied identifier. */ +export const environmentEntityKey = ( + type: string, + collection: string, + id: string, +): string => compositeKey([type, collection, id]); + +/** Key a collection by its type and caller-supplied collection path. */ +export const environmentCollectionKey = ( + type: string, + collection: string, +): string => compositeKey([type, collection]); diff --git a/src/domain/analysisView/environmentStateWriteChecks.ts b/src/domain/analysisView/environmentStateWriteChecks.ts index bb01c1b589..b3d304790d 100644 --- a/src/domain/analysisView/environmentStateWriteChecks.ts +++ b/src/domain/analysisView/environmentStateWriteChecks.ts @@ -1,6 +1,10 @@ import { ServedOrder } from "./environmentStateWriteOrder.js"; import type { JsonValue } from "../jsonValue.js"; import { compositeKey } from "../unicodeCodePointOrder.js"; +import { + environmentCollectionKey, + environmentEntityKey, +} from "./environmentStateIdentity.js"; import { ownMember, at, @@ -35,9 +39,9 @@ import type { InsertRule } from "./environmentStateWriteRules.js"; import { withoutMembers } from "./environmentStateResponseMembers.js"; export const entityTarget = (type: string, collection: string, id: string) => - `e${compositeKey([type, collection, id])}`; + `e${environmentEntityKey(type, collection, id)}`; export const collectionTarget = (type: string, collection: string) => - `c${compositeKey([type, collection])}`; + `c${environmentCollectionKey(type, collection)}`; export const documentTarget = (tool: string) => `d${compositeKey([tool])}`; /** A target `remove` dropped, so a later absence can still name the collection and id serving resolved. */ diff --git a/src/domain/analysisView/nativeObservationView.ts b/src/domain/analysisView/nativeObservationView.ts index 226ea044ff..95549d7d7f 100644 --- a/src/domain/analysisView/nativeObservationView.ts +++ b/src/domain/analysisView/nativeObservationView.ts @@ -31,7 +31,7 @@ import { import { projectNativeGraphRows } from "./nativeGraphView.js"; /** What an address page leaves in the complete parent, when recorded. */ -export const XREFS_ADDRESS_PAGE_LIMITATION = +const XREFS_ADDRESS_PAGE_LIMITATION = "This page contains source addresses only, not reference kinds or provenance. Retrieve or export the complete parent Evidence for any additional provider details it recorded."; const readAddresses = createAnalysisViewResultReader(nativeAddressListSchema); diff --git a/src/domain/android/androidJniBindings.test.ts b/src/domain/android/androidJniBindings.test.ts index 49a034955e..9e82a6188b 100644 --- a/src/domain/android/androidJniBindings.test.ts +++ b/src/domain/android/androidJniBindings.test.ts @@ -6,12 +6,11 @@ import { type AndroidJniLibraryObservation, type AndroidJniSelection, } from "./androidJniBindings.js"; -import type { - AndroidNativeDeclaration, - AndroidNativeDeclarations, -} from "./androidNativeDeclarations.js"; +import type { AndroidNativeDeclarations } from "./androidNativeDeclarations.js"; import type { AndroidJniLibraryFacts } from "./androidJniLibraryFacts.js"; +type AndroidNativeDeclaration = AndroidNativeDeclarations["methods"][number]; + const declaration = ( changes: Partial = {}, ): AndroidNativeDeclaration => ({ diff --git a/src/domain/android/androidNativeDeclarations.ts b/src/domain/android/androidNativeDeclarations.ts index 7d1b5d4a45..b634781e9d 100644 --- a/src/domain/android/androidNativeDeclarations.ts +++ b/src/domain/android/androidNativeDeclarations.ts @@ -32,9 +32,6 @@ export const androidNativeDeclarationsSchema = z.strictObject({ unknowns: z.array(z.string()), }); -export type AndroidNativeDeclaration = z.infer< - typeof androidNativeDeclarationSchema ->; export type AndroidNativeDeclarations = z.infer< typeof androidNativeDeclarationsSchema >; diff --git a/src/domain/artifactComparison.test.ts b/src/domain/artifactComparison.test.ts index 60b0267574..220087afcd 100644 --- a/src/domain/artifactComparison.test.ts +++ b/src/domain/artifactComparison.test.ts @@ -1,9 +1,6 @@ import { expect, it } from "vitest"; import { ARTIFACT_COMPARISON_EXAMPLE } from "../contracts/artifactComparisonExample.js"; -import { - compareArtifacts, - FORMAT_DECLARATION_UNKNOWN_LIMITATION, -} from "./artifactComparison.js"; +import { compareArtifacts } from "./artifactComparison.js"; import { artifactInventoryResultSchema, type ArtifactInventoryResult, @@ -684,7 +681,11 @@ it.each([ status: "unknown", changes: [{ classification: "unknown", dimensions: ["availability"] }], }); - expect(result.limitations).toEqual([FORMAT_DECLARATION_UNKNOWN_LIMITATION]); + expect(result.limitations).toEqual([ + expect.stringMatching( + /metadata declarations are missing.*complete declaration unavailable/u, + ), + ]); }); it("does not compare ESP signature block metadata", () => { @@ -815,7 +816,11 @@ it("compares a node-less record's occurrence fields but not its limitations", () dimensions: ["metadata", "availability"], }), ]); - expect(result.limitations).toEqual([FORMAT_DECLARATION_UNKNOWN_LIMITATION]); + expect(result.limitations).toEqual([ + expect.stringMatching( + /metadata declarations are missing.*complete declaration unavailable/u, + ), + ]); // A record's `executable` marking is a declared field like `encrypted`. expect( compareArtifacts( diff --git a/src/domain/artifactComparison.ts b/src/domain/artifactComparison.ts index 2cfd99de6e..6f75f12aad 100644 --- a/src/domain/artifactComparison.ts +++ b/src/domain/artifactComparison.ts @@ -98,7 +98,7 @@ export type ArtifactComparisonResult = z.infer< type ArtifactChange = z.infer; /** Comparison limitation when a path's format declarations cannot be compared. */ -export const FORMAT_DECLARATION_UNKNOWN_LIMITATION = +const FORMAT_DECLARATION_UNKNOWN_LIMITATION = "Format metadata declarations are missing on one side or differ in kind for matching paths; omitted metadata may be a legacy or non-container observation and leaves the complete declaration unavailable."; const comparisonInventory = (input: unknown, side: "left" | "right") => { diff --git a/src/domain/artifactExpansionBudget.test.ts b/src/domain/artifactExpansionBudget.test.ts index 38b67b54c8..f95c9930b8 100644 --- a/src/domain/artifactExpansionBudget.test.ts +++ b/src/domain/artifactExpansionBudget.test.ts @@ -1,21 +1,10 @@ import { describe, expect, it } from "vitest"; -import { - ARTIFACT_EXPANSION_RATIO, - artifactExpansionBudgetBytes, - MINIMUM_ARTIFACT_EXPANDED_BYTES, -} from "./artifactExpansionBudget.js"; +import { artifactExpansionBudgetBytes } from "./artifactExpansionBudget.js"; describe("artifactExpansionBudgetBytes", () => { it("grants at least the floor and grows with the encoded archive size", () => { - expect(artifactExpansionBudgetBytes(0)).toBe( - MINIMUM_ARTIFACT_EXPANDED_BYTES, - ); - expect(artifactExpansionBudgetBytes(1024)).toBe( - MINIMUM_ARTIFACT_EXPANDED_BYTES, - ); - const large = MINIMUM_ARTIFACT_EXPANDED_BYTES * 4; - expect(artifactExpansionBudgetBytes(large)).toBe( - large * ARTIFACT_EXPANSION_RATIO, - ); + expect(artifactExpansionBudgetBytes(0)).toBe(256 * 1024 * 1024); + expect(artifactExpansionBudgetBytes(1024)).toBe(256 * 1024 * 1024); + expect(artifactExpansionBudgetBytes(1024 * 1024)).toBe(500 * 1024 * 1024); }); it("keeps cumulative byte accounting within the exact number domain", () => { expect(artifactExpansionBudgetBytes(Number.MAX_SAFE_INTEGER)).toBe( diff --git a/src/domain/artifactExpansionBudget.ts b/src/domain/artifactExpansionBudget.ts index 07ca135dd7..e567ed2255 100644 --- a/src/domain/artifactExpansionBudget.ts +++ b/src/domain/artifactExpansionBudget.ts @@ -9,8 +9,8 @@ * an embedded executable carrier. The safe-integer ceiling preserves exact byte * accounting at the JSON/number boundary. */ -export const ARTIFACT_EXPANSION_RATIO = 500; -export const MINIMUM_ARTIFACT_EXPANDED_BYTES = 256 * 1024 * 1024; +const ARTIFACT_EXPANSION_RATIO = 500; +const MINIMUM_ARTIFACT_EXPANDED_BYTES = 256 * 1024 * 1024; /** Largest total readable plaintext bytes for this encoded archive span. */ export const artifactExpansionBudgetBytes = (archiveBytes: number): number => diff --git a/src/domain/desktop/stateFormats.ts b/src/domain/desktop/stateFormats.ts index f32ff051eb..19ff4af6da 100644 --- a/src/domain/desktop/stateFormats.ts +++ b/src/domain/desktop/stateFormats.ts @@ -392,7 +392,7 @@ const jsonView = ( rounded = true; return value; }); - if (!parsed.ok) return err(parsed.error); + if (!parsed.ok) return parsed; if (rounded) return err( "a number in it cannot be read exactly, such as an integer above 2^53", diff --git a/src/domain/evidenceBundle.test.ts b/src/domain/evidenceBundle.test.ts index 2b809fc64b..a46289a9bf 100644 --- a/src/domain/evidenceBundle.test.ts +++ b/src/domain/evidenceBundle.test.ts @@ -5,7 +5,6 @@ import { ANALYSIS_SNAPSHOT_TARGET } from "./analysisSnapshot.fixture.js"; import { createEvidenceBundle, createImmutableEvidenceBundle, - NonCanonicalEvidenceBundleError, evidenceBundleForTarget, parseEvidenceBundle, safeParseEvidenceBundle, @@ -67,7 +66,10 @@ describe("evidence bundle failure classification", () => { expect(parsed.ok).toBe(false); if (parsed.ok) return; expect(parsed.error.kind).toBe("invalid-bundle"); - expect(parsed.error.cause).toBeInstanceOf(NonCanonicalEvidenceBundleError); + expect(parsed.error.cause).toMatchObject({ + name: "NonCanonicalEvidenceBundleError", + message: "Evidence bundle manifests are not canonical", + }); }); it("treats only authenticated standalone Evidence as the wrong input kind", () => { diff --git a/src/domain/evidenceBundle.ts b/src/domain/evidenceBundle.ts index 951449a45e..b423212e80 100644 --- a/src/domain/evidenceBundle.ts +++ b/src/domain/evidenceBundle.ts @@ -62,7 +62,7 @@ export const isImmutableEvidenceBundle = ( typeof input === "object" && input !== null && immutableBundles.has(input); /** Caller input has valid shape but is not in authenticated canonical form. */ -export class NonCanonicalEvidenceBundleError extends TypeError { +class NonCanonicalEvidenceBundleError extends TypeError { constructor() { super("Evidence bundle manifests are not canonical"); this.name = "NonCanonicalEvidenceBundleError"; @@ -70,7 +70,7 @@ export class NonCanonicalEvidenceBundleError extends TypeError { } /** Caller input lists the same Evidence record more than once. */ -export class DuplicateEvidenceRecordError extends TypeError { +class DuplicateEvidenceRecordError extends TypeError { constructor() { super("Evidence bundle contains duplicate record IDs"); this.name = "DuplicateEvidenceRecordError"; diff --git a/src/domain/evidenceCompletionLedger.ts b/src/domain/evidenceCompletionLedger.ts index 49ef503b43..d2f9ca6397 100644 --- a/src/domain/evidenceCompletionLedger.ts +++ b/src/domain/evidenceCompletionLedger.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { digestCanonicalValue } from "./canonicalDigest.js"; import { IDENTIFIER_PATTERN } from "./stringPatterns.js"; -import { prefixedDigestSchema } from "./../domain/digests.js"; +import { prefixedDigestSchema } from "./digests.js"; import { compareAsciiStrings, isSortedUniqueAscii, diff --git a/src/domain/evidenceRecordSummary.ts b/src/domain/evidenceRecordSummary.ts index 373d028fdd..caa2438448 100644 --- a/src/domain/evidenceRecordSummary.ts +++ b/src/domain/evidenceRecordSummary.ts @@ -202,7 +202,7 @@ export const summarizeEvidenceBundle = ( export const EVIDENCE_RECORD_SELECTION_LIMIT = 8; /** Labels every record selection as historical and as distinct from an importable bundle. */ -export const EVIDENCE_RECORD_SELECTION_LIMITATIONS = [ +const EVIDENCE_RECORD_SELECTION_LIMITATIONS = [ "Selected records are retained historical observations; they do not establish current live provider or database state.", "A record selection is not an Evidence bundle: it omits manifests and residual unknowns and cannot be imported. Use export_evidence_bundle for a portable bundle.", ] as const; @@ -280,7 +280,7 @@ export const evidenceLinkStatusFilterSchema = z.enum([ export const EVIDENCE_LINK_INSPECTION_LIMIT = 64; /** Bounds what link membership establishes, in every inspection. */ -export const EVIDENCE_LINK_INSPECTION_LIMITATIONS = [ +const EVIDENCE_LINK_INSPECTION_LIMITATIONS = [ "Link status is membership in this session or bundle: missing means absent here, not nonexistent, and neither status describes analysis coverage or a record's meaning.", ] as const; diff --git a/src/domain/hopperErrors.ts b/src/domain/hopperErrors.ts index fd00aaedce..914049c33c 100644 --- a/src/domain/hopperErrors.ts +++ b/src/domain/hopperErrors.ts @@ -41,7 +41,7 @@ export const HOPPER_STARTUP_TIMEOUT_REMEDIATION = "Review details.launcher and inspect Hopper for ongoing initial analysis or a pending loader, demo, or license dialog. For a large target, set REA_HOPPER_STARTUP_TIMEOUT_MS higher in the environment of a new REA invocation, then rerun the command; that invocation starts Hopper again. Resolve retained cleanup before rerunning."; /** Startup-timeout recovery for a caller whose REA runtime keeps running. */ -export const HOPPER_STARTUP_TIMEOUT_CONTINUING_REMEDIATION = +const HOPPER_STARTUP_TIMEOUT_CONTINUING_REMEDIATION = "Review details.launcher and inspect Hopper for ongoing initial analysis or a pending loader, demo, or license dialog. For a large target, use update_analysis_settings with setting hopper.startup_timeout_ms and action set for the next startup attempt without restarting REA, or increase REA_HOPPER_STARTUP_TIMEOUT_MS for future REA processes. Allow at least that long in the MCP client. Resolve retained cleanup before reopening."; /** Hopper did not respond within the configured operation deadline. */ diff --git a/src/domain/native/nativeDeadlines.ts b/src/domain/native/nativeDeadlines.ts index 856b524b51..472396b1a1 100644 --- a/src/domain/native/nativeDeadlines.ts +++ b/src/domain/native/nativeDeadlines.ts @@ -51,7 +51,7 @@ export const pwntoolsTimeoutField = deadlineField( "Deadline in milliseconds for each owned pwntools command in this call. Omitted means 30000, range 1..2147483647; applies on the next call. Independent CPU, output, diagnostic, file and address-space allowances are selected in producer_limits. input_bytes overrides REA_PWNTOOLS_MAX_INPUT_BYTES; otherwise that launch default applies.", ); -export type NativeDeadlineRead = +type NativeDeadlineRead = | { readonly ok: true; readonly timeoutMs: number } | { readonly ok: false; diff --git a/src/domain/propertyListOpenStep.test.ts b/src/domain/propertyListOpenStep.test.ts index 6bf1066c24..6bf80f4721 100644 --- a/src/domain/propertyListOpenStep.test.ts +++ b/src/domain/propertyListOpenStep.test.ts @@ -3,8 +3,8 @@ import { parseOpenStep } from "plist"; import { decodeOpenStepPropertyList, - OpenStepPropertyListError, startsOpenStepPropertyList, + type OpenStepPropertyListCode, } from "./propertyListOpenStep.js"; it.each([ @@ -30,7 +30,7 @@ const syntaxFailure = (read: () => unknown): Error => { }; const containerRefusal = ( - code: OpenStepPropertyListError["code"], + code: OpenStepPropertyListCode, position: number, ) => ({ code, position }); @@ -75,7 +75,7 @@ it.each([ for (const text of [prefix, prefix + "(".repeat(130)]) { const pinned = syntaxFailure(() => parseOpenStep(text)); const owned = syntaxFailure(() => decodeOpenStepPropertyList(text)); - expect(owned).not.toBeInstanceOf(OpenStepPropertyListError); + expect(owned).not.toHaveProperty("_tag", "OpenStepPropertyListError"); expect(owned.constructor).toBe(pinned.constructor); expect(owned.message).toBe(pinned.message); } @@ -156,8 +156,10 @@ it("does not treat nonterminating whitespace or quoted comment text as line ends const error = syntaxFailure(() => decodeOpenStepPropertyList("// comment without a line end"), ); - expect(error).toBeInstanceOf(OpenStepPropertyListError); - expect(error).toMatchObject({ code: "unexpected_end" }); + expect(error).toMatchObject({ + _tag: "OpenStepPropertyListError", + code: "unexpected_end", + }); }); it.each([ diff --git a/src/domain/propertyListOpenStep.ts b/src/domain/propertyListOpenStep.ts index eabf0943ef..535975fae5 100644 --- a/src/domain/propertyListOpenStep.ts +++ b/src/domain/propertyListOpenStep.ts @@ -169,7 +169,7 @@ export const decodeOpenStepQuotedString = (token: string): string => { export type OpenStepPropertyListCode = (typeof OPEN_STEP_SYNTAX_CODES)[number]; /** OpenStep syntax refusal. `code` is the contract; `position` is the source offset. */ -export class OpenStepPropertyListError extends Error { +class OpenStepPropertyListError extends Error { readonly _tag = "OpenStepPropertyListError"; constructor( diff --git a/src/domain/propertyListXmlSource.ts b/src/domain/propertyListXmlSource.ts index db98ba434c..0ed1550649 100644 --- a/src/domain/propertyListXmlSource.ts +++ b/src/domain/propertyListXmlSource.ts @@ -13,7 +13,7 @@ interface ScalarSpan { readonly selfClosing: boolean; } -export class XmlPlistSourceError extends TypeError { +class XmlPlistSourceError extends TypeError { readonly _tag = "XmlPlistSourceError"; constructor() { super("Cannot locate XML plist scalar in its original source"); diff --git a/src/domain/referenceSourceGraph.test.ts b/src/domain/referenceSourceGraph.test.ts index 39cc38dd5e..80a3c7dc61 100644 --- a/src/domain/referenceSourceGraph.test.ts +++ b/src/domain/referenceSourceGraph.test.ts @@ -8,7 +8,6 @@ import { createHistoricalSourceManifest, historicalSourceGraphSchema, historicalSourceParseFailureKey, - parseHistoricalSourceGraph, type HistoricalSourceGraphInput, } from "./referenceSourceGraph.js"; @@ -129,7 +128,7 @@ const registerSourceCommitmentTests = (): void => { manifest_id: `hsm_${createHash("sha256").update(semanticJson).digest("hex")}`, }); expect(first).toEqual(second); - expect(parseHistoricalSourceGraph(graph)).toEqual(graph); + expect(historicalSourceGraphSchema.parse(graph)).toEqual(graph); expect(() => createHistoricalSourceGraph({ ...graphInput(), @@ -145,7 +144,7 @@ const registerSourceCommitmentTests = (): void => { it("rejects stale roots and commits file, directory, and symlink semantics", () => { const original = createHistoricalSourceGraph(graphInput()); expect(() => - parseHistoricalSourceGraph({ ...original, root_sha256: HASH_A }), + historicalSourceGraphSchema.parse({ ...original, root_sha256: HASH_A }), ).toThrow(/root commitment/u); for (const mutate of [ @@ -214,7 +213,7 @@ describe("historical source graph", () => { manifests: paths, }; const graph = createHistoricalSourceGraph(input); - expect(parseHistoricalSourceGraph(graph)).toEqual(graph); + expect(historicalSourceGraphSchema.parse(graph)).toEqual(graph); expect(() => createHistoricalSourceGraph({ ...input, diff --git a/src/domain/referenceSourceGraph.ts b/src/domain/referenceSourceGraph.ts index 4a901da252..6a3495e147 100644 --- a/src/domain/referenceSourceGraph.ts +++ b/src/domain/referenceSourceGraph.ts @@ -9,7 +9,7 @@ import { isPortableAbsoluteReferenceTarget, } from "./referenceSourcePathIdentity.js"; import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js"; -import { digestSchema, prefixedDigestSchema } from "./../domain/digests.js"; +import { digestSchema, prefixedDigestSchema } from "./digests.js"; const boundedTextSchema = z.string().min(1); const relativePathSchema = z .string() @@ -566,11 +566,6 @@ export const createHistoricalSourceGraph = ( }); }; -/** Parse a graph and reject stale or caller-invented root commitments. */ -export const parseHistoricalSourceGraph = ( - input: unknown, -): HistoricalSourceGraph => historicalSourceGraphSchema.parse(input); - /** Commit semantic graph facts while retaining host-local diagnostic metadata separately. */ const computeHistoricalSourceGraphSha256 = ( graph: HistoricalSourceGraph, @@ -589,7 +584,7 @@ const computeHistoricalSourceGraphSha256 = ( export const createHistoricalSourceManifest = ( input: unknown, ): HistoricalSourceManifest => { - const graph = parseHistoricalSourceGraph(input); + const graph = historicalSourceGraphSchema.parse(input); const semantic = { schema: "HistoricalSourceManifest" as const, authority: "historical-reference" as const, diff --git a/src/domain/releaseExposure.test.ts b/src/domain/releaseExposure.test.ts index f4cfbf56e0..53aa16f93a 100644 --- a/src/domain/releaseExposure.test.ts +++ b/src/domain/releaseExposure.test.ts @@ -4,8 +4,7 @@ import { assembleReleaseExposureReport, exposureMeetsFailOn, PACKAGED_NATIVE_IMAGES, - RELEASE_EXPOSURE_CHECK_IDS, - RELEASE_EXPOSURE_LIMITATION, + type ReleaseExposureCheckId, type ReleaseExposureOwnerFacts, type ReleaseExposureScan, } from "./releaseExposure.js"; @@ -64,16 +63,32 @@ const reportFor = ( const statusOf = ( report: ReturnType, - id: (typeof RELEASE_EXPOSURE_CHECK_IDS)[number], + id: ReleaseExposureCheckId, ) => report.checks.find((check) => check.id === id)?.status; describe("release exposure checks", () => { it("keeps the fixed check order and the security limitation", () => { const report = reportFor(); expect(report.checks.map((check) => check.id)).toEqual([ - ...RELEASE_EXPOSURE_CHECK_IDS, + "javascript.source_map_sources", + "javascript.source_maps", + "electron.run_as_node", + "electron.asar_integrity", + "electron.only_load_from_asar", + "electron.node_cli_inspect", + "packaging.extractable_payload", + "native.debug_companions", + "native.pdb_path", + "go.build_paths", + "rust.dependency_metadata", + "text.credential_markers", + "native.hardening", + "macho.hardened_runtime", + "android.manifest", ]); - expect(report.limitations[0]).toBe(RELEASE_EXPOSURE_LIMITATION); + expect(report.limitations[0]).toMatch( + /static analysis.*does not establish.*secure/u, + ); expect(report.summary.no_exposure_observed_in_assessed_checks).toBe(true); expect(report.summary.by_status.not_assessed).toBe(3); expect(report.summary.highest_exposed_severity).toBeNull(); diff --git a/src/domain/releaseExposure.ts b/src/domain/releaseExposure.ts index 160e26022e..ce460dac2c 100644 --- a/src/domain/releaseExposure.ts +++ b/src/domain/releaseExposure.ts @@ -38,7 +38,7 @@ export const MAX_RELEASE_EXPOSURE_SAMPLES = 8; export const RELEASE_EXPOSURE_OPERATION = "audit_release_exposure"; -export const RELEASE_EXPOSURE_LIMITATION = +const RELEASE_EXPOSURE_LIMITATION = "This report states what static analysis of the selected artifact recovered. It does not establish that the application is secure."; export const RELEASE_EXPOSURE_ALLOWANCES = [ @@ -67,7 +67,7 @@ export const RELEASE_EXPOSURE_SEVERITIES = [ ] as const; export const RELEASE_EXPOSURE_FAIL_LEVELS = ["high", "medium", "low"] as const; -export const RELEASE_EXPOSURE_CHECK_IDS = [ +const RELEASE_EXPOSURE_CHECK_IDS = [ "javascript.source_map_sources", "javascript.source_maps", "electron.run_as_node", diff --git a/src/domain/webNetworkCapture.ts b/src/domain/webNetworkCapture.ts index ed4ed5cecc..1a7e9c5a28 100644 --- a/src/domain/webNetworkCapture.ts +++ b/src/domain/webNetworkCapture.ts @@ -33,7 +33,7 @@ const CAPTURE_REPORTED_RECORD_DEPTH = [ 0, "reported", ].length; -export const MAX_WEB_NETWORK_CAPTURE_NESTING_DEPTH = +const MAX_WEB_NETWORK_CAPTURE_NESTING_DEPTH = MAX_JSON_DEPTH - CAPTURE_REPORTED_RECORD_DEPTH; /** One admitted capture's complete-input, complete-reply and parser policy. */ diff --git a/src/domain/windowsNativeArtifact.ts b/src/domain/windowsNativeArtifact.ts index cdb943cbec..b1d301e8fe 100644 --- a/src/domain/windowsNativeArtifact.ts +++ b/src/domain/windowsNativeArtifact.ts @@ -18,7 +18,7 @@ export type WindowsNativeManifest = typeof WINDOWS_NATIVE_ARTIFACT & { readonly artifactSha256: string; }; -export class WindowsNativeArtifactError extends Error { +class WindowsNativeArtifactError extends Error { readonly _tag = "WindowsNativeArtifactError" as const; } diff --git a/src/domain/xmlDocument.ts b/src/domain/xmlDocument.ts index 44bb465dec..6958e6ef37 100644 --- a/src/domain/xmlDocument.ts +++ b/src/domain/xmlDocument.ts @@ -5,7 +5,7 @@ import { err, ok, type Result } from "./result.js"; /** XML 1.0 literal line endings; Unicode separators remain source characters. */ export const XML_LINE_ENDING = /\r\n?|\n/gu; -export class XmlDocumentParseError extends Error { +class XmlDocumentParseError extends Error { readonly _tag = "XmlDocumentParseError"; constructor( readonly reason: "malformed" | "doctype" | "internal-subset", diff --git a/src/evm/EvmAdmissionError.ts b/src/evm/EvmAdmissionError.ts index 7f0993c979..f74c38b72a 100644 --- a/src/evm/EvmAdmissionError.ts +++ b/src/evm/EvmAdmissionError.ts @@ -14,7 +14,7 @@ const EVM_ADMISSION_CODES = [ "root_not_map", ] as const; -export type EvmAdmissionCode = (typeof EVM_ADMISSION_CODES)[number]; +type EvmAdmissionCode = (typeof EVM_ADMISSION_CODES)[number]; /** EVM limiter, worker, or metadata admission failure. `code` is the stable identity. */ export class EvmAdmissionError extends Error { diff --git a/src/evm/EvmoleFailures.ts b/src/evm/EvmoleFailures.ts index fb92d36bd9..d755bbece0 100644 --- a/src/evm/EvmoleFailures.ts +++ b/src/evm/EvmoleFailures.ts @@ -69,7 +69,7 @@ export interface EvmFileSizeFailureEvidence { } /** Keep carrier read, engine and owned lifecycle failures distinct. */ -export interface EvmFailureContext { +interface EvmFailureContext { readonly phase: "configuration" | "artifact-read" | "worker"; readonly path: string; readonly workerLimits?: EvmWorkerLimits | undefined; diff --git a/src/evm/EvmoleInterfaceProvider.ts b/src/evm/EvmoleInterfaceProvider.ts index 049f28487c..560d588286 100644 --- a/src/evm/EvmoleInterfaceProvider.ts +++ b/src/evm/EvmoleInterfaceProvider.ts @@ -97,14 +97,14 @@ export const evmHostSupported = ( platform: NodeJS.Platform, arch: string, ): boolean => platform === "linux" && arch === "x64"; -export const EVM_PRLIMIT_COMMAND_DEFAULT = "/usr/bin/prlimit"; +const EVM_PRLIMIT_COMMAND_DEFAULT = "/usr/bin/prlimit"; -export interface EvmLimiterProbeFileSystem { +interface EvmLimiterProbeFileSystem { access(path: string, mode: number): Promise; stat(path: string): Promise<{ isFile(): boolean }>; } -export interface EvmLimiterProbeOptions { +interface EvmLimiterProbeOptions { readonly platform?: NodeJS.Platform; readonly arch?: string; readonly fs?: EvmLimiterProbeFileSystem; @@ -254,7 +254,7 @@ type PendingEvmCleanup = { root: | { readonly owner: EvmRuntimeRoot; readonly lease: AnalysisCleanupLease } | undefined; - process: { readonly lease: AnalysisCleanupLease } | undefined; + process: AnalysisCleanupLease | undefined; }; const ownerCleanupMarker = ( @@ -968,7 +968,7 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort { root === undefined || rootOwnerLease === undefined ? undefined : { owner: root, lease: rootOwnerLease }, - process: { lease }, + process: lease, }; if (processCleanup === undefined) { const owners = [lease, rootOwnerLease].filter( @@ -1118,7 +1118,7 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort { if (pending === undefined) return undefined; if (pending.process !== undefined) { try { - await pending.process.lease.release(); + await pending.process.release(); } catch (cause: unknown) { const failure = evmCleanupFailure( pending.input, @@ -1126,7 +1126,7 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort { [ ...new Set([ ...(pending.root === undefined ? [] : [pending.root.owner.path]), - ...(pending.process.lease.cleanupResources ?? []), + ...(pending.process.cleanupResources ?? []), ]), ], cause instanceof Error ? cause.message : String(cause), @@ -1136,7 +1136,7 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort { bindEvmCleanupOwners( failure, [ - pending.process.lease, + pending.process, ...(pending.root === undefined ? [] : [pending.root.lease]), ], pending.result.ok ? undefined : pending.result.error, diff --git a/src/filesystem/OwnedFileHandle.ts b/src/filesystem/OwnedFileHandle.ts index 3f54ffeb08..184a9de2b8 100644 --- a/src/filesystem/OwnedFileHandle.ts +++ b/src/filesystem/OwnedFileHandle.ts @@ -1,7 +1,7 @@ import type { FileHandle } from "node:fs/promises"; /** Close admission for one owned file handle. `code` is the stable identity. */ -export class OwnedFileHandleError extends Error { +class OwnedFileHandleError extends Error { readonly _tag = "OwnedFileHandleError"; constructor(readonly code: "handle_invalidated") { diff --git a/src/flutter/FlutterBuildProvider.ts b/src/flutter/FlutterBuildProvider.ts index fe5259201c..70d7cb322f 100644 --- a/src/flutter/FlutterBuildProvider.ts +++ b/src/flutter/FlutterBuildProvider.ts @@ -369,11 +369,6 @@ const checkApkSize = (operation: string, bytes: number): void => { ); }; -export interface FlutterProviderOptions { - /** Deterministic root removal for tests; production removes recursively. */ - readonly removeRoot?: (path: string) => Promise; -} - /** * Flutter build identification by pure parsing: reads `lib//libapp.so` * and `libflutter.so` through the hardened ZIP reader and reports the Dart @@ -385,16 +380,12 @@ export class FlutterBuildProvider { string, Set<{ close(): Promise }> >(); - private readonly removeRoot: (path: string) => Promise; private readonly cleanupFailures = new Map(); private readonly rootLeases = new Map(); private readonly operations = new ArtifactResourceScope(); - constructor(options: FlutterProviderOptions = {}) { + constructor() { this.operations.addCloseHook(() => this.closeRoots()); - this.removeRoot = - options.removeRoot ?? - ((path) => rm(path, { recursive: true, force: true })); } /** @@ -578,7 +569,7 @@ export class FlutterBuildProvider { return false; } try { - await this.removeRoot(root); + await rm(root, { recursive: true, force: true }); this.ownedRoots.delete(root); this.cleanupFailures.delete(root); return true; diff --git a/src/frida/FridaInstrumentationManager.test.ts b/src/frida/FridaInstrumentationManager.test.ts index 71af2b800a..523fc12ba8 100644 --- a/src/frida/FridaInstrumentationManager.test.ts +++ b/src/frida/FridaInstrumentationManager.test.ts @@ -1774,12 +1774,18 @@ it("serializes script loading against concurrent close and unloads the loaded sc void closing.then(() => { closed = true; }); - expect(manager.closeSession(started.value.sessionId)).toBe(closing); + const secondClose = manager.closeSession(started.value.sessionId); + let secondClosed = false; + void secondClose.then(() => { + secondClosed = true; + }); await Promise.resolve(); expect(closed).toBe(false); + expect(secondClosed).toBe(false); finishScriptLoad?.(); expect((await loading).ok).toBe(true); expect((await closing).ok).toBe(true); + expect((await secondClose).ok).toBe(true); expect(bindings.device.session.scripts[0]?.isDestroyed).toBe(true); expect(bindings.device.session.isDetached()).toBe(true); }); diff --git a/src/gdb/GdbSessionManager.ts b/src/gdb/GdbSessionManager.ts index 2c254aa985..7c10b72d6d 100644 --- a/src/gdb/GdbSessionManager.ts +++ b/src/gdb/GdbSessionManager.ts @@ -381,7 +381,7 @@ const gdbMiGroupsListSpan = ( return groupSpan; }; -/** @internal Pure parser for live PIDs in recursive GDB MI thread groups. */ +/** Parse the complete GDB MI inferior inventory without guessing missing PIDs. */ export const gdbThreadGroupPids = ( mi: string, ): readonly string[] | undefined => { @@ -467,12 +467,6 @@ export const gdbThreadGroupPids = ( return undefined; }; -/** @internal Pure count of recursive GDB MI groups with live PIDs. */ -export const gdbThreadGroupCount = (mi: string): number | undefined => { - const pids = gdbThreadGroupPids(mi); - return pids?.length; -}; - const verifyGdbInferiorOutputSink = async ( pid: string, selectedTty: string, @@ -1538,11 +1532,12 @@ export class GdbSessionManager { throw new Error( "GDB could not confirm its complete, untruncated inferior list before shutdown.", ); - const groupCount = gdbThreadGroupCount(groups.mi); - if (groupCount === undefined) + const pids = gdbThreadGroupPids(groups.mi); + if (pids === undefined) throw new Error( "GDB returned an unknown inferior list before shutdown.", ); + const groupCount = pids.length; if ( groupCount > 1 || (groupCount > 0 && !session.inferiorTtyVerified) diff --git a/src/ghidra/GhidraDolLoadImage.ts b/src/ghidra/GhidraDolLoadImage.ts index 353ac70f94..e993b41ec7 100644 --- a/src/ghidra/GhidraDolLoadImage.ts +++ b/src/ghidra/GhidraDolLoadImage.ts @@ -1,12 +1,11 @@ +import { createLoadImageChecks } from "./GhidraLoadImageChecks.js"; import type { GhidraLanguageOverride } from "../config/ghidraLanguageOverride.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import type { DolHeader } from "../domain/gamecubeDol.js"; -import type { JsonValue } from "../domain/jsonValue.js"; import { nativeAddressCoordinateInputSchema } from "../domain/native/nativeAddress.js"; import { nativeLoadImageSchema, type NativeLoadImage, - type NativeLoadImageCheck, type NativeLoadImageObservation, } from "../domain/native/nativeLoadImage.js"; import { err, ok, type Result } from "../domain/result.js"; @@ -89,23 +88,7 @@ export const attestGhidraDolLoadImage = ( if (snapshot.sectionSha256.length !== header.sections.length) return err("Ghidra DOL snapshot section digests do not match the header"); const language = ghidraDolLanguage(languageOverride); - const checks: NativeLoadImageCheck[] = []; - const check = ( - name: string, - expected: JsonValue, - observed: JsonValue, - fileOffset: number | null = null, - location: string | null = null, - ): void => { - checks.push({ - name, - matched: JSON.stringify(expected) === JSON.stringify(observed), - expected, - observed, - file_offset: fileOffset, - address: location, - }); - }; + const { checks, check } = createLoadImageChecks(); // Field order is fixed: checks compare serialized values. const fileFacts = (mapping: FileFacts) => ({ diff --git a/src/ghidra/GhidraLoadImageChecks.ts b/src/ghidra/GhidraLoadImageChecks.ts new file mode 100644 index 0000000000..9ce31958b2 --- /dev/null +++ b/src/ghidra/GhidraLoadImageChecks.ts @@ -0,0 +1,24 @@ +import type { JsonValue } from "../domain/jsonValue.js"; +import type { NativeLoadImageCheck } from "../domain/native/nativeLoadImage.js"; + +/** Record one attestation's checks, preserving compared values and source locations. */ +export const createLoadImageChecks = () => { + const checks: NativeLoadImageCheck[] = []; + const check = ( + name: string, + expected: JsonValue, + observed: JsonValue, + fileOffset: number | null = null, + location: string | null = null, + ): void => { + checks.push({ + name, + matched: JSON.stringify(expected) === JSON.stringify(observed), + expected, + observed, + file_offset: fileOffset, + address: location, + }); + }; + return { checks, check }; +}; diff --git a/src/ghidra/GhidraLoadImageValues.ts b/src/ghidra/GhidraLoadImageValues.ts index 5e0679d411..198b5deed2 100644 --- a/src/ghidra/GhidraLoadImageValues.ts +++ b/src/ghidra/GhidraLoadImageValues.ts @@ -1,12 +1,11 @@ +import { createLoadImageChecks } from "./GhidraLoadImageChecks.js"; import { createHash } from "node:crypto"; import { nativeLoadImageSchema, type NativeLoadImage, - type NativeLoadImageCheck, type NativeLoadImageObservation, } from "../domain/native/nativeLoadImage.js"; import type { DosMzHeader, MzHeaderEvidence } from "../domain/dosMz.js"; -import type { JsonValue } from "../domain/jsonValue.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { validateDosComLength } from "../domain/dosCom.js"; import { err, ok, type Result } from "../domain/result.js"; @@ -24,28 +23,6 @@ const limitations = [ "A mismatching memory digest identifies the reported source range; it does not locate the first differing byte inside that range.", ]; -/** Record one attestation's checks, preserving compared values and source locations. */ -const createLoadImageChecks = () => { - const checks: NativeLoadImageCheck[] = []; - const check = ( - name: string, - expected: JsonValue, - observed: JsonValue, - fileOffset: number | null = null, - location: string | null = null, - ): void => { - checks.push({ - name, - matched: JSON.stringify(expected) === JSON.stringify(observed), - expected, - observed, - file_offset: fileOffset, - address: location, - }); - }; - return { checks, check }; -}; - export type GhidraDosImage = { readonly header: DosMzHeader; readonly loadSegment: number; diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index ba12ccf820..dad8e75752 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -300,7 +300,7 @@ export const createGhidraProviderClient = (input: { const { config, logger, clientFactory, context, installation } = input; const { target, profile } = input.admission; const prerequisites = ghidraClientPrerequisites(profile, installation); - if (!prerequisites.ok) return err(prerequisites.error); + if (!prerequisites.ok) return prerequisites; const committedProfile = prerequisites.value.profile; const checkpoint = ghidraCheckpointSelectionSchema .optional() diff --git a/src/ghidra/GhidraRequestQueue.ts b/src/ghidra/GhidraRequestQueue.ts index 94c5940a05..9746be3b15 100644 --- a/src/ghidra/GhidraRequestQueue.ts +++ b/src/ghidra/GhidraRequestQueue.ts @@ -9,15 +9,15 @@ import { } from "./GhidraSessionError.js"; /** Result settled by one queued Ghidra Program request. */ -export type GhidraQueuedRequestResult = Result; +type GhidraQueuedRequestResult = Result; /** Socket execution seam used after a request reaches the queue head. */ -export type GhidraRequestExecutor = ( +type GhidraRequestExecutor = ( method: string, parameters: JsonValue, options: GhidraRequestOptions, ) => Promise; /** Stop the provider session when an admitted operation is cancelled. */ -export type GhidraActiveCancellationHandler = () => Promise< +type GhidraActiveCancellationHandler = () => Promise< Result >; diff --git a/src/ghidra/GhidraSocketConnection.ts b/src/ghidra/GhidraSocketConnection.ts index 16ebf9d279..e8274c8362 100644 --- a/src/ghidra/GhidraSocketConnection.ts +++ b/src/ghidra/GhidraSocketConnection.ts @@ -1,15 +1,15 @@ import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; -import { createConnection, type Socket } from "node:net"; +import type { Socket } from "node:net"; +import { connectLocalSocketOnce } from "../process/LocalSocketConnection.js"; import { err, ok, type Result } from "../domain/result.js"; import type { ProviderStartupDeadline } from "../process/ProviderDeadline.js"; import { GHIDRA_STARTUP_DEADLINE_ELAPSED, - GhidraSessionError, + type GhidraSessionError, type GhidraSessionFailureFactory, } from "./GhidraSessionError.js"; import { - type GhidraConnectTarget, type GhidraEndpoint, observeGhidraEndpoint, } from "./GhidraTransport.js"; @@ -86,7 +86,7 @@ export const connectGhidraSocket = async ( return err(interruptionFailure(options)); continue; } - const connected = await connectGhidraSocketOnce( + const connected = await connectLocalSocketOnce( observed.value, options.deadline.signal, ); @@ -104,57 +104,6 @@ export const connectGhidraSocket = async ( ); }; -/** Open one abort-aware connection to an observed local Ghidra endpoint. */ -const connectGhidraSocketOnce = ( - target: GhidraConnectTarget, - signal: AbortSignal, -): Promise> => - new Promise((resolve) => { - const socket = - "path" in target - ? createConnection(target.path) - : createConnection({ host: target.host, port: target.port }); - const detach = (): void => { - socket.off("connect", onConnect); - socket.off("error", onError); - signal.removeEventListener("abort", onAbort); - }; - const onConnect = (): void => { - detach(); - resolve(ok(socket)); - }; - const onError = (cause: Error): void => { - detach(); - socket.destroy(); - resolve( - err( - new GhidraSessionError("start", "Ghidra bridge connection failed", { - endpoint: target, - error: cause.message, - }), - ), - ); - }; - const onAbort = (): void => { - detach(); - socket.destroy(); - resolve( - err( - new GhidraSessionError( - "cancelled", - "Ghidra bridge connection was cancelled", - { endpoint: target }, - { cause: signal.reason, cancellation: { reason: signal.reason } }, - ), - ), - ); - }; - socket.once("connect", onConnect); - socket.once("error", onError); - signal.addEventListener("abort", onAbort, { once: true }); - if (signal.aborted) onAbort(); - }); - const interruptionFailure = ( options: GhidraSocketConnectOptions, cause?: GhidraSessionError, diff --git a/src/gpu/CudaInfo.ts b/src/gpu/CudaInfo.ts index 12e5e08b9f..cfe7b36d93 100644 --- a/src/gpu/CudaInfo.ts +++ b/src/gpu/CudaInfo.ts @@ -39,7 +39,7 @@ export class CudaMetadataBudget { this.#indexBytes += size; } } -export interface CudaInfoRecord { +interface CudaInfoRecord { readonly format: number; readonly attribute: number; readonly value: number; diff --git a/src/gpu/GpuElfKernels.ts b/src/gpu/GpuElfKernels.ts index e122d8bf65..116e6f3871 100644 --- a/src/gpu/GpuElfKernels.ts +++ b/src/gpu/GpuElfKernels.ts @@ -23,7 +23,7 @@ import { type NativeSection, type NativeSectionDirectory, } from "../native/metadata/NativeSectionDirectory.js"; -import { readElfString } from "../native/metadata/ElfStringTable.js"; +import { readElfStringBytes } from "../native/metadata/ElfStringTable.js"; import { readElfSymbol } from "../domain/elfSymbol.js"; import { gpuCount, @@ -321,7 +321,7 @@ const cudaKernels = async ( const kernel = blankKernel( source, `sym:${section.index}:${offset / width}`, - readElfString(names, symbol.name_offset), + readElfStringBytes(names, symbol.name_offset).toString("latin1"), site(source, section, offset, width), ); if ( diff --git a/src/gpu/PtxKernels.ts b/src/gpu/PtxKernels.ts index 4c7d2d270b..9de263787c 100644 --- a/src/gpu/PtxKernels.ts +++ b/src/gpu/PtxKernels.ts @@ -527,7 +527,7 @@ export function* readPtxKernels( if (outerDepth !== 0) rejectGpu("Unbalanced PTX body"); } -export interface PtxTargetFacts { +interface PtxTargetFacts { readonly recognized: boolean; readonly target: string | null; readonly limitation: string | null; diff --git a/src/hopper/BridgeLauncher.ts b/src/hopper/BridgeLauncher.ts index ce9dd6a050..485c7f2687 100644 --- a/src/hopper/BridgeLauncher.ts +++ b/src/hopper/BridgeLauncher.ts @@ -457,7 +457,7 @@ export class HopperApplicationLauncher implements BridgeLauncher { expectedLinuxSha256 = inspected.value.sha256; } const display = await this.#privateDisplay(signal); - if (!display.ok) return err(display.error); + if (!display.ok) return display; const nativeHelperVerified = (this.dependencies.platform ?? process.platform) === "darwin" && this.options.launchMode === "native" && diff --git a/src/hopper/HopperRequestQueue.test.ts b/src/hopper/HopperRequestQueue.test.ts index 657ecc30c2..9ffff13110 100644 --- a/src/hopper/HopperRequestQueue.test.ts +++ b/src/hopper/HopperRequestQueue.test.ts @@ -163,7 +163,7 @@ describe("HopperRequestQueue deadlines", () => { await expect(queued).resolves.toEqual({ ok: false, error: failure }); expect(result).toHaveBeenCalledTimes(1); expect(queue.activity()).toBeNull(); - expect(queue.queuedCount()).toBe(0); + expect(queue.hasQueued(2)).toBe(false); expect(vi.getTimerCount()).toBe(0); }); }); @@ -197,7 +197,9 @@ describe("HopperRequestQueue deadline admission and races", () => { ok: false, error: { _tag: "HopperCancelledError" }, }); - expect(queue.queuedCount()).toBe(0); + expect(queue.hasQueued(2)).toBe(false); + expect(queue.hasQueued(3)).toBe(false); + expect(queue.activity()?.queuedRequests).toBe(0); queue.accept(1, ok(null)); await active; expect(sent).toEqual([1]); @@ -356,7 +358,7 @@ it("does not hand a waiting request to the sender until it reaches the wire", as const active = queue.run(1, "active", { value: "A" }, {}); const queued = queue.run(2, "queued", { value: "B" }, {}); expect(sent).toEqual([1]); - expect(queue.queuedCount()).toBe(1); + expect(queue.activity()?.queuedRequests).toBe(1); queue.accept(1, ok(null)); expect(sent).toEqual([1, 2]); queue.accept(2, ok(null)); diff --git a/src/hopper/HopperRequestQueue.ts b/src/hopper/HopperRequestQueue.ts index f5474fc142..2ba4b4bce3 100644 --- a/src/hopper/HopperRequestQueue.ts +++ b/src/hopper/HopperRequestQueue.ts @@ -15,12 +15,12 @@ import { import type { JsonValue } from "../domain/jsonValue.js"; import { err, type Result } from "../domain/result.js"; -export type HopperRequestResult = Result; +type HopperRequestResult = Result; /** Node timer ceiling; larger deadlines would be clamped instead of honored. */ const MAX_TIMEOUT_MS = 2_147_483_647; -export interface HopperRequestQueueOptions { +interface HopperRequestQueueOptions { readonly signal?: AbortSignal; /** Caller deadline from admission, including time waiting in the FIFO. */ readonly timeoutMs?: number; @@ -221,11 +221,6 @@ export class HopperRequestQueue { return this.#queue.some((entry) => entry.id === id); } - /** Number of caller requests waiting behind the active bridge operation. */ - queuedCount(): number { - return this.#queue.length; - } - #size(): number { return this.#queue.length + (this.#active === undefined ? 0 : 1); } diff --git a/src/hopper/HopperResponseStream.ts b/src/hopper/HopperResponseStream.ts index 6ec6f58b34..1cbb041719 100644 --- a/src/hopper/HopperResponseStream.ts +++ b/src/hopper/HopperResponseStream.ts @@ -4,7 +4,7 @@ import { } from "./protocol.js"; import { Utf8NdjsonLineBuffer } from "../process/Utf8NdjsonLineBuffer.js"; -export interface HopperResponseStreamOptions { +interface HopperResponseStreamOptions { readonly accept: (message: HopperBridgeMessage) => boolean; readonly hasQueued: (id: number) => boolean; readonly nextRequestId: () => number; diff --git a/src/hopper/HopperSocketConnection.ts b/src/hopper/HopperSocketConnection.ts index 77fe8ef300..c6cdd03508 100644 --- a/src/hopper/HopperSocketConnection.ts +++ b/src/hopper/HopperSocketConnection.ts @@ -1,40 +1,23 @@ -import { createConnection, type Socket } from "node:net"; +import type { Socket } from "node:net"; +import { connectLocalSocketOnce } from "../process/LocalSocketConnection.js"; import { hopperCancelledBySignal, type HopperCancelledError, HopperStartError, } from "../domain/hopperErrors.js"; -import { err, ok, type Result } from "../domain/result.js"; +import { err, type Result } from "../domain/result.js"; /** Open one abort-aware Hopper Unix-socket connection attempt. */ -export const connectHopperSocketOnce = ( +export const connectHopperSocketOnce = async ( socketPath: string, signal: AbortSignal, -): Promise> => - new Promise((resolve) => { - const socket = createConnection(socketPath); - const detach = (): void => { - socket.off("connect", onConnect); - socket.off("error", onError); - signal.removeEventListener("abort", onAbort); - }; - const onConnect = (): void => { - detach(); - resolve(ok(socket)); - }; - const onError = (cause: Error): void => { - detach(); - socket.destroy(); - resolve(err(new HopperStartError({ cause }))); - }; - const onAbort = (): void => { - detach(); - socket.destroy(); - resolve(err(hopperCancelledBySignal(signal))); - }; - socket.once("connect", onConnect); - socket.once("error", onError); - signal.addEventListener("abort", onAbort, { once: true }); - if (signal.aborted) onAbort(); - }); +): Promise> => { + const connected = await connectLocalSocketOnce({ path: socketPath }, signal); + if (connected.ok) return connected; + return err( + connected.error.kind === "cancelled" + ? hopperCancelledBySignal(signal) + : new HopperStartError({ cause: connected.error.cause }), + ); +}; diff --git a/src/javascript/sourceMaps/SourceMapCommand.test.ts b/src/javascript/sourceMaps/SourceMapCommand.test.ts index a0184a30a5..02cdc5aeab 100644 --- a/src/javascript/sourceMaps/SourceMapCommand.test.ts +++ b/src/javascript/sourceMaps/SourceMapCommand.test.ts @@ -1,6 +1,7 @@ import { ProcessAdmissionError } from "../../process/ProcessAdmissionError.js"; import { access } from "node:fs/promises"; -import { afterEach, expect, it, vi } from "vitest"; +import { afterEach, expect, it, onTestFinished, vi } from "vitest"; +import * as processOutput from "../../process/ExecFileOutput.js"; import { AnalysisCancelledError, AnalysisOutputError, @@ -95,15 +96,11 @@ it("preserves Linux process inspection failure and recovery before launching the const cause = Object.assign(new Error("ps permission denied"), { code: "EACCES", }); - const host = createSystemProcessOwnershipHost( - "linux", - {}, - { - execFileOutput: async () => { - throw cause; - }, - }, - ); + const command = vi + .spyOn(processOutput, "execFileOutput") + .mockRejectedValue(cause); + onTestFinished(() => command.mockRestore()); + const host = createSystemProcessOwnershipHost("linux", {}); const result = await runSourceMapCommand( "{}", "/probe/source.map", @@ -131,16 +128,14 @@ it("retains a physical process inspection failure when the caller cancels concur const physical = Object.assign(new Error("ps permission denied"), { code: "EACCES", }); - const host = createSystemProcessOwnershipHost( - "linux", - {}, - { - execFileOutput: async () => { - controller.abort(reason); - throw physical; - }, - }, - ); + const command = vi + .spyOn(processOutput, "execFileOutput") + .mockImplementation(async () => { + controller.abort(reason); + throw physical; + }); + onTestFinished(() => command.mockRestore()); + const host = createSystemProcessOwnershipHost("linux", {}); const result = await runSourceMapCommand( "{}", "/probe/source.map", diff --git a/src/kuna/KunaCommand.ts b/src/kuna/KunaCommand.ts index dd32e1a3c2..da1a15a10a 100644 --- a/src/kuna/KunaCommand.ts +++ b/src/kuna/KunaCommand.ts @@ -62,7 +62,7 @@ export type KunaProcessLauncher = ( ) => Promise; /** One bounded Kuna run inside a private workspace. */ -export interface KunaCommandRequest { +interface KunaCommandRequest { readonly installation: KunaInstallation; readonly arguments: readonly string[]; /** Private directory used as the working and temporary directory. */ @@ -90,7 +90,7 @@ export interface KunaCommandRequest { * stopped, so it may still write into the workspace, which therefore stays * with the caller's cleanup owner. */ -export type KunaCommandOutcome = +type KunaCommandOutcome = | { readonly kind: "ok"; readonly snapshot: ProviderProcessSnapshot } | { readonly kind: "failed"; @@ -309,7 +309,7 @@ export interface KunaInputPrefix { } /** A copy's SHA-256 and size, and the leading bytes kept from the same stream. */ -export interface KunaCopiedInput { +interface KunaCopiedInput { readonly sha256: string; readonly bytes: number; /** Empty unless a prefix was requested; the digest covers these exact bytes. */ diff --git a/src/kuna/KunaInstallation.ts b/src/kuna/KunaInstallation.ts index 8da9bc60cd..91a5cae4a8 100644 --- a/src/kuna/KunaInstallation.ts +++ b/src/kuna/KunaInstallation.ts @@ -170,7 +170,7 @@ export const kunaInstallationCancellation = ( }; /** One installation inspection's retained owners and originating errors. */ -export interface KunaResourceScope { +interface KunaResourceScope { readonly owner: RetainedResourceScope; readonly cleanupResources: readonly string[] | undefined; readonly hasPending: boolean; @@ -188,12 +188,10 @@ export const createKunaResourceScope = ( ): KunaResourceScope => { const leases = new Map< Parameters[0], - { readonly lease: AnalysisCleanupLease } + AnalysisCleanupLease >(); const pendingLeases = () => - [...leases.values()] - .map(({ lease }) => lease) - .filter((lease) => !lease.released); + [...leases.values()].filter((lease) => !lease.released); const bindPendingFailure = (error: AnalysisError) => { const retained = pendingLeases(); if (retained.length > 0) new AnalysisCleanupLeaseGroup(error, retained); @@ -236,7 +234,7 @@ export const createKunaResourceScope = ( owner: { retain: (owner, resource) => { const lease = AnalysisCleanupLease.forCloseOwner(owner, resource); - leases.set(owner, { lease }); + leases.set(owner, lease); parent.retain( { close: () => lease.release(), diff --git a/src/native/CommandRunner.ts b/src/native/CommandRunner.ts index b79878b1c8..a9a46c3a59 100644 --- a/src/native/CommandRunner.ts +++ b/src/native/CommandRunner.ts @@ -91,7 +91,7 @@ export class NativeCommandFailure extends Error { } /** Named cleanup ownership carried alongside the ordinary error cause. */ -export interface NativeCommandFailureOptions extends ErrorOptions { +interface NativeCommandFailureOptions extends ErrorOptions { /** Exact opaque reason recorded by the cancellation-owning boundary. */ readonly cancellation?: { readonly reason: unknown }; /** Typed owner retained when command process cleanup was incomplete. */ @@ -113,7 +113,7 @@ export interface NativeCommandCleanupFailure { } /** Partial output and lifecycle details retained when a native command fails. */ -export interface NativeCommandFailureCapture { +interface NativeCommandFailureCapture { readonly stdout: string; readonly stderr: string; readonly stdoutBytes: number; @@ -147,7 +147,7 @@ export type NativeCommandLauncher = ( ) => Promise; /** Resolve one native tool's selected invocation and prelaunch file identity. */ -export type NativeToolResolver = ( +type NativeToolResolver = ( tool: string, signal?: AbortSignal, launcher?: NativeCommandLauncher, diff --git a/src/native/NativeCallObservation.ts b/src/native/NativeCallObservation.ts index 9ac33a2a1b..f10c5dd7af 100644 --- a/src/native/NativeCallObservation.ts +++ b/src/native/NativeCallObservation.ts @@ -32,7 +32,7 @@ import type { NativeCallTrace, NativeCallTracer } from "./LldbCallTracer.js"; const OPERATION = "observe_native_calls"; /** What every call observation leaves unobserved or approximated. */ -export const NATIVE_CALL_OBSERVATION_LIMITATIONS = [ +const NATIVE_CALL_OBSERVATION_LIMITATIONS = [ "Only entries are observed; return values, floating-point and stack-passed arguments are not captured.", "Registers are the raw integer argument registers at entry. For Objective-C methods the first two hold the receiver and the selector.", "Receiver classes come from LLDB's Objective-C runtime reader without running target code; they are null for class methods and for receivers LLDB cannot classify.", @@ -42,7 +42,7 @@ export const NATIVE_CALL_OBSERVATION_LIMITATIONS = [ "The observation window is checked about once a second, and recording pauses the process at every entry, which slows it.", ] as const; -export const NATIVE_CALL_BREAKPOINT_LOCATION_LIST_INCOMPLETE_LIMITATION = +const NATIVE_CALL_BREAKPOINT_LOCATION_LIST_INCOMPLETE_LIMITATION = "The breakpoint location list is incomplete; location_count gives the full matching count."; const breakpointLocationBudgetLimitation = (bytes: number): string => `Resolved breakpoint locations exceeded capture_limits.breakpoint_location_bytes (${String(bytes)} bytes); omitted locations are counted in location_count. This metadata limit does not change the process outcome. Select a larger budget before a new authorized observation; earlier omitted facts cannot be recovered.`; diff --git a/src/native/NativeCommandDeadline.test.ts b/src/native/NativeCommandDeadline.test.ts index d4ebb75d88..a26e3026e1 100644 --- a/src/native/NativeCommandDeadline.test.ts +++ b/src/native/NativeCommandDeadline.test.ts @@ -19,7 +19,6 @@ import type { NativeCommandRunner, } from "./CommandRunner.js"; import { XcrunCommandRunner } from "./CommandRunner.js"; -import { DEFAULT_NATIVE_COMMAND_TIMEOUT_MS } from "../domain/native/nativeDeadlines.js"; import { NativeMacOSProvider } from "./NativeMacOSProvider.js"; vi.mock("../process/OwnedCommand.js", async (original) => ({ @@ -83,7 +82,6 @@ describe("native_command_timeout_ms", () => { expect(runner.calls).toEqual( tools.map((tool) => ({ tool, timeoutMs: 60_000 })), ); - expect(DEFAULT_NATIVE_COMMAND_TIMEOUT_MS).toBe(60_000); }, ); diff --git a/src/native/NativeMachoInspection.ts b/src/native/NativeMachoInspection.ts index 764598f3b1..c97f3bed9a 100644 --- a/src/native/NativeMachoInspection.ts +++ b/src/native/NativeMachoInspection.ts @@ -109,7 +109,7 @@ const OPTIONAL_VTOOL_COMMAND = ["vtool", ["-show-build"]] as const; const VTOOL_UNAVAILABLE_LIMITATION = "vtool is unavailable; build metadata is normalized from binary load commands."; /** Recorded when vtool refuses the universal container and slice facts remain. */ -export const VTOOL_CONTAINER_REFUSAL_LIMITATION = +const VTOOL_CONTAINER_REFUSAL_LIMITATION = "vtool declined the universal container as not Mach-O; build metadata is normalized from binary load commands."; /** Inspect one Mach-O with the required native commands and normalized output. */ export const inspectNativeMacho = async ( @@ -122,7 +122,7 @@ export const inspectNativeMacho = async ( [...prefix, context.target.path], context.signal, ); - if (!captured.ok) return err(captured.error); + if (!captured.ok) return captured; captures.push(captured.value); } const lipo = captures.find(({ tool }) => tool === "lipo"); @@ -186,7 +186,7 @@ export const inspectNativeMacho = async ( vtoolSelectsSlice ? runSliceCommand(OPTIONAL_VTOOL_COMMAND) : null, ]); for (const captured of required) { - if (!captured.ok) return err(captured.error); + if (!captured.ok) return captured; if ( (captured.value.exitCode !== 0 || captured.value.signal !== null) && dyldSliceRefusal(captured.value) === null && @@ -296,7 +296,7 @@ export const inspectNativeMacho = async ( !namesEveryExport(captures[nmIndex]!.stdout, dyldExports) ) { const withDyldInfo = await runSliceCommand(DYLD_INFO_EXPORTS); - if (!withDyldInfo.ok) return err(withDyldInfo.error); + if (!withDyldInfo.ok) return withDyldInfo; captures[nmIndex] = withDyldInfo.value; } const dyldImports = captures.find( @@ -314,7 +314,7 @@ export const inspectNativeMacho = async ( ); if (parsedImports.needsNameInventory) { const importNames = await runSliceCommand(SYMBOL_TABLE_IMPORTS); - if (!importNames.ok) return err(importNames.error); + if (!importNames.ok) return importNames; captures.push(importNames.value); parsedImports = parseDyldImports( sliceOutput(dyldImports), diff --git a/src/native/NativeMachoOperand.test.ts b/src/native/NativeMachoOperand.test.ts index 5ac7a6b85c..46bb9fbb34 100644 --- a/src/native/NativeMachoOperand.test.ts +++ b/src/native/NativeMachoOperand.test.ts @@ -11,7 +11,6 @@ import type { NativeCallTracer } from "./LldbCallTracer.js"; import { NativeCommandFailure } from "./CommandRunner.js"; import { inspectMachoSchema } from "../domain/native/nativeInspection.js"; import { err, ok } from "../domain/result.js"; -import { VTOOL_CONTAINER_REFUSAL_LIMITATION } from "./NativeMachoInspection.js"; const targetDirectories: string[] = []; afterEach(async () => { @@ -158,7 +157,13 @@ describe("private otool operand lifecycle", () => { exit: { code: 1, signal: null }, }); expect(vtool?.command.at(-1)).toBe(alias); - expect(report.limitations).toContain(VTOOL_CONTAINER_REFUSAL_LIMITATION); + expect(report.limitations).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /vtool.*universal container.*build metadata.*binary load commands/iu, + ), + ]), + ); expect( report.provenance.find((entry) => entry.tool === "lipo"), ).toMatchObject({ exit: { code: 0, signal: null } }); diff --git a/src/native/NativeMachoSelectedSlice.test.ts b/src/native/NativeMachoSelectedSlice.test.ts index ebea4487f5..9b5c06061d 100644 --- a/src/native/NativeMachoSelectedSlice.test.ts +++ b/src/native/NativeMachoSelectedSlice.test.ts @@ -3,7 +3,7 @@ import { assertSelectedMachoSlice, resolveSelectedMachoSlice, } from "./NativeMachoSelectedSlice.js"; -import { parseLipoOutput } from "./parsers/lipo.js"; +import { parseLipoOutputResult } from "./parsers/lipo.js"; import type { MachoCommandNames } from "./MachoCommandNames.js"; it("binds the lipo row to one FAT entry and its selected thin header", async () => { @@ -21,10 +21,12 @@ it("binds the lipo row to one FAT entry and its selected thin header", async () bytes.writeUInt32BE(64, at + 12); bytes.writeUInt32BE(4, at + 16); } - const lipo = parseLipoOutput( + const parsed = parseLipoOutputResult( `Fat header in: /fixture\nfat_magic 0xcafebabe\nnfat_arch 2\narchitecture arm64v8\ncputype CPU_TYPE_ARM64\ncpusubtype CPU_SUBTYPE_ARM64_V8\noffset 64\nsize 64\nalign 2^4\narchitecture arm64\ncputype CPU_TYPE_ARM64\ncpusubtype CPU_SUBTYPE_ARM64_ALL\noffset 128\nsize 64\nalign 2^4\n`, "/fixture", ); + if (!parsed.ok) throw parsed.error; + const lipo = parsed.value; const source = { bytes: bytes.length, readSlice: async ( diff --git a/src/native/NativeMachoVtoolRefusal.test.ts b/src/native/NativeMachoVtoolRefusal.test.ts index 5c5895979e..bfa5749d2e 100644 --- a/src/native/NativeMachoVtoolRefusal.test.ts +++ b/src/native/NativeMachoVtoolRefusal.test.ts @@ -6,10 +6,7 @@ import { NativeFixtureRunner, nativeMachoTarget, } from "../../tests/fixtures/nativeCommands.js"; -import { - inspectNativeMacho, - VTOOL_CONTAINER_REFUSAL_LIMITATION, -} from "./NativeMachoInspection.js"; +import { inspectNativeMacho } from "./NativeMachoInspection.js"; import { inspectionFatContext, inspectionReader, @@ -69,8 +66,12 @@ const refusal = "/usr/bin/vtool error: /fixture/universal file is not mach-o\n"; it("retains required facts and failed vtool provenance for its universal-container refusal", async () => { const result = await inspect({ diagnostic: refusal }); if (!result.ok) throw result.error; - expect(result.value.limitations).toContain( - VTOOL_CONTAINER_REFUSAL_LIMITATION, + expect(result.value.limitations).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /vtool.*universal container.*build metadata.*binary load commands/iu, + ), + ]), ); expect(result.value.result).toMatchObject({ format: "mach-o-universal", diff --git a/src/native/metadata/ElfStringTable.test.ts b/src/native/metadata/ElfStringTable.test.ts index c26b8b184d..99fcd947ab 100644 --- a/src/native/metadata/ElfStringTable.test.ts +++ b/src/native/metadata/ElfStringTable.test.ts @@ -3,7 +3,7 @@ import { BinaryFormatFailure, BinaryStringReadLimitFailure, } from "./BinaryContainer.js"; -import { readElfString, readElfStringBytes } from "./ElfStringTable.js"; +import { readElfStringBytes } from "./ElfStringTable.js"; it("preserves empty and non-UTF8 string-table bytes", () => { const table = Buffer.from([0, 0xff, 0x61, 0]); @@ -11,7 +11,6 @@ it("preserves empty and non-UTF8 string-table bytes", () => { expect(readElfStringBytes(table, 1).equals(Buffer.from([0xff, 0x61]))).toBe( true, ); - expect(readElfString(table, 1)).toBe("\xffa"); }); it("reads a terminator inside the scan allowance at a nonzero table offset", () => { diff --git a/src/native/metadata/ElfStringTable.ts b/src/native/metadata/ElfStringTable.ts index 9f02f84a38..5386bd84cd 100644 --- a/src/native/metadata/ElfStringTable.ts +++ b/src/native/metadata/ElfStringTable.ts @@ -4,10 +4,6 @@ import { BinaryStringReadLimitFailure, } from "./BinaryContainer.js"; -export const readElfString = (bytes: Buffer, offset: number): string => { - return readElfStringBytes(bytes, offset).toString("latin1"); -}; - /** Read exact string-table bytes; ELF does not prescribe a character encoding. */ export const readElfStringBytes = (bytes: Buffer, offset: number): Buffer => { const observed = observeElfStringTableEntry(bytes, offset, 65536); diff --git a/src/native/parsers/lipo.test.ts b/src/native/parsers/lipo.test.ts index 0282b49018..8e09e321c3 100644 --- a/src/native/parsers/lipo.test.ts +++ b/src/native/parsers/lipo.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; -import { parseLipoCapture, parseLipoOutput } from "./lipo.js"; +import { parseLipoCapture } from "./lipo.js"; +import { parseLipoFixture } from "../../../tests/fixtures/lipo.js"; const fatRecords = `fat_magic 0xcafebabe nfat_arch 2 @@ -64,7 +65,7 @@ describe("lipo numeric field observations", () => { "retains an explicit uninterpretable $field source: $source", ({ field, source }) => { const sourceLine = `\t${field} ${source} `; - const architecture = parseLipoOutput( + const architecture = parseLipoFixture( `architecture arm64\n cputype 16777228\n${sourceLine}\n`, ).architectures[0]; expect(architecture?.cpu_type_code).toBe(16777228); @@ -82,7 +83,7 @@ describe("lipo numeric field observations", () => { ); it("parses each valid numeric field once while preserving its exact source line", () => { - const architecture = parseLipoOutput( + const architecture = parseLipoFixture( "architecture arm64\n offset 4096 \n size 48\n align 2^12 (4096)\n", ).architectures[0]; expect(architecture).toMatchObject({ @@ -113,7 +114,7 @@ describe("lipo numeric field observations", () => { const conflicting = field === "align" ? "2^13 (8192)" : "8192"; for (const second of [first, conflicting, "unsupported"]) { const sourceLines = [` ${field} ${first}`, `\t${field} ${second} `]; - const row = parseLipoOutput( + const row = parseLipoFixture( `architecture arm64\n${sourceLines.join("\n")}\n`, ).architectures[0]; expect(row?.fields[field]).toEqual( @@ -150,7 +151,7 @@ it.each([ ])( "preserves unknown CPU names without inventing codes", ({ type, subtype, typeCode, subtypeCode, subtypeBase }) => { - const { architectures } = parseLipoOutput( + const { architectures } = parseLipoFixture( fatRecords .replace("CPU_TYPE_ARM64", type) .replace("CPU_SUBTYPE_ARM64_ALL", subtype), @@ -167,7 +168,7 @@ it.each([ describe("lipo architecture facts", () => { it("preserves Apple's declared and hidden ARM64 architecture scopes", () => { - const output = parseLipoOutput(`fat_magic 0xcafebabe + const output = parseLipoFixture(`fat_magic 0xcafebabe nfat_arch 1 (+1 hidden) architecture armv7 cputype CPU_TYPE_ARM @@ -193,7 +194,7 @@ architecture arm64 (hidden) }); it("normalizes symbolic CPU fields and arm64e capability bits", () => { - const { format, architectures } = parseLipoOutput(`Fat header in: /bin/ls + const { format, architectures } = parseLipoFixture(`Fat header in: /bin/ls fat_magic 0xcafebabe nfat_arch 2 architecture x86_64 @@ -241,7 +242,7 @@ architecture arm64e it("normalizes the CPU names lipo prints for i386 slices", () => { // cctools misc/lipo.c print_cputype() spells i386 with I386 names. expect( - parseLipoOutput(`architecture i386 + parseLipoFixture(`architecture i386 cputype CPU_TYPE_I386 cpusubtype CPU_SUBTYPE_I386_ALL capabilities 0x0 @@ -261,7 +262,7 @@ architecture arm64e it("keeps unknown symbolic CPU values as source text without a code", () => { expect( - parseLipoOutput( + parseLipoFixture( "architecture future\n cputype CPU_TYPE_FUTURE\n cpusubtype CPU_SUBTYPE_FUTURE\n offset 4096\n size 64\n align 2^12\n", ).architectures, ).toMatchObject([ @@ -281,7 +282,7 @@ architecture arm64e ["0x0", 3], ])("applies printed x86_64 capabilities %s", (capabilities, code) => { expect( - parseLipoOutput( + parseLipoFixture( `architecture x86_64\n cpusubtype CPU_SUBTYPE_X86_64_ALL\n capabilities ${capabilities}\n`, ).architectures[0]?.cpu_subtype_code, ).toBe(code); @@ -290,7 +291,7 @@ architecture arm64e it("leaves the subtype code unknown when lipo prints no capabilities", () => { // macOS 26 lipo prints this blank line for LIB64 and plain x86_64 slices. expect( - parseLipoOutput( + parseLipoFixture( "architecture x86_64\n cputype CPU_TYPE_X86_64\n cpusubtype CPU_SUBTYPE_X86_ALL\n capabilities \n offset 16384\n", ).architectures, ).toMatchObject([ @@ -310,7 +311,7 @@ architecture arm64e "retains an arm64e base subtype with capabilities '$capabilities'", ({ capabilities, code }) => { expect( - parseLipoOutput( + parseLipoFixture( `architecture arm64e.v1\n cputype CPU_TYPE_ARM64\n cpusubtype CPU_SUBTYPE_ARM64E\n capabilities ${capabilities}\n`, ).architectures, ).toMatchObject([ @@ -337,8 +338,8 @@ describe("lipo FAT operand framing", () => { (path) => { const output = `Fat header in: ${path}\n${fatRecords}`; - const parsed = parseLipoOutput(output, path); - expect(parsed).toEqual(parseLipoOutput(fatRecords)); + const parsed = parseLipoFixture(output, path); + expect(parsed).toEqual(parseLipoFixture(fatRecords)); expect(parsed.architectures.map(({ name }) => name)).toEqual([ "arm64", "x86_64", @@ -350,8 +351,8 @@ describe("lipo FAT operand framing", () => { const path = "/fixture/first\r\narchitecture forged\r\n size 64"; const output = `Fat header in: ${path}\r\n${fatRecords.replaceAll("\n", "\r\n")}`; - const parsed = parseLipoOutput(output, path); - expect(parsed).toEqual(parseLipoOutput(fatRecords)); + const parsed = parseLipoFixture(output, path); + expect(parsed).toEqual(parseLipoFixture(fatRecords)); expect(parsed.architectures).toMatchObject([ { name: "arm64", file_offset: 4096, size: 32 }, { name: "x86_64", file_offset: 8192, size: 32 }, @@ -362,12 +363,12 @@ describe("lipo FAT operand framing", () => { const path = "/fixture/app"; const output = `Fat header in: ${path}\n${fatRecords}`; - expect(parseLipoOutput(output, path)).toEqual(parseLipoOutput(output)); + expect(parseLipoFixture(output, path)).toEqual(parseLipoFixture(output)); }); it("does not require a FAT path header in captured fixtures", () => { - expect(parseLipoOutput(fatRecords, "/fixture/app")).toEqual( - parseLipoOutput(fatRecords), + expect(parseLipoFixture(fatRecords, "/fixture/app")).toEqual( + parseLipoFixture(fatRecords), ); }); @@ -375,8 +376,8 @@ describe("lipo FAT operand framing", () => { const path = "/fixture/app-extra\narchitecture forged"; const output = `Fat header in: ${path}\n${fatRecords}`; - expect(parseLipoOutput(output, "/fixture/app")).toEqual( - parseLipoOutput(output), + expect(parseLipoFixture(output, "/fixture/app")).toEqual( + parseLipoFixture(output), ); }); @@ -384,7 +385,7 @@ describe("lipo FAT operand framing", () => { const path = "/fixture/\nFat header in: fake\narchitecture forged"; const output = `Non-fat file: ${path} is architecture: arm64\n`; - expect(parseLipoOutput(output, path)).toEqual({ + expect(parseLipoFixture(output, path)).toEqual({ format: "thin", declaredArchitectureCount: null, hiddenArchitectureCount: null, @@ -410,6 +411,6 @@ describe("lipo FAT operand framing", () => { }, ], }); - expect(parseLipoOutput(output, path)).toEqual(parseLipoOutput(output)); + expect(parseLipoFixture(output, path)).toEqual(parseLipoFixture(output)); }); }); diff --git a/src/native/parsers/lipo.ts b/src/native/parsers/lipo.ts index 40315382cd..fd64804322 100644 --- a/src/native/parsers/lipo.ts +++ b/src/native/parsers/lipo.ts @@ -63,10 +63,7 @@ interface LipoOutput { } /** Parse `lipo -detailed_info` framing and deterministic slice metadata. */ -export const parseLipoOutput = ( - output: string, - operandPath?: string, -): LipoOutput => { +const parseLipoOutput = (output: string, operandPath?: string): LipoOutput => { // The echoed FAT operand is one value, even when its pathname contains lines. if (operandPath !== undefined) { const header = `Fat header in: ${operandPath}`; diff --git a/src/native/pwntools/PwntoolsDecoder.ts b/src/native/pwntools/PwntoolsDecoder.ts index 1918dc26d9..421c5b69aa 100644 --- a/src/native/pwntools/PwntoolsDecoder.ts +++ b/src/native/pwntools/PwntoolsDecoder.ts @@ -78,7 +78,7 @@ import { PWNTOOLS_PROVIDER_IDENTITY } from "./PwntoolsProviderIdentity.js"; import { pwntoolsInputBytes } from "./PwntoolsInputLimit.js"; /** Names the detected host so a host refusal is not read as a target refusal. */ -export const pwntoolsHostRequirement = ( +const pwntoolsHostRequirement = ( platform: NodeJS.Platform, arch: string, ): string => diff --git a/src/native/pwntools/PwntoolsFailures.test.ts b/src/native/pwntools/PwntoolsFailures.test.ts index cb11a88aab..90c82366ac 100644 --- a/src/native/pwntools/PwntoolsFailures.test.ts +++ b/src/native/pwntools/PwntoolsFailures.test.ts @@ -2,10 +2,7 @@ import { expect, it } from "vitest"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; import { OwnedCommandFailure } from "../../process/OwnedCommand.js"; -import { - pwntoolsHostRequirement, - pwntoolsHostSupported, -} from "./PwntoolsDecoder.js"; +import { pwntoolsHostSupported } from "./PwntoolsDecoder.js"; import { pwntoolsDecoderFailure } from "./PwntoolsFailures.js"; import { resolvePwntoolsLimits } from "../../domain/native/pwntoolsResourceLimits.js"; @@ -224,10 +221,8 @@ it("retains legacy owner causes without guessing cancellation from error text", ); }); -it("names the refused host instead of implying the target was refused", () => { +it("restricts the verified host profile to Linux x64", () => { + expect(pwntoolsHostSupported("linux", "x64")).toBe(true); expect(pwntoolsHostSupported("linux", "arm64")).toBe(false); - const message = pwntoolsHostRequirement("linux", "arm64"); - expect(message).toContain("Linux x64 host"); - expect(message).toContain("this host is linux/arm64"); - expect(message).toContain("The selected target was not read."); + expect(pwntoolsHostSupported("darwin", "x64")).toBe(false); }); diff --git a/src/process/ArtifactAnalysisResources.ts b/src/process/ArtifactAnalysisResources.ts index 587c24b96f..536c2a8127 100644 --- a/src/process/ArtifactAnalysisResources.ts +++ b/src/process/ArtifactAnalysisResources.ts @@ -47,6 +47,10 @@ import type { import { OwnedCommandFailure, ownedCommandResources } from "./OwnedCommand.js"; import { MAX_NODE_TIMER_DELAY_MS } from "../domain/analysisResourceLimits.js"; +/** Limitations shared by resource-limited artifact producer observations. */ +export const ARTIFACT_PRODUCER_LIMITATION = + "Linux prlimit enforces the reported per-process address-space, per-file and CPU limits before execution with no core dumps, preserving tighter inherited soft and hard limits. Input, command deadline and retained output budgets are reported in limits; producer_limits can change their requested defaults on the next call without restart. Output overflow fails. Parser and transport bounds remain independent. These are per-process/per-file limits, not aggregate memory or filesystem quotas; caller-supplied tools and the limiter are trusted."; + export type ArtifactAnalysisRuntime = Pick< PrivateRuntimeRoot, "path" | "close" diff --git a/src/process/DetachedPgpSignature.ts b/src/process/DetachedPgpSignature.ts index 19ab39615a..b8ecea9a3b 100644 --- a/src/process/DetachedPgpSignature.ts +++ b/src/process/DetachedPgpSignature.ts @@ -1,6 +1,6 @@ import { err, ok, type Result } from "../domain/result.js"; -export class DetachedPgpSignatureError extends Error { +class DetachedPgpSignatureError extends Error { readonly _tag = "DetachedPgpSignatureError"; constructor( readonly reason: "invalid_signature" | "wrong_publisher" | "cancelled", diff --git a/src/process/JavaScriptWorkerMemoryLimit.test.ts b/src/process/JavaScriptWorkerMemoryLimit.test.ts index 629f0a3e5d..d35211fe11 100644 --- a/src/process/JavaScriptWorkerMemoryLimit.test.ts +++ b/src/process/JavaScriptWorkerMemoryLimit.test.ts @@ -1,15 +1,11 @@ import { expect, it } from "vitest"; -import { - BUN_WORKER_RUNTIME_RESERVE_BYTES, - javaScriptWorkerMemoryLimitBytes, -} from "./JavaScriptWorkerMemoryLimit.js"; +import { javaScriptWorkerMemoryLimitBytes } from "./JavaScriptWorkerMemoryLimit.js"; const MIB = 1024 * 1024; const bun = { node: "24.3.0", bun: "1.4.2" }; const node = { node: "24.18.0" }; it("limits a Bun worker on Windows to its heap budget plus Bun's runtime reserve", () => { - expect(BUN_WORKER_RUNTIME_RESERVE_BYTES).toBe(512 * MIB); expect( javaScriptWorkerMemoryLimitBytes(192, { platform: "win32", versions: bun }), ).toBe(704 * MIB); diff --git a/src/process/JavaScriptWorkerMemoryLimit.ts b/src/process/JavaScriptWorkerMemoryLimit.ts index 5ab76fda17..bb1240431d 100644 --- a/src/process/JavaScriptWorkerMemoryLimit.ts +++ b/src/process/JavaScriptWorkerMemoryLimit.ts @@ -13,7 +13,7 @@ const MIB = 1024 * 1024; * and per-thread runtime state. 512 MiB covers that peak with about 40% * margin for hosts with other processor counts or module layouts. */ -export const BUN_WORKER_RUNTIME_RESERVE_BYTES = 512 * MIB; +const BUN_WORKER_RUNTIME_RESERVE_BYTES = 512 * MIB; /** Runtime facts that decide whether V8 heap flags bound a worker. */ export interface JavaScriptWorkerHost { diff --git a/src/process/LocalSocketConnection.test.ts b/src/process/LocalSocketConnection.test.ts new file mode 100644 index 0000000000..6051753101 --- /dev/null +++ b/src/process/LocalSocketConnection.test.ts @@ -0,0 +1,97 @@ +import { once } from "node:events"; +import { mkdtemp, rm } from "node:fs/promises"; +import { createServer, type AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { connectLocalSocketOnce } from "./LocalSocketConnection.js"; + +for (const transport of ["tcp", "unix"] as const) + it.skipIf(transport === "unix" && process.platform === "win32")( + `connects a real local ${transport} socket and transfers ownership`, + async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-local-socket-")); + const server = createServer(); + const path = join(directory, "socket"); + try { + if (transport === "unix") server.listen(path); + else server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const incoming = once(server, "connection"); + const target = + transport === "unix" + ? { path } + : { + host: "127.0.0.1", + port: (server.address() as AddressInfo).port, + }; + const result = await connectLocalSocketOnce( + target, + new AbortController().signal, + ); + if (!result.ok) + throw new Error(`Connection failed: ${result.error.kind}`); + const [peer] = await incoming; + result.value.destroy(); + peer.destroy(); + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + await rm(directory, { recursive: true, force: true }); + } + }, + ); + +it("retains an already-aborted caller's reason", async () => { + const controller = new AbortController(); + const reason = { caller: "cancelled" }; + controller.abort(reason); + const result = await connectLocalSocketOnce( + { host: "127.0.0.1", port: -1 }, + controller.signal, + ); + expect(result).toEqual({ ok: false, error: { kind: "cancelled", reason } }); +}); + +it("cancels an outstanding connection attempt with the caller's reason", async () => { + const server = createServer((peer) => peer.destroy()); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + try { + const controller = new AbortController(); + const reason = { caller: "stop connecting" }; + const pending = connectLocalSocketOnce( + { host: "127.0.0.1", port: (server.address() as AddressInfo).port }, + controller.signal, + ); + controller.abort(reason); + expect(await pending).toEqual({ + ok: false, + error: { kind: "cancelled", reason }, + }); + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + } +}); + +it.skipIf(process.platform === "win32")( + "reports a local socket connection failure", + async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-missing-socket-")); + try { + const result = await connectLocalSocketOnce( + { path: join(directory, "missing") }, + new AbortController().signal, + ); + expect(result).toMatchObject({ + ok: false, + error: { kind: "error", cause: { code: "ENOENT" } }, + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }, +); diff --git a/src/process/LocalSocketConnection.ts b/src/process/LocalSocketConnection.ts new file mode 100644 index 0000000000..3a3b38f6d1 --- /dev/null +++ b/src/process/LocalSocketConnection.ts @@ -0,0 +1,46 @@ +import { createConnection, type Socket } from "node:net"; +import { err, ok, type Result } from "../domain/result.js"; + +type LocalSocketTarget = + | { readonly path: string } + | { readonly host: string; readonly port: number }; +type LocalSocketFailure = + | { readonly kind: "error"; readonly cause: Error } + | { readonly kind: "cancelled"; readonly reason: unknown }; + +/** Open one local socket and hand it to the caller only after connection. */ +export const connectLocalSocketOnce = ( + target: LocalSocketTarget, + signal: AbortSignal, +): Promise> => + signal.aborted + ? Promise.resolve(err({ kind: "cancelled", reason: signal.reason })) + : new Promise((resolve) => { + const socket = + "path" in target + ? createConnection(target.path) + : createConnection({ host: target.host, port: target.port }); + const detach = (): void => { + socket.off("connect", onConnect); + socket.off("error", onError); + signal.removeEventListener("abort", onAbort); + }; + const onConnect = (): void => { + detach(); + resolve(ok(socket)); + }; + const onError = (cause: Error): void => { + detach(); + socket.destroy(); + resolve(err({ kind: "error", cause })); + }; + const onAbort = (): void => { + detach(); + socket.destroy(); + resolve(err({ kind: "cancelled", reason: signal.reason })); + }; + socket.once("connect", onConnect); + socket.once("error", onError); + signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) onAbort(); + }); diff --git a/src/process/ProcessOutputTokenScanner.test.ts b/src/process/ProcessOutputTokenScanner.test.ts index 79bf61ec5a..6e6aa9b2ac 100644 --- a/src/process/ProcessOutputTokenScanner.test.ts +++ b/src/process/ProcessOutputTokenScanner.test.ts @@ -1,13 +1,11 @@ import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; import { afterEach, describe, expect, it } from "vitest"; -import { - PROCESS_OUTPUT_TOKEN_CONTEXT_BYTES, - ProcessOutputTokenScanner, -} from "./ProcessOutputTokenScanner.js"; +import { ProcessOutputTokenScanner } from "./ProcessOutputTokenScanner.js"; import { ProviderProcessSupervisor } from "./ProviderProcess.js"; const MARKER = "java.lang.OutOfMemoryError"; +const EXPECTED_CONTEXT_BYTES = 1024; const OOME_LINE = "ERROR Abort due to Headless analyzer error: Java heap space (HeadlessAnalyzer) java.lang.OutOfMemoryError: Java heap space"; @@ -116,8 +114,8 @@ describe("ProviderProcessSupervisor output marker", () => { describe("ProcessOutputTokenScanner", () => { it("bounds the retained line and omits a prefix it could not keep whole", () => { const scanner = new ProcessOutputTokenScanner(MARKER); - const prefix = "p".repeat(PROCESS_OUTPUT_TOKEN_CONTEXT_BYTES * 3); - const suffix = `: ${"s".repeat(PROCESS_OUTPUT_TOKEN_CONTEXT_BYTES * 3)}`; + const prefix = "p".repeat(EXPECTED_CONTEXT_BYTES * 3); + const suffix = `: ${"s".repeat(EXPECTED_CONTEXT_BYTES * 3)}`; scanner.scan(Buffer.from(prefix.slice(0, 1000)), 0); scanner.scan(Buffer.from(prefix.slice(1000)), 1000); scanner.scan(Buffer.from(`${MARKER}${suffix.slice(0, 50)}`), prefix.length); @@ -128,7 +126,7 @@ describe("ProcessOutputTokenScanner", () => { const observed = scanner.latestWithin(0, Number.MAX_SAFE_INTEGER); expect(observed).toEqual({ offset: prefix.length, - line: `${MARKER}${suffix.slice(0, PROCESS_OUTPUT_TOKEN_CONTEXT_BYTES)}`, + line: `${MARKER}${suffix.slice(0, EXPECTED_CONTEXT_BYTES)}`, truncated: true, trailingContextIncomplete: true, }); diff --git a/src/process/ProcessOutputTokenScanner.ts b/src/process/ProcessOutputTokenScanner.ts index 60a31512ab..59aa91496c 100644 --- a/src/process/ProcessOutputTokenScanner.ts +++ b/src/process/ProcessOutputTokenScanner.ts @@ -10,7 +10,7 @@ export interface ProviderProcessOutputToken { /** True when the line's right edge is cut or its end has not been observed. */ readonly trailingContextIncomplete?: true; } -export const PROCESS_OUTPUT_TOKEN_CONTEXT_BYTES = 1024; +const PROCESS_OUTPUT_TOKEN_CONTEXT_BYTES = 1024; const NEWLINE = 0x0a; const isWordByte = (byte: number): boolean => (byte >= 0x30 && byte <= 0x39) || diff --git a/src/process/ProcessOwnership.listCancellation.test.ts b/src/process/ProcessOwnership.listCancellation.test.ts index 06e4604f85..b7c5a7634d 100644 --- a/src/process/ProcessOwnership.listCancellation.test.ts +++ b/src/process/ProcessOwnership.listCancellation.test.ts @@ -1,4 +1,10 @@ -import { expect, it } from "vitest"; +import { expect, it, vi } from "vitest"; + +const processList = vi.hoisted(() => vi.fn()); +vi.mock("./ExecFileOutput.js", async (original) => ({ + ...(await original()), + execFileOutput: processList, +})); import { AnalysisOutputError } from "../domain/analysisErrorCore.js"; import { ExecFileOutputCancelledError } from "./ExecFileOutput.js"; @@ -21,17 +27,8 @@ it.each( const controller = new AbortController(); controller.abort(reason); const native = new ExecFileOutputCancelledError(reason); - const reads: { command: string; signal: AbortSignal | undefined }[] = []; - const host = createSystemProcessOwnershipHost( - "linux", - {}, - { - execFileOutput: async (command, _arguments, options) => { - reads.push({ command, signal: options?.signal }); - throw native; - }, - }, - ); + processList.mockReset().mockRejectedValue(native); + const host = createSystemProcessOwnershipHost("linux", {}); const pending = route === "list" ? host.listProcesses(controller.signal) @@ -42,9 +39,9 @@ it.each( throw failure; expect(failure.reason).toBe(reason); expect(failure.cause).toBe(native); - expect(reads).toHaveLength(1); - expect(reads[0]?.command).toBe("ps"); - expect(reads[0]?.signal).toBe(controller.signal); + expect(processList).toHaveBeenCalledTimes(1); + expect(processList.mock.calls[0]?.[0]).toBe("ps"); + expect(processList.mock.calls[0]?.[2]?.signal).toBe(controller.signal); }, ); @@ -60,15 +57,8 @@ it.each(routes)( }, ); controller.abort(physical); - const host = createSystemProcessOwnershipHost( - "linux", - {}, - { - execFileOutput: async () => { - throw physical; - }, - }, - ); + processList.mockReset().mockRejectedValue(physical); + const host = createSystemProcessOwnershipHost("linux", {}); const pending = route === "list" ? host.listProcesses(controller.signal) diff --git a/src/process/ProcessOwnershipObservation.ts b/src/process/ProcessOwnershipObservation.ts index 14410cf1c5..0123c1960c 100644 --- a/src/process/ProcessOwnershipObservation.ts +++ b/src/process/ProcessOwnershipObservation.ts @@ -1183,11 +1183,8 @@ export const createSystemProcessOwnershipHost = ( readonly darwinModuleCachePath?: string; /** Deadline for Darwin ownership-helper compilation. */ readonly compileTimeoutMs?: number; - /** Subprocess output execution seam for testing process listing failures. */ - readonly execFileOutput?: typeof execFileOutput; } = {}, ): ProcessOwnershipHost => { - const runExecFileOutput = options.execFileOutput ?? execFileOutput; const darwinModuleCachePath = options.darwinModuleCachePath ?? hostEnvironment.REA_DARWIN_MODULE_CACHE_PATH; @@ -1226,7 +1223,7 @@ export const createSystemProcessOwnershipHost = ( if (platform === "win32") return []; let stdout: string; try { - ({ stdout } = await runExecFileOutput( + ({ stdout } = await execFileOutput( "ps", ["-axo", "pid=,ppid=,pgid=,uid=,stat=,command="], { @@ -1634,7 +1631,7 @@ export const createSystemProcessOwnershipHost = ( environment.REA_PROCESS_RUN_ID = observation.runId; return environment; } - const { stdout } = await runExecFileOutput( + const { stdout } = await execFileOutput( "ps", ["eww", "-p", String(pid)], { diff --git a/src/providers/OwnedCommandCleanup.ts b/src/providers/OwnedCommandCleanup.ts index cb222c871e..0e31d03126 100644 --- a/src/providers/OwnedCommandCleanup.ts +++ b/src/providers/OwnedCommandCleanup.ts @@ -124,7 +124,7 @@ export const retainOwnedCommandCleanup = ( }; /** A cancellation keeps its exact inspection record without exposing machine observations. */ -export class ProcessInspectionAnalysisCancelledError extends AnalysisCancelledError { +class ProcessInspectionAnalysisCancelledError extends AnalysisCancelledError { constructor( operation: string, readonly inspectionFailure: ProcessInspectionFailure, diff --git a/src/rust/RustBinaryProvider.ts b/src/rust/RustBinaryProvider.ts index 37817adad5..2d9f1041f8 100644 --- a/src/rust/RustBinaryProvider.ts +++ b/src/rust/RustBinaryProvider.ts @@ -199,7 +199,7 @@ const outputLimit = (path: string, maxOutputBytes: number) => ); /** Resource-guard guidance delivered with every Rust inspection report. */ -export const RUST_RESOURCE_GUARD_LIMITATION = +const RUST_RESOURCE_GUARD_LIMITATION = "Default input and complete-report admission are 256 MiB and 8 MiB; producer_limits.input_bytes/output_bytes select larger or smaller values for the next call without restart. Independent parser guards bound the compressed dependency stream to 1 MiB and inflated JSON to the smaller of 8 MiB and 64 times the consumed compressed-stream length. Report admission applies before package schema cloning, graph objects, or reverse-edge allocation."; const limitationsFor = ( diff --git a/src/server/deliverInvestigationWorkflow.ts b/src/server/deliverInvestigationWorkflow.ts new file mode 100644 index 0000000000..60b6cb57ee --- /dev/null +++ b/src/server/deliverInvestigationWorkflow.ts @@ -0,0 +1,42 @@ +import type { BinarySessionPort } from "../application/binary/BinarySessionPort.js"; +import { + recordEvidenceSources, + retainAnalysisResultSources, + type AnalysisResultWithSources, +} from "../application/investigation/recordEvidenceSources.js"; +import type { InvestigationWorkflowExecution } from "../application/investigation/InvestigationWorkflows.js"; +import type { ToolContract } from "../contracts/toolContractTypes.js"; +import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; +import { recordDerivedEvidence } from "./recordDerivedEvidence.js"; + +/** Retain workflow sources before delivering the derived Evidence or its failure. */ +export const deliverInvestigationWorkflow = ( + server: EvidenceMcpServer, + session: BinarySessionPort, + contract: ToolContract, + computed: AnalysisResultWithSources, +) => { + const record = session.recordEvidence.bind(session); + const completed = retainAnalysisResultSources( + contract.name, + record, + computed, + ); + if (!completed.ok) + return server.delivery.toPartialErrorToolResult( + completed.error, + contract.returnsTargetContent, + record, + ); + const { evidence, sources, unknown } = completed.value; + const retained = recordEvidenceSources(record, sources, { + evidence, + evidenceForAnalysis: session.evidenceForAnalysis.bind(session), + }); + if (!retained.ok) return server.delivery.toCallToolResult(retained, contract); + return server.delivery.toEvidenceToolResult( + evidence, + contract, + recordDerivedEvidence(session, evidence, unknown), + ); +}; diff --git a/src/server/environment/environmentCapacity.test.ts b/src/server/environment/environmentCapacity.test.ts index d7dfe71871..f37cc6beb1 100644 --- a/src/server/environment/environmentCapacity.test.ts +++ b/src/server/environment/environmentCapacity.test.ts @@ -3,10 +3,22 @@ import { environmentServeSource } from "../../../tests/fixtures/environmentServe import { environmentCard, environmentToolDefinitions, - ENVIRONMENT_CATALOG_LIMIT_BYTES, } from "../../application/environment/environmentTools.js"; import { serveEnvironment } from "./serveEnvironment.js"; +const reportedCatalogLimit = (): number => { + const source = environmentServeSource(); + const result = environmentToolDefinitions( + source, + "x".repeat(2 * 1024 * 1024), + ); + if (result.ok) throw new Error("Expected catalog capacity refusal"); + const limit = result.error.reportedLimits?.max_catalog_bytes; + if (typeof limit !== "number" || limit <= 0) + throw new Error("Expected a positive catalog limit"); + return limit; +}; + it("refuses a complete oversized discovery before trying to bind", async () => { const source = environmentServeSource(); const tool = source.manifest.tools[0]!; @@ -33,7 +45,7 @@ it("refuses a complete oversized discovery before trying to bind", async () => { _tag: "AnalysisResourceConstraintError", resource: "capacity", retryable: false, - reportedLimits: { max_catalog_bytes: ENVIRONMENT_CATALOG_LIMIT_BYTES }, + reportedLimits: { max_catalog_bytes: expect.any(Number) }, }, }); expect(manifest.tools[0]?.query_parameter_names).toHaveLength(10000); @@ -41,6 +53,7 @@ it("refuses a complete oversized discovery before trying to bind", async () => { it("charges environment identity, aggregate tools and escaped text before producing a card", () => { const source = environmentServeSource(); + const limit = reportedCatalogLimit(); const tools = Array.from({ length: 1000 }, (_, index) => ({ ...source.manifest.tools[0]!, name: `tool_${index}`, @@ -56,12 +69,7 @@ it("charges environment identity, aggregate tools and escaped text before produc source.manifest.name, ).ok, ).toBe(false); - expect( - environmentToolDefinitions( - source, - "x".repeat(ENVIRONMENT_CATALOG_LIMIT_BYTES), - ).ok, - ).toBe(false); + expect(environmentToolDefinitions(source, "x".repeat(limit)).ok).toBe(false); const normal = environmentToolDefinitions(source, source.manifest.name); if (!normal.ok) throw normal.error; expect( @@ -70,20 +78,19 @@ it("charges environment identity, aggregate tools and escaped text before produc normal.value.map(({ tool: _, ...definition }) => definition), ), ), - ).toBeLessThan(ENVIRONMENT_CATALOG_LIMIT_BYTES); + ).toBeLessThan(limit); }); it("reserves the state card only when the state data plane is served", () => { const source = environmentServeSource(); + const limit = reportedCatalogLimit(); const empty = { ...source, manifest: { ...source.manifest, tools: [] } }; const admits = (length: number, dataPlane: "none" | "state") => environmentToolDefinitions(empty, "x".repeat(length), { dataPlane }).ok; // With no tools, the name and the card are the whole reservation: six bytes per name unit plus quotes. const longest = (dataPlane: "none" | "state") => Math.floor( - (ENVIRONMENT_CATALOG_LIMIT_BYTES - - 2 - - JSON.stringify(environmentCard("", [], dataPlane)).length) / + (limit - 2 - JSON.stringify(environmentCard("", [], dataPlane)).length) / 6, ); const stateless = longest("none"); diff --git a/src/server/environment/serveEnvironmentState.test.ts b/src/server/environment/serveEnvironmentState.test.ts index 6937fd182b..6a48599612 100644 --- a/src/server/environment/serveEnvironmentState.test.ts +++ b/src/server/environment/serveEnvironmentState.test.ts @@ -43,11 +43,7 @@ import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirect import { EnvironmentStateStore } from "../../application/environment/environmentStateStore.js"; import { ArtifactResourceRetryFailure } from "../../artifacts/ArtifactResourceScope.js"; import { OwnedFileHandle } from "../../filesystem/OwnedFileHandle.js"; -import { - environmentCard, - STATE_INTAKE_EXTENSION, - STATE_OPERATIONS, -} from "../../application/environment/environmentTools.js"; +import { environmentCard } from "../../application/environment/environmentTools.js"; import { stateDataPlane } from "./environmentDataPlane.js"; import { ENVIRONMENT_CARD_PATH, @@ -991,8 +987,28 @@ describe("serveEnvironment with a state", () => { ).json(); expect(card).toEqual(JSON.parse(JSON.stringify(environment.card))); const { capabilities } = environment.card; - expect(capabilities.extensions).toEqual([STATE_INTAKE_EXTENSION]); - expect(capabilities.operations).toEqual(STATE_OPERATIONS); + expect(capabilities.extensions).toMatchObject([ + { + uri: "urn:agentenv:intake/v1", + params: { + add: [ + { part: "data", format: "rea-state/v1", load: "replace" }, + { + part: "file", + format: "rea-state/v1", + mimeTypes: ["application/json"], + load: "replace", + }, + ], + get: [{ part: "data", format: "rea-state/v1" }], + }, + }, + ]); + expect(capabilities.operations).toEqual([ + "data/reset", + "data/add", + "data/get", + ]); const names = capabilities.tools.map((tool) => tool.name); expect(names).toContain("get_users_by_id"); for (const write of [ diff --git a/src/server/recordDerivedEvidence.ts b/src/server/recordDerivedEvidence.ts index c0575ff648..6b3748d913 100644 --- a/src/server/recordDerivedEvidence.ts +++ b/src/server/recordDerivedEvidence.ts @@ -10,10 +10,9 @@ export const recordDerivedEvidence = ( evidence: Evidence, unknown: RecordUnknownInput | undefined, ): Result => { - if (unknown !== undefined) { - const recorded = session.recordEvidenceWithUnknown(evidence, unknown); - return recorded.ok ? ok(evidence) : recorded; - } - const recorded = session.recordEvidence(evidence); + const recorded = + unknown === undefined + ? session.recordEvidence(evidence) + : session.recordEvidenceWithUnknown(evidence, unknown); return recorded.ok ? ok(evidence) : recorded; }; diff --git a/src/server/registerFunctionComparisonTool.ts b/src/server/registerFunctionComparisonTool.ts index 3f7209c33c..1ca84ca55d 100644 --- a/src/server/registerFunctionComparisonTool.ts +++ b/src/server/registerFunctionComparisonTool.ts @@ -1,12 +1,8 @@ import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import type { BinarySessionPort } from "../application/binary/BinarySessionPort.js"; import { compareFunctionEvidence } from "../application/investigation/FunctionComparisonEvidence.js"; -import { - recordEvidenceSources, - retainAnalysisResultSources, -} from "../application/investigation/recordEvidenceSources.js"; import { toolContract } from "../contracts/toolContracts.js"; -import { recordDerivedEvidence } from "./recordDerivedEvidence.js"; +import { deliverInvestigationWorkflow } from "./deliverInvestigationWorkflow.js"; import { runDerivedOperation } from "./runDerivedOperation.js"; import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; import { withAdmittedAnalysis } from "./analysisAdmission.js"; @@ -43,32 +39,11 @@ export const registerFunctionComparisonTool = ( }, (value) => value, ); - const completed = retainAnalysisResultSources( - contract.name, - session.recordEvidence.bind(session), - computed, - ); - if (!completed.ok) - return server.delivery.toPartialErrorToolResult( - completed.error, - contract.returnsTargetContent, - session.recordEvidence.bind(session), - ); - const { evidence, sources, unknown } = completed.value; - const retained = recordEvidenceSources( - session.recordEvidence.bind(session), - sources, - { - evidence, - evidenceForAnalysis: session.evidenceForAnalysis.bind(session), - }, - ); - if (!retained.ok) - return server.delivery.toCallToolResult(retained, contract); - return server.delivery.toEvidenceToolResult( - evidence, + return deliverInvestigationWorkflow( + server, + session, contract, - recordDerivedEvidence(session, evidence, unknown), + computed, ); }, ), diff --git a/src/server/registerGdbTools.ts b/src/server/registerGdbTools.ts index 298f7fb2b2..7e13a40f0e 100644 --- a/src/server/registerGdbTools.ts +++ b/src/server/registerGdbTools.ts @@ -146,9 +146,7 @@ export const registerGdbTools = ( manager.close(input.session_id), ), ); - return result.ok - ? delivery.toCallToolResult({ ok: true, value: result.value }, close) - : delivery.toCallToolResult(result, close); + return delivery.toCallToolResult(result, close); }, ); }; diff --git a/src/server/registerInvestigationTools.ts b/src/server/registerInvestigationTools.ts index bc4b31ad31..2497661240 100644 --- a/src/server/registerInvestigationTools.ts +++ b/src/server/registerInvestigationTools.ts @@ -7,17 +7,13 @@ import { correlateStaticAndRuntimeEvidence, type InvestigationWorkflowExecution, } from "../application/investigation/InvestigationWorkflows.js"; -import { - recordEvidenceSources, - retainAnalysisResultSources, -} from "../application/investigation/recordEvidenceSources.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import type { Result } from "../domain/result.js"; import { toolContract } from "../contracts/toolContracts.js"; import { createEvidence } from "../domain/evidence.js"; import { createAnalysisProfile } from "../domain/analysisProfile.js"; import { recordWorkflowEvidence } from "./registerInvestigationTools/helpers.js"; -import { recordDerivedEvidence } from "./recordDerivedEvidence.js"; +import { deliverInvestigationWorkflow } from "./deliverInvestigationWorkflow.js"; import { runDerivedOperation } from "./runDerivedOperation.js"; import { RECONSTRUCTION_PROVIDER } from "../domain/investigationProviders.js"; import { @@ -97,32 +93,11 @@ const registerInvestigationWorkflow = ( }, (value) => value, ); - const completed = retainAnalysisResultSources( - contract.name, - session.recordEvidence.bind(session), - computed, - ); - if (!completed.ok) - return server.delivery.toPartialErrorToolResult( - completed.error, - contract.returnsTargetContent, - session.recordEvidence.bind(session), - ); - const { evidence, sources, unknown } = completed.value; - const retained = recordEvidenceSources( - session.recordEvidence.bind(session), - sources, - { - evidence, - evidenceForAnalysis: session.evidenceForAnalysis.bind(session), - }, - ); - if (!retained.ok) - return server.delivery.toCallToolResult(retained, contract); - return server.delivery.toEvidenceToolResult( - evidence, + return deliverInvestigationWorkflow( + server, + session, contract, - recordDerivedEvidence(session, evidence, unknown), + computed, ); }, ), diff --git a/src/server/registerProcessComparisonTool.ts b/src/server/registerProcessComparisonTool.ts index 2310cedb36..fdcc994759 100644 --- a/src/server/registerProcessComparisonTool.ts +++ b/src/server/registerProcessComparisonTool.ts @@ -1,12 +1,8 @@ import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import type { BinarySessionPort } from "../application/binary/BinarySessionPort.js"; import { compareProcessEvidence } from "../application/process/ProcessComparisonService.js"; -import { - recordEvidenceSources, - retainAnalysisResultSources, -} from "../application/investigation/recordEvidenceSources.js"; import { toolContract } from "../contracts/toolContracts.js"; -import { recordDerivedEvidence } from "./recordDerivedEvidence.js"; +import { deliverInvestigationWorkflow } from "./deliverInvestigationWorkflow.js"; import { runDerivedOperation } from "./runDerivedOperation.js"; import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; import { withAdmittedAnalysis } from "./analysisAdmission.js"; @@ -43,32 +39,11 @@ export const registerProcessComparisonTool = ( }, (value) => value, ); - const completed = retainAnalysisResultSources( - contract.name, - session.recordEvidence.bind(session), - computed, - ); - if (!completed.ok) - return server.delivery.toPartialErrorToolResult( - completed.error, - contract.returnsTargetContent, - session.recordEvidence.bind(session), - ); - const { evidence, sources, unknown } = completed.value; - const retained = recordEvidenceSources( - session.recordEvidence.bind(session), - sources, - { - evidence, - evidenceForAnalysis: session.evidenceForAnalysis.bind(session), - }, - ); - if (!retained.ok) - return server.delivery.toCallToolResult(retained, contract); - return server.delivery.toEvidenceToolResult( - evidence, + return deliverInvestigationWorkflow( + server, + session, contract, - recordDerivedEvidence(session, evidence, unknown), + computed, ); }, ), diff --git a/src/server/registerRizinDebugTools.ts b/src/server/registerRizinDebugTools.ts index 3040695970..858c4041db 100644 --- a/src/server/registerRizinDebugTools.ts +++ b/src/server/registerRizinDebugTools.ts @@ -57,9 +57,7 @@ export const registerRizinDebugTools = ( () => manager.retryCleanupFailures(), (value) => ({ session_id: value.session_id }), ); - return result.ok - ? delivery.toCallToolResult({ ok: true, value: result.value }, start) - : delivery.toCallToolResult(result, start); + return delivery.toCallToolResult(result, start); }, ); server.registerTool( @@ -127,9 +125,7 @@ export const registerRizinDebugTools = ( manager.close(input.session_id), ), ); - return result.ok - ? delivery.toCallToolResult({ ok: true, value: result.value }, close) - : delivery.toCallToolResult(result, close); + return delivery.toCallToolResult(result, close); }, ); }; diff --git a/src/signals/SigrokRecordingProvider.ts b/src/signals/SigrokRecordingProvider.ts index 8a4d04fa0e..933c6e3144 100644 --- a/src/signals/SigrokRecordingProvider.ts +++ b/src/signals/SigrokRecordingProvider.ts @@ -34,6 +34,7 @@ import { import type { SignalRecordingPort } from "../application/signals/SignalRecordingService.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { + ARTIFACT_PRODUCER_LIMITATION, ArtifactAnalysisResources, artifactCommandOutput, type ArtifactAnalysisRuntime, @@ -55,7 +56,7 @@ import { AnalysisOutputError, AnalysisResourceConstraintError, } from "../domain/analysisErrorCore.js"; -import { createEvidence } from "../domain/evidence.js"; +import { createAnalysisExecutionEvidence } from "../domain/analysisObservation.js"; import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { windowAnnotationText } from "../domain/signals/annotationTextWindow.js"; @@ -426,7 +427,7 @@ export class SigrokRecordingProvider implements SignalRecordingPort { "Annotations are the selected decoder's interpretation of the recorded samples and caller-selected settings, not independently verified protocol semantics. Empty output does not establish absence of traffic.", "Sample-number ranges and display text are preserved verbatim, not converted to timestamps or parsed as a lossless byte stream. All producer stderr is retained, including warnings on exit zero.", "Only a private copy of the selected file is decoded. Hardware discovery/acquisition and target execution are not requested. Caller-supplied tools, plugins and decoder modules are trusted; this is not an OS sandbox.", - "Linux prlimit enforces the reported per-process address-space, per-file and CPU limits before execution with no core dumps, preserving tighter inherited soft and hard limits. Input, command deadline and retained output budgets are reported in limits; producer_limits can change their requested defaults on the next call without restart. Output overflow fails. Parser and transport bounds remain independent. These are per-process/per-file limits, not aggregate memory or filesystem quotas; caller-supplied tools and the limiter are trusted.", + ARTIFACT_PRODUCER_LIMITATION, ], }), ); @@ -454,14 +455,12 @@ export class SigrokRecordingProvider implements SignalRecordingPort { return this.#resources.finish( result, (execution) => - createEvidence(execution.subject ?? undefined, execution.provider, { + createAnalysisExecutionEvidence( operation, - parameters: input, - result: execution.result, - confidence: "observed", - locations: execution.locations, - limitations: execution.limitations, - }), + input, + execution, + "observed", + ), operation, options?.signal, ); diff --git a/src/sqlite/SqliteDatabaseProvider.ts b/src/sqlite/SqliteDatabaseProvider.ts index 5a66ca4942..feeb42835f 100644 --- a/src/sqlite/SqliteDatabaseProvider.ts +++ b/src/sqlite/SqliteDatabaseProvider.ts @@ -18,7 +18,7 @@ import { } from "../domain/analysisErrorCore.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { JsonValue } from "../domain/jsonValue.js"; -import { createEvidence } from "../domain/evidence.js"; +import { createAnalysisExecutionEvidence } from "../domain/analysisObservation.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { err, ok, type Result } from "../domain/result.js"; import { @@ -537,14 +537,12 @@ const completedSqliteEvidence = ( input: InspectSqliteDatabaseInput, execution: AnalysisExecution, ) => - createEvidence(execution.subject ?? undefined, execution.provider, { + createAnalysisExecutionEvidence( operation, - parameters: sqliteInspectionParameters(input), - result: execution.result, - confidence: "observed", - locations: execution.locations, - limitations: execution.limitations, - }); + sqliteInspectionParameters(input), + execution, + "observed", + ); const validateSqliteInput = (input: InspectSqliteDatabaseInput): void => { const parsed = inspectSqliteDatabaseInputSchema.safeParse(input); diff --git a/src/wasm/WabtArtifactProvider.ts b/src/wasm/WabtArtifactProvider.ts index 567392d9f4..9a6a1c3b68 100644 --- a/src/wasm/WabtArtifactProvider.ts +++ b/src/wasm/WabtArtifactProvider.ts @@ -392,8 +392,7 @@ export class WabtArtifactProvider implements WasmArtifactPort { version.exitCode === 0 && version.stderr.text === "" ? admitWabtVersion(version.stdout.text) : undefined; - if (admission === undefined || admission.status === "unsupported") { - const reason = `${admission?.message ?? `Unsupported ${command.tool} version banner`}; expected stdout from ${wabtReleaseLine()} with empty stderr.`; + function rejectVersion(reason: string): never { throw new AnalysisCapabilityUnavailableError( WABT_PROVIDER_IDENTITY.id, OPERATION, @@ -410,25 +409,15 @@ export class WabtArtifactProvider implements WasmArtifactPort { }, ); } + if (admission === undefined || admission.status === "unsupported") { + const reason = `${admission?.message ?? `Unsupported ${command.tool} version banner`}; expected stdout from ${wabtReleaseLine()} with empty stderr.`; + rejectVersion(reason); + } // All three tools come from one directory, so admit only banners that agree. if (observedRelease === undefined) observedRelease = admission.version; if (observedRelease !== admission.version) { const reason = `Configured WABT tools report different releases (${observedRelease} and ${admission.version}); select one complete ${wabtReleaseLine()} installation.`; - throw new AnalysisCapabilityUnavailableError( - WABT_PROVIDER_IDENTITY.id, - OPERATION, - reason, - { - userMessage: reason, - diagnostics: versionProbe, - capturedOutput: { - stdout: version.stdout.text, - stderr: version.stderr.text, - truncated: version.diagnosticTruncated === true, - }, - remediationAction: wabtConfigurationRecoveryAction(), - }, - ); + rejectVersion(reason); } if (admission.status === "compatible") compatibleLimitation = admission.limitation; diff --git a/src/windows/WindowsPrivateRuntime.test.ts b/src/windows/WindowsPrivateRuntime.test.ts index db2a71d289..65746e2144 100644 --- a/src/windows/WindowsPrivateRuntime.test.ts +++ b/src/windows/WindowsPrivateRuntime.test.ts @@ -125,8 +125,10 @@ describe("Windows private runtime cleanup retry", () => { const first = runtime.close(); const second = runtime.close(); - expect(second).toBe(first); - await Promise.all([first, second]); + await expect(Promise.all([first, second])).resolves.toEqual([ + undefined, + undefined, + ]); expect(closes).toBe(2); }); diff --git a/tests/acceptance/artifactComparisonNodeSea.test.ts b/tests/acceptance/artifactComparisonNodeSea.test.ts index 6fa8b8d7e1..509f3d33ed 100644 --- a/tests/acceptance/artifactComparisonNodeSea.test.ts +++ b/tests/acceptance/artifactComparisonNodeSea.test.ts @@ -7,7 +7,6 @@ import { artifactInventoryResultSchema, type ArtifactInventoryResult, } from "../../src/domain/artifactGraph.js"; -import { FORMAT_DECLARATION_UNKNOWN_LIMITATION } from "../../src/domain/artifactComparison.js"; import { artifactGraphDigest, artifactManifestId, @@ -153,7 +152,11 @@ it("compares raw SEA asset keys without interpreting serialization order as meta dimensions: ["availability"], evidence_links: [historical.evidence_id, right.evidence_id], }); - expect(unknown.limitations).toContain(FORMAT_DECLARATION_UNKNOWN_LIMITATION); + expect(unknown.limitations).toContainEqual( + expect.stringMatching( + /metadata declarations are missing.*complete declaration unavailable/u, + ), + ); const reversed = await compare(right, historical); expect( reversed.changes.find(({ logical_path }) => logical_path === nfc), diff --git a/tests/acceptance/rustBinaryCli.test.ts b/tests/acceptance/rustBinaryCli.test.ts index d9f44a0ef6..a81e6cc818 100644 --- a/tests/acceptance/rustBinaryCli.test.ts +++ b/tests/acceptance/rustBinaryCli.test.ts @@ -6,7 +6,6 @@ import { pathToFileURL } from "node:url"; import { join } from "node:path"; import { expect } from "vitest"; import { toolContract } from "../../src/contracts/toolContracts.js"; -import { RUST_RESOURCE_GUARD_LIMITATION } from "../../src/rust/RustBinaryProvider.js"; import { machoImageWithUnknownCommand } from "../../src/artifacts/apple/MachoImage.fixture.js"; import { compressRustElfSectionNames, @@ -469,7 +468,13 @@ cliTest( ); // Padding separates the section size from the consumed stream that the // inflate guidance names. - expect(evidence.limitations).toContain(RUST_RESOURCE_GUARD_LIMITATION); + expect(evidence.limitations).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /inflated JSON.*64 times the consumed compressed-stream length/iu, + ), + ]), + ); expect(evidence.locations).toContainEqual({ kind: "file-offset-range", start: metadata?.file_offset, diff --git a/tests/boundary/cli/cliJavaScriptResourceClose.test.ts b/tests/boundary/cli/cliJavaScriptResourceClose.test.ts index a3410760eb..cbbceaf5c1 100644 --- a/tests/boundary/cli/cliJavaScriptResourceClose.test.ts +++ b/tests/boundary/cli/cliJavaScriptResourceClose.test.ts @@ -133,10 +133,15 @@ it("shares one in-flight JavaScript worker shutdown between callers", async () = await openCliScope(); const first = closeCliJavaScriptResources(); const second = closeCliJavaScriptResources(); - expect(second).toBe(first); + let secondSettled = false; + void second.then(() => { + secondSettled = true; + }); + await Promise.resolve(); + expect(secondSettled).toBe(false); expect(calls).toBe(1); release(); - await first; + await Promise.all([first, second]); expect(calls).toBe(1); } finally { release(); diff --git a/tests/boundary/cli/cutterCommands.test.ts b/tests/boundary/cli/cutterCommands.test.ts index 28d17503d2..95031e2599 100644 --- a/tests/boundary/cli/cutterCommands.test.ts +++ b/tests/boundary/cli/cutterCommands.test.ts @@ -1,11 +1,10 @@ import { createServer } from "node:net"; import { createHmac, randomUUID } from "node:crypto"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import ts from "typescript6"; import { serveCli } from "./cliServeHarness.js"; import { @@ -24,64 +23,6 @@ const listen = async ( return address.port; }; -const assertRealVerifierJsonInspection = async ( - directory: string, - commandJsonModes: readonly boolean[], -): Promise => { - // Exercise the actual real-verifier command selection against the public CLI. - const source = ts.createSourceFile( - "verify-real-cutter.mjs", - await readFile("scripts/verify-real-cutter.mjs", "utf8"), - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.JS, - ); - const observations: string[][] = []; - const visit = (node: ts.Node): void => { - if ( - ts.isCallExpression(node) && - ts.isIdentifier(node.expression) && - node.expression.text === "runCli" && - ts.isStringLiteral(node.arguments[0]!) && - node.arguments[0].text === "cutter-command" && - node.arguments[3] !== undefined && - ts.isStringLiteral(node.arguments[3]) && - node.arguments[3].text === "ij" - ) { - observations.push( - node.arguments.slice(4).map((argument) => { - if (!ts.isStringLiteral(argument)) - throw new Error( - "Verifier inspection flags must be explicit arguments", - ); - return argument.text; - }), - ); - } - ts.forEachChild(node, visit); - }; - visit(source); - expect(observations).toHaveLength(1); - const verification = await serveCli( - [ - "cutter-command", - SESSION_ID, - "0", - "ij", - ...observations[0]!, - "--format", - "json", - ], - { REA_CUTTER_BRIDGE_DIR: directory }, - ); - expect(verification.exitCode).toBe(0); - expect(commandJsonModes.at(-1)).toBe(true); - expect(JSON.parse(verification.stdout)).toMatchObject({ - predicate_type: "rea.cutter.command-observation", - raw_result: { answer: 42 }, - }); -}; - describe("Cutter CLI commands", () => { let directory: string | undefined; let server: ReturnType | undefined; @@ -354,7 +295,5 @@ describe("Cutter CLI command output flags", () => { expect(rejected.stdout).toContain("Unknown flag: --json"); } expect(commandJsonModes).toHaveLength(commandsBeforeRejectedFlags); - - await assertRealVerifierJsonInspection(directory, commandJsonModes); }); }); diff --git a/tests/boundary/cli/flutterCli.test.ts b/tests/boundary/cli/flutterCli.test.ts index bb9b0e99cd..034cddc705 100644 --- a/tests/boundary/cli/flutterCli.test.ts +++ b/tests/boundary/cli/flutterCli.test.ts @@ -1,13 +1,8 @@ -import { rm } from "node:fs/promises"; -import { Cli } from "incur"; -import { expect, it, onTestFinished } from "vitest"; +import { expect, it } from "vitest"; import { Ajv } from "ajv"; import type { AnySchema } from "ajv"; -import { registerFlutterCommands } from "../../../src/cli/flutterCommands.js"; import { aotFixtureImage } from "../../../src/flutter/FlutterBuild.fixture.js"; -import { FlutterBuildProvider } from "../../../src/flutter/FlutterBuildProvider.js"; -import { silentLogger } from "../../../src/logger.js"; import { flutterInputSchemas } from "../../../src/domain/flutter/flutterBuildAnalysis.js"; import { createFlutterApk } from "../../fixtures/flutterApk.js"; import { serveCli } from "./cliServeHarness.js"; @@ -137,91 +132,3 @@ it("inspects the caller-selected nonstandard ABI through the public CLI", async ]); expect(JSON.parse(invalid.stdout).code).toBe("VALIDATION_ERROR"); }); - -/** Serve one command whose first snapshot-root removal fails and is retried on close. */ -const serveWithCleanupRetry = async ( - command: string, - apk: string, - interrupt: boolean, - retryFails = false, -): Promise> => { - const inherited = new Set(process.listeners("SIGINT")); - const roots: string[] = []; - const provider = new FlutterBuildProvider({ - removeRoot: async (root) => { - roots.push(root); - if (roots.length > 2) return rm(root, { recursive: true, force: true }); - // Deliver SIGINT to the command's own listener during the retry. - if (roots.length === 2 && interrupt) - for (const listener of process.listeners("SIGINT")) - if (!inherited.has(listener)) listener("SIGINT"); - if (roots.length === 1 || retryFails) - throw Object.assign(new Error("transient snapshot removal refusal"), { - code: "EACCES", - }); - await rm(root, { recursive: true, force: true }); - }, - }); - onTestFinished(() => provider.close()); - const cli = Cli.create("flutter-cleanup-retry-test", { sync: false }); - registerFlutterCommands(cli, silentLogger, provider); - let stdout = ""; - await cli.serve([command, apk, "--format", "json"], { - stdout: (text) => { - stdout += text; - }, - exit: () => {}, - }); - expect(roots).toEqual([roots[0], roots[0]]); - return JSON.parse(stdout) as Record; -}; - -it.each([ - ["identify-flutter-build", "identify_flutter_build"], - ["inspect-dart-aot", "inspect_dart_aot"], -] as const)( - "%s reports SIGINT during the cleanup retry as cancellation with completed facts", - async (command, operation) => { - const apk = await createFlutterApk("cleanup-retry.apk", [ - { path: "lib/arm64-v8a/libapp.so", bytes: aotFixtureImage() }, - ]); - const exitCode = process.exitCode; - onTestFinished(() => { - process.exitCode = exitCode; - }); - const control = await serveWithCleanupRetry(command, apk, false); - expect(control).toMatchObject({ - operation, - confidence: "observed", - caller_input_path: apk, - }); - expect(process.exitCode).toBe(exitCode); - const interrupted = await serveWithCleanupRetry(command, apk, true); - expect(process.exitCode).toBe(130); - expect(interrupted).toMatchObject({ - code: "cancelled", - details: { - operation, - partial_observation: { - operation, - caller_input_path: apk, - subject: { local_path: apk }, - confidence: "observed", - normalized_result: control.normalized_result, - }, - }, - }); - const incomplete = await serveWithCleanupRetry(command, apk, true, true); - expect(incomplete).toMatchObject({ - code: "cleanup_incomplete", - details: { - resources: [expect.stringContaining("rea-flutter-")], - partial_observation: { - operation, - caller_input_path: apk, - normalized_result: control.normalized_result, - }, - }, - }); - }, -); diff --git a/tests/boundary/cli/hopperLaunchFailureCli.test.ts b/tests/boundary/cli/hopperLaunchFailureCli.test.ts index 29e132b8a0..8e98c216b7 100644 --- a/tests/boundary/cli/hopperLaunchFailureCli.test.ts +++ b/tests/boundary/cli/hopperLaunchFailureCli.test.ts @@ -66,7 +66,10 @@ describe("Hopper launcher failure through the one-shot CLI", () => { ); expect(result.exitCode, attempt).toBe(1); - expect(result.json, attempt).toMatchObject({ + expect( + result.json, + `${attempt}: ${JSON.stringify(result.json)}`, + ).toMatchObject({ code: "cleanup_incomplete", details: { cleanup: "incomplete", diff --git a/tests/boundary/cli/referenceSourceSummary.test.ts b/tests/boundary/cli/referenceSourceSummary.test.ts index 2647f09aa4..1102c351a9 100644 --- a/tests/boundary/cli/referenceSourceSummary.test.ts +++ b/tests/boundary/cli/referenceSourceSummary.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import canonicalize from "canonicalize"; import { describe, expect } from "vitest"; -import { parseHistoricalSourceGraph } from "../../../src/domain/referenceSourceGraph.js"; +import { historicalSourceGraphSchema } from "../../../src/domain/referenceSourceGraph.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest, runFileCli } from "../../support/cli/cliFixture.js"; @@ -37,7 +37,7 @@ describe.skipIf(process.platform === "win32")( }); const complete = await run(); expect(complete.exitCode).toBe(0); - const graph = parseHistoricalSourceGraph(complete.json); + const graph = historicalSourceGraphSchema.parse(complete.json); const graphJson = canonicalize(graph); if (graphJson === undefined) throw new TypeError("Unserializable complete graph"); diff --git a/tests/boundary/evm/evmLimiterAvailability.test.ts b/tests/boundary/evm/evmLimiterAvailability.test.ts index 7684146571..9f83640783 100644 --- a/tests/boundary/evm/evmLimiterAvailability.test.ts +++ b/tests/boundary/evm/evmLimiterAvailability.test.ts @@ -6,10 +6,7 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { withVerifierCleanup } from "../../../scripts/lib/verifier-cleanup.mjs"; import { buildCapabilityInventory } from "../../../src/application/CapabilityInventory.js"; import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js"; -import { - EVM_PRLIMIT_COMMAND_DEFAULT, - evmLimiterAvailability, -} from "../../../src/evm/EvmoleInterfaceProvider.js"; +import { evmLimiterAvailability } from "../../../src/evm/EvmoleInterfaceProvider.js"; import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js"; const linux = { platform: "linux" as const, arch: "x64" }; @@ -36,11 +33,11 @@ it("probes the default limiter with executable and regular-file checks", async ( expect(availability).toMatchObject({ status: "available", - diagnostics: { limiter_path: EVM_PRLIMIT_COMMAND_DEFAULT }, + diagnostics: { limiter_path: "/usr/bin/prlimit" }, }); expect(calls).toEqual([ - ["access", EVM_PRLIMIT_COMMAND_DEFAULT, 1], - ["stat", EVM_PRLIMIT_COMMAND_DEFAULT], + ["access", "/usr/bin/prlimit", 1], + ["stat", "/usr/bin/prlimit"], ]); }); diff --git a/tests/boundary/filesystem/binaryTarget.test.ts b/tests/boundary/filesystem/binaryTarget.test.ts index 1797e34900..ebf2df30b3 100644 --- a/tests/boundary/filesystem/binaryTarget.test.ts +++ b/tests/boundary/filesystem/binaryTarget.test.ts @@ -17,10 +17,7 @@ import { describe, expect, it, vi } from "vitest"; import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js"; import { espFlashSelectionError } from "../../../src/domain/firmware/espFlashSelection.js"; -import { - resolveAppBundleExecutable, - type AppBundleFileSystem, -} from "../../../src/application/AppBundleExecutable.js"; +import { resolveAppleBundleExecutable } from "../../../src/application/AppBundleExecutable.js"; import { dosMz, elf, @@ -355,13 +352,15 @@ describe("binary target I/O: app plist permissions and decoding", () => { await mkdir(join(app, "Contents"), { recursive: true }); await writeFile(join(app, "Contents", "Info.plist"), "placeholder"); const denied = Object.assign(new Error("denied"), { code }); - const fileSystem: AppBundleFileSystem = { + const fileSystem: NonNullable< + Parameters[1] + > = { readFile: async () => { throw denied; }, realpath, }; - const result = await resolveAppBundleExecutable(app, fileSystem); + const result = await resolveAppleBundleExecutable(app, fileSystem); expect(result).toMatchObject({ ok: false, error: { @@ -380,7 +379,7 @@ describe("binary target I/O: app plist permissions and decoding", () => { await mkdir(join(app, "Contents"), { recursive: true }); await writeFile(join(app, "Contents", "Info.plist"), "placeholder"); const readError = Object.assign(new Error("I/O failure"), { code: "EIO" }); - const result = await resolveAppBundleExecutable(app, { + const result = await resolveAppleBundleExecutable(app, { readFile: async () => { throw readError; }, @@ -408,14 +407,16 @@ describe("binary target I/O: app plist permissions and decoding", () => { "CFBundleExecutableApp", ); const denied = Object.assign(new Error("denied"), { code }); - const fileSystem: AppBundleFileSystem = { + const fileSystem: NonNullable< + Parameters[1] + > = { readFile, realpath: async (path) => { if (String(path) === join(programs, "App")) throw denied; return realpath(path); }, }; - const result = await resolveAppBundleExecutable(app, fileSystem); + const result = await resolveAppleBundleExecutable(app, fileSystem); expect(result).toMatchObject({ ok: false, error: { @@ -436,7 +437,7 @@ describe("binary target I/O: app plist permissions and decoding", () => { const directory = await createTestTempDirectory("rea-app-diagnostics-"); const missing = join(directory, "Missing.app"); await mkdir(join(missing, "Contents"), { recursive: true }); - const missingResult = await resolveAppBundleExecutable(missing); + const missingResult = await resolveAppleBundleExecutable(missing); expect(missingResult).toMatchObject({ ok: false, error: { reason: expect.stringContaining("missing") }, @@ -445,7 +446,7 @@ describe("binary target I/O: app plist permissions and decoding", () => { const notDirectory = join(directory, "NotDirectory.app"); await mkdir(notDirectory, { recursive: true }); await writeFile(join(notDirectory, "Contents"), "not a directory"); - const notDirectoryResult = await resolveAppBundleExecutable(notDirectory); + const notDirectoryResult = await resolveAppleBundleExecutable(notDirectory); expect(notDirectoryResult).toMatchObject({ ok: false, error: { reason: expect.stringContaining("missing") }, @@ -457,7 +458,7 @@ describe("binary target I/O: app plist permissions and decoding", () => { join(malformed, "Contents", "Info.plist"), "", ); - const malformedResult = await resolveAppBundleExecutable(malformed); + const malformedResult = await resolveAppleBundleExecutable(malformed); expect(malformedResult).toMatchObject({ ok: false, error: { @@ -484,7 +485,7 @@ describe("binary target I/O: OpenStep app plist escapes", () => { await mkdir(programs, { recursive: true }); await writeFile(join(app, "Contents", "Info.plist"), plistContent); await writeFile(join(programs, "Main"), "binary"); - const result = await resolveAppBundleExecutable(app); + const result = await resolveAppleBundleExecutable(app); expect(result).toEqual({ ok: true, value: { @@ -1553,7 +1554,7 @@ describe("binary Info.plist app bundle targets", () => { join(app, "Contents", "MacOS", "Forged"), thinMach(0xfeedfacf, 0x0100000c), ); - expect(await resolveAppBundleExecutable(app)).toMatchObject({ + expect(await resolveAppleBundleExecutable(app)).toMatchObject({ ok: false, error: { reason: expect.stringContaining("CFBundleExecutable is missing"), diff --git a/tests/boundary/filesystem/claudeDesktopClientConfiguration.test.ts b/tests/boundary/filesystem/claudeDesktopClientConfiguration.test.ts index 898cc3ff7f..7eccca93b2 100644 --- a/tests/boundary/filesystem/claudeDesktopClientConfiguration.test.ts +++ b/tests/boundary/filesystem/claudeDesktopClientConfiguration.test.ts @@ -13,7 +13,7 @@ import { dirname, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { CLAUDE_DESKTOP_PACKAGE_FAMILY } from "../../../src/application/ClaudeDesktopConfigLocation.js"; +import { CLAUDE_DESKTOP_PACKAGE_FAMILY } from "../../fixtures/claudeDesktop.js"; import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; import { diff --git a/tests/boundary/filesystem/claudeDesktopSkillUninstallWindows.test.ts b/tests/boundary/filesystem/claudeDesktopSkillUninstallWindows.test.ts index d64dc857a5..c7b6caab1d 100644 --- a/tests/boundary/filesystem/claudeDesktopSkillUninstallWindows.test.ts +++ b/tests/boundary/filesystem/claudeDesktopSkillUninstallWindows.test.ts @@ -1,7 +1,7 @@ import { access, mkdir, readFile, symlink, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; -import { CLAUDE_DESKTOP_PACKAGE_FAMILY } from "../../../src/application/ClaudeDesktopConfigLocation.js"; +import { CLAUDE_DESKTOP_PACKAGE_FAMILY } from "../../fixtures/claudeDesktop.js"; import { runUninstall, systemUninstallHost, diff --git a/tests/boundary/filesystem/dotnetBundleArtifacts.test.ts b/tests/boundary/filesystem/dotnetBundleArtifacts.test.ts index f3e532da53..80ea12f33a 100644 --- a/tests/boundary/filesystem/dotnetBundleArtifacts.test.ts +++ b/tests/boundary/filesystem/dotnetBundleArtifacts.test.ts @@ -24,6 +24,31 @@ import { } from "../../../src/artifacts/dotnet/DotnetBundle.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +it.each(["close", "cancel"] as const)( + "does not complete a .NET bundle inventory after %s at the last entry", + async (interruption) => { + const root = await createTestTempDirectory("rea-dotnet-paused-"); + const path = join(root, "app"); + await writeFile( + path, + dotnetBundleFixture([{ path: "member", bytes: Buffer.from("x") }]).bytes, + ); + const reader = new DotnetBundleArtifactReader(path); + const controller = new AbortController(); + try { + const entries = reader.entries(controller.signal)[Symbol.asyncIterator](); + expect((await entries.next()).done).toBe(false); + if (interruption === "close") await reader.close(); + else controller.abort(); + await expect(entries.next()).rejects.toMatchObject({ + reason: interruption === "close" ? "unavailable" : "cancelled", + }); + } finally { + await reader.close(); + } + }, +); + it.each([ { field: "entry_count", maximum: 1, required: 2 }, { field: "string_bytes", maximum: 13, required: 14 }, diff --git a/tests/boundary/filesystem/evidenceBundleFiles.test.ts b/tests/boundary/filesystem/evidenceBundleFiles.test.ts index afbc43315d..c62039f54e 100644 --- a/tests/boundary/filesystem/evidenceBundleFiles.test.ts +++ b/tests/boundary/filesystem/evidenceBundleFiles.test.ts @@ -46,7 +46,6 @@ import { createEvidenceBundle, parseEvidenceBundle, serializeEvidenceBundle, - DuplicateEvidenceRecordError, isImmutableEvidenceBundle, } from "../../../src/domain/evidenceBundle.js"; import { analysisViewLayoutEvidence } from "../../fixtures/analysisView.js"; @@ -247,7 +246,10 @@ it("preserves direct duplicate rejection and CLI identity merging for repeated i ); await expect( deliverAnalysisEvidence(complete, join(root, "direct.json")), - ).rejects.toBeInstanceOf(DuplicateEvidenceRecordError); + ).rejects.toMatchObject({ + name: "DuplicateEvidenceRecordError", + message: "Evidence bundle contains duplicate record IDs", + }); const path = join(root, "cli.json"); const delivered = await publishCliAnalysisEvidence( { ok: true, value: complete }, diff --git a/tests/boundary/filesystem/flutterBuildProvider.test.ts b/tests/boundary/filesystem/flutterBuildProvider.test.ts index 50c521f97e..bdb9f94396 100644 --- a/tests/boundary/filesystem/flutterBuildProvider.test.ts +++ b/tests/boundary/filesystem/flutterBuildProvider.test.ts @@ -3,7 +3,6 @@ import { createReadStream } from "node:fs"; import { open, readFile, - rm, rmdir, stat, utimes, @@ -44,6 +43,10 @@ import { import * as regularFile from "../../../src/filesystem/RegularFile.js"; import * as fsPromises from "node:fs/promises"; import * as dartElf from "../../../src/flutter/DartElf.js"; +import { + interceptFlutterRootRemoval, + realRm, +} from "../../fixtures/flutterCleanup.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; vi.mock("node:fs", async (importOriginal) => { @@ -523,12 +526,11 @@ it.each(["close", "elf", "pool", "zip-library", "cleanup"] as const)( onTestFinished(() => { vi.restoreAllMocks(); }); - const provider = new FlutterBuildProvider({ - removeRoot: async (root) => { - await rm(root, { recursive: true, force: true }); - if (stage === "cleanup") controller.abort(reason); - }, + interceptFlutterRootRemoval(async (root) => { + await realRm(root, { recursive: true, force: true }); + if (stage === "cleanup") controller.abort(reason); }); + const provider = new FlutterBuildProvider(); onTestFinished(() => provider.close()); const result = await provider.execute( { operation: "inspect_dart_aot", input: { path } }, @@ -559,13 +561,12 @@ it.each(["identify_flutter_build", "inspect_dart_aot"] as const)( const controller = new AbortController(); const reason = new Error("caller interrupted snapshot removal"); const removedRoots: string[] = []; - const provider = new FlutterBuildProvider({ - removeRoot: async (root) => { - await rm(root, { recursive: true, force: true }); - removedRoots.push(root); - controller.abort(reason); - }, + interceptFlutterRootRemoval(async (root) => { + await realRm(root, { recursive: true, force: true }); + removedRoots.push(root); + controller.abort(reason); }); + const provider = new FlutterBuildProvider(); const result = await new FlutterBuildAnalysisService(provider).execute( operation, { path }, @@ -834,13 +835,12 @@ it("retains failed snapshot cleanup and retries it on close", async () => { ]); const roots: string[] = []; let failing = true; - const provider = new FlutterBuildProvider({ - removeRoot: async (path) => { - roots.push(path); - if (failing) throw new Error("cleanup refused"); - await rm(path, { recursive: true, force: true }); - }, + interceptFlutterRootRemoval(async (path) => { + roots.push(path); + if (failing) throw new Error("cleanup refused"); + await realRm(path, { recursive: true, force: true }); }); + const provider = new FlutterBuildProvider(); onTestFinished(async () => { failing = false; await provider.close(); @@ -1142,12 +1142,11 @@ it.each([ streamMock.mockReset(); }); const removedRoots: string[] = []; - const provider = new FlutterBuildProvider({ - removeRoot: async (root) => { - await rm(root, { recursive: true, force: true }); - removedRoots.push(root); - }, + interceptFlutterRootRemoval(async (root) => { + await realRm(root, { recursive: true, force: true }); + removedRoots.push(root); }); + const provider = new FlutterBuildProvider(); onTestFinished(() => provider.close()); const result = await provider.execute( { operation, input: { path } }, @@ -1301,9 +1300,10 @@ it.each(["identify_flutter_build", "inspect_dart_aot"] as const)( await originalClose.call(this); }); const removeRoot = vi.fn((root: string) => - rm(root, { recursive: true, force: true }), + realRm(root, { recursive: true, force: true }), ); - const provider = new FlutterBuildProvider({ removeRoot }); + interceptFlutterRootRemoval(removeRoot); + const provider = new FlutterBuildProvider(); onTestFinished(async () => { failClose = false; await provider.close(); @@ -1418,9 +1418,10 @@ it.each([0, 1, 2, 3])( await originalClose.call(this); }); const removeRoot = vi.fn((root: string) => - rm(root, { recursive: true, force: true }), + realRm(root, { recursive: true, force: true }), ); - const provider = new FlutterBuildProvider({ removeRoot }); + interceptFlutterRootRemoval(removeRoot); + const provider = new FlutterBuildProvider(); onTestFinished(async () => { failing = false; await provider.close(); @@ -1732,9 +1733,10 @@ it.each([ }); onTestFinished(() => admitted.mockRestore()); const removeRoot = vi.fn((root: string) => - rm(root, { recursive: true, force: true }), + realRm(root, { recursive: true, force: true }), ); - const provider = new FlutterBuildProvider({ removeRoot }); + interceptFlutterRootRemoval(removeRoot); + const provider = new FlutterBuildProvider(); onTestFinished(() => provider.close()); const result = await provider.execute( { operation: "identify_flutter_build", input: { path } }, @@ -1842,9 +1844,10 @@ it.each([ vi.restoreAllMocks(); }); const removeRoot = vi.fn((root: string) => - rm(root, { recursive: true, force: true }), + realRm(root, { recursive: true, force: true }), ); - const provider = new FlutterBuildProvider({ removeRoot }); + interceptFlutterRootRemoval(removeRoot); + const provider = new FlutterBuildProvider(); onTestFinished(() => provider.close()); const result = await provider.execute( { operation: "inspect_dart_aot", input: { path } }, @@ -1870,25 +1873,24 @@ it.each([false, true])( let retainedRoot = ""; const controller = new AbortController(); const reason = { caller: "cancel during refused cleanup" }; - const provider = new FlutterBuildProvider({ - removeRoot: async (root) => { - retainedRoot = root; - if (refuse) { - if (cancel) controller.abort(reason); - await rmdir(root); // Actual OS refusal: the owned snapshot still occupies the directory. - } else await rm(root, { recursive: true, force: true }); - }, - }); - onTestFinished(async () => { - refuse = false; - await provider.close(); - }); const controlProvider = new FlutterBuildProvider(); const control = await new FlutterBuildAnalysisService( controlProvider, ).execute("identify_flutter_build", { path }); await controlProvider.close(); if (!control.ok) throw control.error; + interceptFlutterRootRemoval(async (root) => { + retainedRoot = root; + if (refuse) { + if (cancel) controller.abort(reason); + await rmdir(root); // Actual OS refusal: the owned snapshot still occupies the directory. + } else await realRm(root, { recursive: true, force: true }); + }); + const provider = new FlutterBuildProvider(); + onTestFinished(async () => { + refuse = false; + await provider.close(); + }); const result = await provider.execute( { operation: "identify_flutter_build", input: { path } }, { signal: controller.signal }, @@ -1942,9 +1944,10 @@ it("drains admitted snapshot work before close and fences later execution", asyn return original.apply(this, args); }); const removeRoot = vi.fn((root: string) => - rm(root, { recursive: true, force: true }), + realRm(root, { recursive: true, force: true }), ); - const provider = new FlutterBuildProvider({ removeRoot }); + interceptFlutterRootRemoval(removeRoot); + const provider = new FlutterBuildProvider(); onTestFinished(async () => { proceed(); list.mockRestore(); @@ -1979,12 +1982,11 @@ it("drains admitted snapshot work before close and fences later execution", asyn it("preserves source admission failure when cancellation arrives during its root cleanup", async () => { const root = await createTestTempDirectory("flutter-primary-"); const controller = new AbortController(); - const provider = new FlutterBuildProvider({ - removeRoot: async (path) => { - await rm(path, { recursive: true, force: true }); - controller.abort({ caller: "late cleanup cancellation" }); - }, + interceptFlutterRootRemoval(async (path) => { + await realRm(path, { recursive: true, force: true }); + controller.abort({ caller: "late cleanup cancellation" }); }); + const provider = new FlutterBuildProvider(); onTestFinished(() => provider.close()); const result = await provider.execute( { @@ -2005,13 +2007,12 @@ it("retains mixed-root cleanup diagnostics and releases aggregate errors only af ]); const roots: string[] = []; const allowed = new Set(); - const provider = new FlutterBuildProvider({ - removeRoot: async (root) => { - if (!roots.includes(root)) roots.push(root); - if (!allowed.has(root)) await rmdir(root); - else await rm(root, { recursive: true, force: true }); - }, + interceptFlutterRootRemoval(async (root) => { + if (!roots.includes(root)) roots.push(root); + if (!allowed.has(root)) await rmdir(root); + else await realRm(root, { recursive: true, force: true }); }); + const provider = new FlutterBuildProvider(); onTestFinished(async () => { roots.forEach((root) => allowed.add(root)); await provider.close(); @@ -2063,13 +2064,12 @@ it("retries retained cleanup before admitting replacement snapshots", async () = ]); const roots = new Set(); let refuse = true; - const provider = new FlutterBuildProvider({ - removeRoot: async (root) => { - roots.add(root); - if (refuse) await rmdir(root); - else await rm(root, { recursive: true, force: true }); - }, + interceptFlutterRootRemoval(async (root) => { + roots.add(root); + if (refuse) await rmdir(root); + else await realRm(root, { recursive: true, force: true }); }); + const provider = new FlutterBuildProvider(); onTestFinished(async () => { refuse = false; await provider.close(); @@ -2108,17 +2108,16 @@ it("does not create a replacement snapshot when cancelled during cleanup retry", const controller = new AbortController(); const reason = { caller: "cancel during cleanup retry" }; let attempts = 0; - const provider = new FlutterBuildProvider({ - removeRoot: async (root) => { - roots.add(root); - attempts++; - if (attempts === 1) await rmdir(root); - else { - controller.abort(reason); - await rm(root, { recursive: true, force: true }); - } - }, + interceptFlutterRootRemoval(async (root) => { + roots.add(root); + attempts++; + if (attempts === 1) await rmdir(root); + else { + controller.abort(reason); + await realRm(root, { recursive: true, force: true }); + } }); + const provider = new FlutterBuildProvider(); onTestFinished(() => provider.close()); const request = { operation: "identify_flutter_build" as const, @@ -2143,13 +2142,12 @@ it.each([false, true])( const controller = new AbortController(); const reason = { caller: "cancel during snapshot cleanup" }; let refuse = cleanupFails; - const provider = new FlutterBuildProvider({ - removeRoot: async (root) => { - controller.abort(reason); - if (refuse) await rmdir(root); - else await rm(root, { recursive: true, force: true }); - }, + interceptFlutterRootRemoval(async (root) => { + controller.abort(reason); + if (refuse) await rmdir(root); + else await realRm(root, { recursive: true, force: true }); }); + const provider = new FlutterBuildProvider(); onTestFinished(async () => { refuse = false; await provider.close(); @@ -2179,15 +2177,14 @@ it("retains cancellation when a prior failed root cannot be retired on admission const reason = { caller: "abort during failed retry" }; let refuse = true; let attempts = 0; - const provider = new FlutterBuildProvider({ - removeRoot: async (root) => { - attempts += 1; - if (refuse) { - if (attempts === 2) controller.abort(reason); - await rmdir(root); - } else await rm(root, { recursive: true, force: true }); - }, + interceptFlutterRootRemoval(async (root) => { + attempts += 1; + if (refuse) { + if (attempts === 2) controller.abort(reason); + await rmdir(root); + } else await realRm(root, { recursive: true, force: true }); }); + const provider = new FlutterBuildProvider(); onTestFinished(async () => { refuse = false; await provider.close(); @@ -2220,14 +2217,13 @@ it("preserves a completed analysis failure through refused cleanup and late canc if (control.ok) throw new Error("Expected unsupported package control"); const controller = new AbortController(); let refuse = true; - const provider = new FlutterBuildProvider({ - removeRoot: async (root) => { - if (refuse) { - controller.abort({ caller: "late failed cleanup" }); - await rmdir(root); - } else await rm(root, { recursive: true, force: true }); - }, + interceptFlutterRootRemoval(async (root) => { + if (refuse) { + controller.abort({ caller: "late failed cleanup" }); + await rmdir(root); + } else await realRm(root, { recursive: true, force: true }); }); + const provider = new FlutterBuildProvider(); onTestFinished(async () => { refuse = false; await provider.close(); diff --git a/tests/boundary/filesystem/inesCancellation.test.ts b/tests/boundary/filesystem/inesCancellation.test.ts index 1961b2ae5c..14ff5eef64 100644 --- a/tests/boundary/filesystem/inesCancellation.test.ts +++ b/tests/boundary/filesystem/inesCancellation.test.ts @@ -21,6 +21,33 @@ import { import { readFileHandlePrefix } from "../../../src/filesystem/readFileHandlePrefix.js"; import { abortIfNeeded } from "../../../src/artifacts/ArtifactHash.js"; +it.each(["close", "cancel"] as const)( + "does not complete an iNES inventory after %s while paused", + async (interruption) => { + const root = await createTestTempDirectory("rea-ines-paused-"); + const path = join(root, "source.nes"); + await writeFile(path, inesFixture()); + const handle = await open(path, "r"); + const reader = new InesArtifactReader(path, { + handle, + initial: await handle.stat(), + }); + const controller = new AbortController(); + try { + const entries = reader.entries(controller.signal)[Symbol.asyncIterator](); + expect((await entries.next()).done).toBe(false); + if (interruption === "close") await reader.close(); + else controller.abort(); + await expect(entries.next()).rejects.toMatchObject({ + reason: interruption === "close" ? "unavailable" : "cancelled", + }); + } finally { + await reader.close(); + await handle.close(); + } + }, +); + // Complete the real I/O, then cancel while its caller is still awaiting it. const cancelAfterNextStat = ( handle: FileHandle, diff --git a/tests/boundary/gpu/codeObjects.test.ts b/tests/boundary/gpu/codeObjects.test.ts index a57858211b..0a0a73073c 100644 --- a/tests/boundary/gpu/codeObjects.test.ts +++ b/tests/boundary/gpu/codeObjects.test.ts @@ -963,6 +963,16 @@ it.each(["section-names", "symbols"] as const)( ); }, ); +it("preserves non-UTF-8 CUBIN symbol bytes in the kernel name", async () => { + const bytes = cubinFixture(); + const symbolName = bytes.indexOf(Buffer.from("\0add_one\0")); + expect(symbolName).toBeGreaterThanOrEqual(0); + bytes[symbolName + 1] = 0xff; + const result = await inspect(bytes); + if (!result.ok) throw result.error; + expect(result.value.kernels[0]?.name).toBe("ÿdd_one"); +}); + it.each([9, 10])( "preserves parameters and register coverage for text count %d", async (textRegisters) => { diff --git a/tests/boundary/mcp/analysisViewMcp.test.ts b/tests/boundary/mcp/analysisViewMcp.test.ts index 27e21db721..fe2e4f4d08 100644 --- a/tests/boundary/mcp/analysisViewMcp.test.ts +++ b/tests/boundary/mcp/analysisViewMcp.test.ts @@ -210,7 +210,7 @@ it("selects every IL2CPP native type reference by rendered name with stable row it("preserves native relationship coverage through inline and retained MCP views", async () => { const { client, session } = await connect(); - const { ajv, outputSchema } = await advertisedMcpToolSchemas( + const { toolDefinition } = await advertisedMcpToolSchemas( client, "inspect_analysis_view", schemaValidation.assertions, @@ -246,18 +246,17 @@ it("preserves native relationship coverage through inline and retained MCP views { kind: "inline", evidence: parent }, { kind: "retained-evidence", evidence_id: parent.evidence_id }, ]) { - const response = await client.callTool({ - name: "inspect_analysis_view", - arguments: { - source, - view: { kind: "native", facet: "callees", offset: 0, limit: 1 }, + const response = await client.callTool( + { + name: "inspect_analysis_view", + arguments: { + source, + view: { kind: "native", facet: "callees", offset: 0, limit: 1 }, + }, }, - }); + { toolDefinition }, + ); expect(response.isError).not.toBe(true); - expect( - ajv.validate(outputSchema, response.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const evidence = parseEvidence(response.structuredContent); expect(evidence.relationship_coverage).toEqual( parent.relationship_coverage, @@ -344,7 +343,7 @@ it("advertises typed module references and preserves portable/retained Evidence ); const recorded = session.recordEvidence(parent); if (!recorded.ok) throw recorded.error; - const { ajv, inputSchema, outputSchema } = await advertisedMcpToolSchemas( + const { ajv, inputSchema, toolDefinition } = await advertisedMcpToolSchemas( client, "inspect_analysis_view", schemaValidation.assertions, @@ -368,15 +367,11 @@ it("advertises typed module references and preserves portable/retained Evidence view: { ...view, where: { glob: "*" } }, }), ).toBe(false); - const response = await client.callTool({ - name: "inspect_analysis_view", - arguments: { source, view }, - }); + const response = await client.callTool( + { name: "inspect_analysis_view", arguments: { source, view } }, + { toolDefinition }, + ); expect(response.isError).not.toBe(true); - expect( - ajv.validate(outputSchema, response.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const evidence = parseEvidence(response.structuredContent); expect(evidence).toMatchObject({ evidence_links: [parent.evidence_id], @@ -596,7 +591,7 @@ it("advertises exact schemas and projects one layout section from retained Evide inspect: () => Promise.resolve(ok(layout)), }); const { client, session } = await connect(service); - const { ajv, inputSchema, outputSchema } = await advertisedMcpToolSchemas( + const { ajv, inputSchema, toolDefinition } = await advertisedMcpToolSchemas( client, "inspect_analysis_view", schemaValidation.assertions, @@ -630,25 +625,24 @@ it("advertises exact schemas and projects one layout section from retained Evide const parent = toolContract("inspect_binary_layout").outputSchema.parse( inspected.structuredContent, ); - const response = await client.callTool({ - name: "inspect_analysis_view", - arguments: { - source: { - kind: "retained-evidence", - evidence_id: parent.evidence_id, - }, - view: { - kind: "item", - collection: "sections", - selector: { name: ".data" }, + const response = await client.callTool( + { + name: "inspect_analysis_view", + arguments: { + source: { + kind: "retained-evidence", + evidence_id: parent.evidence_id, + }, + view: { + kind: "item", + collection: "sections", + selector: { name: ".data" }, + }, }, }, - }); + { toolDefinition }, + ); expect(response.isError).not.toBe(true); - expect( - ajv.validate(outputSchema, response.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const parsed = toolContract("inspect_analysis_view").outputSchema.parse( response.structuredContent, ); @@ -997,15 +991,11 @@ it("delivers a bounded native dossier view over MCP without a Ghidra provider or ajv.validate(inputSchema, arguments_), JSON.stringify(ajv.errors), ).toBe(true); - const response = await client.callTool({ - name: "inspect_analysis_view", - arguments: arguments_, - }); + const response = await client.callTool( + { name: "inspect_analysis_view", arguments: arguments_ }, + { toolDefinition: advertised }, + ); expect(response.isError).not.toBe(true); - expect( - ajv.validate(outputSchema, response.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const parsed = toolContract("inspect_analysis_view").outputSchema.parse( response.structuredContent, ); @@ -1542,7 +1532,7 @@ it.each([false, true])( it("advertises unserved learned clock rules when a retained capture has no global clock", async () => { const { client, session } = await connect(); - const { ajv, outputSchema } = await advertisedMcpToolSchemas( + const { toolDefinition } = await advertisedMcpToolSchemas( client, "inspect_analysis_view", schemaValidation.assertions, @@ -1558,18 +1548,20 @@ it("advertises unserved learned clock rules when a retained capture has no globa for (const parent of parents) { const original = structuredClone(parent); expect(session.recordEvidence(parent).ok).toBe(true); - const response = await client.callTool({ - name: "inspect_analysis_view", - arguments: { - source: { kind: "retained-evidence", evidence_id: parent.evidence_id }, - view: { kind: "environment-state" }, + const response = await client.callTool( + { + name: "inspect_analysis_view", + arguments: { + source: { + kind: "retained-evidence", + evidence_id: parent.evidence_id, + }, + view: { kind: "environment-state" }, + }, }, - }); + { toolDefinition }, + ); expect(response.isError, JSON.stringify(response.content)).not.toBe(true); - expect( - ajv.validate(outputSchema, response.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const state = analysisViewResultSchema.parse( parseEvidence(response.structuredContent).normalized_result, ); @@ -1605,7 +1597,7 @@ it("advertises unserved learned clock rules when a retained capture has no globa it("advertises Mach-O pages whose selected facet owns their exact rows", async () => { const { client, session } = await connect(); - const { ajv, outputSchema } = await advertisedMcpToolSchemas( + const { ajv, outputSchema, toolDefinition } = await advertisedMcpToolSchemas( client, "inspect_analysis_view", schemaValidation.assertions, @@ -1690,21 +1682,20 @@ it("advertises Mach-O pages whose selected facet owns their exact rows", async ( "encryption", ] as const) { for (const offset of [0, 999]) { - const response = await client.callTool({ - name: "inspect_analysis_view", - arguments: { - source: { - kind: "retained-evidence", - evidence_id: parent.evidence_id, + const response = await client.callTool( + { + name: "inspect_analysis_view", + arguments: { + source: { + kind: "retained-evidence", + evidence_id: parent.evidence_id, + }, + view: { kind: "macho", facet, offset, limit: 64 }, }, - view: { kind: "macho", facet, offset, limit: 64 }, }, - }); + { toolDefinition }, + ); expect(response.isError, JSON.stringify(response.content)).not.toBe(true); - expect( - ajv.validate(outputSchema, response.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const delivered = parseEvidence(response.structuredContent); const page = analysisViewResultSchema.parse(delivered.normalized_result); if (page.kind !== "macho") throw new Error("Expected Mach-O page"); @@ -1744,7 +1735,7 @@ it("advertises Mach-O pages whose selected facet owns their exact rows", async ( it("extracts an environment state identically from retained, portable and file MCP Evidence", async () => { // Full inline capture conformance explicitly selects its transport allowance. const { client, session } = await connect(undefined, undefined, 8_192); - const { ajv, inputSchema, outputSchema } = await advertisedMcpToolSchemas( + const { ajv, inputSchema, toolDefinition } = await advertisedMcpToolSchemas( client, "inspect_analysis_view", schemaValidation.assertions, @@ -1768,15 +1759,14 @@ it("extracts an environment state identically from retained, portable and file M { kind: "evidence-file", path: evidencePath }, ]) { expect(ajv.validate(inputSchema, { source, view })).toBe(true); - const response = await client.callTool({ - name: "inspect_analysis_view", - arguments: { source, view }, - }); + const response = await client.callTool( + { + name: "inspect_analysis_view", + arguments: { source, view }, + }, + { toolDefinition }, + ); expect(response.isError).not.toBe(true); - expect( - ajv.validate(outputSchema, response.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const evidence = parseEvidence(response.structuredContent); expect(evidence.confidence).toBe("inferred"); expect(evidence.normalized_result).toMatchObject({ @@ -1822,7 +1812,7 @@ it("extracts an environment state identically from retained, portable and file M it("reports environment coverage identically from retained, portable and file MCP Evidence", async () => { const { client, session } = await connect(undefined, undefined, 8_192); - const { ajv, inputSchema, outputSchema } = await advertisedMcpToolSchemas( + const { ajv, inputSchema, toolDefinition } = await advertisedMcpToolSchemas( client, "inspect_analysis_view", schemaValidation.assertions, @@ -1843,15 +1833,14 @@ it("reports environment coverage identically from retained, portable and file MC ]) { const view = { kind: "environment-coverage", template_rules: rules }; expect(ajv.validate(inputSchema, { source, view })).toBe(true); - const response = await client.callTool({ - name: "inspect_analysis_view", - arguments: { source, view }, - }); + const response = await client.callTool( + { + name: "inspect_analysis_view", + arguments: { source, view }, + }, + { toolDefinition }, + ); expect(response.isError).not.toBe(true); - expect( - ajv.validate(outputSchema, response.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const evidence = parseEvidence(response.structuredContent); expect(evidence.confidence).toBe("inferred"); expect(evidence.normalized_result).toMatchObject({ @@ -1868,7 +1857,7 @@ it("reports environment coverage identically from retained, portable and file MC it("accounts for every safe HTTP method in environment state and coverage", async () => { const { client } = await connect(); - const { ajv, outputSchema } = await advertisedMcpToolSchemas( + const { toolDefinition } = await advertisedMcpToolSchemas( client, "inspect_analysis_view", schemaValidation.assertions, @@ -1896,18 +1885,17 @@ it("accounts for every safe HTTP method in environment state and coverage", asyn "environment-state", "environment-coverage", ]) { - const response = await client.callTool({ - name: "inspect_analysis_view", - arguments: { - source: { kind: "inline", evidence: parent }, - view: { kind, template_rules: [] }, + const response = await client.callTool( + { + name: "inspect_analysis_view", + arguments: { + source: { kind: "inline", evidence: parent }, + view: { kind, template_rules: [] }, + }, }, - }); + { toolDefinition }, + ); expect(response.isError).not.toBe(true); - expect( - ajv.validate(outputSchema, response.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); results.push(parseEvidence(response.structuredContent).normalized_result); } expect(results[0]).toMatchObject({ @@ -1934,7 +1922,7 @@ it("accounts for every safe HTTP method in environment state and coverage", asyn it("projects an environment manifest identically from retained, portable and file MCP Evidence", async () => { const { client, session } = await connect(undefined, undefined, 8_192); - const { ajv, inputSchema, outputSchema } = await advertisedMcpToolSchemas( + const { ajv, inputSchema, toolDefinition } = await advertisedMcpToolSchemas( client, "inspect_analysis_view", schemaValidation.assertions, @@ -1971,15 +1959,14 @@ it("projects an environment manifest identically from retained, portable and fil view: { ...view, template_rules: ["glob"] }, }), ).toBe(false); - const response = await client.callTool({ - name: "inspect_analysis_view", - arguments: { source, view }, - }); + const response = await client.callTool( + { + name: "inspect_analysis_view", + arguments: { source, view }, + }, + { toolDefinition }, + ); expect(response.isError).not.toBe(true); - expect( - ajv.validate(outputSchema, response.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const evidence = parseEvidence(response.structuredContent); expect(evidence.confidence).toBe("inferred"); expect(evidence.normalized_result).toMatchObject({ @@ -2167,7 +2154,7 @@ it.each([ "pages a recorded row when its parent parameters do not fit", async ({ queryBytes, rowBytes }) => { const { client, session } = await connect(); - const { ajv, outputSchema } = await advertisedMcpToolSchemas( + const { toolDefinition } = await advertisedMcpToolSchemas( client, "inspect_analysis_view", schemaValidation.assertions, @@ -2189,18 +2176,25 @@ it.each([ }, ); expect(session.recordEvidence(parent).ok).toBe(true); - const response = await client.callTool({ - name: "inspect_analysis_view", - arguments: { - source: { kind: "retained-evidence", evidence_id: parent.evidence_id }, - view: { kind: "recorded-array", path: ["values"], offset: 0, limit: 1 }, + const response = await client.callTool( + { + name: "inspect_analysis_view", + arguments: { + source: { + kind: "retained-evidence", + evidence_id: parent.evidence_id, + }, + view: { + kind: "recorded-array", + path: ["values"], + offset: 0, + limit: 1, + }, + }, }, - }); + { toolDefinition }, + ); expect(response.isError).not.toBe(true); - expect( - ajv.validate(outputSchema, response.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const parsed = toolContract("inspect_analysis_view").outputSchema.parse( response.structuredContent, ); diff --git a/tests/boundary/mcp/androidPackageSummaryMcp.test.ts b/tests/boundary/mcp/androidPackageSummaryMcp.test.ts index 831fbdb6b9..2789c30b56 100644 --- a/tests/boundary/mcp/androidPackageSummaryMcp.test.ts +++ b/tests/boundary/mcp/androidPackageSummaryMcp.test.ts @@ -1,6 +1,10 @@ import { unlink } from "node:fs/promises"; import { join } from "node:path"; -import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { + Client, + InMemoryTransport, + type CallToolRequestOptions, +} from "@modelcontextprotocol/client"; import { Ajv2020 } from "ajv/dist/2020.js"; import { expect, it, onTestFinished, vi } from "vitest"; import { AndroidAnalysisService } from "../../../src/application/android/AndroidAnalysisService.js"; @@ -38,29 +42,27 @@ import { async function assertAndroidPackageSelectedFacetContract(options: { client: Pick; - ajv: Ajv2020; - outputSchema: Record; + toolDefinition: NonNullable; apk: string; parent: Evidence; retained: (id: string) => Evidence | undefined; sourceStatus: string; }): Promise { - const { client, ajv, outputSchema, apk, parent, retained, sourceStatus } = + const { client, toolDefinition, apk, parent, retained, sourceStatus } = options; for (const facet of ["components", "component-summary"] as const) { - const selected = await client.callTool({ - name: "inspect_android_package", - arguments: { - path: apk, - detail: "summary", - view: { kind: "android", facet }, + const selected = await client.callTool( + { + name: "inspect_android_package", + arguments: { + path: apk, + detail: "summary", + view: { kind: "android", facet }, + }, }, - }); + { toolDefinition }, + ); expect(selected.isError).not.toBe(true); - expect( - ajv.validate(outputSchema, selected.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const result = parseEvidence(selected.structuredContent).normalized_result; const projected = facet === "components" @@ -185,22 +187,17 @@ it("defaults JNI MCP inspection to a retained summary while preserving explicit ); if (definition?.outputSchema === undefined) throw new Error("Missing JNI output schema"); - const outputSchema: Record = definition.outputSchema; const ajv = new Ajv2020({ strict: false, validateFormats: false }); const request = { path: apk, ...selection }; expect( ajv.validate(definition.inputSchema, request), JSON.stringify(ajv.errors), ).toBe(true); - const compact = await client.callTool({ - name: "inspect_android_jni_bindings", - arguments: request, - }); + const compact = await client.callTool( + { name: "inspect_android_jni_bindings", arguments: request }, + { toolDefinition: definition }, + ); expect(compact.isError).not.toBe(true); - expect( - ajv.validate(outputSchema, compact.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const summaryEvidence = parseEvidence(compact.structuredContent); const summary = androidJniSummaryViewResultSchema.parse( summaryEvidence.normalized_result, @@ -226,15 +223,14 @@ it("defaults JNI MCP inspection to a retained summary while preserving explicit expect(fullReport.declarations).toEqual(declarations); expect(parent.parameters).toEqual(request); expect(summaryEvidence.evidence_links).toEqual([parent.evidence_id]); - const complete = await client.callTool({ - name: "inspect_android_jni_bindings", - arguments: { ...request, detail: "complete" }, - }); + const complete = await client.callTool( + { + name: "inspect_android_jni_bindings", + arguments: { ...request, detail: "complete" }, + }, + { toolDefinition: definition }, + ); expect(complete.isError).not.toBe(true); - expect( - ajv.validate(outputSchema, complete.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const completeEvidence = parseEvidence(complete.structuredContent); expect(completeEvidence.normalized_result).toEqual(fullReport); expect(completeEvidence.raw_result).toEqual(raw); @@ -309,15 +305,14 @@ it.skipIf(process.platform === "win32")( }, ), ).toBe(true); - const reply = await client.callTool({ - name: "inspect_android_package", - arguments: { path: apk, detail: "summary" }, - }); + const reply = await client.callTool( + { + name: "inspect_android_package", + arguments: { path: apk, detail: "summary" }, + }, + { toolDefinition: definition }, + ); expect(reply.isError).not.toBe(true); - expect( - ajv.validate(outputSchema, reply.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const receipt = parseEvidence(reply.structuredContent); const view = androidPackageSummaryViewResultSchema.parse( receipt.normalized_result, @@ -363,8 +358,7 @@ it.skipIf(process.platform === "win32")( } await assertAndroidPackageSelectedFacetContract({ client, - ajv, - outputSchema, + toolDefinition: definition, apk, parent, retained: (id) => session.evidenceById(id), diff --git a/tests/boundary/mcp/appleIpsMcp.test.ts b/tests/boundary/mcp/appleIpsMcp.test.ts index f3c2a4023c..60d7c0f26a 100644 --- a/tests/boundary/mcp/appleIpsMcp.test.ts +++ b/tests/boundary/mcp/appleIpsMcp.test.ts @@ -46,10 +46,6 @@ it("advertises and validates IPS facts and failures through production stdio MCP }); const response = await call("inspect_apple_ips", { path }); expect(response.isError, JSON.stringify(response.content)).not.toBe(true); - expect( - ajv.validate(outputSchema, response.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const evidence = parseEvidence(response.structuredContent); const report = appleIpsSchema.parse(evidence.normalized_result); expect(evidence.operation).toBe("inspect_apple_ips"); @@ -101,10 +97,6 @@ it("advertises and validates IPS facts and failures through production stdio MCP await writeFile(malformedPath, appleIpsBytes({ uuid: "malformed/report" })); const malformed = await call("inspect_apple_ips", { path: malformedPath }); expect(malformed.isError, JSON.stringify(malformed.content)).not.toBe(true); - expect( - ajv.validate(outputSchema, malformed.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const partial = appleIpsSchema.parse( parseEvidence(malformed.structuredContent).normalized_result, ); @@ -126,10 +118,6 @@ it("advertises and validates IPS facts and failures through production stdio MCP ); const indexes = await call("inspect_apple_ips", { path: indexPath }); expect(indexes.isError, JSON.stringify(indexes.content)).not.toBe(true); - expect( - ajv.validate(outputSchema, indexes.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const unresolved = appleIpsSchema.parse( parseEvidence(indexes.structuredContent).normalized_result, ); diff --git a/tests/boundary/mcp/applicationMcpHarnessLifecycle.test.ts b/tests/boundary/mcp/applicationMcpHarnessLifecycle.test.ts index 0003844932..1981eb2417 100644 --- a/tests/boundary/mcp/applicationMcpHarnessLifecycle.test.ts +++ b/tests/boundary/mcp/applicationMcpHarnessLifecycle.test.ts @@ -90,7 +90,6 @@ it("reports a failed session close, joins concurrent attempts and retries the re if (!(reason instanceof AggregateError)) throw new Error("Harness ignored the session close failure"); expect(reason.errors).toContain(failure); - expect(concurrent).toBe(closing); expect(await concurrentOutcome).toBe(reason); expect(failure.cleanupIncomplete).toBe(true); expect(await harness.session.status()).toMatchObject({ diff --git a/tests/boundary/mcp/artifactMcp.inlineInventory.test.ts b/tests/boundary/mcp/artifactMcp.inlineInventory.test.ts index 209af5f6f5..1208ff60de 100644 --- a/tests/boundary/mcp/artifactMcp.inlineInventory.test.ts +++ b/tests/boundary/mcp/artifactMcp.inlineInventory.test.ts @@ -203,7 +203,6 @@ const createInventoryViewReader = async ( throw new Error("Missing advertised view schemas"); const ajv = schemaValidation.assertions; const inputSchema: Record = advertised.inputSchema; - const outputSchema: Record = advertised.outputSchema; const malformed = { source: { kind: "retained-evidence", evidence_id: parent.evidence_id }, view: { kind: "inventory", collection: "nodes", logical_paths: [] }, @@ -229,16 +228,12 @@ const createInventoryViewReader = async ( ajv.validate(inputSchema, arguments_), JSON.stringify(ajv.errors), ).toBe(true); - const reply = await client.callTool({ - name: "inspect_analysis_view", - arguments: arguments_, - }); + const reply = await client.callTool( + { name: "inspect_analysis_view", arguments: arguments_ }, + { toolDefinition: advertised }, + ); if (reply.isError) throw new Error(JSON.stringify(parseMcpToolError(reply))); - expect( - ajv.validate(outputSchema, reply.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const evidence = parseEvidence(reply.structuredContent); return { evidence, diff --git a/tests/boundary/mcp/batchDecompileViewsMcp.test.ts b/tests/boundary/mcp/batchDecompileViewsMcp.test.ts index 810d9fccdd..30847dfd1c 100644 --- a/tests/boundary/mcp/batchDecompileViewsMcp.test.ts +++ b/tests/boundary/mcp/batchDecompileViewsMcp.test.ts @@ -129,28 +129,28 @@ const connect = async ( ); onTestFinished(() => harness.close()); const { tools } = await harness.mcp.listTools(); - const outputSchemas = new Map( + const advertisedTools = new Map( ADVERTISED_OUTPUTS.map((name) => { - const advertised = tools.find((tool) => tool.name === name)?.outputSchema; - if (advertised === undefined) + const advertised = tools.find((tool) => tool.name === name); + if (advertised?.outputSchema === undefined) throw new Error(`${name} advertises no output schema`); - const schema: Record = advertised; - return [name, schema] as const; + return [name, advertised] as const; }), ); - const reply = async (name: string, args: Record) => - z + const reply = async (name: string, args: Record) => { + const toolDefinition = advertisedTools.get(name); + return z .custom() - .parse(await harness.mcp.callTool({ name, arguments: args })); + .parse( + await harness.mcp.callTool( + { name, arguments: args }, + toolDefinition === undefined ? {} : { toolDefinition }, + ), + ); + }; const call = async (name: string, args: Record) => { const result = await reply(name, args); expect(result.isError, JSON.stringify(result)).not.toBe(true); - const schema = outputSchemas.get(name); - if (schema !== undefined) - expect( - schemaValidation.assertions.validate(schema, result.structuredContent), - JSON.stringify(schemaValidation.assertions.errors), - ).toBe(true); return { evidence: parseEvidence(result.structuredContent), result }; }; return { ...harness, calls, reply, call }; diff --git a/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts b/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts index a09acba305..4db106d0c8 100644 --- a/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts +++ b/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts @@ -261,14 +261,13 @@ it("records inline layout with compact schemas and canonical path checks", async approval: true, }), ).toBe(false); - const response = await client.callTool({ - name: "inspect_binary_layout", - arguments: { path: value.artifact.path }, - }); + const response = await client.callTool( + { name: "inspect_binary_layout", arguments: { path: value.artifact.path } }, + { toolDefinition: schemas.toolDefinition }, + ); const evidence = assertRecordedMcpEvidence( response, "inspect_binary_layout", - schemas, session, value, "observed", @@ -326,20 +325,16 @@ it("profiles real bytes with exact offsets, valid advertised schemas and read-on InMemoryTransport.createLinkedPair(); await server.connect(serverTransport); await client.connect(clientTransport); - const { ajv, inputSchema, outputSchema } = await advertisedMcpToolSchemas( + const { ajv, inputSchema, toolDefinition } = await advertisedMcpToolSchemas( client, "inspect_byte_entropy", ); expect(ajv.validate(inputSchema, { path, surprise: true })).toBe(false); - const response = await client.callTool({ - name: "inspect_byte_entropy", - arguments: { path }, - }); + const response = await client.callTool( + { name: "inspect_byte_entropy", arguments: { path } }, + { toolDefinition }, + ); expect(response.isError).not.toBe(true); - expect( - ajv.validate(outputSchema, response.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const evidence = parseEvidence(response.structuredContent); const report = byteEntropySchema.parse(evidence.normalized_result); expect(report.runs).toEqual([ @@ -402,10 +397,10 @@ it("records a portable export inventory through MCP without a binary target", as "inspect_native_exports", ); expect(schemas.ajv.validate(schemas.inputSchema, { path })).toBe(true); - const response = await client.callTool({ - name: "inspect_native_exports", - arguments: { path }, - }); + const response = await client.callTool( + { name: "inspect_native_exports", arguments: { path } }, + { toolDefinition: schemas.toolDefinition }, + ); expect( parseEvidence(response.structuredContent).normalized_result, ).toMatchObject({ @@ -425,7 +420,6 @@ it("records a portable export inventory through MCP without a binary target", as assertRecordedMcpEvidence( response, "inspect_native_exports", - schemas, session, parseNativeExports(bytes, { path, diff --git a/tests/boundary/mcp/browserMcp.test.ts b/tests/boundary/mcp/browserMcp.test.ts index 838083d5d9..3f5b33ea96 100644 --- a/tests/boundary/mcp/browserMcp.test.ts +++ b/tests/boundary/mcp/browserMcp.test.ts @@ -101,21 +101,20 @@ it("preserves Link parameter order and context through the advertised MCP contra if (advertised?.outputSchema === undefined) throw new Error("inspect_web_page must advertise its output schema"); - const inspected = await connected.client.callTool({ - name: "inspect_web_page", - arguments: { - cdp_endpoint: browser.endpoint, - target_id: "allowed-page", - observation_ms: 0, + const inspected = await connected.client.callTool( + { + name: "inspect_web_page", + arguments: { + cdp_endpoint: browser.endpoint, + target_id: "allowed-page", + observation_ms: 0, + }, }, - }); + { toolDefinition: advertised }, + ); expect(inspected.isError).not.toBe(true); const ajv = new Ajv2020({ strict: false, validateFormats: false }); const outputSchema: Record = advertised.outputSchema; - expect( - ajv.validate(outputSchema, inspected.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const normalized = webPageInspectionSchema.parse( normalizedResultOf(inspected.structuredContent), @@ -249,7 +248,7 @@ cliTest( ], environment: process.env, }); - expect(cliResult.exitCode).toBe(0); + expect(cliResult.exitCode, cliResult.stderr).toBe(0); expect(cliResult.signal).toBeNull(); const mcp = await client.callTool({ name: "analyze_web_bundle", @@ -775,7 +774,7 @@ cliTest( ], environment: process.env, }); - expect(cliResult.exitCode).toBe(0); + expect(cliResult.exitCode, cliResult.stderr).toBe(0); expect(cliResult.signal).toBeNull(); const cliEvidence = parseEvidence(cliResult.json); expect(cliEvidence.normalized_result).toMatchObject(expectedResult); diff --git a/tests/boundary/mcp/browserSourceCoordinates.test.ts b/tests/boundary/mcp/browserSourceCoordinates.test.ts index c26384bedd..b3aa0acfe8 100644 --- a/tests/boundary/mcp/browserSourceCoordinates.test.ts +++ b/tests/boundary/mcp/browserSourceCoordinates.test.ts @@ -1,4 +1,3 @@ -import { Ajv2020 } from "ajv/dist/2020.js"; import { expect, it, onTestFinished } from "vitest"; import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; @@ -66,15 +65,16 @@ const inspectSourceCoordinates = async (position: { target_id: "allowed-page", observation_ms: 0, }; - const page = await client.callTool({ - name: "inspect_web_page", - arguments: args, - }); - const tools = await client.callTool({ - name: "discover_webmcp_tools", - arguments: args, - }); - return { client, page, tools }; + const definitions = (await client.listTools()).tools; + const call = async (name: "inspect_web_page" | "discover_webmcp_tools") => { + const toolDefinition = definitions.find((tool) => tool.name === name); + if (toolDefinition?.outputSchema === undefined) + throw new Error(`Missing advertised output schema: ${name}`); + return client.callTool({ name, arguments: args }, { toolDefinition }); + }; + const page = await call("inspect_web_page"); + const tools = await call("discover_webmcp_tools"); + return { page, tools }; }; it("reports malformed source coordinates as unknown through MCP", async () => { @@ -129,26 +129,12 @@ it("reports malformed source coordinates as unknown through MCP", async () => { }, 20_000); it("keeps a negative column Chrome reports in passive source locations", async () => { - const { client, page, tools } = await inspectSourceCoordinates({ + const { page, tools } = await inspectSourceCoordinates({ lineNumber: 21, columnNumber: -40, }); - const advertised = (await client.listTools()).tools; - const ajv = new Ajv2020({ strict: false, validateFormats: false }); - for (const [name, response] of [ - ["inspect_web_page", page], - ["discover_webmcp_tools", tools], - ] as const) { + for (const response of [page, tools]) { expect(response.isError).not.toBe(true); - const outputSchema: Record | undefined = advertised.find( - (tool) => tool.name === name, - )?.outputSchema; - if (outputSchema === undefined) - throw new Error(`Missing advertised output schema: ${name}`); - expect( - ajv.validate(outputSchema, response.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); } const location = { line: 21, column: -40 }; expect(page.structuredContent).toMatchObject({ diff --git a/tests/boundary/mcp/commandObservationViews.test.ts b/tests/boundary/mcp/commandObservationViews.test.ts index dcd4b867b0..03949aaf13 100644 --- a/tests/boundary/mcp/commandObservationViews.test.ts +++ b/tests/boundary/mcp/commandObservationViews.test.ts @@ -131,17 +131,16 @@ it("delivers ordered GDB records once and queries their retained parent after cl "inspect_analysis_view", ); const input = { session_id: sessionId, command: capture.command }; - const full = await client.callTool({ name: "gdb_console", arguments: input }); - const summary = await client.callTool({ - name: "gdb_console", - arguments: { ...input, detail: "summary" }, - }); + const full = await client.callTool( + { name: "gdb_console", arguments: input }, + { toolDefinition: schemas.toolDefinition }, + ); + const summary = await client.callTool( + { name: "gdb_console", arguments: { ...input, detail: "summary" } }, + { toolDefinition: schemas.toolDefinition }, + ); for (const reply of [full, summary]) { expect(reply.isError).not.toBe(true); - expect( - schemas.ajv.validate(schemas.outputSchema, reply.structuredContent), - JSON.stringify(schemas.ajv.errors), - ).toBe(true); } expect(parseEvidence(full.structuredContent)).toEqual(gdbEvidence()); const selected = parseEvidence(summary.structuredContent); @@ -174,23 +173,19 @@ it("delivers ordered GDB records once and queries their retained parent after cl name: "close_gdb_session", arguments: { session_id: sessionId }, }); - const queried = await client.callTool({ - name: "inspect_analysis_view", - arguments: { - source: { - kind: "retained-evidence", - evidence_id: gdbEvidence().evidence_id, + const queried = await client.callTool( + { + name: "inspect_analysis_view", + arguments: { + source: { + kind: "retained-evidence", + evidence_id: gdbEvidence().evidence_id, + }, + view: { kind: "summary" }, }, - view: { kind: "summary" }, }, - }); - expect( - viewSchemas.ajv.validate( - viewSchemas.outputSchema, - queried.structuredContent, - ), - JSON.stringify(viewSchemas.ajv.errors), - ).toBe(true); + { toolDefinition: viewSchemas.toolDefinition }, + ); expect(parseEvidence(queried.structuredContent)).toEqual(selected); expect(ledger.get(gdbEvidence().evidence_id)).toEqual(gdbEvidence()); }); @@ -207,19 +202,17 @@ it("keeps pathless objdump observations subjectless and preserves the default co detail: "summary", }), ).toBe(true); - const full = await client.callTool({ - name: "inspect_with_objdump", - arguments: { operation: "architectures" }, - }); - const selected = await client.callTool({ - name: "inspect_with_objdump", - arguments: { operation: "architectures", detail: "summary" }, - }); - for (const reply of [full, selected]) - expect( - schemas.ajv.validate(schemas.outputSchema, reply.structuredContent), - JSON.stringify(schemas.ajv.errors), - ).toBe(true); + const full = await client.callTool( + { name: "inspect_with_objdump", arguments: { operation: "architectures" } }, + { toolDefinition: schemas.toolDefinition }, + ); + const selected = await client.callTool( + { + name: "inspect_with_objdump", + arguments: { operation: "architectures", detail: "summary" }, + }, + { toolDefinition: schemas.toolDefinition }, + ); const parent = parseEvidence(full.structuredContent); const summary = parseEvidence(selected.structuredContent); expect(summary.normalized_result).toMatchObject({ diff --git a/tests/boundary/mcp/dartAotSummaryMcp.test.ts b/tests/boundary/mcp/dartAotSummaryMcp.test.ts index 9794888856..94d66e0016 100644 --- a/tests/boundary/mcp/dartAotSummaryMcp.test.ts +++ b/tests/boundary/mcp/dartAotSummaryMcp.test.ts @@ -48,21 +48,18 @@ it("retains the selected pool policy through MCP summary projection", async () = ); if (definition?.outputSchema === undefined) throw new Error("Missing AOT output schema"); - const outputSchema: Record = definition.outputSchema; - const ajv = new Ajv2020({ strict: false, validateFormats: false }); - const filteredReply = await client.callTool({ - name: "inspect_dart_aot", - arguments: { - path, - detail: "summary", - pool_policy: { contains: "package:animations/", uri_limit: 1 }, + const filteredReply = await client.callTool( + { + name: "inspect_dart_aot", + arguments: { + path, + detail: "summary", + pool_policy: { contains: "package:animations/", uri_limit: 1 }, + }, }, - }); + { toolDefinition: definition }, + ); expect(filteredReply.isError).not.toBe(true); - expect( - ajv.validate(outputSchema, filteredReply.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const filteredView = dartAotSummaryViewResultSchema.parse( parseEvidence(filteredReply.structuredContent).normalized_result, ); @@ -97,15 +94,14 @@ it("honors explicit ABI and old full requests, validates SDK schemas and reuses { path, detail: "summary" }, ), ).toBe(false); - const reply = await client.callTool({ - name: "inspect_dart_aot", - arguments: { path, abi: "x86_64", detail: "summary" }, - }); + const reply = await client.callTool( + { + name: "inspect_dart_aot", + arguments: { path, abi: "x86_64", detail: "summary" }, + }, + { toolDefinition: definition }, + ); expect(reply.isError).not.toBe(true); - expect( - ajv.validate(outputSchema, reply.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const receipt = parseEvidence(reply.structuredContent); const view = dartAotSummaryViewResultSchema.parse(receipt.normalized_result); const parent = session.evidenceById(view.parent_evidence_id); diff --git a/tests/boundary/mcp/desktopApplicationMcp.test.ts b/tests/boundary/mcp/desktopApplicationMcp.test.ts index 8c20a74dee..3dc9937e77 100644 --- a/tests/boundary/mcp/desktopApplicationMcp.test.ts +++ b/tests/boundary/mcp/desktopApplicationMcp.test.ts @@ -118,15 +118,11 @@ it("advertises exact schemas and retains a compact directory answer with typed s expect( schemas.ajv.validate(schemas.inputSchema, { path: root, execute: true }), ).toBe(false); - const result = await client.callTool({ - name: "inspect_desktop_application", - arguments: { path: root }, - }); + const result = await client.callTool( + { name: "inspect_desktop_application", arguments: { path: root } }, + { toolDefinition: schemas.toolDefinition }, + ); expect(result.isError).not.toBe(true); - expect( - schemas.ajv.validate(schemas.outputSchema, result.structuredContent), - JSON.stringify(schemas.ajv.errors), - ).toBe(true); const summary = parseEvidence(result.structuredContent); const view = analysisViewResultSchema.parse(summary.normalized_result); expect(view.kind).toBe("summary"); @@ -139,27 +135,26 @@ it("advertises exact schemas and retains a compact directory answer with typed s const archive = full.files.find((file) => file.path === "resources/app.asar"); if (archive === undefined) throw new Error("Missing retained ASAR convention"); - const selected = await client.callTool({ - name: "inspect_analysis_view", - arguments: { - source: { - kind: "retained-evidence", - evidence_id: view.parent_evidence_id, - }, - view: { - kind: "desktop-application", - collection: "files", - file_ids: [archive.file_id], - fields: ["path", "read_status"], - limit: 1, + const selected = await client.callTool( + { + name: "inspect_analysis_view", + arguments: { + source: { + kind: "retained-evidence", + evidence_id: view.parent_evidence_id, + }, + view: { + kind: "desktop-application", + collection: "files", + file_ids: [archive.file_id], + fields: ["path", "read_status"], + limit: 1, + }, }, }, - }); + { toolDefinition: views.toolDefinition }, + ); expect(selected.isError).not.toBe(true); - expect( - views.ajv.validate(views.outputSchema, selected.structuredContent), - JSON.stringify(views.ajv.errors), - ).toBe(true); const page = parseEvidence(selected.structuredContent); expect(page.evidence_links).toEqual([view.parent_evidence_id]); expect(page.normalized_result).toMatchObject({ @@ -204,14 +199,14 @@ it("surfaces unavailable probes and filters their complete retained diagnostics join(partialRoot, `probe${String(index).padStart(2, "0")}.exe`), buildNativePeFixture(), ); - const partial = await client.callTool({ - name: "inspect_desktop_application", - arguments: { path: partialRoot, max_probe_bytes: 4096 }, - }); + const partial = await client.callTool( + { + name: "inspect_desktop_application", + arguments: { path: partialRoot, max_probe_bytes: 4096 }, + }, + { toolDefinition: schemas.toolDefinition }, + ); expect(partial.isError).not.toBe(true); - expect( - schemas.ajv.validate(schemas.outputSchema, partial.structuredContent), - ).toBe(true); const partialEvidence = parseEvidence(partial.structuredContent); const partialView = analysisViewResultSchema.parse( partialEvidence.normalized_result, @@ -275,14 +270,11 @@ it("surfaces unavailable probes and filters their complete retained diagnostics }, }; expect(views.ajv.validate(views.inputSchema, args)).toBe(true); - const response = await client.callTool({ - name: "inspect_analysis_view", - arguments: args, - }); + const response = await client.callTool( + { name: "inspect_analysis_view", arguments: args }, + { toolDefinition: views.toolDefinition }, + ); expect(response.isError).not.toBe(true); - expect( - views.ajv.validate(views.outputSchema, response.structuredContent), - ).toBe(true); const evidence = parseEvidence(response.structuredContent); expect(evidence.evidence_links).toEqual([partialView.parent_evidence_id]); expect(evidence.normalized_result).toMatchObject({ @@ -329,21 +321,21 @@ it.skipIf(process.platform === "win32")( kind: "retained-evidence", evidence_id: summary.parent_evidence_id, }; - const listed = await harness.client.callTool({ - name: "inspect_analysis_view", - arguments: { - source, - view: { - kind: "desktop-application", - collection: "omissions", - limit: 2, + const listed = await harness.client.callTool( + { + name: "inspect_analysis_view", + arguments: { + source, + view: { + kind: "desktop-application", + collection: "omissions", + limit: 2, + }, }, }, - }); + { toolDefinition: schemas.toolDefinition }, + ); expect(listed.isError).not.toBe(true); - expect( - schemas.ajv.validate(schemas.outputSchema, listed.structuredContent), - ).toBe(true); const page = analysisViewResultSchema.parse( parseEvidence(listed.structuredContent).normalized_result, ); @@ -369,14 +361,11 @@ it.skipIf(process.platform === "win32")( }, }; expect(schemas.ajv.validate(schemas.inputSchema, args)).toBe(true); - const selected = await harness.client.callTool({ - name: "inspect_analysis_view", - arguments: args, - }); + const selected = await harness.client.callTool( + { name: "inspect_analysis_view", arguments: args }, + { toolDefinition: schemas.toolDefinition }, + ); expect(selected.isError).not.toBe(true); - expect( - schemas.ajv.validate(schemas.outputSchema, selected.structuredContent), - ).toBe(true); const evidence = parseEvidence(selected.structuredContent); expect(evidence.evidence_links).toEqual([summary.parent_evidence_id]); expect(evidence.normalized_result).toHaveProperty( diff --git a/tests/boundary/mcp/devicetreeMcp.test.ts b/tests/boundary/mcp/devicetreeMcp.test.ts index 71806dd0e5..b7337d7a0a 100644 --- a/tests/boundary/mcp/devicetreeMcp.test.ts +++ b/tests/boundary/mcp/devicetreeMcp.test.ts @@ -55,15 +55,11 @@ mcpTest( const root = await createTestTempDirectory("rea-dtb-mcp-"); const path = join(root, "board.dtb"); await writeFile(path, devicetreeFixture()); - const response = await client.callTool({ - name: "inspect_devicetree", - arguments: { path, detail: "summary" }, - }); + const response = await client.callTool( + { name: "inspect_devicetree", arguments: { path, detail: "summary" } }, + { toolDefinition: schemas.toolDefinition }, + ); expect(response.isError, JSON.stringify(response)).not.toBe(true); - expect( - schemas.ajv.validate(schemas.outputSchema, response.structuredContent), - JSON.stringify(schemas.ajv.errors), - ).toBe(true); const result = toolContract("inspect_devicetree").outputSchema.parse( response.structuredContent, ).normalized_result; diff --git a/tests/boundary/mcp/electronFusesMcp.test.ts b/tests/boundary/mcp/electronFusesMcp.test.ts index a18b02d893..01b1b2daaf 100644 --- a/tests/boundary/mcp/electronFusesMcp.test.ts +++ b/tests/boundary/mcp/electronFusesMcp.test.ts @@ -82,15 +82,11 @@ fuseMcpTest( const elf = elfFuseImage([ELECTRON_FUSE_STATE_BYTES.removed, 0x99]); const path = join(root, "application"); await writeFile(path, elf.bytes); - const response = await client.callTool({ - name: "inspect_electron_fuses", - arguments: { path }, - }); + const response = await client.callTool( + { name: "inspect_electron_fuses", arguments: { path } }, + { toolDefinition: advertised }, + ); expect(response.isError, JSON.stringify(response.content)).not.toBe(true); - expect( - ajv.validate(outputSchema, response.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const parsed = toolContract("inspect_electron_fuses").outputSchema.parse( response.structuredContent, ); diff --git a/tests/boundary/mcp/enhancedTools.traceFeatureCoverage.test.ts b/tests/boundary/mcp/enhancedTools.traceFeatureCoverage.test.ts index 99fa648ef7..40e78fd670 100644 --- a/tests/boundary/mcp/enhancedTools.traceFeatureCoverage.test.ts +++ b/tests/boundary/mcp/enhancedTools.traceFeatureCoverage.test.ts @@ -3,7 +3,6 @@ import { afterEach, describe, expect, it } from "vitest"; import type { AnalysisOperationPort } from "../../../src/application/AnalysisProvider.js"; import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js"; import { EnhancedTools } from "../../../src/application/EnhancedTools.js"; -import { TRACE_FEATURE_REFERENCE_LIMITATION } from "../../../src/application/WorkflowEvidence.js"; import { AnalysisCapabilityUnavailableError, AnalysisOutputError, @@ -438,12 +437,11 @@ describe("trace_feature reference coverage", () => { const output = await trace(provider({ emptyXrefsOf: "0x10" })); expect(output.normalized_result).toEqual(expectedTrace([], [], ["0x10"])); - expect(output.limitations).toContain(TRACE_FEATURE_REFERENCE_LIMITATION); - expect(TRACE_FEATURE_REFERENCE_LIMITATION).toMatch(/start address/); - expect(TRACE_FEATURE_REFERENCE_LIMITATION).toMatch( - /interior bytes of strings and procedures/, + expect(output.limitations).toContainEqual( + expect.stringMatching( + /start address.*interior bytes of strings and procedures.*truncated: false/u, + ), ); - expect(TRACE_FEATURE_REFERENCE_LIMITATION).toMatch(/truncated: false/); }); it("marks the trace truncated for the target whose references the provider capped", async () => { diff --git a/tests/boundary/mcp/environmentBundleMcp.test.ts b/tests/boundary/mcp/environmentBundleMcp.test.ts index 47244b7243..c3ab6b8d1e 100644 --- a/tests/boundary/mcp/environmentBundleMcp.test.ts +++ b/tests/boundary/mcp/environmentBundleMcp.test.ts @@ -107,7 +107,7 @@ it("reports an unmodelled literal delete without claiming recorded reads are abs it("writes the bundle environment-build writes from retained capture Evidence", async () => { const { client, session } = await connect(); - const { ajv, inputSchema, outputSchema } = await advertisedMcpToolSchemas( + const { ajv, inputSchema, toolDefinition } = await advertisedMcpToolSchemas( client, TOOL, ); @@ -121,12 +121,11 @@ it("writes the bundle environment-build writes from retained capture Evidence", output_directory: join(root, "grafana"), }; expect(ajv.validate(inputSchema, input)).toBe(true); - const response = await client.callTool({ name: TOOL, arguments: input }); + const response = await client.callTool( + { name: TOOL, arguments: input }, + { toolDefinition }, + ); expect(response.isError, JSON.stringify(response)).not.toBe(true); - expect( - ajv.validate(outputSchema, response.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const report = toolContract(TOOL).outputSchema.parse( response.structuredContent, ); diff --git a/tests/boundary/mcp/evidenceLinkInspectionMcp.test.ts b/tests/boundary/mcp/evidenceLinkInspectionMcp.test.ts index 2981381ca3..7da3a8ff1f 100644 --- a/tests/boundary/mcp/evidenceLinkInspectionMcp.test.ts +++ b/tests/boundary/mcp/evidenceLinkInspectionMcp.test.ts @@ -8,7 +8,6 @@ import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; import { EVIDENCE_LINK_INSPECTION_LIMIT, - EVIDENCE_LINK_INSPECTION_LIMITATIONS, evidenceBundleSummarySchema, evidenceLinkInspectionSchema, } from "../../../src/domain/evidenceRecordSummary.js"; @@ -74,13 +73,16 @@ const connect = async ( }); for (const retained of options.records ?? PARTIAL) expect(session.recordEvidence(retained).ok).toBe(true); - const reply = (args: Record) => - client.callTool({ name: "get_evidence_bundle", arguments: args }); // Every delivered result must satisfy what this profile actually advertises. const { tools } = await client.listTools(); const advertised = tools.find(({ name }) => name === "get_evidence_bundle"); if (advertised?.outputSchema === undefined) throw new Error("get_evidence_bundle advertises no output schema"); + const reply = (args: Record) => + client.callTool( + { name: "get_evidence_bundle", arguments: args }, + { toolDefinition: advertised }, + ); const inputSchema: Record = advertised.inputSchema; const outputSchema: Record = advertised.outputSchema; const validation = schemaValidation.assertions; @@ -93,10 +95,6 @@ const connect = async ( ).toBe(true); const result = await reply(args); expect(result.isError, JSON.stringify(result)).not.toBe(true); - expect( - validation.validate(outputSchema, result.structuredContent), - JSON.stringify(validation.errors), - ).toBe(true); return z.object({ result: z.unknown() }).parse(result.structuredContent) .result; }; @@ -137,7 +135,9 @@ it.each(["full", "compact"] as const)( matching_links: 0, next_offset: null, rows: [], - limitations: [...EVIDENCE_LINK_INSPECTION_LIMITATIONS], + limitations: [ + expect.stringMatching(/missing means absent here.*analysis coverage/u), + ], }); // The record that names the absent source reports it without any payload. diff --git a/tests/boundary/mcp/evidenceRecordSelectionMcp.test.ts b/tests/boundary/mcp/evidenceRecordSelectionMcp.test.ts index 86d267d16d..afb9aec137 100644 --- a/tests/boundary/mcp/evidenceRecordSelectionMcp.test.ts +++ b/tests/boundary/mcp/evidenceRecordSelectionMcp.test.ts @@ -6,7 +6,6 @@ import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; import { MCP_TRANSPORT_EVIDENCE_RECORDS } from "../../../src/domain/analysisErrorPresentation.js"; import { EVIDENCE_RECORD_SELECTION_LIMIT, - EVIDENCE_RECORD_SELECTION_LIMITATIONS, evidenceRecordSelectionSchema, } from "../../../src/domain/evidenceRecordSummary.js"; import { createToolResultDelivery } from "../../../src/server/toolResult.js"; @@ -85,13 +84,21 @@ const connect = async ( }); for (const record of options.records ?? LEDGER) expect(session.recordEvidence(record).ok).toBe(true); + const definitions = (await client.listTools()).tools.filter( + (tool) => tool.name === "get_evidence_bundle", + ); + if (definitions.length !== 1 || definitions[0]?.outputSchema === undefined) + throw new Error("Expected one advertised evidence-bundle output schema"); + const toolDefinition = definitions[0]; const reply = (args: Record) => - client.callTool({ name: "get_evidence_bundle", arguments: args }); + client.callTool( + { name: "get_evidence_bundle", arguments: args }, + { toolDefinition }, + ); const select = async (args: Record) => { const result = await reply({ detail: "records", ...args }); expect(result.isError, JSON.stringify(result)).not.toBe(true); return { - result, selection: evidenceRecordSelectionSchema.parse( z.object({ result: z.unknown() }).parse(result.structuredContent) .result, @@ -123,9 +130,11 @@ it.each(["full", "compact"] as const)( expect(parseEvidence(exact.selection.records[0]).evidence_id).toBe( annotatedA.evidence_id, ); - // Historical and non-bundle labels come from their owning constant. expect(exact.selection.limitations).toEqual([ - ...EVIDENCE_RECORD_SELECTION_LIMITATIONS, + expect.stringMatching( + /historical observations.*not establish current live/u, + ), + expect.stringMatching(/not an Evidence bundle.*cannot be imported/u), ]); const { tools } = await client.listTools(); @@ -140,11 +149,6 @@ it.each(["full", "compact"] as const)( limit: 1, }), ).toBe(true); - const outputSchema: Record = tool.outputSchema!; - expect(ajv.validate(outputSchema, exact.result.structuredContent)).toBe( - true, - ); - // Pages follow canonical bundle order across distinct targets. const annotations = canonical([annotatedA, annotatedB]); const first = await select({ diff --git a/tests/boundary/mcp/evmInterfaceMcp.test.ts b/tests/boundary/mcp/evmInterfaceMcp.test.ts index a63b93533c..26b2c6beef 100644 --- a/tests/boundary/mcp/evmInterfaceMcp.test.ts +++ b/tests/boundary/mcp/evmInterfaceMcp.test.ts @@ -131,14 +131,16 @@ mcpTest( (tool) => tool.name === "inspect_evm_interface", ), ).toHaveLength(1); - const response = await client.callTool({ - name: "inspect_evm_interface", - arguments: { path: value.artifact.path, encoding: "hex" }, - }); + const response = await client.callTool( + { + name: "inspect_evm_interface", + arguments: { path: value.artifact.path, encoding: "hex" }, + }, + { toolDefinition: schemas.toolDefinition }, + ); assertRecordedMcpEvidence( response, "inspect_evm_interface", - schemas, session, value, "inferred", @@ -368,30 +370,28 @@ it("returns the exact inferred interface summary while preserving complete defau const input = { path: value.artifact.path, encoding: "hex" }; let original; for (const detail of [undefined, "complete"] as const) { - const response = await harness.client.callTool({ - name: "inspect_evm_interface", - arguments: { ...input, ...(detail ? { detail } : {}) }, - }); + const response = await harness.client.callTool( + { + name: "inspect_evm_interface", + arguments: { ...input, ...(detail ? { detail } : {}) }, + }, + { toolDefinition: definition }, + ); expect(response.isError).not.toBe(true); - expect( - ajv.validate(outputSchema, response.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const evidence = parseEvidence(response.structuredContent); expect(evidence.normalized_result).toEqual(value); expect(evidence.parameters).toEqual(input); original = evidence; } if (original === undefined) throw new Error("No complete control"); - const response = await harness.client.callTool({ - name: "inspect_evm_interface", - arguments: { ...input, detail: "summary" }, - }); + const response = await harness.client.callTool( + { + name: "inspect_evm_interface", + arguments: { ...input, detail: "summary" }, + }, + { toolDefinition: definition }, + ); expect(response.isError).not.toBe(true); - expect( - ajv.validate(outputSchema, response.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const derived = parseEvidence(response.structuredContent); const view = evmInterfaceSummaryViewResultSchema.parse( derived.normalized_result, diff --git a/tests/boundary/mcp/fridaMcp.test.ts b/tests/boundary/mcp/fridaMcp.test.ts index 26d3e78cfe..6bdc8f6203 100644 --- a/tests/boundary/mcp/fridaMcp.test.ts +++ b/tests/boundary/mcp/fridaMcp.test.ts @@ -226,12 +226,11 @@ it("delivers Frida status summaries with complete retained Evidence for both tru arguments: { session_id: id, detail: "complete" }, }); expect(explicit.structuredContent).toEqual(parent); - const summary = await client.callTool({ - name: tool.name, - arguments: { session_id: id, detail: "summary" }, - }); + const summary = await client.callTool( + { name: tool.name, arguments: { session_id: id, detail: "summary" } }, + { toolDefinition: tool }, + ); expect(summary.isError).not.toBe(true); - expect(ajv.validate(outputSchema, summary.structuredContent)).toBe(true); const evidence = toolContract("frida_session_status").outputSchema.parse( summary.structuredContent, ); @@ -426,17 +425,19 @@ it("advertises Frida MCP schemas and keeps remote credentials out of results", a const outputSchema: Record = advertised.outputSchema; expect(ajv.validateSchema(inputSchema)).toBe(true); expect(ajv.validateSchema(outputSchema)).toBe(true); - const response = await client.callTool({ - name: "list_frida_devices", - arguments: { - remote: { address: "127.0.0.1:27042", token: "synthetic-secret-token" }, + const response = await client.callTool( + { + name: "list_frida_devices", + arguments: { + remote: { address: "127.0.0.1:27042", token: "synthetic-secret-token" }, + }, }, - }); + { toolDefinition: advertised }, + ); expect(response.isError).not.toBe(true); expect(JSON.stringify(response.structuredContent)).not.toContain( "synthetic-secret-token", ); - expect(ajv.validate(outputSchema, response.structuredContent)).toBe(true); expect( toolContract("list_frida_devices").outputSchema.parse( response.structuredContent, diff --git a/tests/boundary/mcp/ghidraMcpHarnessLifecycle.test.ts b/tests/boundary/mcp/ghidraMcpHarnessLifecycle.test.ts index aaaf396a09..d16a8b3672 100644 --- a/tests/boundary/mcp/ghidraMcpHarnessLifecycle.test.ts +++ b/tests/boundary/mcp/ghidraMcpHarnessLifecycle.test.ts @@ -37,8 +37,9 @@ it("reports independent cleanup failures and retries only retained owners", asyn .mockRejectedValueOnce(sdkFailure); try { const first = harness.close(); - expect(harness.close()).toBe(first); + const concurrent = harness.close().catch((cause: unknown) => cause); const failure: unknown = await first.catch((cause: unknown) => cause); + expect(failureLeaves(await concurrent)).toEqual(failureLeaves(failure)); expect(failureLeaves(failure)).toEqual( expect.arrayContaining([ sdkFailure, diff --git a/tests/boundary/mcp/ghidraNativeApiMcp.test.ts b/tests/boundary/mcp/ghidraNativeApiMcp.test.ts index 71b127a4dc..bfb2640611 100644 --- a/tests/boundary/mcp/ghidraNativeApiMcp.test.ts +++ b/tests/boundary/mcp/ghidraNativeApiMcp.test.ts @@ -57,10 +57,6 @@ it("retains complete load-image facts and pages selected relocations through the { toolDefinition: definition }, ); expect(reply.isError).not.toBe(true); - expect( - ajv.validate(outputSchema, reply.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); return sessionEvidence(harness.session, reply.structuredContent); }; const complete = await call("inspect_native_load_image", {}); diff --git a/tests/boundary/mcp/goBinaryMcp.test.ts b/tests/boundary/mcp/goBinaryMcp.test.ts index 0ccb8c3cd9..e3dc205f97 100644 --- a/tests/boundary/mcp/goBinaryMcp.test.ts +++ b/tests/boundary/mcp/goBinaryMcp.test.ts @@ -347,15 +347,11 @@ goMcpTest( moduleText: "path\texample.com/tool\nnew-record\tfuture\n", }); await writeFile(path, fixture.bytes); - const response = await client.callTool({ - name: "inspect_go_binary", - arguments: { path }, - }); + const response = await client.callTool( + { name: "inspect_go_binary", arguments: { path } }, + { toolDefinition: advertised }, + ); expect(response.isError).not.toBe(true); - expect( - ajv.validate(outputSchema, response.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const parsed = evidenceResultOf(goBinarySchema).parse( response.structuredContent, ); diff --git a/tests/boundary/mcp/godotPckMcp.test.ts b/tests/boundary/mcp/godotPckMcp.test.ts index 4c47f381fd..bff61f7029 100644 --- a/tests/boundary/mcp/godotPckMcp.test.ts +++ b/tests/boundary/mcp/godotPckMcp.test.ts @@ -40,7 +40,7 @@ const viewValidators = async (client: Client) => { const outputSchema: Record = contract.outputSchema; expect(ajv.validateSchema(inputSchema)).toBe(true); expect(ajv.validateSchema(outputSchema)).toBe(true); - return { ajv, inputSchema, outputSchema }; + return { ajv, inputSchema, toolDefinition: contract }; }; const retainedInventoryView = @@ -58,17 +58,13 @@ const retainedInventoryView = validators.ajv.validate(validators.inputSchema, arguments_), JSON.stringify(validators.ajv.errors), ).toBe(true); - const reply = await client.callTool({ - name: "inspect_analysis_view", - arguments: arguments_, - }); + const reply = await client.callTool( + { name: "inspect_analysis_view", arguments: arguments_ }, + { toolDefinition: validators.toolDefinition }, + ); expect(reply.isError, JSON.stringify(reply.structuredContent)).not.toBe( true, ); - expect( - validators.ajv.validate(validators.outputSchema, reply.structuredContent), - JSON.stringify(validators.ajv.errors), - ).toBe(true); const evidence = parseEvidence(reply.structuredContent); expect(evidence.evidence_links).toContain(evidenceId); return analysisViewResultSchema.parse(evidence.normalized_result); diff --git a/tests/boundary/mcp/javascriptFactsViewMcp.test.ts b/tests/boundary/mcp/javascriptFactsViewMcp.test.ts index 38b27c241e..cdf36533bf 100644 --- a/tests/boundary/mcp/javascriptFactsViewMcp.test.ts +++ b/tests/boundary/mcp/javascriptFactsViewMcp.test.ts @@ -38,10 +38,15 @@ const facts = async ( source: unknown, view: unknown, ) => { - const wire = await harness.client.callTool({ - name: "inspect_analysis_view", - arguments: { source, view }, - }); + const matches = (await harness.client.listTools()).tools.filter( + (tool) => tool.name === "inspect_analysis_view", + ); + if (matches.length !== 1 || matches[0]?.outputSchema === undefined) + throw new Error("Expected one advertised analysis-view output schema"); + const wire = await harness.client.callTool( + { name: "inspect_analysis_view", arguments: { source, view } }, + { toolDefinition: matches[0] }, + ); expect(wire.isError).not.toBe(true); const evidence = parseEvidence(wire.structuredContent); const page = analysisViewResultSchema.parse(evidence.normalized_result); @@ -137,7 +142,7 @@ it("pages selected real filesystem observations with shared identities and porta property_keys: ["bytes", "parse_status", "operation", "not_present"], limit: 64, }; - const { ajv, inputSchema, outputSchema } = await advertisedMcpToolSchemas( + const { ajv, inputSchema } = await advertisedMcpToolSchemas( harness.client, "inspect_analysis_view", ); @@ -155,7 +160,6 @@ it("pages selected real filesystem observations with shared identities and porta error: { code: "invalid_request", category: "invalid_input" }, }); const full = await facts(harness, source, query); - expect(ajv.validate(outputSchema, full.wire.structuredContent)).toBe(true); const portable = await facts( harness, { kind: "inline", evidence: parent }, @@ -235,10 +239,7 @@ it("pages selected real filesystem observations with shared identities and porta expect(ajv.validate(inputSchema, { source, view: discoveryQuery })).toBe( true, ); - const discovery = await facts(harness, source, discoveryQuery); - expect(ajv.validate(outputSchema, discovery.wire.structuredContent)).toBe( - true, - ); + await facts(harness, source, discoveryQuery); }); const evidenceWithMixedLocations = () => { diff --git a/tests/boundary/mcp/javascriptRecoveryMcp.test.ts b/tests/boundary/mcp/javascriptRecoveryMcp.test.ts index 60748e7c1a..b9eaf79dc1 100644 --- a/tests/boundary/mcp/javascriptRecoveryMcp.test.ts +++ b/tests/boundary/mcp/javascriptRecoveryMcp.test.ts @@ -57,15 +57,11 @@ it ...(multiple ? { additional_paths: [additional] } : {}), }; expect(ajv.validate(inputSchema, args)).toBe(true); - const response = await client.callTool({ - name: "recover_javascript_sources", - arguments: args, - }); + const response = await client.callTool( + { name: "recover_javascript_sources", arguments: args }, + { toolDefinition: advertised }, + ); expect(response.isError, JSON.stringify(response)).not.toBe(true); - expect( - ajv.validate(outputSchema, response.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const parsed = toolContract( "recover_javascript_sources", ).outputSchema.parse(response.structuredContent); diff --git a/tests/boundary/mcp/managedArtifactSummaryMcp.test.ts b/tests/boundary/mcp/managedArtifactSummaryMcp.test.ts index 68b57aed0f..c55475d9fe 100644 --- a/tests/boundary/mcp/managedArtifactSummaryMcp.test.ts +++ b/tests/boundary/mcp/managedArtifactSummaryMcp.test.ts @@ -55,15 +55,14 @@ it("returns a first-call overview and pages the unchanged source after its file { path, detail: "summary" }, ), ).toBe(false); - const reply = await client.callTool({ - name: "inspect_managed_artifact", - arguments: { path, detail: "summary" }, - }); + const reply = await client.callTool( + { + name: "inspect_managed_artifact", + arguments: { path, detail: "summary" }, + }, + { toolDefinition: definition }, + ); expect(reply.isError).not.toBe(true); - expect( - ajv.validate(outputSchema, reply.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const receipt = parseEvidence(reply.structuredContent); const view = managedArtifactSummaryViewResultSchema.parse( receipt.normalized_result, diff --git a/tests/boundary/mcp/managedFieldConstantsMcp.test.ts b/tests/boundary/mcp/managedFieldConstantsMcp.test.ts index 82bad9b386..2827e9cba4 100644 --- a/tests/boundary/mcp/managedFieldConstantsMcp.test.ts +++ b/tests/boundary/mcp/managedFieldConstantsMcp.test.ts @@ -38,17 +38,11 @@ it("retains lossless field constants in MCP Evidence and pages them after the so ); if (definition?.outputSchema === undefined) throw new Error("Member output schema missing"); - const outputSchema: Record = definition.outputSchema; const reply = await client.callTool( { name: definition.name, arguments: { path, detail: "complete" } }, { toolDefinition: definition }, ); expect(reply.isError).not.toBe(true); - const ajv = new Ajv2020({ strict: false, validateFormats: false }); - expect( - ajv.validate(outputSchema, reply.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const parent = parseEvidence(reply.structuredContent); const { fields } = managedMemberInspectionSchema.parse( parent.normalized_result, @@ -68,7 +62,7 @@ it("retains lossless field constants in MCP Evidence and pages them after the so const viewTool = tools.find((tool) => tool.name === "inspect_analysis_view"); if (viewTool?.outputSchema === undefined) throw new Error("View output schema missing"); - const viewOutputSchema: Record = viewTool.outputSchema; + const ajv = new Ajv2020({ strict: false, validateFormats: false }); const request = { source: { kind: "retained-evidence", evidence_id: parent.evidence_id }, view: { @@ -88,10 +82,6 @@ it("retains lossless field constants in MCP Evidence and pages them after the so { toolDefinition: viewTool }, ); expect(page.isError).not.toBe(true); - expect( - ajv.validate(viewOutputSchema, page.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const view = parseEvidence(page.structuredContent).normalized_result; expect(view).toMatchObject({ parent_evidence_id: parent.evidence_id, diff --git a/tests/boundary/mcp/managedMemberSummaryMcp.test.ts b/tests/boundary/mcp/managedMemberSummaryMcp.test.ts index c261085e4d..204cb0da98 100644 --- a/tests/boundary/mcp/managedMemberSummaryMcp.test.ts +++ b/tests/boundary/mcp/managedMemberSummaryMcp.test.ts @@ -211,15 +211,14 @@ it("summarizes selected members and pages every original collection after the fi detail: "summary", }), ).toBe(true); - const reply = await client.callTool({ - name: "inspect_managed_members", - arguments: { path, type_names, detail: "summary" }, - }); + const reply = await client.callTool( + { + name: "inspect_managed_members", + arguments: { path, type_names, detail: "summary" }, + }, + { toolDefinition: definition }, + ); expect(reply.isError).not.toBe(true); - expect( - ajv.validate(outputSchema, reply.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const receipt = parseEvidence(reply.structuredContent); const view = managedMemberSummaryViewResultSchema.parse( receipt.normalized_result, diff --git a/tests/boundary/mcp/nativeInstructionReferencesView.test.ts b/tests/boundary/mcp/nativeInstructionReferencesView.test.ts index 8416916a88..43a9188475 100644 --- a/tests/boundary/mcp/nativeInstructionReferencesView.test.ts +++ b/tests/boundary/mcp/nativeInstructionReferencesView.test.ts @@ -69,7 +69,9 @@ const instruction = ( const checkContinuation = async ( client: Client, - schema: Readonly>, + toolDefinition: NonNullable< + NonNullable[1]>["toolDefinition"] + >, continuation: { name: "inspect_analysis_view"; arguments: z.output; @@ -99,15 +101,11 @@ const checkContinuation = async ( const recovered: unknown[] = []; let request = continuation.arguments; for (;;) { - const response = await client.callTool({ - name: continuation.name, - arguments: request, - }); + const response = await client.callTool( + { name: continuation.name, arguments: request }, + { toolDefinition }, + ); expect(response.isError, JSON.stringify(response.content)).not.toBe(true); - expect( - validation.assertions.validate(schema, response.structuredContent), - JSON.stringify(validation.assertions.errors), - ).toBe(true); const page = analysisViewResultSchema.parse( parseEvidence(response.structuredContent).normalized_result, ); @@ -160,10 +158,10 @@ it.each(["full", "compact"] as const)( }, }, ); - const client = new Client({ - name: "instruction-reference-pages", - version: "1", - }); + const client = new Client( + { name: "instruction-reference-pages", version: "1" }, + { jsonSchemaValidator: validation }, + ); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); try { @@ -218,12 +216,7 @@ it.each(["full", "compact"] as const)( throw new Error("Expected retained parent in continuation"); source = continuation.arguments.source; const before = reads; - await checkContinuation( - client, - advertised.outputSchema, - continuation, - source, - ); + await checkContinuation(client, advertised, continuation, source); expect(reads).toBe(before); } else { expect(result.isError).not.toBe(true); @@ -233,26 +226,22 @@ it.each(["full", "compact"] as const)( }; } const before = reads; - const reply = await client.callTool({ - name: "inspect_analysis_view", - arguments: { - source, - view: { - kind: "native-instruction-references", - collection: "incoming_references", - offset: incoming === "rows" ? 1 : 0, - limit: 1, + const reply = await client.callTool( + { + name: "inspect_analysis_view", + arguments: { + source, + view: { + kind: "native-instruction-references", + collection: "incoming_references", + offset: incoming === "rows" ? 1 : 0, + limit: 1, + }, }, }, - }); + { toolDefinition: advertised }, + ); expect(reply.isError, JSON.stringify(reply.content)).not.toBe(true); - expect( - validation.assertions.validate( - advertised.outputSchema, - reply.structuredContent, - ), - JSON.stringify(validation.assertions.errors), - ).toBe(true); const page = analysisViewResultSchema.parse( parseEvidence(reply.structuredContent).normalized_result, ); diff --git a/tests/boundary/mcp/nativeInventoryViewsMcp.test.ts b/tests/boundary/mcp/nativeInventoryViewsMcp.test.ts index 8a89c274e3..70e9027a11 100644 --- a/tests/boundary/mcp/nativeInventoryViewsMcp.test.ts +++ b/tests/boundary/mcp/nativeInventoryViewsMcp.test.ts @@ -116,15 +116,11 @@ it("shares complete/summary source identity and advertised contracts across all const parameters = operation.startsWith("search_") ? { pattern: ".", mode: "regex" } : {}; - const completeReply = await harness.mcp.callTool({ - name: operation, - arguments: parameters, - }); + const completeReply = await harness.mcp.callTool( + { name: operation, arguments: parameters }, + { toolDefinition: advertised }, + ); if (completeReply.isError) throw new Error(JSON.stringify(completeReply)); - expect( - ajv.validate(outputSchema, completeReply.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const complete = parseEvidence( contract.outputSchema.parse(completeReply.structuredContent), ); @@ -136,15 +132,11 @@ it("shares complete/summary source identity and advertised contracts across all if (operation === "search_strings") expect(complete.normalized_result).toEqual(stringRows); expect(complete.parameters).not.toHaveProperty("detail"); - const summaryReply = await harness.mcp.callTool({ - name: operation, - arguments: { ...parameters, detail: "summary" }, - }); + const summaryReply = await harness.mcp.callTool( + { name: operation, arguments: { ...parameters, detail: "summary" } }, + { toolDefinition: advertised }, + ); if (summaryReply.isError) throw new Error(JSON.stringify(summaryReply)); - expect( - ajv.validate(outputSchema, summaryReply.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const summary = parseEvidence( contract.outputSchema.parse(summaryReply.structuredContent), ); @@ -175,21 +167,20 @@ it("shares complete/summary source identity and advertised contracts across all offset < (complete.normalized_result as unknown[]).length; offset++ ) { - const pageReply = await harness.mcp.callTool({ - name: "inspect_analysis_view", - arguments: { - source: { - kind: "retained-evidence", - evidence_id: complete.evidence_id, + const pageReply = await harness.mcp.callTool( + { + name: "inspect_analysis_view", + arguments: { + source: { + kind: "retained-evidence", + evidence_id: complete.evidence_id, + }, + view: { kind: "native-inventory", offset, limit: 1 }, }, - view: { kind: "native-inventory", offset, limit: 1 }, }, - }); + { toolDefinition: viewTool }, + ); if (pageReply.isError) throw new Error(JSON.stringify(pageReply)); - expect( - ajv.validate(viewOutputSchema, pageReply.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const page = toolContract("inspect_analysis_view").outputSchema.parse( pageReply.structuredContent, ).normalized_result; diff --git a/tests/boundary/mcp/nativeMachoSummaryMcp.test.ts b/tests/boundary/mcp/nativeMachoSummaryMcp.test.ts index d9a2612e15..cb50b6bae4 100644 --- a/tests/boundary/mcp/nativeMachoSummaryMcp.test.ts +++ b/tests/boundary/mcp/nativeMachoSummaryMcp.test.ts @@ -201,15 +201,11 @@ mcpTest( view.ajv.validate(view.inputSchema, request), JSON.stringify(view.ajv.errors), ).toBe(true); - const reply = await client.callTool({ - name: "inspect_analysis_view", - arguments: request, - }); + const reply = await client.callTool( + { name: "inspect_analysis_view", arguments: request }, + { toolDefinition: view.toolDefinition }, + ); expect(reply.isError, JSON.stringify(reply.content)).not.toBe(true); - expect( - view.ajv.validate(view.outputSchema, reply.structuredContent), - JSON.stringify(view.ajv.errors), - ).toBe(true); const result = parseEvidence(reply.structuredContent).normalized_result; expect(result).toMatchObject({ item: [expected], @@ -254,18 +250,17 @@ mcpTest( tool.ajv.validate(tool.outputSchema, historical), JSON.stringify(tool.ajv.errors), ).toBe(true); - const oldView = await client.callTool({ - name: "inspect_analysis_view", - arguments: { - source: { kind: "inline", evidence: historical }, - view: { kind: "macho", facet: "segments" }, + const oldView = await client.callTool( + { + name: "inspect_analysis_view", + arguments: { + source: { kind: "inline", evidence: historical }, + view: { kind: "macho", facet: "segments" }, + }, }, - }); + { toolDefinition: view.toolDefinition }, + ); expect(oldView.isError, JSON.stringify(oldView.content)).not.toBe(true); - expect( - view.ajv.validate(view.outputSchema, oldView.structuredContent), - JSON.stringify(view.ajv.errors), - ).toBe(true); expect( parseEvidence(oldView.structuredContent).normalized_result, ).toMatchObject({ item: [historicalSegment], source_exhaustive: true }); @@ -358,15 +353,11 @@ mcpTest( schemas.ajv.validate(schemas.inputSchema, request), JSON.stringify(schemas.ajv.errors), ).toBe(true); - const reply = await client.callTool({ - name: "inspect_analysis_view", - arguments: request, - }); + const reply = await client.callTool( + { name: "inspect_analysis_view", arguments: request }, + { toolDefinition: schemas.toolDefinition }, + ); expect(reply.isError, JSON.stringify(reply.content)).not.toBe(true); - expect( - schemas.ajv.validate(schemas.outputSchema, reply.structuredContent), - JSON.stringify(schemas.ajv.errors), - ).toBe(true); expect( pageSchema.parse( parseEvidence(reply.structuredContent).normalized_result, diff --git a/tests/boundary/mcp/nativeObservationViewsMcp.test.ts b/tests/boundary/mcp/nativeObservationViewsMcp.test.ts index 745a9b4cad..09981650cb 100644 --- a/tests/boundary/mcp/nativeObservationViewsMcp.test.ts +++ b/tests/boundary/mcp/nativeObservationViewsMcp.test.ts @@ -12,7 +12,6 @@ import { analysisViewResultSchema, inspectAnalysisViewInputSchema, } from "../../../src/domain/analysisView/analysisView.js"; -import { XREFS_ADDRESS_PAGE_LIMITATION } from "../../../src/domain/analysisView/nativeObservationView.js"; import { NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS } from "../../../src/domain/native/nativeObservation.js"; import { connectGhidraMcp } from "./ghidraMcpHarness.js"; import { createMcpSchemaValidation } from "../../fixtures/mcpSchemaValidation.js"; @@ -35,13 +34,6 @@ it("delivers native primitive and graph summaries with exact retained parents an { toolDefinition: tool }, ); expect(result.isError, JSON.stringify(result.content)).not.toBe(true); - expect( - validation.assertions.validate( - tool.outputSchema, - result.structuredContent, - ), - JSON.stringify(validation.assertions.errors), - ).toBe(true); return parseEvidence(result.structuredContent); }; const viewSchemas = await advertisedMcpToolSchemas( @@ -141,7 +133,13 @@ it("delivers native primitive and graph summaries with exact retained parents an }); expect( analysisViewResultSchema.parse(page.normalized_result).unknowns, - ).toContain(XREFS_ADDRESS_PAGE_LIMITATION); + ).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /source addresses only.*reference kinds or provenance.*complete parent Evidence/iu, + ), + ]), + ); const caveats = await call("inspect_analysis_view", { source, view: { kind: "native-context", facet: "limitations" }, diff --git a/tests/boundary/mcp/nativeValueFlowAvailability.test.ts b/tests/boundary/mcp/nativeValueFlowAvailability.test.ts index 7333b3ece1..03f96047f1 100644 --- a/tests/boundary/mcp/nativeValueFlowAvailability.test.ts +++ b/tests/boundary/mcp/nativeValueFlowAvailability.test.ts @@ -121,10 +121,6 @@ it.each([ { toolDefinition: schema.toolDefinition }, ); expect(reply.isError, JSON.stringify(reply)).not.toBe(true); - expect( - schema.ajv.validate(schema.outputSchema, reply.structuredContent), - JSON.stringify(schema.ajv.errors), - ).toBe(true); return reply.structuredContent; }; await call("open_binary", { path: target, format: "dos-com" }); diff --git a/tests/boundary/mcp/nativeValueTraceCompleteness.test.ts b/tests/boundary/mcp/nativeValueTraceCompleteness.test.ts index 5bf1c7a2ab..8f869ad1f9 100644 --- a/tests/boundary/mcp/nativeValueTraceCompleteness.test.ts +++ b/tests/boundary/mcp/nativeValueTraceCompleteness.test.ts @@ -59,8 +59,10 @@ it.each(["full", "compact"] as const)( profile, valueTraceDiamond({ sessionSubject: true }).port, ); - const { toolDefinition, ajv, outputSchema } = - await advertisedMcpToolSchemas(client, "trace_native_values"); + const { toolDefinition, outputSchema } = await advertisedMcpToolSchemas( + client, + "trace_native_values", + ); if (profile === "full") for (const field of [ "page_truncated", @@ -75,10 +77,6 @@ it.each(["full", "compact"] as const)( { toolDefinition }, ); expect(reply.isError).not.toBe(true); - expect( - ajv.validate(outputSchema, reply.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); return nativeValueTraceSchema.parse( parseEvidence(reply.structuredContent).normalized_result, ); @@ -168,8 +166,10 @@ it.each(["full", "compact"] as const)( profile, valueTraceUnboundArguments({ sessionSubject: true }).port, ); - const { toolDefinition, ajv, outputSchema } = - await advertisedMcpToolSchemas(client, "trace_native_values"); + const { toolDefinition } = await advertisedMcpToolSchemas( + client, + "trace_native_values", + ); const reply = await client.callTool( { name: "trace_native_values", @@ -178,10 +178,6 @@ it.each(["full", "compact"] as const)( { toolDefinition }, ); expect(reply.isError).not.toBe(true); - expect( - ajv.validate(outputSchema, reply.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const trace = nativeValueTraceSchema.parse( parseEvidence(reply.structuredContent).normalized_result, ); diff --git a/tests/boundary/mcp/nativeViewPredicateMcp.test.ts b/tests/boundary/mcp/nativeViewPredicateMcp.test.ts index d07ec6bd62..a2ced02fa8 100644 --- a/tests/boundary/mcp/nativeViewPredicateMcp.test.ts +++ b/tests/boundary/mcp/nativeViewPredicateMcp.test.ts @@ -156,13 +156,6 @@ it.each(["full", "compact"] as const)( expect(goodReply.isError, JSON.stringify(goodReply.content)).not.toBe( true, ); - expect( - validation.assertions.validate( - viewTool.outputSchema, - goodReply.structuredContent, - ), - JSON.stringify(validation.assertions.errors), - ).toBe(true); const goodView = parseEvidence(goodReply.structuredContent); expect(goodView.evidence_links).toContain(good.evidence_id); const raw = join(root, `${index}.json`), diff --git a/tests/boundary/mcp/peCertificatesMcp.test.ts b/tests/boundary/mcp/peCertificatesMcp.test.ts index 99c6386391..20e886c89a 100644 --- a/tests/boundary/mcp/peCertificatesMcp.test.ts +++ b/tests/boundary/mcp/peCertificatesMcp.test.ts @@ -56,15 +56,11 @@ mcpTest( ]), ), ); - const result = await client.callTool({ - name: "inspect_pe_certificates", - arguments: { path }, - }); + const result = await client.callTool( + { name: "inspect_pe_certificates", arguments: { path } }, + { toolDefinition: advertised }, + ); expect(result.isError).not.toBe(true); - expect( - ajv.validate(schema, result.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); }, ); diff --git a/tests/boundary/mcp/peImportsMcp.test.ts b/tests/boundary/mcp/peImportsMcp.test.ts index c4efbc4f8d..3002408267 100644 --- a/tests/boundary/mcp/peImportsMcp.test.ts +++ b/tests/boundary/mcp/peImportsMcp.test.ts @@ -27,11 +27,10 @@ mcpTest( async ({ mcp }) => { const { client, session } = await connect(mcp); try { - const { - toolDefinition: advertised, - ajv, - outputSchema, - } = await advertisedMcpToolSchemas(client, "inspect_pe_imports"); + const { toolDefinition: advertised } = await advertisedMcpToolSchemas( + client, + "inspect_pe_imports", + ); expect(advertised?.annotations).toMatchObject({ readOnlyHint: false, idempotentHint: true, @@ -39,15 +38,11 @@ mcpTest( const root = await createTestTempDirectory("rea-pe-imports-mcp-"); const path = join(root, "app.exe"); await writeFile(path, peImportImage({ bits: 64 })); - const response = await client.callTool({ - name: "inspect_pe_imports", - arguments: { path }, - }); + const response = await client.callTool( + { name: "inspect_pe_imports", arguments: { path } }, + { toolDefinition: advertised }, + ); expect(response.isError, JSON.stringify(response.content)).not.toBe(true); - expect( - ajv.validate(outputSchema, response.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const evidence = toolContract("inspect_pe_imports").outputSchema.parse( response.structuredContent, ); @@ -62,15 +57,11 @@ mcpTest( ] as const) { const partialPath = join(root, `invalid-first-descriptor-${label}.exe`); await writeFile(partialPath, bytes); - const partial = await client.callTool({ - name: "inspect_pe_imports", - arguments: { path: partialPath }, - }); + const partial = await client.callTool( + { name: "inspect_pe_imports", arguments: { path: partialPath } }, + { toolDefinition: advertised }, + ); expect(partial.isError, JSON.stringify(partial.content)).not.toBe(true); - expect( - ajv.validate(outputSchema, partial.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const partialReport = toolContract( "inspect_pe_imports", ).outputSchema.parse(partial.structuredContent).normalized_result; diff --git a/tests/boundary/mcp/pseudocodeDeliveryMcp.test.ts b/tests/boundary/mcp/pseudocodeDeliveryMcp.test.ts index a7a1ab4963..55564664cf 100644 --- a/tests/boundary/mcp/pseudocodeDeliveryMcp.test.ts +++ b/tests/boundary/mcp/pseudocodeDeliveryMcp.test.ts @@ -41,15 +41,11 @@ it("retains a complete pseudocode observation once and pages its exact text thro const schema = await advertisedMcpToolSchemas(mcp, "procedure_pseudo_code"); const input = { procedure: "fixture_main", detail: "summary" }; expect(schema.ajv.validate(schema.inputSchema, input)).toBe(true); - const reply = await mcp.callTool({ - name: "procedure_pseudo_code", - arguments: input, - }); + const reply = await mcp.callTool( + { name: "procedure_pseudo_code", arguments: input }, + { toolDefinition: schema.toolDefinition }, + ); expect(reply.isError, JSON.stringify(reply)).not.toBe(true); - expect( - schema.ajv.validate(schema.outputSchema, reply.structuredContent), - JSON.stringify(schema.ajv.errors), - ).toBe(true); const summary = parseEvidence(reply.structuredContent); const result = jsonObjectSchema.parse(summary.normalized_result); expect(result).toMatchObject({ diff --git a/tests/boundary/mcp/pyinstallerArtifactMcp.test.ts b/tests/boundary/mcp/pyinstallerArtifactMcp.test.ts index d079b4b4b5..eab462af30 100644 --- a/tests/boundary/mcp/pyinstallerArtifactMcp.test.ts +++ b/tests/boundary/mcp/pyinstallerArtifactMcp.test.ts @@ -33,10 +33,10 @@ const assertMetadataQueries = async ( const definition = (await client.listTools()).tools.find( (tool) => tool.name === "inspect_analysis_view", ); - expect(definition?.outputSchema).toBeDefined(); + if (definition?.outputSchema === undefined) + throw new Error("Analysis view output schema is missing"); const ajv = schemaValidation.assertions; - const inputSchema: Record = definition!.inputSchema; - const outputSchema: Record = definition!.outputSchema!; + const inputSchema: Record = definition.inputSchema; const ids: string[] = []; const selected: unknown[] = []; for (const offset of [0, 1, 2, 3]) { @@ -54,15 +54,11 @@ const assertMetadataQueries = async ( expect(ajv.validate(inputSchema, args), JSON.stringify(ajv.errors)).toBe( true, ); - const reply = await client.callTool({ - name: "inspect_analysis_view", - arguments: args, - }); + const reply = await client.callTool( + { name: "inspect_analysis_view", arguments: args }, + { toolDefinition: definition }, + ); expect(reply.isError).not.toBe(true); - expect( - ajv.validate(outputSchema, reply.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const evidence = parseEvidence(reply.structuredContent); const view = analysisViewResultSchema.parse(evidence.normalized_result); if (view.kind !== "inventory") throw new Error("Expected inventory page"); diff --git a/tests/boundary/mcp/pythonPycMcp.test.ts b/tests/boundary/mcp/pythonPycMcp.test.ts index 9fde999066..cade91afbd 100644 --- a/tests/boundary/mcp/pythonPycMcp.test.ts +++ b/tests/boundary/mcp/pythonPycMcp.test.ts @@ -167,18 +167,14 @@ it("advertises inspect_python_pyc and reads nested code objects", async () => { }), ]), ); - const response = await client.callTool({ - name: "inspect_python_pyc", - arguments: { path }, - }); + const response = await client.callTool( + { name: "inspect_python_pyc", arguments: { path } }, + { toolDefinition: advertised }, + ); expect(response.isError, JSON.stringify(response.content)).not.toBe(true); const evidence = completePycEvidenceSchema.parse( response.structuredContent, ); - expect( - ajv.validate(outputSchema, response.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); expect(evidence.normalized_result.header.python_version).toBe("3.12"); expect(evidence.normalized_result.code_objects[0]?.firstlineno).toBe(-1); expect(evidence.normalized_result.code_objects[1]?.name).toBe("inner"); @@ -218,8 +214,6 @@ it("advertised pyc output schema preserves historical signed fields", async () = ); if (advertised?.outputSchema === undefined) throw new Error("Missing inspect_python_pyc output schema"); - const outputSchema: Record = advertised.outputSchema; - const ajv = schemaValidation.assertions; const root = await createTestTempDirectory("rea-python-pyc-mcp-flags-"); for (const testCase of [ { @@ -259,18 +253,14 @@ it("advertised pyc output schema preserves historical signed fields", async () = marshalCode(testCase.layout, testCase.fields), ]), ); - const response = await client.callTool({ - name: "inspect_python_pyc", - arguments: { path }, - }); + const response = await client.callTool( + { name: "inspect_python_pyc", arguments: { path } }, + { toolDefinition: advertised }, + ); expect(response.isError, JSON.stringify(response.content)).not.toBe(true); const evidence = completePycEvidenceSchema.parse( response.structuredContent, ); - expect( - ajv.validate(outputSchema, response.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); expect(evidence.normalized_result.code_objects[0]).toMatchObject( testCase.expected, ); diff --git a/tests/boundary/mcp/recordedCrashMcp.test.ts b/tests/boundary/mcp/recordedCrashMcp.test.ts index f52a1d72b5..10fc985834 100644 --- a/tests/boundary/mcp/recordedCrashMcp.test.ts +++ b/tests/boundary/mcp/recordedCrashMcp.test.ts @@ -65,15 +65,14 @@ it("records inline recorded-core evidence with compact schemas and canonical pat approval: true, }), ).toBe(false); - const response = await client.callTool({ - name: "inspect_recorded_crash", - arguments: { path: value.artifact.path }, - }); + const response = await client.callTool( + { + name: "inspect_recorded_crash", + arguments: { path: value.artifact.path }, + }, + { toolDefinition: advertised }, + ); expect(response.isError).not.toBe(true); - expect( - ajv.validate(outputSchema, response.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const parsed = toolContract("inspect_recorded_crash").outputSchema.parse( response.structuredContent, ); diff --git a/tests/boundary/mcp/sameFunctionObservation.test.ts b/tests/boundary/mcp/sameFunctionObservation.test.ts index 915c4e5d1a..948d2913a1 100644 --- a/tests/boundary/mcp/sameFunctionObservation.test.ts +++ b/tests/boundary/mcp/sameFunctionObservation.test.ts @@ -1,6 +1,4 @@ import { afterAll, expect, it } from "vitest"; -import { Ajv2020 } from "ajv/dist/2020.js"; -import type { AnySchema } from "ajv"; import type { Client } from "@modelcontextprotocol/client"; import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; @@ -102,21 +100,22 @@ it.each(["full", "compact"] as const)( environment: { REA_MCP_OUTPUT_SCHEMA_PROFILE: profile }, }, }); - const schema = (await client.listTools()).tools.find( + const toolDefinition = (await client.listTools()).tools.find( ({ name }) => name === "compare_functions", - )?.outputSchema; - if (schema === undefined) throw new Error("Missing output schema"); - const reply = await client.callTool({ - name: "compare_functions", - arguments: { left: ref(main.evidence_id), right: ref(main.evidence_id) }, - }); + ); + if (toolDefinition?.outputSchema === undefined) + throw new Error("Missing output schema"); + const reply = await client.callTool( + { + name: "compare_functions", + arguments: { + left: ref(main.evidence_id), + right: ref(main.evidence_id), + }, + }, + { toolDefinition }, + ); expect(reply.isError, JSON.stringify(reply.content)).not.toBe(true); - expect( - new Ajv2020({ strict: false }).validate( - schema as AnySchema, - reply.structuredContent, - ), - ).toBe(true); const result = parseEvidence(reply.structuredContent).normalized_result; expect(result).toMatchObject({ status: "unknown", diff --git a/tests/boundary/mcp/shutdown.test.ts b/tests/boundary/mcp/shutdown.test.ts index 663a27c803..b2b7ff2e38 100644 --- a/tests/boundary/mcp/shutdown.test.ts +++ b/tests/boundary/mcp/shutdown.test.ts @@ -471,7 +471,7 @@ it("does not leave a provider running when a target open is queued behind shutdo }, }); expect(session.activeTarget()).toBeUndefined(); - expect(session.activeBundleResourceRoot()).toBeUndefined(); + expect(await session.status()).toMatchObject({ open: false }); expect(created).toBe(closed); }); diff --git a/tests/boundary/mcp/sourcePublicationContext.test.ts b/tests/boundary/mcp/sourcePublicationContext.test.ts index c7261b661b..d3c40bc65c 100644 --- a/tests/boundary/mcp/sourcePublicationContext.test.ts +++ b/tests/boundary/mcp/sourcePublicationContext.test.ts @@ -126,13 +126,6 @@ it.each( { timeout: 120000, toolDefinition: tool }, ); expect(result.isError, JSON.stringify(result.content)).not.toBe(true); - expect( - validation.assertions.validate( - tool.outputSchema, - result.structuredContent, - ), - JSON.stringify(validation.assertions.errors), - ).toBe(true); expect(result.structuredContent).toMatchObject({ result: { name: { state: "available", value: "entry" }, diff --git a/tests/boundary/mcp/wasmArtifactMcp.test.ts b/tests/boundary/mcp/wasmArtifactMcp.test.ts index dede3d394f..0716f3b637 100644 --- a/tests/boundary/mcp/wasmArtifactMcp.test.ts +++ b/tests/boundary/mcp/wasmArtifactMcp.test.ts @@ -36,6 +36,7 @@ const assertFirstSummaryDelivery = async ( ajv: wasmAjv, inputSchema, outputSchema, + toolDefinition, } = await advertisedMcpToolSchemas( harness.client, "inspect_wasm_artifact", @@ -43,15 +44,11 @@ const assertFirstSummaryDelivery = async ( ); const summaryInput = { path: value.artifact.path, detail: "summary" }; expect(wasmAjv.validate(inputSchema, summaryInput)).toBe(true); - const directSummary = await harness.client.callTool({ - name: "inspect_wasm_artifact", - arguments: summaryInput, - }); + const directSummary = await harness.client.callTool( + { name: "inspect_wasm_artifact", arguments: summaryInput }, + { toolDefinition }, + ); expect(directSummary.isError).not.toBe(true); - expect( - wasmAjv.validate(outputSchema, directSummary.structuredContent), - JSON.stringify(wasmAjv.errors), - ).toBe(true); const summaryEvidence = parseEvidence(directSummary.structuredContent); const summary = wasmArtifactSummaryViewResultSchema.parse( summaryEvidence.normalized_result, @@ -227,14 +224,16 @@ it("records inline WASM evidence with compact inputs and canonical option checks approval: true, }), ).toBe(false); - const response = await client.callTool({ - name: "inspect_wasm_artifact", - arguments: { path: value.artifact.path, command_timeout_ms: 42_000 }, - }); + const response = await client.callTool( + { + name: "inspect_wasm_artifact", + arguments: { path: value.artifact.path, command_timeout_ms: 42_000 }, + }, + { toolDefinition: schemas.toolDefinition }, + ); assertRecordedMcpEvidence( response, "inspect_wasm_artifact", - schemas, session, value, "observed", @@ -549,7 +548,6 @@ it("reads an oversized completed WASM result through its reported retained refer throw new Error("Missing WASM view schemas"); const ajv = schemaValidation.assertions; const input: Record = advertised.inputSchema; - const output: Record = advertised.outputSchema; for (const [view, expected] of [ [ { kind: "summary" }, @@ -581,18 +579,14 @@ it("reads an oversized completed WASM result through its reported retained refer ] as const) { const args = { source: reference, view }; expect(ajv.validate(input, args), JSON.stringify(ajv.errors)).toBe(true); - const selected = await harness.client.callTool({ - name: "inspect_analysis_view", - arguments: args, - }); + const selected = await harness.client.callTool( + { name: "inspect_analysis_view", arguments: args }, + { toolDefinition: advertised }, + ); expect( selected.isError, JSON.stringify(selected.structuredContent), ).not.toBe(true); - expect( - ajv.validate(output, selected.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const evidence = parseEvidence(selected.structuredContent); expect(evidence.confidence).toBe("derived"); expect(evidence.evidence_links).toEqual([reference.evidence_id]); diff --git a/tests/boundary/mcp/webBundleViewsMcp.test.ts b/tests/boundary/mcp/webBundleViewsMcp.test.ts index 978ec282c2..b48f1163b6 100644 --- a/tests/boundary/mcp/webBundleViewsMcp.test.ts +++ b/tests/boundary/mcp/webBundleViewsMcp.test.ts @@ -150,18 +150,18 @@ it("projects web bundle summary views over MCP using retained and inline sources const parent = makeParent(); expect(session.recordEvidence(parent).ok).toBe(true); - const summaryCall = await client.callTool({ - ...schemas.toolDefinition, - arguments: { - source: { kind: "retained-evidence", evidence_id: parent.evidence_id }, - view: { kind: "summary" }, + const summaryCall = await client.callTool( + { + name: schemas.toolDefinition.name, + arguments: { + source: { kind: "retained-evidence", evidence_id: parent.evidence_id }, + view: { kind: "summary" }, + }, }, - }); + { toolDefinition: schemas.toolDefinition }, + ); expect(summaryCall.isError, JSON.stringify(summaryCall)).not.toBe(true); - expect( - schemas.ajv.validate(schemas.outputSchema, summaryCall.structuredContent), - JSON.stringify(schemas.ajv.errors), - ).toBe(true); + const summaryResult = webBundleSummaryViewResultSchema.parse( parseEvidence(summaryCall.structuredContent).normalized_result, ); @@ -173,21 +173,18 @@ it("projects web bundle summary views over MCP using retained and inline sources completeness: { status: "partial" }, }); - const collectionSummaryCall = await client.callTool({ - ...schemas.toolDefinition, - arguments: { - source: { kind: "inline", evidence: parent }, - view: { kind: "web-bundle", collection: "summary" }, + const collectionSummaryCall = await client.callTool( + { + name: schemas.toolDefinition.name, + arguments: { + source: { kind: "inline", evidence: parent }, + view: { kind: "web-bundle", collection: "summary" }, + }, }, - }); + { toolDefinition: schemas.toolDefinition }, + ); expect(collectionSummaryCall.isError).not.toBe(true); - expect( - schemas.ajv.validate( - schemas.outputSchema, - collectionSummaryCall.structuredContent, - ), - JSON.stringify(schemas.ajv.errors), - ).toBe(true); + const collectionSummaryResult = webBundlePageViewResultSchema.parse( parseEvidence(collectionSummaryCall.structuredContent).normalized_result, ); @@ -205,23 +202,23 @@ it("pages web bundle endpoints and inferences over MCP using evidence-file sourc const evidenceFile = join(tempDir, "webbundle-evidence.json"); await writeFile(evidenceFile, JSON.stringify(parent)); - const page1Call = await client.callTool({ - ...schemas.toolDefinition, - arguments: { - source: { kind: "evidence-file", path: evidenceFile }, - view: { - kind: "web-bundle", - collection: "endpoints", - offset: 0, - limit: 2, + const page1Call = await client.callTool( + { + name: schemas.toolDefinition.name, + arguments: { + source: { kind: "evidence-file", path: evidenceFile }, + view: { + kind: "web-bundle", + collection: "endpoints", + offset: 0, + limit: 2, + }, }, }, - }); + { toolDefinition: schemas.toolDefinition }, + ); expect(page1Call.isError).not.toBe(true); - expect( - schemas.ajv.validate(schemas.outputSchema, page1Call.structuredContent), - JSON.stringify(schemas.ajv.errors), - ).toBe(true); + const page1 = webBundlePageViewResultSchema.parse( parseEvidence(page1Call.structuredContent).normalized_result, ); @@ -243,23 +240,23 @@ it("pages web bundle endpoints and inferences over MCP using evidence-file sourc }); expect(page1.completeness.status).toBe("partial"); - const page2Call = await client.callTool({ - ...schemas.toolDefinition, - arguments: { - source: { kind: "evidence-file", path: evidenceFile }, - view: { - kind: "web-bundle", - collection: "endpoints", - offset: 2, - limit: 2, + const page2Call = await client.callTool( + { + name: schemas.toolDefinition.name, + arguments: { + source: { kind: "evidence-file", path: evidenceFile }, + view: { + kind: "web-bundle", + collection: "endpoints", + offset: 2, + limit: 2, + }, }, }, - }); + { toolDefinition: schemas.toolDefinition }, + ); expect(page2Call.isError).not.toBe(true); - expect( - schemas.ajv.validate(schemas.outputSchema, page2Call.structuredContent), - JSON.stringify(schemas.ajv.errors), - ).toBe(true); + const page2 = webBundlePageViewResultSchema.parse( parseEvidence(page2Call.structuredContent).normalized_result, ); @@ -276,25 +273,22 @@ it("pages web bundle endpoints and inferences over MCP using evidence-file sourc exhausted: true, }); - const candidatesCall = await client.callTool({ - ...schemas.toolDefinition, - arguments: { - source: { kind: "evidence-file", path: evidenceFile }, - view: { - kind: "web-bundle", - collection: "inferences", - kinds: ["endpoint_candidate"], + const candidatesCall = await client.callTool( + { + name: schemas.toolDefinition.name, + arguments: { + source: { kind: "evidence-file", path: evidenceFile }, + view: { + kind: "web-bundle", + collection: "inferences", + kinds: ["endpoint_candidate"], + }, }, }, - }); + { toolDefinition: schemas.toolDefinition }, + ); expect(candidatesCall.isError).not.toBe(true); - expect( - schemas.ajv.validate( - schemas.outputSchema, - candidatesCall.structuredContent, - ), - JSON.stringify(schemas.ajv.errors), - ).toBe(true); + const candidatesPage = webBundlePageViewResultSchema.parse( parseEvidence(candidatesCall.structuredContent).normalized_result, ); @@ -307,23 +301,23 @@ it("pages web bundle endpoints and inferences over MCP using evidence-file sourc }), ]); - const emptyCall = await client.callTool({ - ...schemas.toolDefinition, - arguments: { - source: { kind: "evidence-file", path: evidenceFile }, - view: { - kind: "web-bundle", - collection: "endpoints", - offset: 100, - limit: 10, + const emptyCall = await client.callTool( + { + name: schemas.toolDefinition.name, + arguments: { + source: { kind: "evidence-file", path: evidenceFile }, + view: { + kind: "web-bundle", + collection: "endpoints", + offset: 100, + limit: 10, + }, }, }, - }); + { toolDefinition: schemas.toolDefinition }, + ); expect(emptyCall.isError).not.toBe(true); - expect( - schemas.ajv.validate(schemas.outputSchema, emptyCall.structuredContent), - JSON.stringify(schemas.ajv.errors), - ).toBe(true); + const emptyPage = webBundlePageViewResultSchema.parse( parseEvidence(emptyCall.structuredContent).normalized_result, ); diff --git a/tests/boundary/mcp/webModuleTraceMcp.test.ts b/tests/boundary/mcp/webModuleTraceMcp.test.ts index 1707726c6d..8f8c30f260 100644 --- a/tests/boundary/mcp/webModuleTraceMcp.test.ts +++ b/tests/boundary/mcp/webModuleTraceMcp.test.ts @@ -52,18 +52,13 @@ it("publishes valid SDK schemas and records the named module trace without a bin throw new Error("module trace must advertise both schemas"); const ajv = new Ajv2020({ strict: false, validateFormats: false }); const inputSchema: Record = advertised.inputSchema; - const outputSchema: Record = advertised.outputSchema; const args = { manifest_path: "/analysis/manifest.json", script_index: 0 }; expect(ajv.validate(inputSchema, args)).toBe(true); - const response = await client.callTool({ - name: "trace_web_module_imports", - arguments: args, - }); + const response = await client.callTool( + { name: "trace_web_module_imports", arguments: args }, + { toolDefinition: advertised }, + ); expect(response.isError).not.toBe(true); - expect( - ajv.validate(outputSchema, response.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const parsed = toolContract("trace_web_module_imports").outputSchema.parse( response.structuredContent, ); diff --git a/tests/boundary/mcp/webNetworkCaptureMcp.test.ts b/tests/boundary/mcp/webNetworkCaptureMcp.test.ts index 51c9d47c4a..514c2d372c 100644 --- a/tests/boundary/mcp/webNetworkCaptureMcp.test.ts +++ b/tests/boundary/mcp/webNetworkCaptureMcp.test.ts @@ -69,15 +69,11 @@ it("publishes valid SDK schemas and retains historical inline Evidence with the expect(ajv.validateSchema(outputSchema)).toBe(true); const args = { capture_path: "/capture.har", format: "har" }; expect(ajv.validate(inputSchema, args)).toBe(true); - const response = await client.callTool({ - name: "inspect_web_network_capture", - arguments: args, - }); + const response = await client.callTool( + { name: "inspect_web_network_capture", arguments: args }, + { toolDefinition: advertised }, + ); expect(response.isError).not.toBe(true); - expect( - ajv.validate(outputSchema, response.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const parsed = toolContract("inspect_web_network_capture").outputSchema.parse( response.structuredContent, ); diff --git a/tests/boundary/mcp/webRuntimeMcp.test.ts b/tests/boundary/mcp/webRuntimeMcp.test.ts index 7f66b2eba9..9000da2b66 100644 --- a/tests/boundary/mcp/webRuntimeMcp.test.ts +++ b/tests/boundary/mcp/webRuntimeMcp.test.ts @@ -71,12 +71,11 @@ it("advertises actual SDK schemas, instrumentation effects and session-owned inl selector: "#selected", }; expect(ajv.validate(inputSchema, args)).toBe(true); - const response = await client.callTool({ name, arguments: args }); + const response = await client.callTool( + { name, arguments: args }, + { toolDefinition: advertised }, + ); expect(response.isError).not.toBe(true); - expect( - ajv.validate(outputSchema, response.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const parsed = toolContract(name).outputSchema.parse( response.structuredContent, ); @@ -178,7 +177,7 @@ cliTest( ], environment: process.env, }); - expect(cliResult.exitCode).toBe(0); + expect(cliResult.exitCode, cliResult.stderr).toBe(0); expect(cliResult.signal).toBeNull(); expect(parseEvidence(cliResult.json).normalized_result).toMatchObject({ target: { @@ -219,25 +218,22 @@ cliTest( await client.connect(clientTransport); const advertised = (await client.listTools()).tools.find( ({ name }) => name === "inspect_web_event_listeners", - )?.outputSchema; - if (advertised === undefined) + ); + if (advertised?.outputSchema === undefined) throw new Error("Missing advertised listener output schema"); - const outputSchema: Record = advertised; - const response = await client.callTool({ - name: "inspect_web_event_listeners", - arguments: { - cdp_endpoint: browser.endpoint, - target_id: "allowed-page", - allowed_origins: [browser.allowedOrigin], - selector: "#selected", + const response = await client.callTool( + { + name: "inspect_web_event_listeners", + arguments: { + cdp_endpoint: browser.endpoint, + target_id: "allowed-page", + allowed_origins: [browser.allowedOrigin], + selector: "#selected", + }, }, - }); + { toolDefinition: advertised }, + ); expect(response.isError).not.toBe(true); - const ajv = schemaValidation.assertions; - expect( - ajv.validate(outputSchema, response.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const cliResult = await cli.run({ arguments: [ "inspect-web-event-listeners", @@ -250,7 +246,7 @@ cliTest( ], environment: process.env, }); - expect(cliResult.exitCode).toBe(0); + expect(cliResult.exitCode, cliResult.stderr).toBe(0); for (const evidence of [ toolContract("inspect_web_event_listeners").outputSchema.parse( response.structuredContent, diff --git a/tests/boundary/mcp/webSourceLocationMcp.test.ts b/tests/boundary/mcp/webSourceLocationMcp.test.ts index 98828cc4e6..a46fb994f4 100644 --- a/tests/boundary/mcp/webSourceLocationMcp.test.ts +++ b/tests/boundary/mcp/webSourceLocationMcp.test.ts @@ -54,17 +54,12 @@ it("publishes valid SDK schemas and records the named module trace without a bin throw new Error("module trace must advertise both schemas"); const ajv = new Ajv2020({ strict: false, validateFormats: false }); const inputSchema: Record = advertised.inputSchema; - const outputSchema: Record = advertised.outputSchema; expect(ajv.validate(inputSchema, args)).toBe(true); - const response = await client.callTool({ - name: "trace_web_source_location", - arguments: args, - }); + const response = await client.callTool( + { name: "trace_web_source_location", arguments: args }, + { toolDefinition: advertised }, + ); expect(response.isError).not.toBe(true); - expect( - ajv.validate(outputSchema, response.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const parsed = toolContract("trace_web_source_location").outputSchema.parse( response.structuredContent, ); diff --git a/tests/boundary/mcp/windowsShortcutMcp.test.ts b/tests/boundary/mcp/windowsShortcutMcp.test.ts index dc2fbc05b9..a4eaf83080 100644 --- a/tests/boundary/mcp/windowsShortcutMcp.test.ts +++ b/tests/boundary/mcp/windowsShortcutMcp.test.ts @@ -29,11 +29,10 @@ mcpTest( createServer({ kind: "session", session }), ); try { - const { - toolDefinition: advertised, - ajv, - outputSchema, - } = await advertisedMcpToolSchemas(client, "inspect_windows_shortcut"); + const { toolDefinition: advertised } = await advertisedMcpToolSchemas( + client, + "inspect_windows_shortcut", + ); expect(advertised?.annotations).toMatchObject({ readOnlyHint: false, idempotentHint: true, @@ -41,15 +40,11 @@ mcpTest( const root = await createTestTempDirectory("rea-windows-shortcut-mcp-"); const path = join(root, "shortcut.lnk"); await writeFile(path, powershellHiddenIconSpoofShortcut()); - const response = await client.callTool({ - name: "inspect_windows_shortcut", - arguments: { path }, - }); + const response = await client.callTool( + { name: "inspect_windows_shortcut", arguments: { path } }, + { toolDefinition: advertised }, + ); expect(response.isError, JSON.stringify(response.content)).not.toBe(true); - expect( - ajv.validate(outputSchema, response.structuredContent), - JSON.stringify(ajv.errors), - ).toBe(true); const evidence = shortcutContract.outputSchema.parse( response.structuredContent, ); diff --git a/tests/boundary/pcap/pcapCommand.test.ts b/tests/boundary/pcap/pcapCommand.test.ts index 30aeaf9bce..f165b1caf6 100644 --- a/tests/boundary/pcap/pcapCommand.test.ts +++ b/tests/boundary/pcap/pcapCommand.test.ts @@ -18,10 +18,9 @@ it.skipIf(process.platform !== "darwin")( const controller = new AbortController(); const reason = new ProviderCleanupError("caller", ["foreign-resource"], {}); const prepare = vi - .spyOn(ownership, "prepareProcessOwnershipInspection") - .mockImplementationOnce(async (signal) => { + .spyOn(ownership.systemProcessOwnershipHost, "prepare") + .mockImplementationOnce(async () => { controller.abort(reason); - signal?.throwIfAborted(); }); onTestFinished(() => prepare.mockRestore()); const launcher = vi.fn(async () => { diff --git a/tests/boundary/process/processCaptureProgress.test.ts b/tests/boundary/process/processCaptureProgress.test.ts index c127d90c5c..ad41e7eb02 100644 --- a/tests/boundary/process/processCaptureProgress.test.ts +++ b/tests/boundary/process/processCaptureProgress.test.ts @@ -335,19 +335,16 @@ itWithCaptureCapability( code: "process_capture_failed", details: { execution_failure: expect.stringContaining(missingTempRoot) }, }); - // With caller-owned compilation, Darwin fails preparing its inspector - // before acquiring capture resources. - // Other hosts reach the capture root allocation, whose failure is accounted. - const cleanupState = - process.platform === "darwin" ? "unverified" : "not_required"; + // Inspector preparation or temporary-root allocation failed before any + // capture resources were acquired, so no capture cleanup is required. expect(capture.lines.at(-1)?.update).toMatchObject({ phase: "cleanup", terminal: true, details: { disposition: "failed", - owned_process_group: cleanupState, - terminal_renderer: cleanupState, - temporary_root: cleanupState, + owned_process_group: "not_required", + terminal_renderer: "not_required", + temporary_root: "not_required", }, }); }, diff --git a/tests/boundary/process/providerProcess.test.ts b/tests/boundary/process/providerProcess.test.ts index 38c6da05ac..414fdf5052 100644 --- a/tests/boundary/process/providerProcess.test.ts +++ b/tests/boundary/process/providerProcess.test.ts @@ -64,7 +64,6 @@ describe("provider process runtime and wait primitives", () => { const first = runtime.close(); const second = runtime.close(); - expect(second).toBe(first); await Promise.all([first, second]); await expect(access(runtime.path)).rejects.toMatchObject({ code: "ENOENT", @@ -283,6 +282,7 @@ describe("provider process output and cleanup primitives", () => { ownsProcessLifetime: true, }); await waitForProviderProcessReady(child); + const signal = vi.spyOn(child, "kill"); const first = supervisor.stop({ terminationGraceMs: 20, @@ -292,11 +292,13 @@ describe("provider process output and cleanup primitives", () => { terminationGraceMs: 20, killGraceMs: 500, }); - // Concurrent callers must share one escalation instead of signalling - // the same child twice. - expect(second).toBe(first); - await expect(first).resolves.toEqual({ status: "killed" }); + // Both callers observe the completed escalation. + await expect(Promise.all([first, second])).resolves.toEqual([ + { status: "killed" }, + { status: "killed" }, + ]); expect(child.signalCode).toBe("SIGKILL"); + expect(signal.mock.calls).toEqual([["SIGTERM"], ["SIGKILL"]]); }, ); diff --git a/tests/boundary/process/regexMatcher.test.ts b/tests/boundary/process/regexMatcher.test.ts index dc628aeadf..4535a7a2e7 100644 --- a/tests/boundary/process/regexMatcher.test.ts +++ b/tests/boundary/process/regexMatcher.test.ts @@ -18,7 +18,6 @@ import { matchRegexValues, REGEX_MATCHING_LIMITATION, regexMatchingDeadlineError, - regexMatchingLimitation, regexWorkerHeapError, execArgvFromCommandLine, resolveLaunchRegexWorkerHeap, @@ -73,7 +72,6 @@ it("names the configurable regex deadline and the effective worker heap", () => readLaunchExecArgv(), readLaunchNodeOptions(), ); - expect(REGEX_MATCHING_LIMITATION).toBe(regexMatchingLimitation(heap)); expect(REGEX_MATCHING_LIMITATION).toMatch( /5000 ms matching deadline \(source default\)/iu, ); diff --git a/tests/boundary/process/verifierCommandSemantics.test.ts b/tests/boundary/process/verifierCommandSemantics.test.ts index dbeedc9523..c213b36a47 100644 --- a/tests/boundary/process/verifierCommandSemantics.test.ts +++ b/tests/boundary/process/verifierCommandSemantics.test.ts @@ -444,7 +444,6 @@ it.skipIf(process.platform === "win32")( throw new Error("Expected the exact acquired process owner"); controller.abort(reason); const closing = commands.close(); - expect(commands.close()).toBe(closing); let closeSettled = false; const closeOutcome = observed(closing).then((outcome) => { closeSettled = true; diff --git a/tests/boundary/providers/CutterBridgeClient.test.ts b/tests/boundary/providers/CutterBridgeClient.test.ts index c4be289712..81334c929d 100644 --- a/tests/boundary/providers/CutterBridgeClient.test.ts +++ b/tests/boundary/providers/CutterBridgeClient.test.ts @@ -507,13 +507,10 @@ describe("CutterBridgeClient: scenario group 2", () => { ); const sessionId = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a"; const token = "synthetic-cutter-bridge-token-for-test"; - server = createServer((connection) => { - connection.setEncoding("utf8"); - connection.once("data", () => - connection.end( - `${JSON.stringify({ ok: true, session_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" })}\n`, - ), - ); + server = statusServer(token, null, 0); + let connections = 0; + server.on("connection", () => { + connections += 1; }); const port = await listen(server); await writeDescriptor(directory, sessionId, port, token); @@ -565,16 +562,12 @@ describe("CutterBridgeClient: scenario group 2", () => { ).toBe(0o600); } } - // Parsing and filename identity reject only the malformed candidates; - // the authenticated candidate remains discoverable before status checking. - expect( - (await client.listDescriptors()).map(({ session_id }) => session_id), - ).toEqual([sessionId]); const result = await client.listSessions(); expect(result).toMatchObject({ - discovery_status: "no_live_bridge_found", - sessions: [], + discovery_status: "sessions_found", + sessions: [{ session_id: sessionId }], }); + expect(connections).toBe(1); }); it("ignores a descriptor whose status endpoint is unavailable", async () => { @@ -1052,14 +1045,16 @@ describe("CutterBridgeClient: scenario group 6", () => { if (process.platform === "win32") return; directory = await mkdtemp(join(tmpdir(), "rea-cutter-unsafe-descriptor-")); const sessionId = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a"; + const token = "synthetic-cutter-bridge-token-for-test"; + server = statusServer(token, null, 0); + let connections = 0; + server.on("connection", () => { + connections += 1; + }); + const port = await listen(server); const name = `cutter-${process.pid}-${sessionId}.json`; const target = join(directory, "target.json"); - await writeDescriptor( - directory, - sessionId, - 1234, - "synthetic-cutter-bridge-token-for-test", - ); + await writeDescriptor(directory, sessionId, port, token); await writeFile(target, await readFile(join(directory, name)), { mode: 0o600, }); @@ -1067,15 +1062,36 @@ describe("CutterBridgeClient: scenario group 6", () => { await expect( new CutterBridgeClient({ REA_CUTTER_BRIDGE_DIR: directory, - }).listDescriptors(), - ).resolves.toEqual([]); + }).listSessions(), + ).resolves.toMatchObject({ + sessions: [], + discovery_status: "no_live_bridge_found", + }); + expect(connections).toBe(0); await rm(join(directory, name)); await symlink(target, join(directory, name)); await expect( new CutterBridgeClient({ REA_CUTTER_BRIDGE_DIR: directory, - }).listDescriptors(), - ).resolves.toEqual([]); + }).listSessions(), + ).resolves.toMatchObject({ + sessions: [], + discovery_status: "no_live_bridge_found", + }); + expect(connections).toBe(0); + await rm(join(directory, name)); + await writeFile(join(directory, name), await readFile(target), { + mode: 0o600, + }); + await expect( + new CutterBridgeClient({ + REA_CUTTER_BRIDGE_DIR: directory, + }).listSessions(), + ).resolves.toMatchObject({ + sessions: [{ session_id: sessionId }], + discovery_status: "sessions_found", + }); + expect(connections).toBe(1); }); }); @@ -1126,12 +1142,10 @@ describe("CutterBridgeClient: scenario group 7", () => { it("supports platforms without O_NOFOLLOW using the documented read-only flags", async () => { directory = await mkdtemp(join(tmpdir(), "rea-cutter-no-nofollow-")); const sessionId = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a"; - await writeDescriptor( - directory, - sessionId, - 1234, - "synthetic-cutter-bridge-token-for-test", - ); + const token = "synthetic-cutter-bridge-token-for-test"; + server = statusServer(token, null, 0); + const port = await listen(server); + await writeDescriptor(directory, sessionId, port, token); await vi.resetModules(); vi.doMock("node:fs", async () => { const real = await vi.importActual("node:fs"); @@ -1146,8 +1160,11 @@ describe("CutterBridgeClient: scenario group 7", () => { await expect( new NoNoFollowClient({ REA_CUTTER_BRIDGE_DIR: directory, - }).listDescriptors(), - ).resolves.toMatchObject([{ session_id: sessionId }]); + }).listSessions(), + ).resolves.toMatchObject({ + sessions: [{ session_id: sessionId }], + discovery_status: "sessions_found", + }); } finally { vi.doUnmock("node:fs"); await vi.resetModules(); @@ -1157,11 +1174,15 @@ describe("CutterBridgeClient: scenario group 7", () => { describe("CutterBridgeClient: conservative platform checks", () => { let directory: string | undefined; + let server: ReturnType | undefined; afterEach(async () => { + if (server !== undefined) + await new Promise((resolve) => server?.close(() => resolve())); if (directory !== undefined) await rm(directory, { recursive: true, force: true }); directory = undefined; + server = undefined; }); it("uses conservative platform and Windows descriptor checks", async () => { @@ -1183,12 +1204,14 @@ describe("CutterBridgeClient: conservative platform checks", () => { getuid.mockRestore(); } const sessionId = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a"; - await writeDescriptor( - directory, - sessionId, - 1234, - "synthetic-cutter-bridge-token-for-test", - ); + const token = "synthetic-cutter-bridge-token-for-test"; + server = statusServer(token, null, 0); + let connections = 0; + server.on("connection", () => { + connections += 1; + }); + const port = await listen(server); + await writeDescriptor(directory, sessionId, port, token); const result = await new CutterBridgeClient( { REA_CUTTER_BRIDGE_DIR: directory }, { @@ -1198,8 +1221,27 @@ describe("CutterBridgeClient: conservative platform checks", () => { readDescriptor: () => Buffer.alloc(64 * 1024 + 1), }, }, - ).listDescriptors(); - expect(result).toEqual([]); + ).listSessions(); + expect(result).toMatchObject({ + sessions: [], + discovery_status: "no_live_bridge_found", + }); + expect(connections).toBe(0); + const valid = await new CutterBridgeClient( + { REA_CUTTER_BRIDGE_DIR: directory }, + { + platform: "win32", + windowsPrivateReader: { + verifyDirectory: () => true, + readDescriptor: (root, name) => readFileSync(join(root, name)), + }, + }, + ).listSessions(); + expect(valid).toMatchObject({ + sessions: [{ session_id: sessionId }], + discovery_status: "sessions_found", + }); + expect(connections).toBe(1); await expect( new CutterBridgeClient( { LOCALAPPDATA: "C:\\Users\\fixture\\AppData\\Local" }, @@ -1333,13 +1375,16 @@ describe("CutterBridgeClient: bridge paths and native descriptor reads", () => { it("uses the packaged Windows authority for descriptor verification and reads", async () => { directory = await mkdtemp(join(tmpdir(), "rea-cutter-native-reader-")); const sessionId = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a"; + const token = "synthetic-cutter-bridge-token-for-test"; + server = statusServer(token, null, 0); + const port = await listen(server); const entry = `cutter-${process.pid}-${sessionId}.json`; const descriptor = { session_id: sessionId, pid: process.pid, host: "127.0.0.1", - port: 1234, - token: "synthetic-cutter-bridge-token-for-test", + port, + token, document_generation: 0, current_file: null, cutter_version: null, @@ -1375,9 +1420,10 @@ describe("CutterBridgeClient: bridge paths and native descriptor reads", () => { bridge_directory_security: "unverified_platform_acl", }); failVerification = false; - await expect(client.listDescriptors()).resolves.toMatchObject([ - { session_id: sessionId, pid: process.pid }, - ]); + await expect(client.listSessions()).resolves.toMatchObject({ + sessions: [{ session_id: sessionId, pid: process.pid }], + discovery_status: "sessions_found", + }); } finally { vi.doUnmock("../../../src/windows/WindowsNativeLoader.js"); await vi.resetModules(); diff --git a/tests/boundary/providers/GdbSessionManagerCoverage.test.ts b/tests/boundary/providers/GdbSessionManagerCoverage.test.ts index 11fa9ffd1c..5df9028c8d 100644 --- a/tests/boundary/providers/GdbSessionManagerCoverage.test.ts +++ b/tests/boundary/providers/GdbSessionManagerCoverage.test.ts @@ -14,7 +14,7 @@ import { import { commandMayChangeInferiorOutput, gdbOneShotCommandMayInvalidateDetachProof, - gdbThreadGroupCount, + gdbThreadGroupPids, GdbSessionManager, miStringField, } from "../../../src/gdb/GdbSessionManager.js"; @@ -114,13 +114,21 @@ it.each([ undefined, ], ] as const)("parses recursive GDB MI groups: %s", (_label, mi, expected) => { - expect(gdbThreadGroupCount(mi)).toBe(expected); + expect(gdbThreadGroupPids(mi)?.length).toBe(expected); +}); + +it("preserves the reported inferior PIDs in inventory order", () => { + expect( + gdbThreadGroupPids( + '1^done,groups=[{id="i1",pid="123"},{id="i2",pid="456"}]', + ), + ).toEqual(["123", "456"]); }); it("keeps GDB inventory unknown when MI nesting exceeds the parser frame budget", () => { const nestedValue = "[".repeat(257) + "]".repeat(257); expect( - gdbThreadGroupCount("1^done,groups=[],extension=" + nestedValue), + gdbThreadGroupPids("1^done,groups=[],extension=" + nestedValue), ).toBeUndefined(); }); diff --git a/tests/boundary/providers/cutterBridgeRequestLimit.test.ts b/tests/boundary/providers/cutterBridgeRequestLimit.test.ts index 0de0ef406d..34a2e9e55e 100644 --- a/tests/boundary/providers/cutterBridgeRequestLimit.test.ts +++ b/tests/boundary/providers/cutterBridgeRequestLimit.test.ts @@ -175,7 +175,23 @@ it.each([false, true])( async (malformed) => { const directory = await privateDirectory(); const currentFile = "/selected/\uFFFD"; - await writeDescriptor(directory, SESSION, 9, TOKEN, { currentFile }); + let connections = 0; + const server = authenticatedServer( + TOKEN, + SESSION, + (request, respond) => { + respond({ ok: true, session_id: request.session_id }); + }, + true, + ); + server.on("connection", () => { + connections += 1; + }); + const port = await listen(server); + await writeDescriptor(directory, SESSION, port, TOKEN, { + currentFile, + maxRequestBytes: 8192, + }); const path = join(directory, `cutter-${process.pid}-${SESSION}.json`); if (malformed) { const bytes = await readFile(path); @@ -192,9 +208,13 @@ it.each([false, true])( ); } const client = clientFor(directory, "8192"); - const descriptors = await client.listDescriptors(); - expect(descriptors).toHaveLength(malformed ? 0 : 1); - if (!malformed) expect(descriptors[0]?.current_file).toBe(currentFile); + const result = await client.listSessions(); + expect(result.discovery_status).toBe( + malformed ? "no_live_bridge_found" : "sessions_found", + ); + expect(result.sessions).toHaveLength(malformed ? 0 : 1); + if (!malformed) expect(result.sessions[0]?.current_file).toBe(currentFile); + expect(connections).toBe(malformed ? 0 : 1); }, ); diff --git a/tests/boundary/providers/native/nativeCallObservation.test.ts b/tests/boundary/providers/native/nativeCallObservation.test.ts index 4447396d79..efd6999f7e 100644 --- a/tests/boundary/providers/native/nativeCallObservation.test.ts +++ b/tests/boundary/providers/native/nativeCallObservation.test.ts @@ -13,7 +13,6 @@ import type { AnalysisError } from "../../../../src/domain/analysisErrorBase.js" import { EvidenceIntegrityError } from "../../../../src/domain/evidenceErrors.js"; import { nativeCallPartialObservationSchema } from "../../../../src/domain/native/nativeCallPartialObservation.js"; import { nativeCallObservationResultSchema } from "../../../../src/domain/native/nativeCallObservation.js"; -import { NATIVE_CALL_BREAKPOINT_LOCATION_LIST_INCOMPLETE_LIMITATION } from "../../../../src/native/NativeCallObservation.js"; import { err, ok, type Result } from "../../../../src/domain/result.js"; import type { NativeCallTrace, @@ -295,8 +294,12 @@ describe("observe_native_calls projection: keeps locations within the metadata b const result = nativeCallObservationResultSchema.parse( observed.value.result, ); - expect(result.limitations).toContain( - NATIVE_CALL_BREAKPOINT_LOCATION_LIST_INCOMPLETE_LIMITATION, + expect(result.limitations).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /breakpoint location list is incomplete.*location_count.*full matching count/iu, + ), + ]), ); }); }); @@ -352,8 +355,12 @@ describe("observe_native_calls projection: bounded breakpoint metadata", () => { "capture_limits.breakpoint_location_bytes (1024 bytes)", ), ); - expect(result.limitations).not.toContain( - NATIVE_CALL_BREAKPOINT_LOCATION_LIST_INCOMPLETE_LIMITATION, + expect(result.limitations).not.toEqual( + expect.arrayContaining([ + expect.stringMatching( + /breakpoint location list is incomplete.*location_count.*full matching count/iu, + ), + ]), ); expect(result.limitations.join(" ")).not.toContain("process was killed"); }); diff --git a/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts b/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts index 76fda7c2b4..618cef76dd 100644 --- a/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts +++ b/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts @@ -459,7 +459,10 @@ describe("native DMG directory ownership", () => { await expect(realpath(mount.root() ?? "")).rejects.toMatchObject({ code: "ENOENT", }); - expect((await iterator.next()).done).toBe(true); + await expect(iterator.next()).rejects.toMatchObject({ + reason: "unavailable", + message: "DMG reader is closed", + }); }); }); @@ -517,7 +520,10 @@ describe("native DMG traversal shutdown", () => { await Promise.resolve(); expect(state.detachAttempts).toBe(4); releaseAdvance(); - expect((await advancing).done).toBe(true); + await expect(advancing).rejects.toMatchObject({ + reason: "unavailable", + message: "DMG reader is closed", + }); await closing; expect(state.detachAttempts).toBe(5); await expect(realpath(state.mountRoot ?? "")).rejects.toMatchObject({ diff --git a/tests/boundary/providers/native/nativeProvider.test.ts b/tests/boundary/providers/native/nativeProvider.test.ts index f885eb2d3b..ba5886b9f8 100644 --- a/tests/boundary/providers/native/nativeProvider.test.ts +++ b/tests/boundary/providers/native/nativeProvider.test.ts @@ -24,7 +24,7 @@ import { type NativeCommandRunner, } from "../../../../src/native/CommandRunner.js"; import { err, ok } from "../../../../src/domain/result.js"; -import { parseLipoOutput } from "../../../../src/native/parsers/lipo.js"; +import { parseLipoFixture } from "../../../fixtures/lipo.js"; import { dylibCommand, LC, @@ -1789,11 +1789,11 @@ describe("native macOS provider failures and parsing", () => { }); it("parses thin and universal lipo fixtures", async () => { - expect(parseLipoOutput(await fixture("lipo-thin.txt"))).toMatchObject({ + expect(parseLipoFixture(await fixture("lipo-thin.txt"))).toMatchObject({ format: "thin", architectures: [{ name: "arm64" }], }); - expect(parseLipoOutput(await fixture("lipo-fat.txt"))).toMatchObject({ + expect(parseLipoFixture(await fixture("lipo-fat.txt"))).toMatchObject({ format: "fat", architectures: [ { name: "x86_64", file_offset: 16384, size: 4096 }, @@ -1811,7 +1811,7 @@ describe("native macOS provider failures and parsing", () => { " size 0x10", " align 2^14 (16384)", ].join("\n"); - expect(parseLipoOutput(output).architectures).toMatchObject([ + expect(parseLipoFixture(output).architectures).toMatchObject([ { name: "arm64", file_offset: null, size: null, alignment: 16384 }, ]); }); @@ -1824,11 +1824,11 @@ describe("native macOS provider failures and parsing", () => { " align 2^14 (16384)", ].join("\n"); expect( - parseLipoOutput(output.replace("(16384)", "(8192)")).architectures[0] + parseLipoFixture(output.replace("(16384)", "(8192)")).architectures[0] ?.alignment, ).toBeNull(); expect( - parseLipoOutput(output.replace("2^14 (16384)", "2^14")).architectures[0] + parseLipoFixture(output.replace("2^14 (16384)", "2^14")).architectures[0] ?.alignment, ).toBe(16384); }); diff --git a/tests/boundary/providers/native/nativeTextRobustness.test.ts b/tests/boundary/providers/native/nativeTextRobustness.test.ts index c1ee638e81..f4349e6f3c 100644 --- a/tests/boundary/providers/native/nativeTextRobustness.test.ts +++ b/tests/boundary/providers/native/nativeTextRobustness.test.ts @@ -1,7 +1,7 @@ import { expect, it } from "vitest"; import { readPlistJson } from "../../../../src/native/parsers/plist.js"; -import { parseLipoOutput } from "../../../../src/native/parsers/lipo.js"; +import { parseLipoOutputResult } from "../../../../src/native/parsers/lipo.js"; it.each([ "line\nbreak", @@ -11,9 +11,9 @@ it.each([ "retains the reported thin architecture when lipo echoes filename %j", (filename) => { const output = `input file /owned/${filename} is not a fat file\nNon-fat file: /owned/${filename} is architecture: arm64\n`; - expect(parseLipoOutput(output)).toMatchObject({ - format: "thin", - architectures: [{ name: "arm64" }], + expect(parseLipoOutputResult(output)).toMatchObject({ + ok: true, + value: { format: "thin", architectures: [{ name: "arm64" }] }, }); }, ); @@ -37,12 +37,15 @@ it("does not mistake fat slice output for a thin Non-fat file record", () => { " align 2^12 (4096)", "", ].join("\n"); - expect(parseLipoOutput(output)).toMatchObject({ - format: "fat", - architectures: [ - { name: "arm64", file_offset: 16384 }, - { name: "x86_64", file_offset: 16793600 }, - ], + expect(parseLipoOutputResult(output)).toMatchObject({ + ok: true, + value: { + format: "fat", + architectures: [ + { name: "arm64", file_offset: 16384 }, + { name: "x86_64", file_offset: 16793600 }, + ], + }, }); }); diff --git a/tests/composition/reverseProviderLaunchErrors.test.ts b/tests/composition/reverseProviderLaunchErrors.test.ts index 73ecd8abf6..1cd768efa7 100644 --- a/tests/composition/reverseProviderLaunchErrors.test.ts +++ b/tests/composition/reverseProviderLaunchErrors.test.ts @@ -1,6 +1,7 @@ import { rm } from "node:fs/promises"; import { resolve } from "node:path"; -import { expect, it, vi } from "vitest"; +import { expect, it, onTestFinished, vi } from "vitest"; +import * as processOutput from "../../src/process/ExecFileOutput.js"; import { OwnedCommandFailure } from "../../src/process/OwnedCommand.js"; import { DarwinProcessOwnershipInspectionError, @@ -799,15 +800,13 @@ it("restricts Linux process ownership prerequisite remediation to missing ps and }); let currentError: Error = eioError; - const host = createSystemProcessOwnershipHost( - "linux", - {}, - { - execFileOutput: async () => { - throw currentError; - }, - }, - ); + const command = vi + .spyOn(processOutput, "execFileOutput") + .mockImplementation(async () => { + throw currentError; + }); + onTestFinished(() => command.mockRestore()); + const host = createSystemProcessOwnershipHost("linux", {}); currentError = eioError; const eioFailure = (await host @@ -871,18 +870,13 @@ it("restricts Linux process ownership prerequisite remediation to missing ps and }); expect(noCodeFailure.diagnostics).not.toHaveProperty("system_code"); - const emptyTableHost = createSystemProcessOwnershipHost( - "linux", - {}, - { - execFileOutput: async () => ({ - stdout: " 1 0 1 0 Ss /sbin/init\n", - stderr: "", - stdoutBytes: 40, - stderrBytes: 0, - }), - }, - ); + command.mockResolvedValue({ + stdout: " 1 0 1 0 Ss /sbin/init\n", + stderr: "", + stdoutBytes: 40, + stderrBytes: 0, + }); + const emptyTableHost = createSystemProcessOwnershipHost("linux", {}); const unreportedFailure = (await emptyTableHost .prepare?.() .catch((error: unknown) => error)) as LinuxOwnedProcessPrerequisiteError; diff --git a/tests/conformance/package/environmentBundlePackagePin.test.ts b/tests/conformance/package/environmentBundlePackagePin.test.ts index a75e324cf8..2cbecdfead 100644 --- a/tests/conformance/package/environmentBundlePackagePin.test.ts +++ b/tests/conformance/package/environmentBundlePackagePin.test.ts @@ -4,12 +4,31 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; import { - bundleDockerfile, + planEnvironmentBundle, registryPackageSource, } from "../../../src/application/environment/environmentBundle.js"; +import { + environmentServeFixture, + environmentServeServed, +} from "../../fixtures/environmentServe.js"; import { PRODUCT_IDENTITY } from "../../../src/identity.js"; import { supportsNodeVersion } from "../../../src/domain/runtimeVersion.js"; +const plannedDockerfile = (): string => { + const { source, served } = environmentServeServed(environmentServeFixture()); + const planned = planEnvironmentBundle(source, { + packageSource: registryPackageSource(), + served: () => served, + }); + if (!planned.ok) throw planned.error; + const dockerfile = planned.value.files.find(({ path }) => + path.endsWith("/Dockerfile"), + ); + if (dockerfile === undefined) + throw new Error("Expected a planned Dockerfile"); + return dockerfile.content; +}; + /** * The exported Dockerfile installs the CLI that produced the bundle, so the * pin must be the exact package version of this build, read from the package @@ -20,7 +39,7 @@ describe("environment bundle package pin", () => { const pinned = ( await readFile(new URL("../../../.nvmrc", import.meta.url), "utf8") ).trim(); - const dockerfile = bundleDockerfile(registryPackageSource()); + const dockerfile = plannedDockerfile(); const nodeVersion = /^FROM node:(\d+\.\d+\.\d+)(?:[-@][^\s]+)?$/u.exec( dockerfile.split("\n")[0] ?? "", )?.[1]; @@ -39,7 +58,7 @@ describe("environment bundle package pin", () => { ), ), ); - const dockerfile = bundleDockerfile(registryPackageSource()); + const dockerfile = plannedDockerfile(); const install = dockerfile .split("\n") .find((line) => line.startsWith("RUN npm install -g")); diff --git a/tests/evaluation/numericLimitCheck.test.ts b/tests/evaluation/numericLimitCheck.test.ts index 09fd0e54c5..96764d0dcf 100644 --- a/tests/evaluation/numericLimitCheck.test.ts +++ b/tests/evaluation/numericLimitCheck.test.ts @@ -265,6 +265,26 @@ it("keeps mutable and shadowed aliases from proving a setting read", () => { ); }); +it("does not attribute an unrelated same-named local to a numeric limit", () => { + const discovered = discoverNumericLimitUses([ + { + path: "src/shadowed-paths.ts", + source: ` + const selectedPath = process.env.REA_SAMPLE_MAX_BYTES; + Number(selectedPath); + function inspect(path: string) { + let selectedPath = path; + consume(selectedPath); + } + `, + }, + ]); + expect(discovered.get("REA_SAMPLE_MAX_BYTES")?.sites).toEqual( + expect.arrayContaining([expect.objectContaining({ evidence: "direct" })]), + ); + expect(discovered.unresolved).toEqual([]); +}); + it("keeps a destructured environment key, including a rename and a default", () => { const name = "REA_NEW_MAX_BYTES"; const discovered = discoverNumericLimitUses([ diff --git a/tests/fixtures/advertisedMcpTool.ts b/tests/fixtures/advertisedMcpTool.ts index b1d254f1bd..cd3751bca1 100644 --- a/tests/fixtures/advertisedMcpTool.ts +++ b/tests/fixtures/advertisedMcpTool.ts @@ -54,16 +54,11 @@ export const assertRecordedMcpEvidence = ( | "inspect_binary_layout" | "inspect_evm_interface" | "inspect_native_exports", - schemas: Awaited>, session: BinarySession, expected: { readonly artifact: { readonly sha256: string } }, confidence: ReturnType["confidence"], ) => { expect(response.isError).not.toBe(true); - expect( - schemas.ajv.validate(schemas.outputSchema, response.structuredContent), - JSON.stringify(schemas.ajv.errors), - ).toBe(true); const parsed = toolContract(name).outputSchema.parse( response.structuredContent, ); diff --git a/tests/fixtures/claudeDesktop.ts b/tests/fixtures/claudeDesktop.ts new file mode 100644 index 0000000000..84a6aa46e9 --- /dev/null +++ b/tests/fixtures/claudeDesktop.ts @@ -0,0 +1,2 @@ +/** Known Claude Desktop MSIX identity used by the registration fixtures. */ +export const CLAUDE_DESKTOP_PACKAGE_FAMILY = "Claude_pzs8sxrjxfjjc"; diff --git a/tests/fixtures/flutterCleanup.ts b/tests/fixtures/flutterCleanup.ts new file mode 100644 index 0000000000..baa1a541a3 --- /dev/null +++ b/tests/fixtures/flutterCleanup.ts @@ -0,0 +1,20 @@ +import * as fsPromises from "node:fs/promises"; +import { onTestFinished, vi } from "vitest"; + +export const realRm = ( + await vi.importActual("node:fs/promises") +).rm; + +export const interceptFlutterRootRemoval = ( + remove: (root: string) => Promise, +): void => { + const spy = vi + .spyOn(fsPromises, "rm") + .mockImplementation((path, options) => + typeof path === "string" && + path.split(/[\\/]/u).at(-1)?.startsWith("rea-flutter-") + ? remove(path) + : realRm(path, options), + ); + onTestFinished(() => spy.mockRestore()); +}; diff --git a/tests/fixtures/lipo.ts b/tests/fixtures/lipo.ts new file mode 100644 index 0000000000..fefd7cf9d6 --- /dev/null +++ b/tests/fixtures/lipo.ts @@ -0,0 +1,8 @@ +import { parseLipoOutputResult } from "../../src/native/parsers/lipo.js"; + +/** Unwrap the production parser boundary for fixture setup and error assertions. */ +export const parseLipoFixture = (output: string, operandPath?: string) => { + const parsed = parseLipoOutputResult(output, operandPath); + if (!parsed.ok) throw parsed.error; + return parsed.value; +}; diff --git a/tests/process-global/cutterDescriptorLifetime.test.ts b/tests/process-global/cutterDescriptorLifetime.test.ts index f0e6044626..f2838321b2 100644 --- a/tests/process-global/cutterDescriptorLifetime.test.ts +++ b/tests/process-global/cutterDescriptorLifetime.test.ts @@ -40,6 +40,7 @@ import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.j import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; import { awaitAcknowledgement } from "../fixtures/lifecycleAcknowledgement.js"; import { cliTest } from "../support/cli/cliFixture.js"; +import { authenticatedServer } from "../fixtures/cutterBridgeProtocol.js"; beforeEach(() => { fault.root = ""; @@ -54,7 +55,7 @@ afterEach(async () => { for (const handle of fault.handles) await handle.close(); }); -const descriptor = async (root: string, index: number) => { +const descriptor = async (root: string, index: number, port = 9) => { const session_id = `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`; const path = join(root, `cutter-${process.pid}-${session_id}.json`); await writeFile( @@ -63,7 +64,7 @@ const descriptor = async (root: string, index: number) => { session_id, pid: process.pid, host: "127.0.0.1", - port: 9, + port, token: "fixture-token-never-sent-to-network", document_generation: 0, current_file: null, @@ -109,16 +110,14 @@ it("retains exact failed descriptors and keeps aggregate status until every clos const client = new CutterBridgeClient({ REA_CUTTER_BRIDGE_DIR: root }); try { const original = await client - .listDescriptors() + .listSessions() .catch((cause: unknown) => cause); if (!(original instanceof AnalysisError)) throw new Error("Expected typed cleanup aggregate"); expect(original.cleanupIncomplete).toBe(true); expect(original.cleanupResources).toHaveLength(2); expect(fault.handles).toHaveLength(2); - const retry = await client - .listDescriptors() - .catch((cause: unknown) => cause); + const retry = await client.listSessions().catch((cause: unknown) => cause); if (!(retry instanceof AnalysisError)) throw new Error("Expected blocked retry"); expect(fault.handles).toHaveLength(2); @@ -150,7 +149,18 @@ it("shutdown waits for descriptor finalization before retrying retained cleanup" const root = await createTestTempDirectory( "rea-cutter-descriptor-finalization-", ); - await descriptor(root, 1); + const sessionId = "00000000-0000-4000-8000-000000000001"; + const server = authenticatedServer( + "fixture-token-never-sent-to-network", + sessionId, + (request, respond) => respond({ ok: true, session_id: request.session_id }), + true, + ); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (address === null || typeof address === "string") + throw new Error("Expected a local Cutter test port"); + await descriptor(root, 1, address.port); fault.root = root; let entered!: () => void; let release!: () => void; @@ -165,7 +175,7 @@ it("shutdown waits for descriptor finalization before retrying retained cleanup" await gate; }; const client = new CutterBridgeClient({ REA_CUTTER_BRIDGE_DIR: root }); - const reading = client.listDescriptors(); + const reading = client.listSessions(); let shutdown: Promise | undefined; try { await awaitAcknowledgement(closing, reading, "descriptor finalization"); @@ -177,7 +187,10 @@ it("shutdown waits for descriptor finalization before retrying retained cleanup" expect(settled).toBe(false); fault.blocked.clear(); release(); - expect(await reading).toHaveLength(1); + expect(await reading).toMatchObject({ + discovery_status: "sessions_found", + sessions: [{ session_id: sessionId }], + }); await shutdown; expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true); } finally { @@ -186,6 +199,7 @@ it("shutdown waits for descriptor finalization before retrying retained cleanup" fault.blocked.clear(); await Promise.allSettled([reading, ...(shutdown ? [shutdown] : [])]); await client.close(); + await new Promise((resolve) => server.close(() => resolve())); } }); diff --git a/tests/process-global/firmwareFileLifetime.test.ts b/tests/process-global/firmwareFileLifetime.test.ts index 75bf3db075..4f54a555f6 100644 --- a/tests/process-global/firmwareFileLifetime.test.ts +++ b/tests/process-global/firmwareFileLifetime.test.ts @@ -1040,7 +1040,12 @@ it("shares one workspace cleanup attempt across concurrent shutdown callers", as try { await entered; const second = provider.close(); - expect(second).toBe(first); + let secondSettled = false; + void second.then(() => { + secondSettled = true; + }); + await Promise.resolve(); + expect(secondSettled).toBe(false); expect(attempts).toBe(2); release(); await Promise.all([first, second]); diff --git a/tests/process-global/flutterCliCleanup.test.ts b/tests/process-global/flutterCliCleanup.test.ts new file mode 100644 index 0000000000..c13144a4e1 --- /dev/null +++ b/tests/process-global/flutterCliCleanup.test.ts @@ -0,0 +1,103 @@ +import { Cli } from "incur"; +import { expect, it, onTestFinished, vi } from "vitest"; +import { registerFlutterCommands } from "../../src/cli/flutterCommands.js"; +import { aotFixtureImage } from "../../src/flutter/FlutterBuild.fixture.js"; +import { FlutterBuildProvider } from "../../src/flutter/FlutterBuildProvider.js"; +import { silentLogger } from "../../src/logger.js"; +import { + interceptFlutterRootRemoval, + realRm, +} from "../fixtures/flutterCleanup.js"; +import { createFlutterApk } from "../fixtures/flutterApk.js"; + +vi.mock("node:fs/promises", async (importOriginal) => { + const original = await importOriginal(); + return { ...original }; +}); + +/** Serve one command whose first snapshot-root removal fails and is retried on close. */ +const serveWithCleanupRetry = async ( + command: string, + apk: string, + interrupt: boolean, + retryFails = false, +): Promise> => { + const inherited = new Set(process.listeners("SIGINT")); + const roots: string[] = []; + interceptFlutterRootRemoval(async (root) => { + roots.push(root); + if (roots.length > 2) return realRm(root, { recursive: true, force: true }); + // Deliver SIGINT to the command's own listener during the retry. + if (roots.length === 2 && interrupt) + for (const listener of process.listeners("SIGINT")) + if (!inherited.has(listener)) listener("SIGINT"); + if (roots.length === 1 || retryFails) + throw Object.assign(new Error("transient snapshot removal refusal"), { + code: "EACCES", + }); + await realRm(root, { recursive: true, force: true }); + }); + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + const cli = Cli.create("flutter-cleanup-retry-test", { sync: false }); + registerFlutterCommands(cli, silentLogger, provider); + let stdout = ""; + await cli.serve([command, apk, "--format", "json"], { + stdout: (text) => { + stdout += text; + }, + exit: () => {}, + }); + expect(roots).toEqual([roots[0], roots[0]]); + return JSON.parse(stdout) as Record; +}; + +it.each([ + ["identify-flutter-build", "identify_flutter_build"], + ["inspect-dart-aot", "inspect_dart_aot"], +] as const)( + "%s reports SIGINT during the cleanup retry as cancellation with completed facts", + async (command, operation) => { + const apk = await createFlutterApk("cleanup-retry.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotFixtureImage() }, + ]); + const exitCode = process.exitCode; + onTestFinished(() => { + process.exitCode = exitCode; + }); + const control = await serveWithCleanupRetry(command, apk, false); + expect(control).toMatchObject({ + operation, + confidence: "observed", + caller_input_path: apk, + }); + expect(process.exitCode).toBe(exitCode); + const interrupted = await serveWithCleanupRetry(command, apk, true); + expect(process.exitCode).toBe(130); + expect(interrupted).toMatchObject({ + code: "cancelled", + details: { + operation, + partial_observation: { + operation, + caller_input_path: apk, + subject: { local_path: apk }, + confidence: "observed", + normalized_result: control.normalized_result, + }, + }, + }); + const incomplete = await serveWithCleanupRetry(command, apk, true, true); + expect(incomplete).toMatchObject({ + code: "cleanup_incomplete", + details: { + resources: [expect.stringContaining("rea-flutter-")], + partial_observation: { + operation, + caller_input_path: apk, + normalized_result: control.normalized_result, + }, + }, + }); + }, +); diff --git a/tests/process-global/hopperDownloadDeadline.test.ts b/tests/process-global/hopperDownloadDeadline.test.ts index 7ede01a7df..a73c609c28 100644 --- a/tests/process-global/hopperDownloadDeadline.test.ts +++ b/tests/process-global/hopperDownloadDeadline.test.ts @@ -1,12 +1,10 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; -import { - downloadHopperPackage, - HOPPER_DOWNLOAD_STALL_MS, - HOPPER_DOWNLOAD_TIMEOUT_MS, -} from "../../src/application/HopperDownload.js"; +import { downloadHopperPackage } from "../../src/application/HopperDownload.js"; import { err, ok } from "../../src/domain/result.js"; +const HOPPER_DOWNLOAD_STALL_MS = 30_000; +const HOPPER_DOWNLOAD_TIMEOUT_MS = 15 * 60_000; const url = "https://www.hopperapp.com/downloader/public/Hopper-Linux-demo.rpm"; beforeEach(() => { diff --git a/tests/process-global/updateMaintenance.test.ts b/tests/process-global/updateMaintenance.test.ts index 75b18ad8ef..e19ea97e35 100644 --- a/tests/process-global/updateMaintenance.test.ts +++ b/tests/process-global/updateMaintenance.test.ts @@ -1,7 +1,4 @@ -import { - UpdateCommandRunner, - UPDATE_COMMAND_OUTPUT_BYTES, -} from "../../src/application/UpdateCommandRunner.js"; +import { UpdateCommandRunner } from "../../src/application/UpdateCommandRunner.js"; import { mkdir, readFile, rm, writeFile, access } from "node:fs/promises"; import { delimiter, dirname, join, relative, resolve } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -19,6 +16,7 @@ import { systemUpdateHost } from "../../src/application/UpdateRuntime.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; import { cliTest } from "../support/cli/cliFixture.js"; +const UPDATE_COMMAND_OUTPUT_BYTES = 4 * 1024 * 1024; const entryPoint = resolve("scripts/rea.mjs"); let home: string; diff --git a/tests/process-global/updateOutput.test.ts b/tests/process-global/updateOutput.test.ts index a8de5b7cd1..2620264f05 100644 --- a/tests/process-global/updateOutput.test.ts +++ b/tests/process-global/updateOutput.test.ts @@ -1,14 +1,12 @@ import { ProviderProcessSupervisor } from "../../src/process/ProviderProcess.js"; import { systemUpdateHost } from "../../src/application/UpdateRuntime.js"; -import { - UpdateCommandRunner, - UPDATE_COMMAND_OUTPUT_BYTES, -} from "../../src/application/UpdateCommandRunner.js"; +import { UpdateCommandRunner } from "../../src/application/UpdateCommandRunner.js"; import { spawn, type ChildProcess } from "node:child_process"; import { setImmediate as nextTurn } from "node:timers/promises"; import { Writable } from "node:stream"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; +const UPDATE_COMMAND_OUTPUT_BYTES = 4 * 1024 * 1024; const execution = vi.hoisted(() => ({ spawn: vi.fn() })); vi.mock("cross-spawn", () => ({ default: execution.spawn })); let child: ChildProcess | undefined;