From 513d9b15558f9174a8e2c0ab00e31cbc05dbaba7 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:00:38 +0000 Subject: [PATCH] test: overhaul deterministic suite architecture --- .oxlintrc.json | 80 +- .../BinarySessionComposition.test.ts | 35 + .../boundedCartesianProjection.test.ts | 2 +- {tests => src/domain}/jsonShape.test.ts | 2 +- {tests => src/domain}/symbolAnalysis.test.ts | 2 +- .../analysis}/packageAnalysis.test.ts | 2 +- .../applicationEvidenceMcp.test.ts | 244 ++ .../applicationExportShapeMcp.test.ts | 142 + .../applications/applicationReplayMcp.test.ts | 277 ++ .../applicationWorkflowCli.test.ts | 41 +- .../applicationWorkflowVerifier.test.ts | 4 +- .../javascriptApplicationWorkflows.test.ts | 46 +- .../mobileApplicationInvestigation.test.ts | 3 +- .../setup}/packageInstallWorkflow.test.ts | 17 +- .../setup/setupConfiguration.test.ts | 194 ++ .../setup}/setupInstallFailure.test.ts | 2 +- .../setup}/setupLifecycle.test.ts | 10 +- tests/acceptance/setup/setupPlanning.test.ts | 244 ++ tests/acceptance/setup/setupRecovery.test.ts | 139 + tests/acceptance/setup/setupTestSupport.ts | 170 ++ tests/{ => acceptance/setup}/upgrade.test.ts | 2 +- tests/applicationWorkflowMcp.test.ts | 582 ---- tests/artifactMcp.test.ts | 479 --- tests/artifactProvider.test.ts | 523 ---- tests/binarySession.test.ts | 919 ------ .../browser}/browserProcessStartup.test.ts | 4 +- ...erProvider.discovery_authorization.test.ts | 216 ++ ...cdpBrowserProvider.document_script.test.ts | 441 +++ ...BrowserProvider.lifecycle_failures.test.ts | 484 +++ .../cdpBrowserProvider.network.test.ts | 27 + .../cdpBrowserProvider.sensitive_data.test.ts | 387 +++ .../browser/cdpBrowserProvider.support.ts | 15 + .../browser}/cdpCaptureCompleteness.test.ts | 2 +- .../browser}/cdpCaptureDocuments.test.ts | 4 +- .../browser}/cdpConnection.test.ts | 4 +- .../browser}/cdpElectronProvider.test.ts | 52 +- .../browser}/cdpSafeMetadata.test.ts | 2 +- .../browser}/customProtocolCapture.test.ts | 2 +- .../javascriptRuntimeObservation.test.ts | 44 +- .../playwrightBrowserScenarioProvider.test.ts | 12 +- .../playwrightExecutionBoundary.test.ts | 4 +- .../browser}/playwrightScenarioEvents.test.ts | 8 +- .../browser}/protocolCapture.test.ts | 2 +- .../browser}/webSourceMapFetcher.test.ts | 8 +- tests/{ => boundary/cli}/browserCli.test.ts | 27 +- tests/{ => boundary/cli}/cliLogging.test.ts | 7 +- tests/{ => boundary/cli}/cliOutput.test.ts | 138 +- .../cli}/cliPolicyCommands.test.ts | 6 +- tests/{ => boundary/cli}/cliSetup.test.ts | 6 +- tests/boundary/cli/dispatcher.test.ts | 103 + tests/{ => boundary/cli}/doctor.test.ts | 73 +- .../cli/doctorRuntimeExecutables.test.ts | 93 + tests/{ => boundary/cli}/electronCli.test.ts | 37 +- .../javascriptRuntimeObservationCli.test.ts | 12 +- .../cli}/packageRunnerBootstrap.test.ts | 4 +- tests/{ => boundary/cli}/processCli.test.ts | 8 +- .../{ => boundary/cli}/productCatalog.test.ts | 46 +- .../reconstructionObligationLedgerCli.test.ts | 2 +- .../cli}/reconstructionReadinessCli.test.ts | 2 +- .../filesystem}/configIntegration.test.ts | 2 +- .../filesystem}/dependencyInstall.test.ts | 2 +- .../filesystem}/evidenceBundleFiles.test.ts | 10 +- .../filesystem}/installScript.test.ts | 2 +- .../filesystem}/referenceSourceImport.test.ts | 14 +- .../filesystem}/referenceSourceReader.test.ts | 8 +- .../filesystem}/safeOutputTree.test.ts | 4 +- .../filesystem}/temporaryDirectory.test.ts | 2 +- tests/boundary/mcp/artifactMcp.test.ts | 467 +++ tests/boundary/mcp/browserMcp.test.ts | 330 +++ .../mcp}/browserScenarioMcp.test.ts | 28 +- .../mcp}/bundleComparisonMcp.test.ts | 14 +- tests/{ => boundary/mcp}/createServer.test.ts | 19 +- .../mcp}/derivedOperation.test.ts | 2 +- tests/boundary/mcp/electronMcp.test.ts | 351 +++ ....01-lists-the-complete-target-open.test.ts | 315 ++ ...-returns-evidence-ids-and-explicit.test.ts | 262 ++ ...3-returns-typed-graph-failures-and.test.ts | 297 ++ ...ows-name-pagination-for-exhaustive.test.ts | 316 ++ ...ools.05-stops-trace-feature-at-its.test.ts | 304 ++ ...ts-incomplete-function-dossiers-at.test.ts | 242 ++ tests/boundary/mcp/evidenceResources.test.ts | 269 ++ .../mcp}/functionComparisonMcp.test.ts | 16 +- tests/boundary/mcp/ghidraEvidenceMcp.test.ts | 150 + tests/boundary/mcp/ghidraMcpHarness.ts | 299 ++ tests/boundary/mcp/ghidraNativeApiMcp.test.ts | 88 + tests/boundary/mcp/ghidraRoutingMcp.test.ts | 75 + .../mcp/investigationAnalysisMcp.test.ts | 348 +++ .../mcp/investigationWorkspaceMcp.test.ts | 395 +++ .../javascriptRuntimeObservationMcp.test.ts | 20 +- tests/{ => boundary/mcp}/mainErrors.test.ts | 2 +- tests/{ => boundary/mcp}/mainReload.test.ts | 21 +- tests/boundary/mcp/managedMcp.test.ts | 400 +++ tests/{ => boundary/mcp}/mcpDoctor.test.ts | 9 +- .../mcp}/mcpStartupPolicy.test.ts | 4 +- .../{ => boundary/mcp}/officialTools.test.ts | 14 +- .../{ => boundary/mcp}/permissionMcp.test.ts | 8 +- .../mcp/processCaptureElicitation.test.ts | 89 + .../processCaptureElicitationGrants.test.ts | 149 + ...ocessCaptureElicitationGrantsPart2.test.ts | 167 ++ ...ocessCaptureElicitationGrantsPart3.test.ts | 182 ++ .../processCaptureElicitationPart2.test.ts | 141 + .../mcp/processTraceAdaptersParity.test.ts | 163 ++ tests/{ => boundary/mcp}/progressMcp.test.ts | 10 +- .../mcp}/promptCompletion.test.ts | 28 +- tests/{ => boundary/mcp}/promptMcp.test.ts | 27 +- .../mcp}/providerSelectionMcp.test.ts | 20 +- .../mcp}/reconstructionCoverageMcp.test.ts | 20 +- .../reconstructionObligationLedgerMcp.test.ts | 10 +- .../mcp}/reconstructionReadinessMcp.test.ts | 10 +- .../mcp}/replayMachineMcp.test.ts | 10 +- .../{ => boundary/mcp}/serverIdentity.test.ts | 342 +-- tests/boundary/mcp/serverLifecycleMcp.test.ts | 119 + tests/boundary/mcp/serverUnknownsMcp.test.ts | 307 ++ tests/boundary/mcp/serverWorkflowMcp.test.ts | 236 ++ tests/{ => boundary/mcp}/sessionMcp.test.ts | 195 +- tests/boundary/mcp/sessionMcpHarness.ts | 195 ++ .../mcp/toolRegistrationOptions.test.ts | 68 + tests/{ => boundary/mcp}/toolResult.test.ts | 14 +- .../process}/eventProcessTree.test.ts | 10 +- ...riptRuntimeReconciliationAmbiguity.test.ts | 354 +++ .../process/processCaptureComparison.test.ts | 250 ++ .../process/processCaptureLifecycle.test.ts | 238 ++ .../process/processCaptureMultisource.test.ts | 269 ++ .../process/processCaptureObservation.test.ts | 407 +++ .../process/processCaptureReactive.test.ts | 424 +++ .../processCaptureReplayMachine.test.ts | 230 ++ .../processCaptureReplayTransport.test.ts | 328 +++ .../process/processCaptureTerminal.test.ts | 190 ++ .../processCaptureTraceProjection.test.ts | 197 ++ .../process/processCaptureValidation.test.ts | 328 +++ .../process}/processObservation.test.ts | 6 +- .../boundary/process/processOwnership.test.ts | 155 + .../process/processOwnershipPart2.test.ts | 143 + .../process/processOwnershipPart3.test.ts | 72 + .../processOwnershipValidation.test.ts | 165 ++ .../processOwnershipValidationPart2.test.ts | 134 + .../processOwnershipValidationPart3.test.ts | 37 + .../process}/providerProcess.test.ts | 20 +- .../runtimeExecutableDiagnostics.test.ts | 4 +- .../ghidra}/ghidraBridgeTruthfulness.test.ts | 10 +- .../providers/ghidra}/ghidraClient.test.ts | 35 +- .../providers/ghidra}/ghidraDoctor.test.ts | 13 +- .../ghidra}/ghidraFunctionValues.test.ts | 8 +- .../ghidra}/ghidraInstallation.test.ts | 4 +- .../ghidra}/ghidraInventoryValues.test.ts | 4 +- .../providers/ghidra}/ghidraLauncher.test.ts | 8 +- .../providers/ghidra}/ghidraProvider.test.ts | 131 +- .../ghidra}/ghidraTargetSnapshot.test.ts | 4 +- .../providers/ghidra}/ghidraTransport.test.ts | 4 +- .../providers/hopper}/bridgeLauncher.test.ts | 6 +- .../hopper/bridgeRegex.literalBounds.test.ts | 278 ++ .../hopper/bridgeRegex.matching.test.ts} | 166 +- .../hopper/bridgeRegex.workLimits.test.ts | 396 +++ .../hopper}/bridgeTruthfulness.test.ts | 2 +- .../hopper}/hopperAnalysisProfile.test.ts | 8 +- .../hopper}/hopperBridgeFacade.test.ts | 7 +- .../hopper/hopperClient.cleanup.test.ts | 260 ++ .../hopper/hopperClient.progress.test.ts | 198 ++ .../hopper/hopperClient.protocol.test.ts | 183 ++ .../hopper/hopperClient.restart.test.ts | 111 + .../hopperClient.startupFailures.test.ts | 281 ++ .../providers/hopper}/hopperValues.test.ts | 2 +- .../providers/hopper}/linuxHopper.test.ts | 2 +- .../hopper}/linuxPrivateDisplay.test.ts | 10 +- .../providers/hopper}/macHopper.test.ts | 2 +- .../managed}/managedApplicationGraph.test.ts | 27 +- ...anagedArtifactProvider.cilDecoding.test.ts | 138 + .../managedArtifactProvider.cilLimits.test.ts | 73 + ...anagedArtifactProvider.cliEvidence.test.ts | 59 + .../managedArtifactProvider.failures.test.ts | 176 ++ .../managedArtifactProvider.inventory.test.ts | 171 ++ .../managedArtifactProvider.members.test.ts | 140 + ...dArtifactProvider.nativeBoundaries.test.ts | 154 + .../managed}/managedMemberComparison.test.ts | 20 +- .../managed}/managedReconstruction.test.ts | 10 +- .../providers/managed}/peInspection.test.ts | 2 +- .../artifactProvider.archiveInventory.test.ts | 161 + .../artifactProvider.archiveSafety.test.ts | 138 + ...rtifactProvider.directoryInventory.test.ts | 89 + .../artifactProvider.extraction.test.ts | 140 + ...tifactProvider.paginationAndSlices.test.ts | 116 + .../native}/bytecodeProvider.test.ts | 2 +- .../native}/nativeApiInspection.test.ts | 6 +- .../native}/nativeDmgArtifactReader.test.ts | 2 +- .../providers/native}/nativeProvider.test.ts | 21 +- .../native}/objcSwiftMetadata.test.ts | 3 +- .../replay}/javascriptReplay.test.ts | 18 +- .../replay}/javascriptReplayWorker.test.ts | 0 .../replay}/replayMachineRun.test.ts | 8 +- .../replay/replayMachineRuntime.test.ts | 333 +++ .../replayMachineRuntimeCaptures.test.ts | 308 ++ .../replay}/replayWorkerProtocol.test.ts | 2 +- tests/browserMcp.test.ts | 324 -- tests/cdpBrowserProvider.test.ts | 1496 ---------- .../analysisContextQueries.test.ts | 12 +- .../analysisProfile.test.ts | 2 +- .../analysisProviderRegistry.test.ts | 20 +- .../analysisSnapshot.test.ts | 22 +- .../androidApplication.test.ts | 10 +- .../appleApplication.test.ts | 10 +- .../artifactComparison.test.ts | 10 +- .../artifactInventorySnapshot.test.ts | 4 +- .../authorizedArtifactInventory.test.ts | 4 +- ...turns-detached-provider-target-and.test.ts | 133 + ...ssion.02-runs-through-a-non-hopper.test.ts | 186 ++ ...3-allocates-one-fresh-run-identity.test.ts | 117 + ...replays-exact-immutable-calls-from.test.ts | 226 ++ ...ovider-health-availability-changes.test.ts | 199 ++ ...unavailability-without-dispatching.test.ts | 158 + ...07-replaces-the-active-client-when.test.ts | 167 ++ ...els-legacy-profile-resolution-even.test.ts | 247 ++ ...on.09-does-not-start-a-replacement.test.ts | 51 + .../analysis-sessions}/binaryTarget.test.ts | 4 +- .../analysis-sessions}/callPath.test.ts | 10 +- .../capabilityInventory.test.ts | 6 +- .../compositeProvider.test.ts | 35 +- .../contractSnapshot.test.ts | 87 +- .../crossVersionInventory.test.ts | 6 +- .../analysis-sessions}/errors.test.ts | 30 +- .../functionComparison.test.ts | 12 +- .../hopperProviderSelection.test.ts | 8 +- .../analysis-sessions}/identity.test.ts | 4 +- .../inputIssueProjection.test.ts | 4 +- .../analysis-sessions}/jsonLimits.test.ts | 2 +- .../progressReporter.test.ts | 2 +- .../promptContracts.test.ts | 4 +- .../providerArchitecture.test.ts | 14 +- .../providerBinding.test.ts | 25 +- .../analysis-sessions}/runtime.test.ts | 8 +- .../analysis-sessions}/toolContracts.test.ts | 16 +- .../changedBehavior.test.ts | 6 +- .../completionLedgerGeneration.test.ts | 10 +- .../evidence-investigations}/evidence.test.ts | 18 +- .../evidenceCompletionLedger.test.ts | 2 +- .../investigationWorkspaceHistory.test.ts | 278 ++ .../investigationWorkspaceIntegrity.test.ts | 184 ++ .../investigationWorkspaceMigration.test.ts} | 370 +-- .../reconstructionCoverage.test.ts | 18 +- ...structionObligationLedgerAuthority.test.ts | 350 +++ ...nstructionObligationLedgerClosure.test.ts} | 282 +- .../reconstructionReadiness.test.ts | 6 +- .../reconstructionVerification.test.ts | 16 +- .../residualUnknown.test.ts | 12 +- .../bundleComparison.test.ts | 10 +- .../electronApplicationAnalysis.test.ts | 340 +++ ...ines-the-complete-provider-neutral.test.ts | 137 + ...tic-observations-static-inferences.test.ts | 191 ++ ...s-structural-identity-and-evidence.test.ts | 161 + ...-admits-source-map-and-observation.test.ts | 123 + ...ects-duplicate-nodes-missing-roots.test.ts | 159 + ...equires-every-identity-strategy-to.test.ts | 211 ++ ...ejects-false-truncation-claims-and.test.ts | 88 + .../javascriptArtifactPathResolution.test.ts | 10 +- .../javascriptArtifactReconstruction.test.ts | 331 +++ ...scriptArtifactReconstructionLimits.test.ts | 344 +++ .../javascriptBundlerAdapters.test.ts | 6 +- .../javascriptExportInstrumentation.test.ts | 2 +- .../javascriptExportShapeComparison.test.ts | 38 +- .../javascriptModuleRelationships.test.ts | 8 +- .../javascriptSemanticAnalysis.calls.test.ts | 241 ++ ...avascriptSemanticAnalysis.dataflow.test.ts | 264 ++ ...ptSemanticAnalysis.native_bindings.test.ts | 173 ++ ...vascriptSemanticAnalysis.rejection.test.ts | 128 + ...emanticAnalysis.runtime_boundaries.test.ts | 129 + ...vascriptSemanticAnalysis.structure.test.ts | 209 ++ .../javascriptSemanticAnalysis.support.ts | 61 + .../javascriptSemanticGraphBuilder.test.ts | 317 ++ .../javascriptSemanticGraphQuery.test.ts | 278 ++ .../javascriptSemanticGraphResources.test.ts | 318 ++ .../javascriptSemanticGraphTraversal.test.ts | 446 +++ .../referenceSourceGraph.test.ts | 6 +- .../sourceToBundleComparison.test.ts | 10 +- .../webBundleAnalyzer.test.ts | 8 +- .../webInventory.test.ts | 4 +- .../processCapturePermission.test.ts | 4 +- .../processPairedExperiment.test.ts | 14 +- .../processReactiveCoordinator.test.ts | 235 +- ...processReactiveCoordinatorFailures.test.ts | 229 ++ ...ssReactiveCoordinatorFailuresPart2.test.ts | 131 + .../processReactiveRuntime.test.ts | 234 ++ .../processReactiveRuntimePart2.test.ts | 250 ++ .../processReactiveRuntimePart3.test.ts | 226 ++ .../processReactiveRuntimePredicates.test.ts | 271 ++ ...cessReactiveRuntimePredicatesPart2.test.ts | 253 ++ .../processReactiveScenario.test.ts | 8 +- .../processTraceAdapters.test.ts | 112 +- .../processTraceComparison.test.ts | 245 +- .../processTraceComparisonPart2.test.ts | 262 ++ .../processTraceSpecification.test.ts | 185 ++ .../sensitiveTextCapture.test.ts | 2 +- .../browserObservation.test.ts | 16 +- .../browserScenario.test.ts | 4 +- .../browserScenarioDiff.test.ts | 6 +- .../electronFileScope.test.ts | 4 +- .../javascriptRuntimeReconciliation.test.ts | 374 +++ .../managedRuntimeCorrelation.test.ts | 20 +- .../nodeRuntimeCharacterization.test.ts | 12 +- .../pngVisualDiff.test.ts | 4 +- .../replayMachine.test.ts | 4 +- .../runtimeCharacterization.test.ts | 2 +- .../runtimeIdentification.test.ts | 8 +- .../staticRuntimeCorrelation.test.ts | 8 +- .../webCaptureDiff.test.ts | 26 +- .../setup-lifecycle/browserConfig.test.ts | 113 + .../clientRegistrationStatus.test.ts | 6 +- .../setup-lifecycle}/config.test.ts | 116 +- .../directAnalysisPermissions.test.ts | 24 +- .../permissionAuthority.test.ts | 10 +- .../setup-lifecycle}/permissionPolicy.test.ts | 4 +- .../projectPermissionStore.test.ts | 4 +- .../setup-lifecycle}/setupConfig.test.ts | 10 +- .../setup-lifecycle}/setupSkill.test.ts | 6 +- .../setup-lifecycle}/statusProjection.test.ts | 10 +- .../providers}/conformanceFixtures.test.ts | 2 +- .../providers}/conformancePackage.test.ts | 2 +- .../providers}/conformanceReplay.test.ts | 4 +- .../providers}/conformanceTrustGate.test.ts | 4 +- .../providers}/coverageMonotonicity.test.ts | 12 +- .../ghidra}/ghidraWindowsFixture.test.ts | 4 +- .../ghidra}/realGhidraWindowsWorkflow.test.ts | 5 +- .../ghidra}/windowsCapabilities.test.ts | 2 +- .../hopper}/realHopperAssertions.test.ts | 4 +- .../hopper}/realHopperFixture.test.ts | 4 +- .../hopper}/realHopperPagination.test.ts | 2 +- .../hopper}/realHopperSemantic.test.ts | 2 +- .../managedConformanceFixture.test.ts | 6 +- .../managedNativeVerification.test.ts | 10 +- .../readmeConsistency.test.ts | 22 +- .../releaseConfiguration.test.ts | 0 tests/{ => conformance}/scanTodos.test.ts | 2 +- tests/dispatcher.test.ts | 110 - tests/electronApplicationAnalysis.test.ts | 326 --- tests/electronMcp.test.ts | 358 --- tests/enhancedTools.test.ts | 865 ------ .../model-evals}/codexAgentEval.test.ts | 6 +- tests/evidenceResources.test.ts | 248 -- tests/fixtures/fakeCdpBrowser.ts | 1026 +------ tests/fixtures/fakeCdpBrowserEvents.ts | 404 +++ tests/fixtures/fakeCdpBrowserResults.ts | 400 +++ tests/fixtures/fakeCdpBrowserTargets.ts | 148 + tests/fixtures/fakeCdpBrowserTypes.ts | 66 + tests/fixtures/javascriptApplicationGraph.ts | 575 ++-- tests/fixtures/temporaryDirectory.ts | 27 +- tests/ghidraMcp.test.ts | 595 ---- tests/hopperClient.test.ts | 763 ----- tests/integration/serverIntegration.test.ts | 603 ---- tests/investigationMcp.test.ts | 684 ----- tests/javascriptApplicationGraph.test.ts | 836 ------ .../javascriptArtifactReconstruction.test.ts | 672 ----- tests/javascriptRuntimeReconciliation.test.ts | 543 ---- tests/javascriptSemanticAnalysis.test.ts | 1143 -------- tests/javascriptSemanticGraphBuilder.test.ts | 578 ---- tests/javascriptSemanticGraphQuery.test.ts | 517 ---- tests/managedArtifactProvider.test.ts | 713 ----- tests/managedMcp.test.ts | 372 --- .../{ => process-global}/verifierRun.test.ts | 2 +- .../vitestConfiguration.test.ts | 123 + tests/processCapture.test.ts | 2593 ----------------- tests/processCaptureElicitation.test.ts | 623 ---- tests/processOwnership.test.ts | 617 ---- tests/processReactiveRuntime.test.ts | 678 ----- tests/replayMachineRuntime.test.ts | 581 ---- tests/setup.test.ts | 725 ----- tests/support/cli/cliFixture.ts | 69 + tests/support/fixtures.ts | 61 + tests/support/mcp/mcpFixture.ts | 72 + tests/support/network/loopbackFixture.ts | 72 + tests/support/process/processFixture.ts | 157 + tests/support/workspace/workspaceFixture.ts | 88 + tests/vitestConfiguration.test.ts | 82 - vitest.config.ts | 165 +- 371 files changed, 30879 insertions(+), 24389 deletions(-) create mode 100644 src/application/BinarySessionComposition.test.ts rename {tests => src/domain}/boundedCartesianProjection.test.ts (87%) rename {tests => src/domain}/jsonShape.test.ts (96%) rename {tests => src/domain}/symbolAnalysis.test.ts (98%) rename tests/{ => acceptance/analysis}/packageAnalysis.test.ts (98%) create mode 100644 tests/acceptance/applications/applicationEvidenceMcp.test.ts create mode 100644 tests/acceptance/applications/applicationExportShapeMcp.test.ts create mode 100644 tests/acceptance/applications/applicationReplayMcp.test.ts rename tests/{ => acceptance/applications}/applicationWorkflowCli.test.ts (92%) rename tests/{ => acceptance/applications}/applicationWorkflowVerifier.test.ts (94%) rename tests/{ => acceptance/applications}/javascriptApplicationWorkflows.test.ts (85%) rename tests/{ => acceptance/investigations}/mobileApplicationInvestigation.test.ts (98%) rename tests/{ => acceptance/setup}/packageInstallWorkflow.test.ts (87%) create mode 100644 tests/acceptance/setup/setupConfiguration.test.ts rename tests/{ => acceptance/setup}/setupInstallFailure.test.ts (95%) rename tests/{ => acceptance/setup}/setupLifecycle.test.ts (97%) create mode 100644 tests/acceptance/setup/setupPlanning.test.ts create mode 100644 tests/acceptance/setup/setupRecovery.test.ts create mode 100644 tests/acceptance/setup/setupTestSupport.ts rename tests/{ => acceptance/setup}/upgrade.test.ts (99%) delete mode 100644 tests/applicationWorkflowMcp.test.ts delete mode 100644 tests/artifactMcp.test.ts delete mode 100644 tests/artifactProvider.test.ts delete mode 100644 tests/binarySession.test.ts rename tests/{ => boundary/browser}/browserProcessStartup.test.ts (95%) create mode 100644 tests/boundary/browser/cdpBrowserProvider.discovery_authorization.test.ts create mode 100644 tests/boundary/browser/cdpBrowserProvider.document_script.test.ts create mode 100644 tests/boundary/browser/cdpBrowserProvider.lifecycle_failures.test.ts create mode 100644 tests/boundary/browser/cdpBrowserProvider.network.test.ts create mode 100644 tests/boundary/browser/cdpBrowserProvider.sensitive_data.test.ts create mode 100644 tests/boundary/browser/cdpBrowserProvider.support.ts rename tests/{ => boundary/browser}/cdpCaptureCompleteness.test.ts (95%) rename tests/{ => boundary/browser}/cdpCaptureDocuments.test.ts (96%) rename tests/{ => boundary/browser}/cdpConnection.test.ts (93%) rename tests/{ => boundary/browser}/cdpElectronProvider.test.ts (87%) rename tests/{ => boundary/browser}/cdpSafeMetadata.test.ts (97%) rename tests/{ => boundary/browser}/customProtocolCapture.test.ts (98%) rename tests/{ => boundary/browser}/javascriptRuntimeObservation.test.ts (87%) rename tests/{ => boundary/browser}/playwrightBrowserScenarioProvider.test.ts (95%) rename tests/{ => boundary/browser}/playwrightExecutionBoundary.test.ts (87%) rename tests/{ => boundary/browser}/playwrightScenarioEvents.test.ts (88%) rename tests/{ => boundary/browser}/protocolCapture.test.ts (99%) rename tests/{ => boundary/browser}/webSourceMapFetcher.test.ts (96%) rename tests/{ => boundary/cli}/browserCli.test.ts (92%) rename tests/{ => boundary/cli}/cliLogging.test.ts (91%) rename tests/{ => boundary/cli}/cliOutput.test.ts (66%) rename tests/{ => boundary/cli}/cliPolicyCommands.test.ts (95%) rename tests/{ => boundary/cli}/cliSetup.test.ts (98%) create mode 100644 tests/boundary/cli/dispatcher.test.ts rename tests/{ => boundary/cli}/doctor.test.ts (87%) create mode 100644 tests/boundary/cli/doctorRuntimeExecutables.test.ts rename tests/{ => boundary/cli}/electronCli.test.ts (90%) rename tests/{ => boundary/cli}/javascriptRuntimeObservationCli.test.ts (89%) rename tests/{ => boundary/cli}/packageRunnerBootstrap.test.ts (96%) rename tests/{ => boundary/cli}/processCli.test.ts (95%) rename tests/{ => boundary/cli}/productCatalog.test.ts (86%) rename tests/{ => boundary/cli}/reconstructionObligationLedgerCli.test.ts (94%) rename tests/{ => boundary/cli}/reconstructionReadinessCli.test.ts (90%) rename tests/{integration => boundary/filesystem}/configIntegration.test.ts (98%) rename tests/{ => boundary/filesystem}/dependencyInstall.test.ts (97%) rename tests/{ => boundary/filesystem}/evidenceBundleFiles.test.ts (93%) rename tests/{ => boundary/filesystem}/installScript.test.ts (99%) rename tests/{ => boundary/filesystem}/referenceSourceImport.test.ts (93%) rename tests/{ => boundary/filesystem}/referenceSourceReader.test.ts (96%) rename tests/{ => boundary/filesystem}/safeOutputTree.test.ts (93%) rename tests/{ => boundary/filesystem}/temporaryDirectory.test.ts (93%) create mode 100644 tests/boundary/mcp/artifactMcp.test.ts create mode 100644 tests/boundary/mcp/browserMcp.test.ts rename tests/{ => boundary/mcp}/browserScenarioMcp.test.ts (88%) rename tests/{ => boundary/mcp}/bundleComparisonMcp.test.ts (90%) rename tests/{ => boundary/mcp}/createServer.test.ts (51%) rename tests/{ => boundary/mcp}/derivedOperation.test.ts (96%) create mode 100644 tests/boundary/mcp/electronMcp.test.ts create mode 100644 tests/boundary/mcp/enhancedTools.01-lists-the-complete-target-open.test.ts create mode 100644 tests/boundary/mcp/enhancedTools.02-returns-evidence-ids-and-explicit.test.ts create mode 100644 tests/boundary/mcp/enhancedTools.03-returns-typed-graph-failures-and.test.ts create mode 100644 tests/boundary/mcp/enhancedTools.04-follows-name-pagination-for-exhaustive.test.ts create mode 100644 tests/boundary/mcp/enhancedTools.05-stops-trace-feature-at-its.test.ts create mode 100644 tests/boundary/mcp/enhancedTools.06-rejects-incomplete-function-dossiers-at.test.ts create mode 100644 tests/boundary/mcp/evidenceResources.test.ts rename tests/{ => boundary/mcp}/functionComparisonMcp.test.ts (89%) create mode 100644 tests/boundary/mcp/ghidraEvidenceMcp.test.ts create mode 100644 tests/boundary/mcp/ghidraMcpHarness.ts create mode 100644 tests/boundary/mcp/ghidraNativeApiMcp.test.ts create mode 100644 tests/boundary/mcp/ghidraRoutingMcp.test.ts create mode 100644 tests/boundary/mcp/investigationAnalysisMcp.test.ts create mode 100644 tests/boundary/mcp/investigationWorkspaceMcp.test.ts rename tests/{ => boundary/mcp}/javascriptRuntimeObservationMcp.test.ts (85%) rename tests/{ => boundary/mcp}/mainErrors.test.ts (98%) rename tests/{ => boundary/mcp}/mainReload.test.ts (93%) create mode 100644 tests/boundary/mcp/managedMcp.test.ts rename tests/{ => boundary/mcp}/mcpDoctor.test.ts (93%) rename tests/{ => boundary/mcp}/mcpStartupPolicy.test.ts (93%) rename tests/{ => boundary/mcp}/officialTools.test.ts (93%) rename tests/{ => boundary/mcp}/permissionMcp.test.ts (84%) create mode 100644 tests/boundary/mcp/processCaptureElicitation.test.ts create mode 100644 tests/boundary/mcp/processCaptureElicitationGrants.test.ts create mode 100644 tests/boundary/mcp/processCaptureElicitationGrantsPart2.test.ts create mode 100644 tests/boundary/mcp/processCaptureElicitationGrantsPart3.test.ts create mode 100644 tests/boundary/mcp/processCaptureElicitationPart2.test.ts create mode 100644 tests/boundary/mcp/processTraceAdaptersParity.test.ts rename tests/{ => boundary/mcp}/progressMcp.test.ts (88%) rename tests/{ => boundary/mcp}/promptCompletion.test.ts (89%) rename tests/{ => boundary/mcp}/promptMcp.test.ts (93%) rename tests/{ => boundary/mcp}/providerSelectionMcp.test.ts (90%) rename tests/{ => boundary/mcp}/reconstructionCoverageMcp.test.ts (90%) rename tests/{ => boundary/mcp}/reconstructionObligationLedgerMcp.test.ts (86%) rename tests/{ => boundary/mcp}/reconstructionReadinessMcp.test.ts (84%) rename tests/{ => boundary/mcp}/replayMachineMcp.test.ts (84%) rename tests/{ => boundary/mcp}/serverIdentity.test.ts (51%) create mode 100644 tests/boundary/mcp/serverLifecycleMcp.test.ts create mode 100644 tests/boundary/mcp/serverUnknownsMcp.test.ts create mode 100644 tests/boundary/mcp/serverWorkflowMcp.test.ts rename tests/{ => boundary/mcp}/sessionMcp.test.ts (64%) create mode 100644 tests/boundary/mcp/sessionMcpHarness.ts create mode 100644 tests/boundary/mcp/toolRegistrationOptions.test.ts rename tests/{ => boundary/mcp}/toolResult.test.ts (90%) rename tests/{ => boundary/process}/eventProcessTree.test.ts (97%) create mode 100644 tests/boundary/process/javascriptRuntimeReconciliationAmbiguity.test.ts create mode 100644 tests/boundary/process/processCaptureComparison.test.ts create mode 100644 tests/boundary/process/processCaptureLifecycle.test.ts create mode 100644 tests/boundary/process/processCaptureMultisource.test.ts create mode 100644 tests/boundary/process/processCaptureObservation.test.ts create mode 100644 tests/boundary/process/processCaptureReactive.test.ts create mode 100644 tests/boundary/process/processCaptureReplayMachine.test.ts create mode 100644 tests/boundary/process/processCaptureReplayTransport.test.ts create mode 100644 tests/boundary/process/processCaptureTerminal.test.ts create mode 100644 tests/boundary/process/processCaptureTraceProjection.test.ts create mode 100644 tests/boundary/process/processCaptureValidation.test.ts rename tests/{ => boundary/process}/processObservation.test.ts (95%) create mode 100644 tests/boundary/process/processOwnership.test.ts create mode 100644 tests/boundary/process/processOwnershipPart2.test.ts create mode 100644 tests/boundary/process/processOwnershipPart3.test.ts create mode 100644 tests/boundary/process/processOwnershipValidation.test.ts create mode 100644 tests/boundary/process/processOwnershipValidationPart2.test.ts create mode 100644 tests/boundary/process/processOwnershipValidationPart3.test.ts rename tests/{ => boundary/process}/providerProcess.test.ts (94%) rename tests/{ => boundary/process}/runtimeExecutableDiagnostics.test.ts (96%) rename tests/{ => boundary/providers/ghidra}/ghidraBridgeTruthfulness.test.ts (93%) rename tests/{ => boundary/providers/ghidra}/ghidraClient.test.ts (92%) rename tests/{ => boundary/providers/ghidra}/ghidraDoctor.test.ts (93%) rename tests/{ => boundary/providers/ghidra}/ghidraFunctionValues.test.ts (97%) rename tests/{ => boundary/providers/ghidra}/ghidraInstallation.test.ts (98%) rename tests/{ => boundary/providers/ghidra}/ghidraInventoryValues.test.ts (97%) rename tests/{ => boundary/providers/ghidra}/ghidraLauncher.test.ts (96%) rename tests/{ => boundary/providers/ghidra}/ghidraProvider.test.ts (81%) rename tests/{ => boundary/providers/ghidra}/ghidraTargetSnapshot.test.ts (90%) rename tests/{ => boundary/providers/ghidra}/ghidraTransport.test.ts (93%) rename tests/{ => boundary/providers/hopper}/bridgeLauncher.test.ts (95%) create mode 100644 tests/boundary/providers/hopper/bridgeRegex.literalBounds.test.ts rename tests/{bridgeRegex.test.ts => boundary/providers/hopper/bridgeRegex.matching.test.ts} (72%) create mode 100644 tests/boundary/providers/hopper/bridgeRegex.workLimits.test.ts rename tests/{ => boundary/providers/hopper}/bridgeTruthfulness.test.ts (99%) rename tests/{ => boundary/providers/hopper}/hopperAnalysisProfile.test.ts (93%) rename tests/{ => boundary/providers/hopper}/hopperBridgeFacade.test.ts (94%) create mode 100644 tests/boundary/providers/hopper/hopperClient.cleanup.test.ts create mode 100644 tests/boundary/providers/hopper/hopperClient.progress.test.ts create mode 100644 tests/boundary/providers/hopper/hopperClient.protocol.test.ts create mode 100644 tests/boundary/providers/hopper/hopperClient.restart.test.ts create mode 100644 tests/boundary/providers/hopper/hopperClient.startupFailures.test.ts rename tests/{ => boundary/providers/hopper}/hopperValues.test.ts (98%) rename tests/{ => boundary/providers/hopper}/linuxHopper.test.ts (99%) rename tests/{ => boundary/providers/hopper}/linuxPrivateDisplay.test.ts (94%) rename tests/{ => boundary/providers/hopper}/macHopper.test.ts (98%) rename tests/{ => boundary/providers/managed}/managedApplicationGraph.test.ts (89%) create mode 100644 tests/boundary/providers/managed/managedArtifactProvider.cilDecoding.test.ts create mode 100644 tests/boundary/providers/managed/managedArtifactProvider.cilLimits.test.ts create mode 100644 tests/boundary/providers/managed/managedArtifactProvider.cliEvidence.test.ts create mode 100644 tests/boundary/providers/managed/managedArtifactProvider.failures.test.ts create mode 100644 tests/boundary/providers/managed/managedArtifactProvider.inventory.test.ts create mode 100644 tests/boundary/providers/managed/managedArtifactProvider.members.test.ts create mode 100644 tests/boundary/providers/managed/managedArtifactProvider.nativeBoundaries.test.ts rename tests/{ => boundary/providers/managed}/managedMemberComparison.test.ts (92%) rename tests/{ => boundary/providers/managed}/managedReconstruction.test.ts (91%) rename tests/{ => boundary/providers/managed}/peInspection.test.ts (99%) create mode 100644 tests/boundary/providers/native/artifactProvider.archiveInventory.test.ts create mode 100644 tests/boundary/providers/native/artifactProvider.archiveSafety.test.ts create mode 100644 tests/boundary/providers/native/artifactProvider.directoryInventory.test.ts create mode 100644 tests/boundary/providers/native/artifactProvider.extraction.test.ts create mode 100644 tests/boundary/providers/native/artifactProvider.paginationAndSlices.test.ts rename tests/{ => boundary/providers/native}/bytecodeProvider.test.ts (98%) rename tests/{ => boundary/providers/native}/nativeApiInspection.test.ts (88%) rename tests/{ => boundary/providers/native}/nativeDmgArtifactReader.test.ts (97%) rename tests/{ => boundary/providers/native}/nativeProvider.test.ts (94%) rename tests/{ => boundary/providers/native}/objcSwiftMetadata.test.ts (98%) rename tests/{ => boundary/providers/replay}/javascriptReplay.test.ts (94%) rename tests/{ => boundary/providers/replay}/javascriptReplayWorker.test.ts (100%) rename tests/{ => boundary/providers/replay}/replayMachineRun.test.ts (98%) create mode 100644 tests/boundary/providers/replay/replayMachineRuntime.test.ts create mode 100644 tests/boundary/providers/replay/replayMachineRuntimeCaptures.test.ts rename tests/{ => boundary/providers/replay}/replayWorkerProtocol.test.ts (95%) delete mode 100644 tests/browserMcp.test.ts delete mode 100644 tests/cdpBrowserProvider.test.ts rename tests/{ => composition/analysis-sessions}/analysisContextQueries.test.ts (94%) rename tests/{ => composition/analysis-sessions}/analysisProfile.test.ts (97%) rename tests/{ => composition/analysis-sessions}/analysisProviderRegistry.test.ts (95%) rename tests/{ => composition/analysis-sessions}/analysisSnapshot.test.ts (88%) rename tests/{ => composition/analysis-sessions}/androidApplication.test.ts (88%) rename tests/{ => composition/analysis-sessions}/appleApplication.test.ts (92%) rename tests/{ => composition/analysis-sessions}/artifactComparison.test.ts (93%) rename tests/{ => composition/analysis-sessions}/artifactInventorySnapshot.test.ts (91%) rename tests/{ => composition/analysis-sessions}/authorizedArtifactInventory.test.ts (83%) create mode 100644 tests/composition/analysis-sessions/binarySession.01-returns-detached-provider-target-and.test.ts create mode 100644 tests/composition/analysis-sessions/binarySession.02-runs-through-a-non-hopper.test.ts create mode 100644 tests/composition/analysis-sessions/binarySession.03-allocates-one-fresh-run-identity.test.ts create mode 100644 tests/composition/analysis-sessions/binarySession.04-replays-exact-immutable-calls-from.test.ts create mode 100644 tests/composition/analysis-sessions/binarySession.05-publishes-provider-health-availability-changes.test.ts create mode 100644 tests/composition/analysis-sessions/binarySession.06-returns-typed-unavailability-without-dispatching.test.ts create mode 100644 tests/composition/analysis-sessions/binarySession.07-replaces-the-active-client-when.test.ts create mode 100644 tests/composition/analysis-sessions/binarySession.08-cancels-legacy-profile-resolution-even.test.ts create mode 100644 tests/composition/analysis-sessions/binarySession.09-does-not-start-a-replacement.test.ts rename tests/{ => composition/analysis-sessions}/binaryTarget.test.ts (98%) rename tests/{ => composition/analysis-sessions}/callPath.test.ts (93%) rename tests/{ => composition/analysis-sessions}/capabilityInventory.test.ts (97%) rename tests/{ => composition/analysis-sessions}/compositeProvider.test.ts (86%) rename tests/{ => composition/analysis-sessions}/contractSnapshot.test.ts (70%) rename tests/{ => composition/analysis-sessions}/crossVersionInventory.test.ts (93%) rename tests/{ => composition/analysis-sessions}/errors.test.ts (94%) rename tests/{ => composition/analysis-sessions}/functionComparison.test.ts (95%) rename tests/{ => composition/analysis-sessions}/hopperProviderSelection.test.ts (92%) rename tests/{ => composition/analysis-sessions}/identity.test.ts (93%) rename tests/{ => composition/analysis-sessions}/inputIssueProjection.test.ts (86%) rename tests/{ => composition/analysis-sessions}/jsonLimits.test.ts (92%) rename tests/{ => composition/analysis-sessions}/progressReporter.test.ts (93%) rename tests/{ => composition/analysis-sessions}/promptContracts.test.ts (96%) rename tests/{ => composition/analysis-sessions}/providerArchitecture.test.ts (89%) rename tests/{ => composition/analysis-sessions}/providerBinding.test.ts (93%) rename tests/{ => composition/analysis-sessions}/runtime.test.ts (94%) rename tests/{ => composition/analysis-sessions}/toolContracts.test.ts (83%) rename tests/{ => composition/evidence-investigations}/changedBehavior.test.ts (97%) rename tests/{ => composition/evidence-investigations}/completionLedgerGeneration.test.ts (95%) rename tests/{ => composition/evidence-investigations}/evidence.test.ts (95%) rename tests/{ => composition/evidence-investigations}/evidenceCompletionLedger.test.ts (98%) create mode 100644 tests/composition/evidence-investigations/investigationWorkspaceHistory.test.ts create mode 100644 tests/composition/evidence-investigations/investigationWorkspaceIntegrity.test.ts rename tests/{investigationWorkspace.test.ts => composition/evidence-investigations/investigationWorkspaceMigration.test.ts} (51%) rename tests/{ => composition/evidence-investigations}/reconstructionCoverage.test.ts (95%) create mode 100644 tests/composition/evidence-investigations/reconstructionObligationLedgerAuthority.test.ts rename tests/{reconstructionObligationLedger.test.ts => composition/evidence-investigations/reconstructionObligationLedgerClosure.test.ts} (62%) rename tests/{ => composition/evidence-investigations}/reconstructionReadiness.test.ts (94%) rename tests/{ => composition/evidence-investigations}/reconstructionVerification.test.ts (94%) rename tests/{ => composition/evidence-investigations}/residualUnknown.test.ts (96%) rename tests/{ => composition/javascript-applications}/bundleComparison.test.ts (95%) create mode 100644 tests/composition/javascript-applications/electronApplicationAnalysis.test.ts create mode 100644 tests/composition/javascript-applications/javascriptApplicationGraph.01-defines-the-complete-provider-neutral.test.ts create mode 100644 tests/composition/javascript-applications/javascriptApplicationGraph.02-keeps-static-observations-static-inferences.test.ts create mode 100644 tests/composition/javascript-applications/javascriptApplicationGraph.03-normalizes-structural-identity-and-evidence.test.ts create mode 100644 tests/composition/javascript-applications/javascriptApplicationGraph.04-admits-source-map-and-observation.test.ts create mode 100644 tests/composition/javascript-applications/javascriptApplicationGraph.05-rejects-duplicate-nodes-missing-roots.test.ts create mode 100644 tests/composition/javascript-applications/javascriptApplicationGraph.06-requires-every-identity-strategy-to.test.ts create mode 100644 tests/composition/javascript-applications/javascriptApplicationGraph.07-rejects-false-truncation-claims-and.test.ts rename tests/{ => composition/javascript-applications}/javascriptArtifactPathResolution.test.ts (95%) create mode 100644 tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts create mode 100644 tests/composition/javascript-applications/javascriptArtifactReconstructionLimits.test.ts rename tests/{ => composition/javascript-applications}/javascriptBundlerAdapters.test.ts (97%) rename tests/{ => composition/javascript-applications}/javascriptExportInstrumentation.test.ts (96%) rename tests/{ => composition/javascript-applications}/javascriptExportShapeComparison.test.ts (91%) rename tests/{ => composition/javascript-applications}/javascriptModuleRelationships.test.ts (96%) create mode 100644 tests/composition/javascript-applications/javascriptSemanticAnalysis.calls.test.ts create mode 100644 tests/composition/javascript-applications/javascriptSemanticAnalysis.dataflow.test.ts create mode 100644 tests/composition/javascript-applications/javascriptSemanticAnalysis.native_bindings.test.ts create mode 100644 tests/composition/javascript-applications/javascriptSemanticAnalysis.rejection.test.ts create mode 100644 tests/composition/javascript-applications/javascriptSemanticAnalysis.runtime_boundaries.test.ts create mode 100644 tests/composition/javascript-applications/javascriptSemanticAnalysis.structure.test.ts create mode 100644 tests/composition/javascript-applications/javascriptSemanticAnalysis.support.ts create mode 100644 tests/composition/javascript-applications/javascriptSemanticGraphBuilder.test.ts create mode 100644 tests/composition/javascript-applications/javascriptSemanticGraphQuery.test.ts create mode 100644 tests/composition/javascript-applications/javascriptSemanticGraphResources.test.ts create mode 100644 tests/composition/javascript-applications/javascriptSemanticGraphTraversal.test.ts rename tests/{ => composition/javascript-applications}/referenceSourceGraph.test.ts (98%) rename tests/{ => composition/javascript-applications}/sourceToBundleComparison.test.ts (95%) rename tests/{ => composition/javascript-applications}/webBundleAnalyzer.test.ts (96%) rename tests/{ => composition/javascript-applications}/webInventory.test.ts (97%) rename tests/{ => composition/process-capture}/processCapturePermission.test.ts (83%) rename tests/{ => composition/process-capture}/processPairedExperiment.test.ts (93%) rename tests/{ => composition/process-capture}/processReactiveCoordinator.test.ts (57%) create mode 100644 tests/composition/process-capture/processReactiveCoordinatorFailures.test.ts create mode 100644 tests/composition/process-capture/processReactiveCoordinatorFailuresPart2.test.ts create mode 100644 tests/composition/process-capture/processReactiveRuntime.test.ts create mode 100644 tests/composition/process-capture/processReactiveRuntimePart2.test.ts create mode 100644 tests/composition/process-capture/processReactiveRuntimePart3.test.ts create mode 100644 tests/composition/process-capture/processReactiveRuntimePredicates.test.ts create mode 100644 tests/composition/process-capture/processReactiveRuntimePredicatesPart2.test.ts rename tests/{ => composition/process-capture}/processReactiveScenario.test.ts (97%) rename tests/{ => composition/process-capture}/processTraceAdapters.test.ts (66%) rename tests/{ => composition/process-capture}/processTraceComparison.test.ts (53%) create mode 100644 tests/composition/process-capture/processTraceComparisonPart2.test.ts create mode 100644 tests/composition/process-capture/processTraceSpecification.test.ts rename tests/{ => composition/process-capture}/sensitiveTextCapture.test.ts (93%) rename tests/{ => composition/runtime-observation}/browserObservation.test.ts (93%) rename tests/{ => composition/runtime-observation}/browserScenario.test.ts (98%) rename tests/{ => composition/runtime-observation}/browserScenarioDiff.test.ts (97%) rename tests/{ => composition/runtime-observation}/electronFileScope.test.ts (92%) create mode 100644 tests/composition/runtime-observation/javascriptRuntimeReconciliation.test.ts rename tests/{ => composition/runtime-observation}/managedRuntimeCorrelation.test.ts (90%) rename tests/{ => composition/runtime-observation}/nodeRuntimeCharacterization.test.ts (94%) rename tests/{ => composition/runtime-observation}/pngVisualDiff.test.ts (95%) rename tests/{ => composition/runtime-observation}/replayMachine.test.ts (98%) rename tests/{ => composition/runtime-observation}/runtimeCharacterization.test.ts (97%) rename tests/{ => composition/runtime-observation}/runtimeIdentification.test.ts (94%) rename tests/{ => composition/runtime-observation}/staticRuntimeCorrelation.test.ts (96%) rename tests/{ => composition/runtime-observation}/webCaptureDiff.test.ts (95%) create mode 100644 tests/composition/setup-lifecycle/browserConfig.test.ts rename tests/{ => composition/setup-lifecycle}/clientRegistrationStatus.test.ts (90%) rename tests/{ => composition/setup-lifecycle}/config.test.ts (77%) rename tests/{ => composition/setup-lifecycle}/directAnalysisPermissions.test.ts (79%) rename tests/{ => composition/setup-lifecycle}/permissionAuthority.test.ts (97%) rename tests/{ => composition/setup-lifecycle}/permissionPolicy.test.ts (98%) rename tests/{ => composition/setup-lifecycle}/projectPermissionStore.test.ts (97%) rename tests/{ => composition/setup-lifecycle}/setupConfig.test.ts (95%) rename tests/{ => composition/setup-lifecycle}/setupSkill.test.ts (91%) rename tests/{ => composition/setup-lifecycle}/statusProjection.test.ts (88%) rename tests/{ => conformance/providers}/conformanceFixtures.test.ts (96%) rename tests/{ => conformance/providers}/conformancePackage.test.ts (98%) rename tests/{ => conformance/providers}/conformanceReplay.test.ts (97%) rename tests/{ => conformance/providers}/conformanceTrustGate.test.ts (96%) rename tests/{ => conformance/providers}/coverageMonotonicity.test.ts (94%) rename tests/{ => conformance/providers/ghidra}/ghidraWindowsFixture.test.ts (92%) rename tests/{ => conformance/providers/ghidra}/realGhidraWindowsWorkflow.test.ts (90%) rename tests/{ => conformance/providers/ghidra}/windowsCapabilities.test.ts (97%) rename tests/{ => conformance/providers/hopper}/realHopperAssertions.test.ts (98%) rename tests/{ => conformance/providers/hopper}/realHopperFixture.test.ts (96%) rename tests/{ => conformance/providers/hopper}/realHopperPagination.test.ts (95%) rename tests/{ => conformance/providers/hopper}/realHopperSemantic.test.ts (97%) rename tests/{ => conformance/providers/managed}/managedConformanceFixture.test.ts (78%) rename tests/{ => conformance/providers/managed}/managedNativeVerification.test.ts (93%) rename tests/{ => conformance}/readmeConsistency.test.ts (80%) rename tests/{ => conformance}/releaseConfiguration.test.ts (100%) rename tests/{ => conformance}/scanTodos.test.ts (94%) delete mode 100644 tests/dispatcher.test.ts delete mode 100644 tests/electronApplicationAnalysis.test.ts delete mode 100644 tests/electronMcp.test.ts delete mode 100644 tests/enhancedTools.test.ts rename tests/{ => evaluation/model-evals}/codexAgentEval.test.ts (97%) delete mode 100644 tests/evidenceResources.test.ts create mode 100644 tests/fixtures/fakeCdpBrowserEvents.ts create mode 100644 tests/fixtures/fakeCdpBrowserResults.ts create mode 100644 tests/fixtures/fakeCdpBrowserTargets.ts create mode 100644 tests/fixtures/fakeCdpBrowserTypes.ts delete mode 100644 tests/ghidraMcp.test.ts delete mode 100644 tests/hopperClient.test.ts delete mode 100644 tests/integration/serverIntegration.test.ts delete mode 100644 tests/investigationMcp.test.ts delete mode 100644 tests/javascriptApplicationGraph.test.ts delete mode 100644 tests/javascriptArtifactReconstruction.test.ts delete mode 100644 tests/javascriptRuntimeReconciliation.test.ts delete mode 100644 tests/javascriptSemanticAnalysis.test.ts delete mode 100644 tests/javascriptSemanticGraphBuilder.test.ts delete mode 100644 tests/javascriptSemanticGraphQuery.test.ts delete mode 100644 tests/managedArtifactProvider.test.ts delete mode 100644 tests/managedMcp.test.ts rename tests/{ => process-global}/verifierRun.test.ts (99%) create mode 100644 tests/process-global/vitestConfiguration.test.ts delete mode 100644 tests/processCapture.test.ts delete mode 100644 tests/processCaptureElicitation.test.ts delete mode 100644 tests/processOwnership.test.ts delete mode 100644 tests/processReactiveRuntime.test.ts delete mode 100644 tests/replayMachineRuntime.test.ts delete mode 100644 tests/setup.test.ts create mode 100644 tests/support/cli/cliFixture.ts create mode 100644 tests/support/fixtures.ts create mode 100644 tests/support/mcp/mcpFixture.ts create mode 100644 tests/support/network/loopbackFixture.ts create mode 100644 tests/support/process/processFixture.ts create mode 100644 tests/support/workspace/workspaceFixture.ts delete mode 100644 tests/vitestConfiguration.test.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index aa9e7f711..be3774510 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -18,10 +18,84 @@ }, "overrides": [ { - "files": ["tests/**/*.ts"], + "files": ["tests/**/*.ts", "src/**/*.test.ts"], "rules": { - "max-lines-per-function": "off", - "max-lines": "off" + "max-lines": [ + "error", + { "max": 500, "skipBlankLines": true, "skipComments": true } + ], + "max-lines-per-function": [ + "error", + { "max": 150, "skipBlankLines": true, "skipComments": true } + ] + } + }, + { + "files": ["src/domain/**/*.test.ts"], + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": [ + "**/application/**", + "**/server/**", + "**/process/**", + "**/browser/**", + "**/ghidra/**", + "**/hopper/**", + "**/replay/**", + "**/cli.*", + "**/main.*" + ], + "message": "Domain tests may import only their owning domain and inward dependencies." + } + ] + } + ] + } + }, + { + "files": ["src/application/**/*.test.ts"], + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": ["**/server/**", "**/cli.*", "**/main.*"], + "message": "Service tests must use application ports rather than CLI or MCP entrypoints." + } + ] + } + ] + } + }, + { + "files": ["tests/composition/**/*.test.ts"], + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": [ + "**/server/**", + "**/cli.*", + "**/main.*", + "node:child_process", + "node:net", + "node:http", + "node:https", + "ws", + "playwright" + ], + "message": "Composition tests may wire provider-neutral sessions but must not invoke CLI, MCP, process, network, or browser boundaries." + } + ] + } + ] } } ], diff --git a/src/application/BinarySessionComposition.test.ts b/src/application/BinarySessionComposition.test.ts new file mode 100644 index 000000000..b957997f1 --- /dev/null +++ b/src/application/BinarySessionComposition.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import type { + AnalysisClient, + AnalysisProvider, + ProviderIdentity, +} from "./AnalysisProvider.js"; +import { AnalysisProviderRegistry } from "./AnalysisProviderRegistry.js"; +import { composeBinarySession } from "./BinarySessionComposition.js"; + +const auxiliaryProvider = (identity: ProviderIdentity): AnalysisProvider => ({ + identity: () => identity, + capabilities: () => [], + createClient: (): AnalysisClient => { + throw new Error("The composition check must not create provider clients"); + }, +}); + +describe("composeBinarySession", () => { + it("exposes the complete configured provider set without starting clients", () => { + const session = composeBinarySession(new AnalysisProviderRegistry([]), [ + auxiliaryProvider({ + id: "test-auxiliary", + name: "Test auxiliary provider", + version: "1.0.0", + }), + ]); + + expect(session.providerIdentity()).toEqual({ + id: "composite:test-auxiliary", + name: "REA composite analysis provider", + version: null, + }); + }); +}); diff --git a/tests/boundedCartesianProjection.test.ts b/src/domain/boundedCartesianProjection.test.ts similarity index 87% rename from tests/boundedCartesianProjection.test.ts rename to src/domain/boundedCartesianProjection.test.ts index 9a8953c5d..0d94d2517 100644 --- a/tests/boundedCartesianProjection.test.ts +++ b/src/domain/boundedCartesianProjection.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { projectBoundedCartesian } from "../src/domain/boundedCartesianProjection.js"; +import { projectBoundedCartesian } from "./boundedCartesianProjection.js"; describe("bounded Cartesian projection", () => { it("stops projection work at the output limit", () => { diff --git a/tests/jsonShape.test.ts b/src/domain/jsonShape.test.ts similarity index 96% rename from tests/jsonShape.test.ts rename to src/domain/jsonShape.test.ts index 91a0e0ccd..896ca799f 100644 --- a/tests/jsonShape.test.ts +++ b/src/domain/jsonShape.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { inferJsonShape } from "../src/domain/jsonShape.js"; +import { inferJsonShape } from "./jsonShape.js"; describe("inferJsonShape", () => { it("retains paths and types without retaining JSON values", () => { diff --git a/tests/symbolAnalysis.test.ts b/src/domain/symbolAnalysis.test.ts similarity index 98% rename from tests/symbolAnalysis.test.ts rename to src/domain/symbolAnalysis.test.ts index 4989d97c2..cad85a929 100644 --- a/tests/symbolAnalysis.test.ts +++ b/src/domain/symbolAnalysis.test.ts @@ -5,7 +5,7 @@ import { discoverObjcClasses, discoverObjcProtocols, discoverSwiftClasses, -} from "../src/domain/symbolAnalysis.js"; +} from "./symbolAnalysis.js"; describe("symbol analysis", () => { it("filters Swift classes and reports the uncapped match count", () => { diff --git a/tests/packageAnalysis.test.ts b/tests/acceptance/analysis/packageAnalysis.test.ts similarity index 98% rename from tests/packageAnalysis.test.ts rename to tests/acceptance/analysis/packageAnalysis.test.ts index d84e07ca2..88d10830c 100644 --- a/tests/packageAnalysis.test.ts +++ b/tests/acceptance/analysis/packageAnalysis.test.ts @@ -11,7 +11,7 @@ import { totalResourceSize, type PackageManifest, type DigitalSignature, -} from "../src/domain/packageAnalysis.js"; +} from "../../../src/domain/packageAnalysis.js"; describe("package analysis", () => { it("detects package format from extension", () => { diff --git a/tests/acceptance/applications/applicationEvidenceMcp.test.ts b/tests/acceptance/applications/applicationEvidenceMcp.test.ts new file mode 100644 index 000000000..a05747397 --- /dev/null +++ b/tests/acceptance/applications/applicationEvidenceMcp.test.ts @@ -0,0 +1,244 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { describe, expect, it } from "vitest"; + +import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js"; +import type { BinarySession } from "../../../src/application/BinarySession.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { + JAVASCRIPT_APPLICATION_VERSION_COMPARISON_EXAMPLE, + JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE, + JAVASCRIPT_FEATURE_TRACE_EXAMPLE, + JAVASCRIPT_VERSION_COMPARISON_FULL_EVIDENCE_EXAMPLE, + SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE, +} from "../../../src/contracts/javascriptApplicationWorkflowExamples.js"; +import { createEvidence } from "../../../src/domain/evidence.js"; +import { observed } from "../../fixtures/analysisExecution.js"; + +interface TestHarness { + readonly client: Client; + readonly session: BinarySession; + readonly resourceListChanges: () => number; + readonly close: () => Promise; +} + +async function createTestHarness(): Promise { + const session = composeBinarySessionFromFactory(() => ({ + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(), + })); + const server = createServer(session, session); + const client = new Client({ + name: "application-workflow-test", + version: "1", + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + let resourceListChanges = 0; + client.setNotificationHandler("notifications/resources/list_changed", () => { + resourceListChanges += 1; + }); + await server.connect(serverTransport); + await client.connect(clientTransport); + + return { + client, + session, + resourceListChanges: () => resourceListChanges, + close: async () => { + await client.close(); + await server.close(); + await session.close(); + }, + }; +} + +async function runInlineEvidenceScenarios( + client: Client, + session: BinarySession, +): Promise { + const traced = await client.callTool({ + name: "trace_application_feature", + arguments: JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE, + }); + expect(traced.isError).not.toBe(true); + expect(traced.structuredContent).toMatchObject({ + result: { + schema_version: 1, + coverage: { status: expect.any(String) }, + }, + }); + + const compared = await client.callTool({ + name: "compare_application_versions", + arguments: { + ...JAVASCRIPT_VERSION_COMPARISON_FULL_EVIDENCE_EXAMPLE, + unknown_registry_approved: true, + }, + }); + expect(compared.isError).not.toBe(true); + expect(compared.structuredContent).toMatchObject({ + result: { + schema_version: 1, + summary: { unknown: expect.any(Number) }, + coverage: { status: expect.any(String) }, + }, + }); + + const sourceCompared = await client.callTool({ + name: "compare_source_to_bundle", + arguments: { + reference: SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE.reference, + application: JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE.application, + }, + }); + expect(sourceCompared.isError).not.toBe(true); + expect(sourceCompared.structuredContent).toMatchObject({ + result: { + schema_version: 1, + reference: { + root_sha256: SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE.reference.root_sha256, + }, + scoring: { algorithm: "rea-source-to-bundle-signals/v1" }, + }, + }); + expect(session.exportEvidenceBundle().records.length).toBeGreaterThan(2); +} + +async function runEvidenceIdScenarios(harness: TestHarness): Promise { + await new Promise((resolve) => setImmediate(resolve)); + const notificationsBeforeIdReuse = harness.resourceListChanges(); + const tracedById = await harness.client.callTool({ + name: "trace_application_feature", + arguments: { + ...JAVASCRIPT_FEATURE_TRACE_EXAMPLE, + application_evidence_id: + JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE.application.evidence_id, + }, + }); + expect(tracedById.isError).not.toBe(true); + await new Promise((resolve) => setImmediate(resolve)); + expect(harness.resourceListChanges()).toBe(notificationsBeforeIdReuse); + + const comparedById = await harness.client.callTool({ + name: "compare_application_versions", + arguments: { + ...JAVASCRIPT_APPLICATION_VERSION_COMPARISON_EXAMPLE, + left_evidence_id: + JAVASCRIPT_VERSION_COMPARISON_FULL_EVIDENCE_EXAMPLE.left.evidence_id, + right_evidence_id: + JAVASCRIPT_VERSION_COMPARISON_FULL_EVIDENCE_EXAMPLE.right.evidence_id, + }, + }); + expect(comparedById).toMatchObject({ + structuredContent: { + result: { + evidence_links: expect.arrayContaining([ + JAVASCRIPT_VERSION_COMPARISON_FULL_EVIDENCE_EXAMPLE.left.evidence_id, + JAVASCRIPT_VERSION_COMPARISON_FULL_EVIDENCE_EXAMPLE.right.evidence_id, + ]), + }, + }, + }); + + const sourceComparedById = await harness.client.callTool({ + name: "compare_source_to_bundle", + arguments: { + ...SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE, + application_evidence_id: + JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE.application.evidence_id, + }, + }); + expect(sourceComparedById.isError).not.toBe(true); +} + +async function assertRejectedEvidenceReferences( + client: Client, + session: BinarySession, +): Promise { + const missing = await client.callTool({ + name: "trace_application_feature", + arguments: JAVASCRIPT_FEATURE_TRACE_EXAMPLE, + }); + expect(missing).toMatchObject({ + isError: true, + structuredContent: { + error: { details: { reason: "missing" } }, + }, + }); + + const wrongOperation = createEvidence( + undefined, + { id: "fixture", name: "Fixture", version: "1" }, + { operation: "inventory_artifact", parameters: {}, result: {} }, + ); + const wrongPredicate = createEvidence( + undefined, + { id: "fixture", name: "Fixture", version: "1" }, + { + predicateType: "fixture.application/v1", + operation: "analyze_javascript_application", + parameters: {}, + result: {}, + }, + ); + expect(session.recordEvidence(wrongOperation).ok).toBe(true); + expect(session.recordEvidence(wrongPredicate).ok).toBe(true); + for (const [record, reason] of [ + [wrongOperation, "wrong_operation"], + [wrongPredicate, "wrong_predicate"], + ] as const) { + const rejected = await client.callTool({ + name: "trace_application_feature", + arguments: { + ...JAVASCRIPT_FEATURE_TRACE_EXAMPLE, + application_evidence_id: record.evidence_id, + }, + }); + expect(rejected).toMatchObject({ + isError: true, + structuredContent: { error: { details: { reason } } }, + }); + } +} + +async function assertRejectedInlineEvidence(client: Client): Promise { + const duplicateNative = await client.callTool({ + name: "trace_application_feature", + arguments: { + ...JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE, + native_observations: [ + JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE.application, + ], + native_observation_evidence_ids: [ + JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE.application.evidence_id, + ], + }, + }); + expect(duplicateNative.isError).toBe(true); + + const spoofed = await client.callTool({ + name: "trace_application_feature", + arguments: { + ...JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE, + application: { + ...JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE.application, + provider: { id: "spoofed", name: "spoofed", version: "1" }, + }, + }, + }); + expect(spoofed.isError).toBe(true); +} + +describe("application workflow MCP parity", () => { + it("traces and compares authenticated graph Evidence in the session", async () => { + const harness = await createTestHarness(); + try { + await runInlineEvidenceScenarios(harness.client, harness.session); + await runEvidenceIdScenarios(harness); + await assertRejectedEvidenceReferences(harness.client, harness.session); + await assertRejectedInlineEvidence(harness.client); + } finally { + await harness.close(); + } + }); +}); diff --git a/tests/acceptance/applications/applicationExportShapeMcp.test.ts b/tests/acceptance/applications/applicationExportShapeMcp.test.ts new file mode 100644 index 000000000..d00b8fe58 --- /dev/null +++ b/tests/acceptance/applications/applicationExportShapeMcp.test.ts @@ -0,0 +1,142 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { describe, expect, it } from "vitest"; +import { copyFile, mkdir, rm } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { observed } from "../../fixtures/analysisExecution.js"; +import { analyzeJavaScriptApplication } from "../../../src/application/JavaScriptApplicationService.js"; +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascriptApplicationAnalysis.js"; +import { permissionAuthorityForRoot } from "../../fixtures/permissionAuthority.js"; + +describe("application workflow MCP parity", () => { + it("compares exact parser export shapes through full Evidence and session IDs", async () => { + const root = await createTestTempDirectory("rea-export-shape-mcp-"); + const leftRoot = join(root, "left"); + const rightRoot = join(root, "right"); + await Promise.all([mkdir(leftRoot), mkdir(rightRoot)]); + await Promise.all([ + copyFile( + resolve("tests/fixtures/replay/parser.mjs"), + join(leftRoot, "parser.mjs"), + ), + copyFile( + resolve("tests/fixtures/replay/parser-v2.mjs"), + join(rightRoot, "parser.mjs"), + ), + ]); + const authority = await permissionAuthorityForRoot( + root, + ["investigation_input"], + ["investigation_input"], + ); + const [left, right] = await Promise.all([ + analyzeJavaScriptApplication(authority, { + input_path: leftRoot, + approved: true, + }), + analyzeJavaScriptApplication(authority, { + input_path: rightRoot, + approved: true, + }), + ]); + if (!left.ok) throw left.error; + if (!right.ok) throw right.error; + const session = composeBinarySessionFromFactory(() => ({ + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(), + })); + const server = createServer(session, session); + const client = new Client({ name: "export-shape-test", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const selectors = { + left_module_path: "parser.mjs", + left_export_name: "default", + right_module_path: "parser.mjs", + right_export_name: "default", + }; + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const full = await client.callTool({ + name: "compare_javascript_export_shapes", + arguments: { left: left.value, right: right.value, ...selectors }, + }); + expect(full.isError).not.toBe(true); + expect(full.structuredContent).toMatchObject({ + result: { + summary: { added: 1, removed: 0, changed: 0, unknown: 0 }, + changes: [ + { + status: "added", + path: "/depth", + right: { availability: "literal", value: 1 }, + }, + ], + }, + }); + expect(session.exportEvidenceBundle().records).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + operation: "compare_javascript_export_shapes", + predicate_type: "rea.javascript-export-shape-comparison/v1", + }), + ]), + ); + + const byId = await client.callTool({ + name: "compare_javascript_export_shapes", + arguments: { + left_evidence_id: left.value.evidence_id, + right_evidence_id: right.value.evidence_id, + ...selectors, + }, + }); + expect(byId.isError).not.toBe(true); + expect(byId.structuredContent).toMatchObject({ + result: { + evidence_links: [left.value.evidence_id, right.value.evidence_id], + changes: [expect.objectContaining({ path: "/depth" })], + }, + }); + + const analyzed = javascriptApplicationAnalysisResultSchema.parse( + left.value.normalized_result, + ); + if (analyzed.schema_version !== 2) + throw new TypeError("Expected semantic application Evidence"); + const seed = analyzed.semantic_graph.relations[0]?.source_node_id; + if (seed === undefined) + throw new TypeError("Expected at least one semantic relation"); + const semantic = await client.callTool({ + name: "trace_javascript_semantics", + arguments: { + application: left.value, + query: { + seed: { kind: "semantic-node", node_id: seed }, + direction: "forward-influence", + include_ambiguous_dynamic_edges: true, + }, + }, + }); + expect(semantic.isError).not.toBe(true); + expect(semantic.structuredContent).toMatchObject({ + result: { + schema_version: 1, + source_evidence_id: left.value.evidence_id, + source_graph_id: analyzed.semantic_graph.graph_id, + summary: { retained_seed_matches: 1 }, + }, + }); + } finally { + await client.close(); + await server.close(); + await session.close(); + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/acceptance/applications/applicationReplayMcp.test.ts b/tests/acceptance/applications/applicationReplayMcp.test.ts new file mode 100644 index 000000000..03e1e95f6 --- /dev/null +++ b/tests/acceptance/applications/applicationReplayMcp.test.ts @@ -0,0 +1,277 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { createHash } from "node:crypto"; +import { readFile, realpath } from "node:fs/promises"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js"; +import type { BinarySession } from "../../../src/application/BinarySession.js"; +import { PermissionAuthority } from "../../../src/application/PermissionAuthority.js"; +import { controlledReplayOutputSchema } from "../../../src/domain/javascriptReplay.js"; +import { nodeCharacterizationPreparationOutputSchema } from "../../../src/domain/nodeRuntimeCharacterization.js"; +import { createPermissionPolicy } from "../../../src/domain/permissionPolicy.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { observed } from "../../fixtures/analysisExecution.js"; + +function createReplayPolicy(root: string) { + return { + enabled: true, + roots: [root], + nodePath: process.execPath, + bubblewrapPath: process.execPath, + systemdRunPath: process.execPath, + systemctlPath: process.execPath, + shellPath: process.execPath, + }; +} + +function createReplayAuthority(policy: ReturnType) { + const ceiling = { + capability: "javascript_replay" as const, + roots: policy.roots, + executables: [ + policy.nodePath, + policy.bubblewrapPath, + policy.systemdRunPath, + policy.systemctlPath, + policy.shellPath, + ], + environment_names: [], + network: "none" as const, + mount: true, + }; + return new PermissionAuthority( + createPermissionPolicy( + [ceiling], + [ + { + ...ceiling, + grant_id: "administrator:javascript_replay", + lifetime: "administrator", + operation_identity: null, + expires_at: null, + }, + ], + ), + ); +} + +function createReplayScenario(root: string) { + const policy = createReplayPolicy(root); + const session = composeBinarySessionFromFactory(() => ({ + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(), + })); + const server = createServer(session, session, { + permissionAuthority: createReplayAuthority(policy), + javascriptReplayPolicy: policy, + javascriptReplayHost: { + readSource: async (path) => ({ + canonicalPath: await realpath(path), + bytes: await readFile(path), + }), + identifyExecutable: async (path) => ({ + path, + version: "fixture", + sha256: "1".repeat(64), + }), + identifyWorker: async () => ({ + path: "/fixture/worker.js", + version: "fixture-worker-1", + sha256: "2".repeat(64), + }), + identifyRuntimeClosure: async (path) => [ + { + sourcePath: path, + destinationPath: "/runtime/node", + sha256: "1".repeat(64), + }, + ], + seccompDigest: () => "3".repeat(64), + probe: async () => undefined, + }, + javascriptReplayRunner: { + execute: async (prepared) => ({ + schema_version: 1, + plan_digest: prepared.publicPlan.plan_digest, + outcomes: [ + { + case_id: "heading", + outcome: "return", + value: { type: "heading", text: "Title" }, + input_sha256: + prepared.publicPlan.cases[0]?.sha256 ?? "0".repeat(64), + output_sha256: "2".repeat(64), + truncated: false, + }, + ], + stderr: "", + termination: "completed", + cleanup: { state: "complete", residual_resources: [] }, + limitations: ["fixture runner"], + reproducer: null, + }), + }, + }); + const client = new Client({ name: "replay-parity-test", version: "1" }); + return { client, server, session }; +} + +function createReplayInput(root: string) { + return { + mode: "plan", + left: { + modules: [ + { + alias: "parser", + path: resolve(root, "parser.mjs"), + format: "esm", + role: "module", + dependencies: {}, + }, + ], + entry_alias: "parser", + entry_export: "default", + }, + cases: [{ case_id: "heading", arguments: ["# Title"] }], + approved: false, + }; +} + +async function executeReplayScenario( + client: Client, + session: BinarySession, + root: string, +) { + const input = createReplayInput(root); + const planned = await client.callTool({ + name: "run_controlled_replay", + arguments: input, + }); + expect(planned.isError).not.toBe(true); + const digest = controlledReplayOutputSchema.parse(planned.structuredContent) + .plan?.plan_digest; + expect(digest).toMatch(/^[a-f0-9]{64}$/u); + const executed = await client.callTool({ + name: "run_controlled_replay", + arguments: { + ...input, + mode: "execute", + approved: true, + plan_digest: digest, + }, + }); + expect(executed.isError).not.toBe(true); + expect(executed.structuredContent).toMatchObject({ + phase: "execute", + evidence: { + authority: "controlled-replay", + provider: { id: "rea-javascript-replay" }, + }, + }); + expect(session.exportEvidenceBundle().records).toEqual( + expect.arrayContaining([ + expect.objectContaining({ authority: "controlled-replay" }), + ]), + ); +} + +async function createCharacterizationInput(root: string) { + const factoryPath = resolve(root, "sanitizer.factory.txt"); + const factoryBytes = await readFile(factoryPath); + const factorySha256 = createHash("sha256").update(factoryBytes).digest("hex"); + return { + preparation_approved: true, + selected_alias: "bundle", + expected_effect: "pure", + instrumentation: { + artifact_path: factoryPath, + artifact_sha256: factorySha256, + selection: { + byte_start: 0, + byte_end: factoryBytes.byteLength, + selected_sha256: factorySha256, + export_name: "selected", + }, + }, + replay: { + mode: "plan", + left: { + modules: [ + { + alias: "bundle", + path: factoryPath, + format: "commonjs-factory", + role: "module", + dependencies: {}, + }, + ], + entry_alias: "bundle", + entry_export: "selected", + }, + cases: [{ case_id: "heading", arguments: ["# Title"] }], + approved: false, + }, + }; +} + +async function executeCharacterizationScenario( + client: Client, + session: BinarySession, + root: string, +) { + const input = await createCharacterizationInput(root); + const plan = await client.callTool({ + name: "prepare_node_characterization", + arguments: input, + }); + expect(plan.isError).not.toBe(true); + const approvedPlan = nodeCharacterizationPreparationOutputSchema.parse( + plan.structuredContent, + ).plan; + const characterized = await client.callTool({ + name: "execute_node_characterization", + arguments: { + execution_approved: true, + approved_plan_sha256: approvedPlan.plan_sha256, + preparation: input, + }, + }); + expect(characterized.isError).not.toBe(true); + expect(characterized.structuredContent).toMatchObject({ + phase: "execution", + evidence: { + authority: "controlled-replay", + provider: { id: "rea-node-characterization" }, + }, + }); + expect(session.exportEvidenceBundle().records).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + predicate_type: "rea.javascript-export-transformation/v1", + }), + expect.objectContaining({ + predicate_type: "rea.runtime-characterization/v1", + }), + ]), + ); +} + +describe("application workflow MCP parity", () => { + it("plans and executes the same controlled replay contract", async () => { + const root = resolve("tests/fixtures/replay"); + const { client, server, session } = createReplayScenario(root); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + await executeReplayScenario(client, session, root); + await executeCharacterizationScenario(client, session, root); + } finally { + await client.close(); + await server.close(); + await session.close(); + } + }); +}); diff --git a/tests/applicationWorkflowCli.test.ts b/tests/acceptance/applications/applicationWorkflowCli.test.ts similarity index 92% rename from tests/applicationWorkflowCli.test.ts rename to tests/acceptance/applications/applicationWorkflowCli.test.ts index d7bbb05e3..7fe90a571 100644 --- a/tests/applicationWorkflowCli.test.ts +++ b/tests/acceptance/applications/applicationWorkflowCli.test.ts @@ -5,7 +5,7 @@ import { promisify } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { JAVASCRIPT_APPLICATION_VERSION_COMPARISON_EXAMPLE, @@ -13,29 +13,28 @@ import { JAVASCRIPT_FEATURE_TRACE_EXAMPLE, JAVASCRIPT_VERSION_COMPARISON_FULL_EVIDENCE_EXAMPLE, SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE, -} from "../src/contracts/javascriptApplicationWorkflowExamples.js"; +} from "../../../src/contracts/javascriptApplicationWorkflowExamples.js"; import { createEvidenceBundle, serializeEvidenceBundle, -} from "../src/domain/evidenceBundle.js"; -import { analyzeJavaScriptApplication } from "../src/application/JavaScriptApplicationService.js"; -import { permissionAuthorityForRoot } from "./fixtures/permissionAuthority.js"; -import { REPLAY_MACHINE_RUN_EXAMPLE } from "../src/contracts/replayMachineExample.js"; -import { javascriptApplicationAnalysisResultSchema } from "../src/domain/javascriptApplicationAnalysis.js"; +} from "../../../src/domain/evidenceBundle.js"; +import { analyzeJavaScriptApplication } from "../../../src/application/JavaScriptApplicationService.js"; +import { permissionAuthorityForRoot } from "../../fixtures/permissionAuthority.js"; +import { REPLAY_MACHINE_RUN_EXAMPLE } from "../../../src/contracts/replayMachineExample.js"; +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascriptApplicationAnalysis.js"; const execute = promisify(execFile); +const temporary: string[] = []; + +afterEach(async () => { + await Promise.all( + temporary + .splice(0) + .map(async (path) => rm(path, { recursive: true, force: true })), + ); +}); describe("application workflow CLI parity", () => { - const temporary: string[] = []; - - afterEach(async () => { - await Promise.all( - temporary - .splice(0) - .map(async (path) => rm(path, { recursive: true, force: true })), - ); - }); - it("accepts inline trace JSON and file-backed comparison JSON", async () => { const traced = await runCli([ "trace-application-feature", @@ -127,7 +126,9 @@ describe("application workflow CLI parity", () => { }, }); }, 20_000); +}); +describe("application workflow CLI Evidence resolution", () => { it("resolves Evidence IDs from an explicitly authorized bundle", async () => { const root = await createTestTempDirectory("rea-application-bundle-cli-"); temporary.push(root); @@ -223,8 +224,10 @@ describe("application workflow CLI parity", () => { environment, ); expect(denied).toMatchObject({ code: "permission_required" }); - }, 20_000); + }, 30_000); +}); +describe("application workflow CLI export Evidence", () => { it("compares exact export shapes from an authorized Evidence bundle", async () => { const root = await createTestTempDirectory("rea-export-shape-cli-"); temporary.push(root); @@ -295,7 +298,9 @@ describe("application workflow CLI parity", () => { }, }); }, 20_000); +}); +describe("application workflow CLI validation and replay", () => { it("returns safe actionable JSON validation details", async () => { const malformed = await runCli([ "trace-application-feature", diff --git a/tests/applicationWorkflowVerifier.test.ts b/tests/acceptance/applications/applicationWorkflowVerifier.test.ts similarity index 94% rename from tests/applicationWorkflowVerifier.test.ts rename to tests/acceptance/applications/applicationWorkflowVerifier.test.ts index 6e604eca6..2fa3a999f 100644 --- a/tests/applicationWorkflowVerifier.test.ts +++ b/tests/acceptance/applications/applicationWorkflowVerifier.test.ts @@ -5,9 +5,9 @@ import { promisify } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { writeVersionedJavaScriptApplicationFixtures } from "./fixtures/javascriptArtifactApplication.js"; +import { writeVersionedJavaScriptApplicationFixtures } from "../../fixtures/javascriptArtifactApplication.js"; const execute = promisify(execFile); diff --git a/tests/javascriptApplicationWorkflows.test.ts b/tests/acceptance/applications/javascriptApplicationWorkflows.test.ts similarity index 85% rename from tests/javascriptApplicationWorkflows.test.ts rename to tests/acceptance/applications/javascriptApplicationWorkflows.test.ts index 651c2a90a..4f450fbfe 100644 --- a/tests/javascriptApplicationWorkflows.test.ts +++ b/tests/acceptance/applications/javascriptApplicationWorkflows.test.ts @@ -2,34 +2,34 @@ import { rm } from "node:fs/promises"; import { afterEach, describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { analyzeJavaScriptApplication } from "../src/application/JavaScriptApplicationService.js"; -import { compareApplicationVersionsEvidence } from "../src/application/JavaScriptApplicationWorkflowService.js"; -import { createEvidence } from "../src/domain/evidence.js"; -import { applicationVersionComparisonResultSchema } from "../src/domain/javascriptApplicationVersionComparisonSchemas.js"; -import { compareJavaScriptApplicationVersions } from "../src/domain/javascriptApplicationVersionComparison.js"; -import { createJavaScriptApplicationGraph } from "../src/domain/javascriptApplicationGraph.js"; -import { traceApplicationFeature } from "../src/domain/javascriptFeatureTrace.js"; -import { applicationFeatureTraceResultSchema } from "../src/domain/javascriptFeatureTraceSchemas.js"; +import { analyzeJavaScriptApplication } from "../../../src/application/JavaScriptApplicationService.js"; +import { compareApplicationVersionsEvidence } from "../../../src/application/JavaScriptApplicationWorkflowService.js"; +import { createEvidence } from "../../../src/domain/evidence.js"; +import { applicationVersionComparisonResultSchema } from "../../../src/domain/javascriptApplicationVersionComparisonSchemas.js"; +import { compareJavaScriptApplicationVersions } from "../../../src/domain/javascriptApplicationVersionComparison.js"; +import { createJavaScriptApplicationGraph } from "../../../src/domain/javascriptApplicationGraph.js"; +import { traceApplicationFeature } from "../../../src/domain/javascriptFeatureTrace.js"; +import { applicationFeatureTraceResultSchema } from "../../../src/domain/javascriptFeatureTraceSchemas.js"; import { APPLICATION_GRAPH_DIGESTS, buildSyntheticJavaScriptApplicationGraph, -} from "./fixtures/javascriptApplicationGraph.js"; -import { writeVersionedJavaScriptApplicationFixtures } from "./fixtures/javascriptArtifactApplication.js"; -import { permissionAuthorityForRoot } from "./fixtures/permissionAuthority.js"; +} from "../../fixtures/javascriptApplicationGraph.js"; +import { writeVersionedJavaScriptApplicationFixtures } from "../../fixtures/javascriptArtifactApplication.js"; +import { permissionAuthorityForRoot } from "../../fixtures/permissionAuthority.js"; + +const temporary: string[] = []; + +afterEach(async () => { + await Promise.all( + temporary + .splice(0) + .map(async (path) => rm(path, { recursive: true, force: true })), + ); +}); describe("JavaScript application workflows", () => { - const temporary: string[] = []; - - afterEach(async () => { - await Promise.all( - temporary - .splice(0) - .map(async (path) => rm(path, { recursive: true, force: true })), - ); - }); - it("traces a contextBridge API through IPC into linked native evidence", () => { const graph = buildSyntheticJavaScriptApplicationGraph(); const nativeEvidence = createEvidence( @@ -129,7 +129,9 @@ describe("JavaScript application workflows", () => { expect(bounded.coverage.omitted_nodes).toBeGreaterThan(0); expect(bounded.summary.native_handoffs).toBe(0); }); +}); +describe("JavaScript application comparison workflows", () => { it("matches rechunked modules by exact source and minified modules by structural fingerprint", async () => { const root = await createTestTempDirectory("rea-application-versions-"); temporary.push(root); diff --git a/tests/mobileApplicationInvestigation.test.ts b/tests/acceptance/investigations/mobileApplicationInvestigation.test.ts similarity index 98% rename from tests/mobileApplicationInvestigation.test.ts rename to tests/acceptance/investigations/mobileApplicationInvestigation.test.ts index a51410c68..6f6d984e9 100644 --- a/tests/mobileApplicationInvestigation.test.ts +++ b/tests/acceptance/investigations/mobileApplicationInvestigation.test.ts @@ -10,11 +10,10 @@ import { nativeLibsByArchitecture, type MobileArtifactManifest, type AndroidManifest, - type IosManifest, type SigningInfo, type NativeLibrary, type MobileResource, -} from "../src/domain/mobileApplicationInvestigation.js"; +} from "../../../src/domain/mobileApplicationInvestigation.js"; describe("mobile application investigation", () => { it("detects mobile format from extension", () => { diff --git a/tests/packageInstallWorkflow.test.ts b/tests/acceptance/setup/packageInstallWorkflow.test.ts similarity index 87% rename from tests/packageInstallWorkflow.test.ts rename to tests/acceptance/setup/packageInstallWorkflow.test.ts index c6dd0071c..384e6b0f3 100644 --- a/tests/packageInstallWorkflow.test.ts +++ b/tests/acceptance/setup/packageInstallWorkflow.test.ts @@ -1,19 +1,22 @@ import { readFile } from "node:fs/promises"; import { describe, expect, it } from "vitest"; -import { packageHopperEnvironment } from "../scripts/verify-package-environment.mjs"; +import { packageHopperEnvironment } from "../../../scripts/verify-package-environment.mjs"; describe("package installation workflows", () => { it("runs package E2E without the retired native rebuild script", async () => { const continuousIntegration = await readFile( - new URL("../.github/workflows/ci.yml", import.meta.url), + new URL("../../../.github/workflows/ci.yml", import.meta.url), "utf8", ); const realHopperLinux = await readFile( - new URL("../.github/workflows/real-hopper-linux.yml", import.meta.url), + new URL( + "../../../.github/workflows/real-hopper-linux.yml", + import.meta.url, + ), "utf8", ); const realHopperMac = await readFile( - new URL("../.github/workflows/real-hopper.yml", import.meta.url), + new URL("../../../.github/workflows/real-hopper.yml", import.meta.url), "utf8", ); @@ -21,7 +24,7 @@ describe("package installation workflows", () => { expect(continuousIntegration).toContain("npm run verify:package"); expect(continuousIntegration).toContain("name: Static checks"); expect(continuousIntegration).toContain("npm run check:ci"); - expect(continuousIntegration).toContain("shard: [1/2, 2/2]"); + expect(continuousIntegration).toContain("shard: [1/4, 2/4, 3/4, 4/4]"); expect(continuousIntegration).toContain("name: Build package"); expect(continuousIntegration).toContain("npm run build:cached"); expect(continuousIntegration).toContain("name: rea-dist"); @@ -61,11 +64,11 @@ describe("package installation workflows", () => { it("verifies the package before publish and runs published canaries outside the checkout", async () => { const release = await readFile( - new URL("../.github/workflows/release.yml", import.meta.url), + new URL("../../../.github/workflows/release.yml", import.meta.url), "utf8", ); const canary = await readFile( - new URL("../scripts/verify-published-package.mjs", import.meta.url), + new URL("../../../scripts/verify-published-package.mjs", import.meta.url), "utf8", ); diff --git a/tests/acceptance/setup/setupConfiguration.test.ts b/tests/acceptance/setup/setupConfiguration.test.ts new file mode 100644 index 000000000..4840df75e --- /dev/null +++ b/tests/acceptance/setup/setupConfiguration.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from "vitest"; + +import { runSetup } from "../../../src/application/Setup.js"; +import { FakeSetupHost, options } from "./setupTestSupport.js"; + +describe("setup workflow", () => { + it("omits an aligned managed skill from an otherwise empty plan", async () => { + const host = new FakeSetupHost(); + host.hopper = "/Applications/Hopper"; + host.skill = "unchanged"; + const result = await runSetup(options(false), host); + expect(result.status).toBe("ready"); + expect(result.plannedActions).toEqual([]); + expect(result.appliedActions).toEqual([]); + expect(host.skillInstalls).toBe(0); + }); + + it("installs missing Linux demo dependencies for existing Hopper", async () => { + const host = new FakeSetupHost("linux"); + host.distribution = { + id: "ubuntu", + versionId: "24.04", + packageFamily: "deb", + supported: true, + }; + host.hopper = "/opt/hopper/bin/Hopper"; + host.linuxDemoRuntimeMissing = true; + const result = await runSetup( + { ...options(false), structured: false }, + host, + () => Promise.resolve(true), + ); + expect(result.status).toBe("ready"); + expect(host.hopperInstalls).toBe(1); + expect(result.plannedActions.map(({ kind }) => kind)).toContain( + "install_hopper", + ); + }); + + it("does not reinstall Hopper for an unsupported configured path", async () => { + const host = new FakeSetupHost("linux"); + host.distribution = { + id: "ubuntu", + versionId: "24.04", + packageFamily: "deb", + supported: true, + }; + host.hopper = "/custom/Hopper"; + host.unsupportedHopperVersion = true; + const result = await runSetup( + { ...options(false), structured: false }, + host, + () => Promise.resolve(true), + ); + expect(result.status).toBe("needs_human"); + expect(host.hopperInstalls).toBe(0); + expect(result.plannedActions.map(({ kind }) => kind)).not.toContain( + "install_hopper", + ); + }); + + it("reports a failed Hopper installation without configuring clients", async () => { + const host = new FakeSetupHost(); + host.hopperInstallSucceeds = false; + host.clients = [{ name: "cursor", configPath: "/cursor.json" }]; + const result = await runSetup(options(true, true), host); + expect(result.status).toBe("needs_human"); + expect(result.code).toBe("download_failed"); + expect(result.remediation).toBe("Download failed."); + expect(host.configurations).toBe(0); + }); + + it("blocks malformed client configuration before Hopper or file mutation", async () => { + const host = new FakeSetupHost(); + host.clients = [ + { name: "codex", displayName: "Codex", configPath: "/codex.toml" }, + ]; + host.clientInspections.set("codex", { + status: "invalid", + remediation: "Repair the existing TOML.", + }); + + const result = await runSetup(options(true, true), host); + + expect(result.status).toBe("needs_human"); + expect(result.remediation).toBe("Codex: Repair the existing TOML."); + expect(host.hopperInstalls).toBe(0); + expect(host.configurations).toBe(0); + expect(host.skillInstalls).toBe(0); + }); + + it("blocks malformed configuration before unrelated writes with an existing provider", async () => { + const host = new FakeSetupHost(); + host.hopper = "/Applications/Hopper"; + host.clients = [ + { + name: "claude_desktop", + displayName: "Claude Desktop", + configPath: "/claude.json", + }, + { name: "cursor", displayName: "Cursor", configPath: "/cursor.json" }, + ]; + host.clientInspections.set("claude_desktop", { + status: "invalid", + remediation: "Repair the existing JSON.", + }); + + const result = await runSetup(options(true), host); + + expect(result.status).toBe("needs_human"); + expect(result.remediation).toBe( + "Claude Desktop: Repair the existing JSON.", + ); + expect(host.configurations).toBe(0); + expect(host.skillInstalls).toBe(0); + }); + + it("plans a client update when Hopper installation changes its desired environment", async () => { + const host = new FakeSetupHost(); + host.clients = [ + { name: "codex", displayName: "Codex", configPath: "/codex.toml" }, + ]; + host.clientInspections.set("codex", { status: "already_current" }); + + const result = await runSetup(options(false), host); + + expect( + result.plannedActions.map(({ id, operation }) => ({ + id, + operation, + })), + ).toContainEqual({ + id: "configure_client:codex", + operation: "update", + }); + expect(host.configurations).toBe(0); + }); +}); + +describe("setup workflow action selection", () => { + it("executes only actions selected by the interactive adapter", async () => { + const host = new FakeSetupHost(); + host.hopper = "/Applications/Hopper"; + host.clients = [ + { name: "codex", displayName: "Codex", configPath: "/codex.toml" }, + { name: "cursor", displayName: "Cursor", configPath: "/cursor.json" }, + ]; + + const result = await runSetup( + { ...options(false), structured: false }, + host, + () => + Promise.resolve({ + approved: true, + selectedActionIds: ["configure_client:codex"], + }), + ); + + expect(result.plannedActions.map(({ id }) => id)).toEqual([ + "configure_client:codex", + ]); + expect(result.clients).toEqual({ codex: { status: "configured" } }); + expect(host.configurations).toBe(1); + expect(host.skillInstalls).toBe(0); + }); + + it.each([ + [ + "path", + "Agent configuration path could not be safely verified. Check its permissions and, if it is a symbolic link, verify that the link resolves to a regular file owned by the current user, then rerun setup.", + ], + [ + "backup", + "Agent configuration could not be backed up, so no change was made. Check file permissions, then rerun setup.", + ], + [ + "write", + "Agent configuration could not be updated. Check file permissions, then rerun setup.", + ], + [ + "readback", + "Agent configuration could not be verified after writing. Repair the configuration file or restore its `.rea.backup`, then rerun setup.", + ], + ] as const)("explains %s configuration recovery", async (reason, message) => { + const host = new FakeSetupHost(); + host.hopper = "/Applications/Hopper"; + host.clients = [{ name: "internal_client_key", configPath: "/agent.json" }]; + host.clientResults.set("internal_client_key", { status: "failed", reason }); + const result = await runSetup(options(true), host); + expect(result.status).toBe("needs_human"); + expect(result.remediation).toBe(message); + expect(result.remediation).not.toContain("internal_client_key"); + }); +}); diff --git a/tests/setupInstallFailure.test.ts b/tests/acceptance/setup/setupInstallFailure.test.ts similarity index 95% rename from tests/setupInstallFailure.test.ts rename to tests/acceptance/setup/setupInstallFailure.test.ts index 0fafb9812..9745c88e8 100644 --- a/tests/setupInstallFailure.test.ts +++ b/tests/acceptance/setup/setupInstallFailure.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { setupInstallFailure, type HopperInstallFailureReason, -} from "../src/application/SetupInstallFailure.js"; +} from "../../../src/application/SetupInstallFailure.js"; describe("setup installer failure projection", () => { it.each([ diff --git a/tests/setupLifecycle.test.ts b/tests/acceptance/setup/setupLifecycle.test.ts similarity index 97% rename from tests/setupLifecycle.test.ts rename to tests/acceptance/setup/setupLifecycle.test.ts index 28ff54226..05e88789c 100644 --- a/tests/setupLifecycle.test.ts +++ b/tests/acceptance/setup/setupLifecycle.test.ts @@ -11,17 +11,17 @@ import { import { dirname, join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { configureTomlClient, detectClients, -} from "../src/application/Setup.js"; +} from "../../../src/application/Setup.js"; import { runUninstall, systemUninstallHost, type UninstallFileSystem, -} from "../src/application/Uninstall.js"; +} from "../../../src/application/Uninstall.js"; const roots: string[] = []; afterEach(async () => { @@ -142,7 +142,9 @@ describe("agent lifecycle", () => { readFile(`${configPath}.rea.backup`, "utf8"), ).rejects.toThrow(); }); +}); +describe("agent lifecycle removal", () => { it("uninstalls only owned entries and refuses purge symlinks", async () => { const home = await createTestTempDirectory("rea-uninstall-"); roots.push(home); @@ -250,7 +252,9 @@ describe("agent lifecycle", () => { ); expect(await readFile(config, "utf8")).toBe("not-json"); }); +}); +describe("agent lifecycle failure reporting", () => { it.each([ [ "read", diff --git a/tests/acceptance/setup/setupPlanning.test.ts b/tests/acceptance/setup/setupPlanning.test.ts new file mode 100644 index 000000000..d410f16e5 --- /dev/null +++ b/tests/acceptance/setup/setupPlanning.test.ts @@ -0,0 +1,244 @@ +import { describe, expect, it } from "vitest"; + +import { + registrationPermissionEnvironment, + runSetup, + type SetupProgressEvent, +} from "../../../src/application/Setup.js"; +import { FakeSetupHost, options } from "./setupTestSupport.js"; + +describe("setup workflow", () => { + it("propagates only the explicit non-secret investigation root policy", () => { + expect( + registrationPermissionEnvironment({ + REA_INVESTIGATION_INPUT_ROOTS_JSON: '["/approved/apps"]', + AUTHORIZATION: "secret", + }), + ).toEqual({ + REA_INVESTIGATION_INPUT_ROOTS_JSON: '["/approved/apps"]', + }); + expect(registrationPermissionEnvironment({})).toEqual({}); + }); + + it("returns a complete plan without mutation", async () => { + const host = new FakeSetupHost(); + host.clients = [{ name: "cursor", configPath: "/cursor.json" }]; + const result = await runSetup(options(false), host); + expect(result.status).toBe("needs_confirmation"); + expect(result.plannedActions.map(({ kind }) => kind)).toEqual([ + "install_hopper", + "configure_client", + "install_skill", + ]); + expect(host.hopperInstalls).toBe(0); + expect(host.configurations).toBe(0); + expect(result.remediation).toBe( + "Review the setup plan, then rerun interactively or with --yes.", + ); + }); + + it("reports ready after an accepted Linux plan installs healthy Hopper", async () => { + const host = new FakeSetupHost("linux"); + host.distribution = { + id: "ubuntu", + versionId: "24.04", + packageFamily: "deb", + supported: true, + }; + const result = await runSetup( + { ...options(false), structured: false }, + host, + () => Promise.resolve(true), + ); + expect(result.status).toBe("ready"); + expect(result.appliedActions).toEqual([ + "installed_hopper", + "installed_skill", + ]); + expect(host.hopperInstalls).toBe(1); + expect(result.remediation).toBeUndefined(); + }); + + it("requires first-run activation after installing Hopper on macOS", async () => { + const host = new FakeSetupHost(); + const result = await runSetup( + { ...options(false), structured: false }, + host, + () => Promise.resolve(true), + ); + expect(result.status).toBe("needs_human"); + expect(result.remediation).toContain("choose its demo mode"); + }); + + it("declines an interactive plan without mutation", async () => { + const host = new FakeSetupHost(); + const result = await runSetup( + { ...options(false), structured: false }, + host, + () => Promise.resolve(false), + ); + expect(result.status).toBe("planned"); + expect(result.appliedActions).toEqual([]); + expect(host.hopperInstalls).toBe(0); + expect(host.doctorCalls).toBe(1); + }); + + it("treats an empty interactive selection as cancellation", async () => { + const host = new FakeSetupHost(); + host.clients = [{ name: "codex", configPath: "/codex.toml" }]; + + const result = await runSetup( + { ...options(false), structured: false }, + host, + () => Promise.resolve({ approved: false, selectedActionIds: [] }), + ); + + expect(result.status).toBe("planned"); + expect(result.plannedActions).toEqual([]); + expect(result.appliedActions).toEqual([]); + expect(host.configurations).toBe(0); + expect(host.skillInstalls).toBe(0); + expect(host.hopperInstalls).toBe(0); + expect(host.doctorCalls).toBe(1); + }); + + it("requires the Hopper flag for unattended setup", async () => { + const host = new FakeSetupHost(); + const result = await runSetup(options(true), host); + expect(result.status).toBe("needs_human"); + expect(host.hopperInstalls).toBe(0); + expect(result.appliedActions).toEqual(["installed_skill"]); + expect(result.remediation).toContain("--install-hopper"); + expect(result.remediation).toBe( + "Hopper is optional for non-Hopper providers. Rerun with --yes --install-hopper for deep native analysis.", + ); + }); + + it("installs Hopper when unattended authorization is explicit", async () => { + const host = new FakeSetupHost("linux"); + host.distribution = { + id: "ubuntu", + versionId: "24.04", + packageFamily: "deb", + supported: true, + }; + const result = await runSetup(options(true, true), host); + expect(result.appliedActions).toEqual([ + "installed_hopper", + "installed_skill", + ]); + expect(host.hopperInstalls).toBe(1); + expect(result.status).toBe("ready"); + expect(result.remediation).toBeUndefined(); + }); + + it("reuses existing Hopper and configures detected clients", async () => { + const host = new FakeSetupHost(); + host.hopper = "/Applications/Hopper"; + host.clients = [ + { name: "claude", configPath: "/claude.json" }, + { name: "cursor", configPath: "/cursor.json" }, + ]; + const result = await runSetup(options(true), host); + expect(result.status).toBe("ready"); + expect(result.appliedActions).toEqual([ + "configured_claude", + "configured_cursor", + "installed_skill", + ]); + expect(host.hopperInstalls).toBe(0); + }); +}); + +describe("setup workflow lifecycle and provider planning", () => { + it("reports append-only lifecycle progress for each selected action", async () => { + const host = new FakeSetupHost(); + host.hopper = "/Applications/Hopper"; + host.clients = [ + { + name: "codex", + displayName: "Codex", + configPath: "/codex.toml", + }, + ]; + const progress: SetupProgressEvent[] = []; + await runSetup( + { ...options(true), onProgress: (event) => progress.push(event) }, + host, + ); + expect(progress).toEqual([ + { actionId: "configure_client:codex", label: "Codex", state: "started" }, + { + actionId: "configure_client:codex", + label: "Codex", + state: "completed", + }, + { + actionId: "install_skill", + label: "REA reverse-engineering skill", + state: "started", + }, + { + actionId: "install_skill", + label: "REA reverse-engineering skill", + state: "completed", + }, + ]); + }); + + it("plans and propagates BYO Ghidra without installing software", async () => { + const host = new FakeSetupHost("linux"); + host.distribution = { + id: "ubuntu", + versionId: "24.04", + packageFamily: "deb", + supported: true, + }; + host.ghidra = "/opt/ghidra_12.1.2_PUBLIC"; + host.javaHome = "/usr/lib/jvm/jdk-21"; + host.doctorHealthy = true; + host.skill = "unchanged"; + host.clients = [{ name: "cursor", configPath: "/cursor.json" }]; + + const plan = await runSetup(options(false), host); + expect(plan.plannedActions.map(({ kind }) => kind)).toEqual([ + "configure_client", + ]); + expect(plan.plannedActions[0]?.detail).toContain( + "GHIDRA_INSTALL_DIR=/opt/ghidra_12.1.2_PUBLIC", + ); + expect(plan.plannedActions[0]?.detail).toContain( + "JAVA_HOME=/usr/lib/jvm/jdk-21", + ); + expect(host.hopperInstalls).toBe(0); + expect(host.configurations).toBe(0); + + const applied = await runSetup(options(true), host); + expect(applied.status).toBe("ready"); + expect(host.hopperInstalls).toBe(0); + expect(host.configuredProviderEnvironments).toContainEqual({ + GHIDRA_INSTALL_DIR: "/opt/ghidra_12.1.2_PUBLIC", + JAVA_HOME: "/usr/lib/jvm/jdk-21", + }); + }); + + it("reinstalls existing Hopper when explicitly requested", async () => { + const host = new FakeSetupHost(); + host.hopper = "/Applications/Hopper"; + host.skill = "unchanged"; + host.clients = [{ name: "codex", configPath: "/codex.toml" }]; + host.clientResults.set("codex", { status: "unchanged" }); + + const result = await runSetup(options(true, true), host); + + expect(result.plannedActions.map(({ kind }) => kind)).toEqual([ + "install_hopper", + "configure_client", + ]); + expect(result.appliedActions).toEqual(["installed_hopper"]); + expect(host.hopperInstalls).toBe(1); + expect(host.hopperReplaceRequests).toEqual([true]); + expect(host.configurations).toBe(0); + expect(host.checkedHopperPaths).toEqual(["/manual/Hopper"]); + }); +}); diff --git a/tests/acceptance/setup/setupRecovery.test.ts b/tests/acceptance/setup/setupRecovery.test.ts new file mode 100644 index 000000000..a89bf2fc9 --- /dev/null +++ b/tests/acceptance/setup/setupRecovery.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; + +import { runSetup } from "../../../src/application/Setup.js"; +import { FakeSetupHost, options } from "./setupTestSupport.js"; + +describe("setup workflow", () => { + it("records every detected client outcome after an earlier failure", async () => { + const host = new FakeSetupHost(); + host.hopper = "/Applications/Hopper"; + host.clients = [ + { name: "first", configPath: "/first.json" }, + { name: "second", configPath: "/second.json" }, + { name: "third", configPath: "/third.json" }, + ]; + host.clientResults.set("first", { status: "failed", reason: "write" }); + host.clientResults.set("second", { status: "configured" }); + host.clientResults.set("third", { status: "failed", reason: "readback" }); + + const result = await runSetup(options(true), host); + + expect(host.configurations).toBe(3); + expect(result.clients).toEqual({ + first: { status: "failed", reason: "write" }, + second: { status: "configured" }, + third: { status: "failed", reason: "readback" }, + }); + expect(result.appliedActions).toEqual(["configured_second"]); + expect(result.status).toBe("needs_human"); + expect(result.remediation).toContain("could not be updated"); + }); + + it("explains skill installation recovery", async () => { + const host = new FakeSetupHost(); + host.hopper = "/Applications/Hopper"; + host.skill = "failed"; + const result = await runSetup(options(true), host); + expect(result.status).toBe("needs_human"); + expect(result.remediation).toBe( + "REA analysis skill could not be installed or verified. Check permissions for `~/.agents/skills`, then rerun setup.", + ); + }); + + it("delegates remaining unhealthy checks to doctor remediation", async () => { + const host = new FakeSetupHost(); + host.hopper = "/Applications/Hopper"; + host.doctorHealthy = false; + const result = await runSetup(options(true), host); + expect(result.remediation).toBe( + "Run rea doctor and apply each reported remediation.", + ); + }); + + it("uses requested readiness scope without hiding environment drift", async () => { + const host = new FakeSetupHost(); + host.hopper = "/Applications/Hopper"; + host.doctorHealthy = false; + host.scopedDoctorHealthy = true; + const readinessScope = { + clients: ["codex"], + providers: [] as string[], + skill: true, + }; + + const result = await runSetup( + { + ...options(true), + proposeHopper: false, + installSkill: false, + readinessScope, + }, + host, + ); + + expect(result).toMatchObject({ + status: "ready", + doctor: { healthy: true, environment_healthy: false }, + }); + expect(host.doctorScopes).toEqual(expect.arrayContaining([readinessScope])); + }); + + it("explains unsupported Node and macOS recovery", async () => { + const platformHost = new FakeSetupHost("win32"); + expect((await runSetup(options(true), platformHost)).remediation).toBe( + "REA supports Hopper on macOS and selected 64-bit Linux distributions.", + ); + + const nodeHost = new FakeSetupHost(); + nodeHost.nodeVersion = "20.0.0"; + expect((await runSetup(options(true), nodeHost)).remediation).toBe( + "Install Node.js 22.19+ or 24.11+ and rerun setup.", + ); + + const macHost = new FakeSetupHost(); + macHost.version = "11.7"; + expect((await runSetup(options(true), macHost)).remediation).toBe( + "Upgrade to macOS 12 or newer.", + ); + }); + + it("rejects unsupported hosts before mutation", async () => { + const host = new FakeSetupHost("linux"); + host.distribution = { + id: "debian", + versionId: "13", + packageFamily: "deb", + supported: false, + }; + const result = await runSetup(options(true, true), host); + expect(result.status).toBe("needs_human"); + expect(host.hopperInstalls).toBe(0); + expect(result.remediation).toBe( + "Automated Hopper setup supports Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux; configure an existing supported provider instead.", + ); + }); + + it("configures BYO Ghidra on Linux outside Hopper's installer matrix", async () => { + const host = new FakeSetupHost("linux"); + host.distribution = { + id: "debian", + versionId: "13", + packageFamily: "deb", + supported: false, + }; + host.ghidra = "/opt/ghidra_12.1.2_PUBLIC"; + host.javaHome = "/usr/lib/jvm/jdk-21"; + host.doctorHealthy = true; + host.skill = "unchanged"; + host.clients = [{ name: "codex", configPath: "/codex.toml" }]; + + const result = await runSetup(options(true), host); + + expect(result.status).toBe("ready"); + expect(host.hopperInstalls).toBe(0); + expect(host.configuredProviderEnvironments).toContainEqual({ + GHIDRA_INSTALL_DIR: "/opt/ghidra_12.1.2_PUBLIC", + JAVA_HOME: "/usr/lib/jvm/jdk-21", + }); + }); +}); diff --git a/tests/acceptance/setup/setupTestSupport.ts b/tests/acceptance/setup/setupTestSupport.ts new file mode 100644 index 000000000..f69513d48 --- /dev/null +++ b/tests/acceptance/setup/setupTestSupport.ts @@ -0,0 +1,170 @@ +import { + type ClientConfigurationInspection, + type ClientConfigurationResult, + type SetupClient, + type SetupHost, + type SetupOptions, + type SetupHopperInstallResult, + type SetupProviderEnvironment, +} from "../../../src/application/Setup.js"; +import type { + DoctorCheck, + DoctorReport, + DoctorScope, +} from "../../../src/application/Doctor.js"; +import type { LinuxDistribution } from "../../../src/application/LinuxHopper.js"; + +export class FakeSetupHost implements SetupHost { + readonly platform: NodeJS.Platform; + nodeVersion = "25.1.0"; + version: string | undefined = "14.5"; + distribution: LinuxDistribution | undefined; + hopper: string | undefined; + ghidra: string | undefined; + javaHome: string | undefined; + hopperInstallSucceeds = true; + skill: "installed" | "unchanged" | "failed" = "installed"; + clients: readonly SetupClient[] = []; + clientResults = new Map(); + clientInspections = new Map(); + hopperInstalls = 0; + hopperReplaceRequests: boolean[] = []; + configurations = 0; + skillInstalls = 0; + doctorCalls = 0; + doctorScopes: Array = []; + checkedHopperPaths: Array = []; + configuredProviderEnvironments: SetupProviderEnvironment[] = []; + doctorHealthy: boolean | undefined; + scopedDoctorHealthy: boolean | undefined; + linuxDemoRuntimeMissing = false; + unsupportedHopperVersion = false; + + constructor(platform: NodeJS.Platform = "darwin") { + this.platform = platform; + } + + macosVersion = (): Promise => + Promise.resolve(this.version); + linuxDistribution = (): Promise => + Promise.resolve(this.distribution); + hopperPath = (): Promise => Promise.resolve(this.hopper); + providerEnvironment = (): Promise => + Promise.resolve({ + ...(this.hopper === undefined + ? {} + : { HOPPER_LAUNCHER_PATH: this.hopper }), + ...(this.ghidra === undefined ? {} : { GHIDRA_INSTALL_DIR: this.ghidra }), + ...(this.javaHome === undefined ? {} : { JAVA_HOME: this.javaHome }), + }); + installHopper = ( + replaceExisting: boolean, + ): Promise => { + this.hopperInstalls += 1; + this.hopperReplaceRequests.push(replaceExisting); + this.linuxDemoRuntimeMissing = false; + if (this.hopperInstallSucceeds) { + this.hopper = "/manual/Hopper"; + return Promise.resolve({ + status: "installed", + launcherPath: this.hopper, + }); + } + return Promise.resolve({ + status: "failed", + code: "download_failed", + remediation: "Download failed.", + }); + }; + detectedClients = (): Promise => + Promise.resolve(this.clients); + configureClient = ( + client: SetupClient, + providerEnvironment: SetupProviderEnvironment, + ): Promise => { + this.configurations += 1; + this.configuredProviderEnvironments.push(providerEnvironment); + return Promise.resolve( + this.clientResults.get(client.name) ?? { status: "configured" }, + ); + }; + clientNeedsConfigure = ( + client: SetupClient, + providerEnvironment: SetupProviderEnvironment, + ): Promise => { + this.checkedHopperPaths.push(providerEnvironment.HOPPER_LAUNCHER_PATH); + return Promise.resolve( + this.clientResults.get(client.name)?.status !== "unchanged", + ); + }; + inspectClientConfiguration = ( + client: SetupClient, + ): Promise => + Promise.resolve( + this.clientInspections.get(client.name) ?? { status: "update" }, + ); + skillNeedsInstall = (): Promise => + Promise.resolve(this.skill !== "unchanged"); + installSkill = (): Promise<"installed" | "unchanged" | "failed"> => { + this.skillInstalls += 1; + return Promise.resolve(this.skill); + }; + doctor = (scope?: DoctorScope): Promise => { + this.doctorCalls += 1; + this.doctorScopes.push(scope); + const checks: DoctorCheck[] = [ + ...(this.linuxDemoRuntimeMissing + ? ([ + { + name: "hopper-demo-runtime", + ok: false, + classification: "missing_dependency", + }, + ] as const) + : []), + ...(this.unsupportedHopperVersion + ? ([ + { + name: "hopper-version", + ok: false, + classification: "config_drift", + detail: this.hopper ?? "", + }, + ] as const) + : []), + ]; + const environmentHealthy = + this.doctorHealthy ?? + (this.hopper !== undefined && + !this.linuxDemoRuntimeMissing && + !this.unsupportedHopperVersion); + const healthy = + scope === undefined + ? environmentHealthy + : (this.scopedDoctorHealthy ?? environmentHealthy); + return Promise.resolve({ + healthy, + environment_healthy: environmentHealthy, + scope: { + mode: scope === undefined ? "audit-wide" : "explicit", + clients: scope?.clients ?? [], + providers: scope?.providers ?? [], + skill: scope?.skill === true, + target: null, + }, + scope_checks: checks, + informational_checks: [], + ...(this.hopper === undefined ? {} : { hopperPath: this.hopper }), + checks, + }); + }; +} + +export const options = ( + approved: boolean, + installHopper = false, +): SetupOptions => ({ + approved, + installHopper, + structured: true, +}); diff --git a/tests/upgrade.test.ts b/tests/acceptance/setup/upgrade.test.ts similarity index 99% rename from tests/upgrade.test.ts rename to tests/acceptance/setup/upgrade.test.ts index 56064fb53..1aaea5369 100644 --- a/tests/upgrade.test.ts +++ b/tests/acceptance/setup/upgrade.test.ts @@ -6,7 +6,7 @@ import { type UpgradeHost, type UpgradeOutput, type NpmInstallationHost, -} from "../src/application/Upgrade.js"; +} from "../../../src/application/Upgrade.js"; class FakeUpgradeHost implements UpgradeHost { latest: string | undefined = "0.6.0"; diff --git a/tests/applicationWorkflowMcp.test.ts b/tests/applicationWorkflowMcp.test.ts deleted file mode 100644 index d99256c0c..000000000 --- a/tests/applicationWorkflowMcp.test.ts +++ /dev/null @@ -1,582 +0,0 @@ -import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; -import { createHash } from "node:crypto"; -import { describe, expect, it } from "vitest"; -import { copyFile, mkdir, readFile, realpath, rm } from "node:fs/promises"; -import { join, resolve } from "node:path"; - -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; - -import { BinarySession } from "../src/application/BinarySession.js"; -import { - JAVASCRIPT_APPLICATION_VERSION_COMPARISON_EXAMPLE, - JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE, - JAVASCRIPT_FEATURE_TRACE_EXAMPLE, - JAVASCRIPT_VERSION_COMPARISON_FULL_EVIDENCE_EXAMPLE, - SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE, -} from "../src/contracts/javascriptApplicationWorkflowExamples.js"; -import { createEvidence } from "../src/domain/evidence.js"; -import { createServer } from "../src/server/createServer.js"; -import { observed } from "./fixtures/analysisExecution.js"; -import { PermissionAuthority } from "../src/application/PermissionAuthority.js"; -import { createPermissionPolicy } from "../src/domain/permissionPolicy.js"; -import { controlledReplayOutputSchema } from "../src/domain/javascriptReplay.js"; -import { nodeCharacterizationPreparationOutputSchema } from "../src/domain/nodeRuntimeCharacterization.js"; -import { analyzeJavaScriptApplication } from "../src/application/JavaScriptApplicationService.js"; -import { javascriptApplicationAnalysisResultSchema } from "../src/domain/javascriptApplicationAnalysis.js"; -import { permissionAuthorityForRoot } from "./fixtures/permissionAuthority.js"; - -describe("application workflow MCP parity", () => { - it("compares exact parser export shapes through full Evidence and session IDs", async () => { - const root = await createTestTempDirectory("rea-export-shape-mcp-"); - const leftRoot = join(root, "left"); - const rightRoot = join(root, "right"); - await Promise.all([mkdir(leftRoot), mkdir(rightRoot)]); - await Promise.all([ - copyFile( - resolve("tests/fixtures/replay/parser.mjs"), - join(leftRoot, "parser.mjs"), - ), - copyFile( - resolve("tests/fixtures/replay/parser-v2.mjs"), - join(rightRoot, "parser.mjs"), - ), - ]); - const authority = await permissionAuthorityForRoot( - root, - ["investigation_input"], - ["investigation_input"], - ); - const [left, right] = await Promise.all([ - analyzeJavaScriptApplication(authority, { - input_path: leftRoot, - approved: true, - }), - analyzeJavaScriptApplication(authority, { - input_path: rightRoot, - approved: true, - }), - ]); - if (!left.ok) throw left.error; - if (!right.ok) throw right.error; - const session = new BinarySession(() => ({ - execute: () => Promise.resolve(observed(null)), - close: () => Promise.resolve(), - })); - const server = createServer(session, session); - const client = new Client({ name: "export-shape-test", version: "1" }); - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - const selectors = { - left_module_path: "parser.mjs", - left_export_name: "default", - right_module_path: "parser.mjs", - right_export_name: "default", - }; - try { - await server.connect(serverTransport); - await client.connect(clientTransport); - const full = await client.callTool({ - name: "compare_javascript_export_shapes", - arguments: { left: left.value, right: right.value, ...selectors }, - }); - expect(full.isError).not.toBe(true); - expect(full.structuredContent).toMatchObject({ - result: { - summary: { added: 1, removed: 0, changed: 0, unknown: 0 }, - changes: [ - { - status: "added", - path: "/depth", - right: { availability: "literal", value: 1 }, - }, - ], - }, - }); - expect(session.exportEvidenceBundle().records).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - operation: "compare_javascript_export_shapes", - predicate_type: "rea.javascript-export-shape-comparison/v1", - }), - ]), - ); - - const byId = await client.callTool({ - name: "compare_javascript_export_shapes", - arguments: { - left_evidence_id: left.value.evidence_id, - right_evidence_id: right.value.evidence_id, - ...selectors, - }, - }); - expect(byId.isError).not.toBe(true); - expect(byId.structuredContent).toMatchObject({ - result: { - evidence_links: [left.value.evidence_id, right.value.evidence_id], - changes: [expect.objectContaining({ path: "/depth" })], - }, - }); - - const analyzed = javascriptApplicationAnalysisResultSchema.parse( - left.value.normalized_result, - ); - if (analyzed.schema_version !== 2) - throw new TypeError("Expected semantic application Evidence"); - const seed = analyzed.semantic_graph.relations[0]?.source_node_id; - if (seed === undefined) - throw new TypeError("Expected at least one semantic relation"); - const semantic = await client.callTool({ - name: "trace_javascript_semantics", - arguments: { - application: left.value, - query: { - seed: { kind: "semantic-node", node_id: seed }, - direction: "forward-influence", - include_ambiguous_dynamic_edges: true, - }, - }, - }); - expect(semantic.isError).not.toBe(true); - expect(semantic.structuredContent).toMatchObject({ - result: { - schema_version: 1, - source_evidence_id: left.value.evidence_id, - source_graph_id: analyzed.semantic_graph.graph_id, - summary: { retained_seed_matches: 1 }, - }, - }); - } finally { - await client.close(); - await server.close(); - await session.close(); - await rm(root, { recursive: true, force: true }); - } - }); - - it("traces and compares authenticated graph Evidence in the session", async () => { - const session = new BinarySession(() => ({ - execute: () => Promise.resolve(observed(null)), - close: () => Promise.resolve(), - })); - const server = createServer(session, session); - const client = new Client({ - name: "application-workflow-test", - version: "1", - }); - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - let resourceListChanges = 0; - client.setNotificationHandler( - "notifications/resources/list_changed", - () => { - resourceListChanges += 1; - }, - ); - try { - await server.connect(serverTransport); - await client.connect(clientTransport); - - const traced = await client.callTool({ - name: "trace_application_feature", - arguments: JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE, - }); - expect(traced.isError).not.toBe(true); - expect(traced.structuredContent).toMatchObject({ - result: { - schema_version: 1, - coverage: { status: expect.any(String) }, - }, - }); - - const compared = await client.callTool({ - name: "compare_application_versions", - arguments: { - ...JAVASCRIPT_VERSION_COMPARISON_FULL_EVIDENCE_EXAMPLE, - unknown_registry_approved: true, - }, - }); - expect(compared.isError).not.toBe(true); - expect(compared.structuredContent).toMatchObject({ - result: { - schema_version: 1, - summary: { unknown: expect.any(Number) }, - coverage: { status: expect.any(String) }, - }, - }); - const sourceCompared = await client.callTool({ - name: "compare_source_to_bundle", - arguments: { - reference: SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE.reference, - application: - JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE.application, - }, - }); - expect(sourceCompared.isError).not.toBe(true); - expect(sourceCompared.structuredContent).toMatchObject({ - result: { - schema_version: 1, - reference: { - root_sha256: - SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE.reference.root_sha256, - }, - scoring: { algorithm: "rea-source-to-bundle-signals/v1" }, - }, - }); - expect(session.exportEvidenceBundle().records.length).toBeGreaterThan(2); - - await new Promise((resolve) => setImmediate(resolve)); - const notificationsBeforeIdReuse = resourceListChanges; - const tracedById = await client.callTool({ - name: "trace_application_feature", - arguments: { - ...JAVASCRIPT_FEATURE_TRACE_EXAMPLE, - application_evidence_id: - JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE.application - .evidence_id, - }, - }); - expect(tracedById.isError).not.toBe(true); - await new Promise((resolve) => setImmediate(resolve)); - expect(resourceListChanges).toBe(notificationsBeforeIdReuse); - - const comparedById = await client.callTool({ - name: "compare_application_versions", - arguments: { - ...JAVASCRIPT_APPLICATION_VERSION_COMPARISON_EXAMPLE, - left_evidence_id: - JAVASCRIPT_VERSION_COMPARISON_FULL_EVIDENCE_EXAMPLE.left - .evidence_id, - right_evidence_id: - JAVASCRIPT_VERSION_COMPARISON_FULL_EVIDENCE_EXAMPLE.right - .evidence_id, - }, - }); - expect(comparedById).toMatchObject({ - structuredContent: { - result: { - evidence_links: expect.arrayContaining([ - JAVASCRIPT_VERSION_COMPARISON_FULL_EVIDENCE_EXAMPLE.left - .evidence_id, - JAVASCRIPT_VERSION_COMPARISON_FULL_EVIDENCE_EXAMPLE.right - .evidence_id, - ]), - }, - }, - }); - const sourceComparedById = await client.callTool({ - name: "compare_source_to_bundle", - arguments: { - ...SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE, - application_evidence_id: - JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE.application - .evidence_id, - }, - }); - expect(sourceComparedById.isError).not.toBe(true); - - const missing = await client.callTool({ - name: "trace_application_feature", - arguments: JAVASCRIPT_FEATURE_TRACE_EXAMPLE, - }); - expect(missing).toMatchObject({ - isError: true, - structuredContent: { - error: { details: { reason: "missing" } }, - }, - }); - - const wrongOperation = createEvidence( - undefined, - { id: "fixture", name: "Fixture", version: "1" }, - { operation: "inventory_artifact", parameters: {}, result: {} }, - ); - const wrongPredicate = createEvidence( - undefined, - { id: "fixture", name: "Fixture", version: "1" }, - { - predicateType: "fixture.application/v1", - operation: "analyze_javascript_application", - parameters: {}, - result: {}, - }, - ); - expect(session.recordEvidence(wrongOperation).ok).toBe(true); - expect(session.recordEvidence(wrongPredicate).ok).toBe(true); - for (const [record, reason] of [ - [wrongOperation, "wrong_operation"], - [wrongPredicate, "wrong_predicate"], - ] as const) { - const rejected = await client.callTool({ - name: "trace_application_feature", - arguments: { - ...JAVASCRIPT_FEATURE_TRACE_EXAMPLE, - application_evidence_id: record.evidence_id, - }, - }); - expect(rejected).toMatchObject({ - isError: true, - structuredContent: { error: { details: { reason } } }, - }); - } - - const duplicateNative = await client.callTool({ - name: "trace_application_feature", - arguments: { - ...JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE, - native_observations: [ - JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE.application, - ], - native_observation_evidence_ids: [ - JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE.application - .evidence_id, - ], - }, - }); - expect(duplicateNative.isError).toBe(true); - - const spoofed = await client.callTool({ - name: "trace_application_feature", - arguments: { - ...JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE, - application: { - ...JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE.application, - provider: { id: "spoofed", name: "spoofed", version: "1" }, - }, - }, - }); - expect(spoofed.isError).toBe(true); - } finally { - await client.close(); - await server.close(); - await session.close(); - } - }); - - it("plans and executes the same controlled replay contract", async () => { - const root = resolve("tests/fixtures/replay"); - const policy = { - enabled: true, - roots: [root], - nodePath: process.execPath, - bubblewrapPath: process.execPath, - systemdRunPath: process.execPath, - systemctlPath: process.execPath, - shellPath: process.execPath, - }; - const ceiling = { - capability: "javascript_replay" as const, - roots: [root], - executables: [ - policy.nodePath, - policy.bubblewrapPath, - policy.systemdRunPath, - policy.systemctlPath, - policy.shellPath, - ], - environment_names: [], - network: "none" as const, - mount: true, - }; - const authority = new PermissionAuthority( - createPermissionPolicy( - [ceiling], - [ - { - ...ceiling, - grant_id: "administrator:javascript_replay", - lifetime: "administrator", - operation_identity: null, - expires_at: null, - }, - ], - ), - ); - const session = new BinarySession(() => ({ - execute: () => Promise.resolve(observed(null)), - close: () => Promise.resolve(), - })); - const server = createServer(session, session, { - permissionAuthority: authority, - javascriptReplayPolicy: policy, - javascriptReplayHost: { - readSource: async (path) => ({ - canonicalPath: await realpath(path), - bytes: await readFile(path), - }), - identifyExecutable: async (path) => ({ - path, - version: "fixture", - sha256: "1".repeat(64), - }), - identifyWorker: async () => ({ - path: "/fixture/worker.js", - version: "fixture-worker-1", - sha256: "2".repeat(64), - }), - identifyRuntimeClosure: async (path) => [ - { - sourcePath: path, - destinationPath: "/runtime/node", - sha256: "1".repeat(64), - }, - ], - seccompDigest: () => "3".repeat(64), - probe: async () => undefined, - }, - javascriptReplayRunner: { - execute: async (prepared) => ({ - schema_version: 1, - plan_digest: prepared.publicPlan.plan_digest, - outcomes: [ - { - case_id: "heading", - outcome: "return", - value: { type: "heading", text: "Title" }, - input_sha256: - prepared.publicPlan.cases[0]?.sha256 ?? "0".repeat(64), - output_sha256: "2".repeat(64), - truncated: false, - }, - ], - stderr: "", - termination: "completed", - cleanup: { state: "complete", residual_resources: [] }, - limitations: ["fixture runner"], - reproducer: null, - }), - }, - }); - const client = new Client({ name: "replay-parity-test", version: "1" }); - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - const input = { - mode: "plan", - left: { - modules: [ - { - alias: "parser", - path: resolve(root, "parser.mjs"), - format: "esm", - role: "module", - dependencies: {}, - }, - ], - entry_alias: "parser", - entry_export: "default", - }, - cases: [{ case_id: "heading", arguments: ["# Title"] }], - approved: false, - }; - try { - await server.connect(serverTransport); - await client.connect(clientTransport); - const planned = await client.callTool({ - name: "run_controlled_replay", - arguments: input, - }); - expect(planned.isError).not.toBe(true); - const digest = controlledReplayOutputSchema.parse( - planned.structuredContent, - ).plan?.plan_digest; - expect(digest).toMatch(/^[a-f0-9]{64}$/u); - const executed = await client.callTool({ - name: "run_controlled_replay", - arguments: { - ...input, - mode: "execute", - approved: true, - plan_digest: digest, - }, - }); - expect(executed.isError).not.toBe(true); - expect(executed.structuredContent).toMatchObject({ - phase: "execute", - evidence: { - authority: "controlled-replay", - provider: { id: "rea-javascript-replay" }, - }, - }); - expect(session.exportEvidenceBundle().records).toEqual( - expect.arrayContaining([ - expect.objectContaining({ authority: "controlled-replay" }), - ]), - ); - const factoryPath = resolve(root, "sanitizer.factory.txt"); - const factoryBytes = await readFile(factoryPath); - const factorySha256 = createHash("sha256") - .update(factoryBytes) - .digest("hex"); - const characterizationInput = { - preparation_approved: true, - selected_alias: "bundle", - expected_effect: "pure", - instrumentation: { - artifact_path: factoryPath, - artifact_sha256: factorySha256, - selection: { - byte_start: 0, - byte_end: factoryBytes.byteLength, - selected_sha256: factorySha256, - export_name: "selected", - }, - }, - replay: { - mode: "plan", - left: { - modules: [ - { - alias: "bundle", - path: factoryPath, - format: "commonjs-factory", - role: "module", - dependencies: {}, - }, - ], - entry_alias: "bundle", - entry_export: "selected", - }, - cases: [{ case_id: "heading", arguments: ["# Title"] }], - approved: false, - }, - }; - const characterizationPlan = await client.callTool({ - name: "prepare_node_characterization", - arguments: characterizationInput, - }); - expect(characterizationPlan.isError).not.toBe(true); - const approvedPlan = nodeCharacterizationPreparationOutputSchema.parse( - characterizationPlan.structuredContent, - ).plan; - const characterized = await client.callTool({ - name: "execute_node_characterization", - arguments: { - execution_approved: true, - approved_plan_sha256: approvedPlan.plan_sha256, - preparation: characterizationInput, - }, - }); - expect(characterized.isError).not.toBe(true); - expect(characterized.structuredContent).toMatchObject({ - phase: "execution", - evidence: { - authority: "controlled-replay", - provider: { id: "rea-node-characterization" }, - }, - }); - expect(session.exportEvidenceBundle().records).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - predicate_type: "rea.javascript-export-transformation/v1", - }), - expect.objectContaining({ - predicate_type: "rea.runtime-characterization/v1", - }), - ]), - ); - } finally { - await client.close(); - await server.close(); - await session.close(); - } - }); -}); diff --git a/tests/artifactMcp.test.ts b/tests/artifactMcp.test.ts deleted file mode 100644 index 69af32600..000000000 --- a/tests/artifactMcp.test.ts +++ /dev/null @@ -1,479 +0,0 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; - -import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; -import { createPackageWithOptions } from "@electron/asar"; -import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; -import { describe, expect, it } from "vitest"; -import { z } from "zod"; - -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; - -import { BinarySession } from "../src/application/BinarySession.js"; -import { ArtifactProvider } from "../src/artifacts/ArtifactProvider.js"; -import { ARTIFACT_COMPARISON_EXAMPLE } from "../src/contracts/artifactComparisonExample.js"; -import { evidenceBundleSchema } from "../src/domain/evidenceBundle.js"; -import { createServer } from "../src/server/createServer.js"; -import { observed } from "./fixtures/analysisExecution.js"; - -describe("artifact graph MCP integration", () => { - it.each([ - { unpacked: false, label: "embedded" }, - { unpacked: true, label: "unpacked" }, - ])( - "returns actionable local details for an $label ASAR integrity error", - async ({ unpacked }) => { - const root = await createTestTempDirectory("rea-asar-integrity-mcp-"); - const source = join(root, "source"); - const original = "console.log('ok');\n"; - const changed = "console.log('no');\n"; - await mkdir(source); - await writeFile(join(source, "main.js"), original); - const archive = join(root, "fixture.asar"); - await createPackageWithOptions( - source, - archive, - unpacked ? { unpack: "*.js" } : {}, - ); - if (unpacked) { - await writeFile(join(`${archive}.unpacked`, "main.js"), changed); - } else { - const bytes = await readFile(archive); - const contentOffset = bytes.indexOf(original); - expect(contentOffset).toBeGreaterThanOrEqual(0); - bytes.write(changed, contentOffset, "utf8"); - await writeFile(archive, bytes); - } - - const session = new BinarySession(new ArtifactProvider()); - const server = createServer(session, session); - const client = new Client({ name: "asar-integrity-test", version: "1" }); - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - try { - await server.connect(serverTransport); - await client.connect(clientTransport); - const opened = await client.callTool({ - name: "open_binary", - arguments: { path: archive }, - }); - expect(opened.isError).not.toBe(true); - - const result = await client.callTool({ - name: "inventory_artifact", - arguments: {}, - }); - expect(result.isError).toBe(true); - expect(result.structuredContent).toEqual({ - error: { - code: "artifact_integrity_mismatch", - category: "integrity_mismatch", - message: - "Artifact is invalid or has changed. Get a fresh copy and try again.", - details: { - logical_path: "main.js", - declared_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u), - calculated_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u), - unpacked, - }, - retryable: false, - remediation: { - action: - "Artifact is invalid or has changed. Get a fresh copy and try again.", - restart_required: false, - }, - }, - }); - } finally { - await Promise.allSettled([ - client.close(), - server.close(), - session.close(), - ]); - } - }, - ); - - it("records an approved mismatch, preserves verified siblings, and never reports equivalence", async () => { - const root = await createTestTempDirectory("rea-asar-continue-mcp-"); - const source = join(root, "source"); - await mkdir(source); - const original = "console.log('ok');\n"; - const changed = "console.log('no');\n"; - const secondOriginal = "console.log('up');\n"; - const secondChanged = "console.log('dn');\n"; - await writeFile(join(source, "main.js"), original); - await writeFile(join(source, "second.js"), secondOriginal); - await writeFile(join(source, "sibling.js"), "console.log('sibling');\n"); - const archive = join(root, "fixture.asar"); - await createPackageWithOptions(source, archive, {}); - const bytes = await readFile(archive); - const contentOffset = bytes.indexOf(original); - const secondOffset = bytes.indexOf(secondOriginal); - expect(contentOffset).toBeGreaterThanOrEqual(0); - expect(secondOffset).toBeGreaterThanOrEqual(0); - bytes.write(changed, contentOffset, "utf8"); - bytes.write(secondChanged, secondOffset, "utf8"); - await writeFile(archive, bytes); - - const session = new BinarySession(new ArtifactProvider(false, true)); - const server = createServer(session, session); - const client = new Client({ name: "asar-continue-test", version: "1" }); - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - try { - await server.connect(serverTransport); - await client.connect(clientTransport); - await client.callTool({ - name: "open_binary", - arguments: { path: archive }, - }); - const limited = await client.callTool({ - name: "inventory_artifact", - arguments: { - integrity_policy: "record-and-continue", - integrity_continue_approved: true, - max_integrity_mismatches: 1, - }, - }); - expect(limited).toMatchObject({ - isError: true, - structuredContent: { error: { code: "truncated" } }, - }); - const result = await client.callTool({ - name: "inventory_artifact", - arguments: { - integrity_policy: "record-and-continue", - integrity_continue_approved: true, - }, - }); - expect(result.isError).not.toBe(true); - const compact = compactResult(result.structuredContent); - const evidence = session.evidenceById(compact.evidence_id); - if (evidence === undefined) throw new Error("missing inventory Evidence"); - const inventory = z - .object({ - occurrences: z.object({ - items: z.array( - z.object({ logical_path: z.string(), hash_status: z.string() }), - ), - }), - integrity_contradictions: z.array( - z.object({ - logical_path: z.string(), - declared_sha256: z.string(), - observed_sha256: z.string(), - trust: z.literal("observed-untrusted"), - }), - ), - }) - .parse(compact.result); - expect(inventory.integrity_contradictions).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - logical_path: "main.js", - trust: "observed-untrusted", - declared_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u), - observed_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u), - }), - expect.objectContaining({ logical_path: "second.js" }), - ]), - ); - expect(inventory.integrity_contradictions).toHaveLength(2); - expect(inventory.occurrences.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - logical_path: "main.js", - hash_status: "mismatched", - }), - expect.objectContaining({ - logical_path: "sibling.js", - hash_status: "verified", - }), - expect.objectContaining({ - logical_path: "second.js", - hash_status: "mismatched", - }), - ]), - ); - const inspected = await client.callTool({ - name: "inspect_artifact", - arguments: { - integrity_policy: "record-and-continue", - integrity_continue_approved: true, - }, - }); - expect(inspected.isError).not.toBe(true); - expect(compactResult(inspected.structuredContent).result).toMatchObject({ - contradictions: [ - expect.objectContaining({ occurrence_id: expect.any(String) }), - expect.objectContaining({ occurrence_id: expect.any(String) }), - ], - substeps: [ - expect.objectContaining({ - operation: "inventory_artifact", - status: "completed", - }), - ], - }); - const compared = await client.callTool({ - name: "compare_artifacts", - arguments: { - left_evidence_ids: [evidence.evidence_id], - right_evidence_ids: [evidence.evidence_id], - }, - }); - expect(compared.isError).not.toBe(true); - expect(compactResult(compared.structuredContent).result).toMatchObject({ - status: "contradiction", - summary: { contradiction: 2 }, - changes: { - items: expect.arrayContaining([ - expect.objectContaining({ - logical_path: "main.js", - classification: "contradiction", - dimensions: ["integrity"], - }), - expect.objectContaining({ - logical_path: "second.js", - classification: "contradiction", - }), - ]), - }, - }); - } finally { - await Promise.allSettled([ - client.close(), - server.close(), - session.close(), - ]); - } - }); - - it("rejects altered payloads that reuse session Evidence IDs", async () => { - const session = new BinarySession(() => ({ - health: () => Promise.resolve(), - execute: () => Promise.resolve(observed(null)), - close: () => Promise.resolve(), - })); - session.recordEvidence(ARTIFACT_COMPARISON_EXAMPLE.left); - session.recordEvidence(ARTIFACT_COMPARISON_EXAMPLE.right); - const server = createServer(session, session); - const client = new Client({ - name: "artifact-authority-test", - version: "1", - }); - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - try { - await server.connect(serverTransport); - await client.connect(clientTransport); - const result = await client.callTool({ - name: "compare_artifacts", - arguments: { - ...ARTIFACT_COMPARISON_EXAMPLE, - left: { - ...ARTIFACT_COMPARISON_EXAMPLE.left, - limitations: ["caller altered this record"], - }, - }, - }); - expect(result.isError).toBe(true); - expect(session.exportEvidenceBundle().records).toHaveLength(2); - } finally { - await Promise.allSettled([ - client.close(), - server.close(), - session.close(), - ]); - } - }); - - it("rejects comparison Evidence that is not owned by the session", async () => { - const session = new BinarySession(() => ({ - health: () => Promise.resolve(), - execute: () => Promise.resolve(observed(null)), - close: () => Promise.resolve(), - })); - const server = createServer(session, session); - const client = new Client({ - name: "artifact-ownership-test", - version: "1", - }); - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - try { - await server.connect(serverTransport); - await client.connect(clientTransport); - const result = await client.callTool({ - name: "compare_artifacts", - arguments: ARTIFACT_COMPARISON_EXAMPLE, - }); - expect(result.isError).toBe(true); - expect(session.exportEvidenceBundle().records).toEqual([]); - } finally { - await Promise.allSettled([ - client.close(), - server.close(), - session.close(), - ]); - } - }); - - it("opens archives without Hopper, compares them, and exports linked evidence", async () => { - const directory = await createTestTempDirectory("rea-artifact-mcp-"); - const archive = join(directory, "fixture.ipa"); - const changedArchive = join(directory, "changed.ipa"); - const writer = new ZipWriter(new Uint8ArrayWriter()); - await writer.add("Payload/Fixture.app/main.js", new TextReader("main();")); - await writeFile(archive, await writer.close()); - const changedWriter = new ZipWriter(new Uint8ArrayWriter()); - await changedWriter.add( - "Payload/Fixture.app/main.js", - new TextReader("changed();"), - ); - await writeFile(changedArchive, await changedWriter.close()); - const session = new BinarySession(new ArtifactProvider()); - const server = createServer(session, session); - const client = new Client({ name: "artifact-mcp-test", version: "1" }); - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - try { - await server.connect(serverTransport); - await client.connect(clientTransport); - const opened = await client.callTool({ - name: "open_binary", - arguments: { path: archive }, - }); - expect(opened.isError).not.toBe(true); - const inspected = await client.callTool({ - name: "inspect_artifact", - arguments: { max_observations: 1, max_relationships: 1 }, - }); - expect(inspected.isError).not.toBe(true); - const inspectionResult = compactResult(inspected.structuredContent); - const inspectionEvidence = session.evidenceById( - inspectionResult.evidence_id, - ); - if (inspectionEvidence === undefined) - throw new Error("missing inspection Evidence"); - const inspection = z - .object({ - substeps: z.array( - z.object({ - evidence_id: z.string(), - status: z.literal("completed"), - }), - ), - coverage: z.object({ - status: z.literal("truncated"), - substeps_completed: z.literal(1), - }), - unexplored_branches: z.array(z.object({ reason: z.string() })), - }) - .parse(inspectionResult.result); - expect(inspection.substeps).toHaveLength(1); - expect( - session.evidenceById(inspection.substeps[0]?.evidence_id ?? ""), - ).toMatchObject({ operation: "inventory_artifact" }); - expect(inspectionEvidence.evidence_links).toEqual([ - inspection.substeps[0]?.evidence_id, - ]); - expect(inspection.unexplored_branches).toEqual( - expect.arrayContaining([ - expect.objectContaining({ reason: "page-limit" }), - ]), - ); - const inventory = await client.callTool({ - name: "inventory_artifact", - arguments: {}, - }); - expect(inventory.isError).not.toBe(true); - const inventoryResult = compactResult(inventory.structuredContent); - const evidence = session.evidenceById(inventoryResult.evidence_id); - if (evidence === undefined) throw new Error("missing inventory Evidence"); - expect(evidence).toMatchObject({ - provider: { id: "rea-artifact-graph" }, - subject: { format: "ipa" }, - }); - expect(inventoryResult.result).toMatchObject({ - manifest: { root_format: "ipa" }, - }); - const openedChanged = await client.callTool({ - name: "open_binary", - arguments: { path: changedArchive }, - }); - expect(openedChanged.isError).not.toBe(true); - const changedInventory = await client.callTool({ - name: "inventory_artifact", - arguments: {}, - }); - const changedResult = compactResult(changedInventory.structuredContent); - const changedEvidence = session.evidenceById(changedResult.evidence_id); - if (changedEvidence === undefined) - throw new Error("missing changed Evidence"); - const compared = await client.callTool({ - name: "compare_artifacts", - arguments: { - left_evidence_ids: [evidence.evidence_id], - right_evidence_ids: [changedEvidence.evidence_id], - unknown_registry_approved: true, - }, - }); - expect(compared.isError).not.toBe(true); - expect(compactResult(compared.structuredContent)).toMatchObject({ - result: { status: "changed" }, - evidence_id: expect.stringMatching(/^ev_/u), - }); - const unknowns = await client.callTool({ - name: "list_unknowns", - arguments: { domain: "artifact-comparison" }, - }); - expect(unknowns.structuredContent).toMatchObject({ - result: { - items: [ - expect.objectContaining({ - unknown: expect.objectContaining({ - domain: "artifact-comparison", - }), - }), - ], - }, - }); - const snapshotted = await client.callTool({ - name: "snapshot_evidence_bundle", - arguments: {}, - }); - const bundleUri = z - .object({ result: z.object({ bundle_uri: z.string() }) }) - .parse(snapshotted.structuredContent).result.bundle_uri; - const bundleResource = await client.readResource({ uri: bundleUri }); - const bundle = evidenceBundleSchema.parse( - JSON.parse( - z.object({ text: z.string() }).parse(bundleResource.contents[0]).text, - ), - ); - expect(bundle.records).toHaveLength(6); - expect(bundle.artifacts).toContainEqual({ - digest: { sha256: evidence.subject?.digest.sha256 }, - format: "ipa", - architecture: null, - }); - } finally { - await Promise.allSettled([ - client.close(), - server.close(), - session.close(), - ]); - } - }); -}); - -const compactResult = (value: unknown) => - z - .object({ - result: z.unknown(), - evidence_id: z.string(), - evidence_uri: z.string(), - }) - .parse(value); diff --git a/tests/artifactProvider.test.ts b/tests/artifactProvider.test.ts deleted file mode 100644 index d3852aebb..000000000 --- a/tests/artifactProvider.test.ts +++ /dev/null @@ -1,523 +0,0 @@ -import { - access, - mkdir, - readFile, - readdir, - symlink, - writeFile, -} from "node:fs/promises"; -import { join } from "node:path"; - -import { createPackage, createPackageWithOptions } from "@electron/asar"; -import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; -import { describe, expect, it } from "vitest"; - -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; - -import { ArtifactProvider } from "../src/artifacts/ArtifactProvider.js"; -import { MachOSliceArtifactReader } from "../src/artifacts/MachOSliceArtifactReader.js"; -import { runProviderAnalysis } from "../src/application/DirectAnalysis.js"; -import { - ArtifactPathRegistry, - normalizeArtifactPath, -} from "../src/artifacts/ArtifactPaths.js"; -import { - artifactExtractionInputSchema, - artifactInspectionInputSchema, - artifactInventoryInputSchema, -} from "../src/contracts/artifactToolContracts.js"; -import type { BinaryTarget } from "../src/domain/binaryTarget.js"; -import { parseBinaryTarget } from "../src/domain/binaryTarget.js"; -import { parseEvidence } from "../src/domain/evidence.js"; -import { ok } from "../src/domain/result.js"; -import type { NativeCommandRunner } from "../src/native/CommandRunner.js"; -import { - artifactExtractionResultSchema, - artifactInventoryResultSchema, -} from "../src/domain/artifactGraph.js"; -import { artifactInspectionResultSchema } from "../src/domain/artifactInspection.js"; - -describe("artifact graph provider", () => { - it("inventories app trees deterministically without following symlinks", async () => { - const root = await createTestTempDirectory("rea-artifacts-"); - const app = join(root, "Fixture.app"); - await mkdir(join(app, "Contents", "Resources"), { recursive: true }); - await mkdir(join(app, "Contents", "MacOS"), { recursive: true }); - await mkdir(join(app, "Contents", "Frameworks", "Fixture.framework"), { - recursive: true, - }); - const machO = Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 0x0c, 0, 0, 1]); - await writeFile(join(app, "Contents", "MacOS", "Fixture"), machO, { - mode: 0o755, - }); - await writeFile( - join(app, "Contents", "Frameworks", "Fixture.framework", "Fixture"), - machO, - ); - await writeFile(join(app, "Contents", "Resources", "main.js"), "main();\n"); - await writeFile(join(app, "Contents", "Resources", "main.js.MAP"), "{}\n"); - await symlink("/etc/passwd", join(app, "Contents", "Resources", "outside")); - const client = new ArtifactProvider().createClient( - target(app, "directory"), - ); - const input = artifactInventoryInputSchema.parse({ - node_limit: 500, - occurrence_limit: 500, - edge_limit: 500, - }); - const first = await client.execute("inventory_artifact", input); - const second = await client.execute("inventory_artifact", input); - expect(first.ok).toBe(true); - expect(second.ok).toBe(true); - if (!first.ok || !second.ok) return; - const left = artifactInventoryResultSchema.parse(first.value.result); - const right = artifactInventoryResultSchema.parse(second.value.result); - expect(left.manifest).toEqual(right.manifest); - expect(left.nodes.items.map(({ kind }) => kind)).toEqual( - expect.arrayContaining([ - "executable", - "framework", - "javascript", - "source-map", - ]), - ); - expect( - left.occurrences.items.find(({ logical_path: path }) => - path.endsWith("outside"), - ), - ).toMatchObject({ artifact_id: null, entry_kind: "symlink" }); - const script = left.occurrences.items.find(({ logical_path: path }) => - path.endsWith("main.js"), - ); - const sourceMap = left.occurrences.items.find(({ logical_path: path }) => - path.endsWith("main.js.MAP"), - ); - expect(left.edges.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - relation: "maps-source", - parent_artifact_id: script?.artifact_id, - child_artifact_id: sourceMap?.artifact_id, - }), - ]), - ); - }); - - it("streams ZIP and official ASAR inventories within shared limits", async () => { - const root = await createTestTempDirectory("rea-containers-"); - const zipPath = join(root, "fixture.apk"); - const zipWriter = new ZipWriter(new Uint8ArrayWriter()); - await zipWriter.add("assets/index.js", new TextReader("export default 1;")); - await zipWriter.add("lib/arm64-v8a/addon.so", new TextReader("native")); - await writeFile(zipPath, await zipWriter.close()); - const parsed = await parseBinaryTarget(zipPath); - expect(parsed.ok && parsed.value.format).toBe("apk"); - if (!parsed.ok) return; - const zipResult = await inventory(parsed.value); - expect(zipResult.occurrences.total).toBe(3); - expect(zipResult.nodes.items.map(({ kind }) => kind)).toEqual( - expect.arrayContaining(["javascript", "dynamic-library"]), - ); - const cliEvidence = parseEvidence( - await runProviderAnalysis(zipPath, "inventory_artifact", {}), - ); - expect(cliEvidence).toMatchObject({ - operation: "inventory_artifact", - provider: { id: "rea-artifact-graph" }, - subject: { format: "apk" }, - }); - const inspectionEvidence = parseEvidence( - await runProviderAnalysis(zipPath, "inspect_artifact", { - max_observations: 2, - max_relationships: 1, - }), - ); - const inspection = artifactInspectionResultSchema.parse( - inspectionEvidence.normalized_result, - ); - expect(inspection).toMatchObject({ - substeps: [ - { - operation: "inventory_artifact", - status: "completed", - evidence_id: expect.stringMatching(/^ev_[a-f0-9]{64}$/u), - }, - ], - coverage: { status: "truncated", substeps_completed: 1 }, - }); - expect(inspectionEvidence.evidence_links).toEqual( - inspection.evidence_links, - ); - const cancellation = new AbortController(); - cancellation.abort(); - await expect( - runProviderAnalysis( - zipPath, - "inventory_artifact", - {}, - undefined, - cancellation.signal, - ), - ).resolves.toMatchObject({ code: "cancelled" }); - await expect( - runProviderAnalysis( - zipPath, - "inspect_artifact", - {}, - undefined, - cancellation.signal, - ), - ).resolves.toMatchObject({ code: "cancelled" }); - - const source = join(root, "asar-source"); - await mkdir(source); - await writeFile(join(source, "main.js"), "console.log('ok');\n"); - const asarPath = join(root, "app.asar"); - await createPackage(source, asarPath); - const asarResult = await inventory(target(asarPath, "asar")); - expect(asarResult.occurrences.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ logical_path: "main.js" }), - ]), - ); - - const unpackedPath = join(root, "unpacked.asar"); - await createPackageWithOptions(source, unpackedPath, { unpack: "*.js" }); - const unpackedResult = await inventory(target(unpackedPath, "asar")); - expect(unpackedResult.occurrences.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - logical_path: "main.js", - hash_status: "verified", - }), - ]), - ); - await writeFile( - join(`${unpackedPath}.unpacked`, "main.js"), - "changed();\n", - ); - const corrupted = await new ArtifactProvider() - .createClient(target(unpackedPath, "asar")) - .execute("inventory_artifact", artifactInventoryInputSchema.parse({})); - expect(corrupted).toMatchObject({ - ok: false, - error: { - _tag: "ArtifactOperationError", - reason: "integrity", - artifactDetails: { - logicalPath: "main.js", - unpacked: true, - }, - }, - }); - const failedInspection = await new ArtifactProvider() - .createClient(target(unpackedPath, "asar")) - .execute("inspect_artifact", artifactInspectionInputSchema.parse({})); - expect(failedInspection).toMatchObject({ - ok: false, - error: { _tag: "ArtifactOperationError", reason: "integrity" }, - }); - }); - - it.each([ - ["fixture.msix", "msix"], - ["fixture.appxbundle", "appx"], - ] as const)( - "reuses the hardened ZIP reader for %s package inventory", - async (name, format) => { - const root = await createTestTempDirectory("rea-windows-package-"); - const path = join(root, name); - const writer = new ZipWriter(new Uint8ArrayWriter()); - await writer.add( - "Assets/main.js", - new TextReader("export default 'windows';"), - ); - await writer.add( - "VFS/ProgramFilesX64/App/addon.node", - new TextReader("native"), - ); - await writeFile(path, await writer.close()); - - const parsed = await parseBinaryTarget(path); - expect(parsed.ok && parsed.value).toMatchObject({ - kind: "archive", - format, - }); - if (!parsed.ok) return; - const result = await inventory(parsed.value); - expect(result.manifest.root_format).toBe(format); - expect(result.nodes.items.map(({ kind }) => kind)).toEqual( - expect.arrayContaining(["javascript", "native-addon"]), - ); - const selected = result.occurrences.items.find( - ({ logical_path: logicalPath }) => logicalPath === "Assets/main.js", - ); - expect(selected).toBeDefined(); - if (selected === undefined) return; - const output = join(root, "output"); - const extracted = await new ArtifactProvider() - .createClient(parsed.value) - .execute( - "extract_artifact", - artifactExtractionInputSchema.parse({ - approved: true, - output_root: output, - occurrence_ids: [selected.occurrence_id], - }), - ); - expect(extracted.ok).toBe(true); - expect(await readFile(join(output, "Assets", "main.js"), "utf8")).toBe( - "export default 'windows';", - ); - expect( - parseEvidence( - await runProviderAnalysis(path, "inventory_artifact", { - node_limit: 10, - occurrence_limit: 10, - edge_limit: 10, - }), - ), - ).toMatchObject({ - operation: "inventory_artifact", - subject: { format }, - }); - }, - ); - - it("rejects unsafe, colliding, and over-ratio entries", async () => { - expect(() => normalizeArtifactPath("../escape", limits())).toThrow( - /normalized/u, - ); - const registry = new ArtifactPathRegistry(); - registry.add("A.js", "file"); - expect(() => registry.add("a.js", "file")).toThrow(/collision/u); - - const root = await createTestTempDirectory("rea-bomb-"); - const zipPath = join(root, "bomb.zip"); - const writer = new ZipWriter(new Uint8ArrayWriter()); - await writer.add("zeros", new TextReader("0".repeat(100_000))); - await writeFile(zipPath, await writer.close()); - const client = new ArtifactProvider().createClient(target(zipPath, "zip")); - const result = await client.execute( - "inventory_artifact", - artifactInventoryInputSchema.parse({ max_compression_ratio: 2 }), - ); - expect(result).toMatchObject({ - ok: false, - error: { _tag: "ArtifactOperationError", reason: "limit" }, - }); - }); - - it("extracts only selected occurrences through an exclusively owned output tree", async () => { - const root = await createTestTempDirectory("rea-extract-"); - const source = join(root, "source"); - await mkdir(join(source, "assets"), { recursive: true }); - await writeFile(join(source, "assets", "selected.js"), "selected();\n"); - await writeFile(join(source, "assets", "ignored.js"), "ignored();\n"); - const targetValue = target(source, "directory"); - const graph = await inventory(targetValue); - const selected = graph.occurrences.items.find( - ({ logical_path: path }) => path === "assets/selected.js", - ); - expect(selected).toBeDefined(); - if (selected === undefined) return; - const cancelledOutput = join(root, "cancelled-output"); - const controller = new AbortController(); - controller.abort(); - const cancelled = await new ArtifactProvider() - .createClient(targetValue) - .execute( - "extract_artifact", - artifactExtractionInputSchema.parse({ - approved: true, - output_root: cancelledOutput, - occurrence_ids: [selected.occurrence_id], - }), - { signal: controller.signal }, - ); - expect(cancelled).toMatchObject({ - ok: false, - error: { _tag: "ArtifactOperationError", reason: "cancelled" }, - }); - await expect(access(cancelledOutput)).rejects.toThrow(); - const output = join(root, "output"); - const result = await new ArtifactProvider() - .createClient(targetValue) - .execute( - "extract_artifact", - artifactExtractionInputSchema.parse({ - approved: true, - output_root: output, - occurrence_ids: [selected.occurrence_id], - }), - ); - expect(result.ok).toBe(true); - if (!result.ok) return; - const firstExtraction = artifactExtractionResultSchema.parse( - result.value.result, - ); - expect(firstExtraction).toMatchObject({ - output_root: "$OUTPUT_ROOT", - containment_verified: true, - artifacts: { total: 1 }, - extraction_manifest: { output_root_alias: "$OUTPUT_ROOT" }, - }); - expect(await readFile(join(output, "assets", "selected.js"), "utf8")).toBe( - "selected();\n", - ); - await expect( - access(join(output, "assets", "ignored.js")), - ).rejects.toThrow(); - - const relocatedOutput = join(root, "relocated-output"); - const relocated = await new ArtifactProvider() - .createClient(targetValue) - .execute( - "extract_artifact", - artifactExtractionInputSchema.parse({ - approved: true, - output_root: relocatedOutput, - occurrence_ids: [selected.occurrence_id], - }), - ); - expect(relocated.ok).toBe(true); - if (!relocated.ok) return; - expect( - artifactExtractionResultSchema.parse(relocated.value.result) - .extraction_manifest, - ).toEqual(firstExtraction.extraction_manifest); - - const second = await new ArtifactProvider() - .createClient(targetValue) - .execute( - "extract_artifact", - artifactExtractionInputSchema.parse({ - approved: true, - output_root: output, - occurrence_ids: [selected.occurrence_id], - }), - ); - expect(second).toMatchObject({ - ok: false, - error: { _tag: "ArtifactOperationError", reason: "path" }, - }); - expect(await readdir(root)).not.toEqual( - expect.arrayContaining([expect.stringMatching(/^\.output\.rea-/u)]), - ); - }); - - it("paginates wide graphs without changing manifest identity", async () => { - const root = await createTestTempDirectory("rea-wide-"); - await Promise.all( - Array.from({ length: 520 }, async (_, index) => - writeFile( - join(root, `file-${String(index).padStart(4, "0")}.txt`), - `${index}`, - ), - ), - ); - const client = new ArtifactProvider().createClient( - target(root, "directory"), - ); - const [first, secondPage] = await Promise.all([ - client.execute( - "inventory_artifact", - artifactInventoryInputSchema.parse({ occurrence_limit: 500 }), - ), - client.execute( - "inventory_artifact", - artifactInventoryInputSchema.parse({ - occurrence_offset: 500, - occurrence_limit: 500, - }), - ), - ]); - expect(first.ok && secondPage.ok).toBe(true); - if (!first.ok || !secondPage.ok) return; - const left = artifactInventoryResultSchema.parse(first.value.result); - const right = artifactInventoryResultSchema.parse(secondPage.value.result); - expect(left.manifest).toEqual(right.manifest); - expect(left.occurrences).toMatchObject({ total: 521, next_offset: 500 }); - expect(right.occurrences).toMatchObject({ - offset: 500, - next_offset: null, - }); - }, 15_000); - - it("uses bounded native lipo metadata for universal slice ranges", async () => { - const root = await createTestTempDirectory("rea-slices-"); - const binary = join(root, "fat"); - await writeFile(binary, Buffer.from("0123456789abcdef")); - const runner: NativeCommandRunner = { - run: () => - Promise.resolve( - ok({ - tool: "lipo", - executable: "/usr/bin/lipo", - executableSha256: "1".repeat(64), - toolVersion: null, - versionReason: "fixture", - arguments: ["-detailed_info", binary], - stdout: - "architecture x86_64\n cputype 16777223\n cpusubtype 3\n offset 0\n size 8\n align 2^2\narchitecture arm64\n cputype 16777228\n cpusubtype 0\n offset 8\n size 8\n align 2^2\n", - stderr: "", - stdoutBytes: 1, - stderrBytes: 0, - stdoutTruncated: false, - stderrTruncated: false, - exitCode: 0, - signal: null, - }), - ), - }; - const reader = new MachOSliceArtifactReader(binary, runner); - const entries = []; - for await (const entry of reader.entries()) entries.push(entry); - expect(entries).toHaveLength(2); - const secondEntry = entries[1]; - expect(secondEntry).toBeDefined(); - if (secondEntry === undefined) return; - expect(secondEntry).toMatchObject({ - path: "slices/arm64", - byteOffset: 8, - declaredSize: 8, - }); - const chunks: Buffer[] = []; - const stream = await reader.open(secondEntry); - for await (const chunk of stream) chunks.push(Buffer.from(chunk)); - expect(Buffer.concat(chunks).toString()).toBe("89abcdef"); - expect(reader.provenance()).toEqual([ - expect.objectContaining({ tool: "lipo", effects: ["read"] }), - ]); - const provenance = reader.provenance(); - if (provenance[0] !== undefined) - Reflect.set(provenance[0], "tool", "forged"); - expect(reader.provenance()[0]?.tool).toBe("lipo"); - }); -}); - -const inventory = async (targetValue: BinaryTarget) => { - const result = await new ArtifactProvider().createClient(targetValue).execute( - "inventory_artifact", - artifactInventoryInputSchema.parse({ - node_limit: 500, - occurrence_limit: 500, - edge_limit: 500, - }), - ); - if (!result.ok) throw result.error; - return artifactInventoryResultSchema.parse(result.value.result); -}; - -const target = ( - path: string, - format: BinaryTarget["format"] | "directory", -): BinaryTarget => ({ - path, - sourcePath: path, - sha256: "0".repeat(64), - kind: format === "directory" ? "executable" : "archive", - format: format === "directory" ? "mach-o" : format, -}); - -const limits = () => ({ maxDepth: 20, maxPathBytes: 4_096 }); diff --git a/tests/binarySession.test.ts b/tests/binarySession.test.ts deleted file mode 100644 index 12fa66a83..000000000 --- a/tests/binarySession.test.ts +++ /dev/null @@ -1,919 +0,0 @@ -import { rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; - -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; - -import { BinarySession } from "../src/application/BinarySession.js"; -import type { AnalysisClient } from "../src/application/AnalysisProvider.js"; -import type { - AnalysisProvider, - CapabilityDescriptor, -} from "../src/application/AnalysisProvider.js"; -import { - HopperStartError, - ProviderAdapterError, -} from "../src/domain/errors.js"; -import { ProviderCleanupError } from "../src/domain/providerCleanupError.js"; -import { err, ok as resultOk } from "../src/domain/result.js"; -import { createEvidenceBundle } from "../src/domain/evidenceBundle.js"; -import { createAnalysisProfile } from "../src/domain/analysisProfile.js"; -import { createInvestigationWorkspace } from "../src/domain/investigationWorkspace.js"; -import { observed as ok } from "./fixtures/analysisExecution.js"; - -let directory: string | undefined; -afterEach(async () => { - if (directory !== undefined) - await rm(directory, { recursive: true, force: true }); - directory = undefined; -}); - -describe("binary session", () => { - it("returns detached provider, target, and workspace metadata", async () => { - const [first] = await targets(); - const session = new BinarySession(cacheProvider([])); - expect((await session.open(first)).ok).toBe(true); - expect(session.status()).toMatchObject({ - analysis_run: { - run_id: expect.any(String), - process_lineage: { status: "not_observed" }, - }, - }); - expect(session.listUnknowns()).toEqual([]); - const identity = session.providerIdentity(); - Reflect.set(identity, "id", "forged"); - expect(session.providerIdentity().id).toBe("fixture"); - - const active = session.activeTarget(); - expect(active).toBeDefined(); - if (active !== undefined) Reflect.set(active, "path", "/tmp/forged"); - expect(session.activeTarget()?.path).toBe(first); - - const workspace = createInvestigationWorkspace( - "Detached workspace", - createEvidenceBundle([]), - [], - ); - expect(session.retainInvestigationWorkspace(workspace)).toBe("added"); - const direct = session.investigationWorkspace(workspace.workspace_id, 1); - if (direct !== undefined) Reflect.set(direct, "revision", 999); - const listed = session.investigationWorkspaces()[0]; - if (listed !== undefined) Reflect.set(listed, "workspace_id", "forged"); - expect( - session.investigationWorkspace(workspace.workspace_id, 1), - ).toMatchObject({ revision: 1, workspace_id: workspace.workspace_id }); - await session.close(); - }); - - it("runs through a non-Hopper analysis provider", async () => { - const [first] = await targets(); - const operations: string[] = []; - const provider: AnalysisProvider = { - identity: () => ({ id: "fixture", name: "Fixture", version: "1" }), - capabilities: () => [ - { - provider: { id: "fixture", name: "Fixture", version: "1" }, - operation: "address_name", - inputContractVersion: 1, - outputContractVersion: 1, - available: true, - reason: null, - pagination: "none", - exhaustive: true, - effects: { - mutatesArtifact: false, - launchesProcess: false, - mayShowUi: false, - mayAccessNetwork: false, - mayWriteFilesystem: false, - changesPermissions: false, - requiresRoot: false, - }, - limits: { - maxResults: null, - maxPayloadBytes: null, - timeoutMs: null, - }, - limitations: [], - }, - ], - createClient: (_target, _profile, context) => { - if (context === undefined) - throw new Error("missing analysis run context"); - return { - execute: (operation) => { - operations.push(operation); - return Promise.resolve(ok(operation)); - }, - runtimeLineageSnapshots: () => [ - { - provider: provider.identity(), - observation: { - status: "verified", - observedAt: "2026-07-22T10:00:00.000Z", - lineage: { - schemaVersion: 1, - runId: context.runId, - launcherPid: 100, - launcherParentPid: 1, - processGroupId: 100, - descendants: [ - { pid: 101, parentPid: 100, processGroupId: 100 }, - ], - }, - }, - }, - ], - requestActivitySnapshots: () => [ - { - provider: provider.identity(), - active: { - requestId: 7, - operation: "analyze_function", - elapsedMs: 31_000, - timeoutMs: 30_000, - callerState: "timed_out", - }, - queuedRequests: 2, - }, - ], - close: () => Promise.resolve(), - }; - }, - }; - const session = new BinarySession(provider); - expect((await session.open(first)).ok).toBe(true); - expect(await session.execute("address_name", {})).toEqual({ - ok: true, - value: { - result: "address_name", - rawResult: "address_name", - provider: { - id: "fixture", - name: "Fixture analysis provider", - version: "1", - }, - limitations: [], - locations: [], - subject: null, - }, - }); - expect(provider.identity().id).toBe("fixture"); - expect(provider.capabilities()[0]?.operation).toBe("address_name"); - expect(session.status()).toMatchObject({ - provider: { id: "fixture", name: "Fixture", version: "1" }, - providers: [{ id: "fixture", name: "Fixture", version: "1" }], - capabilities: [ - { - operation: "address_name", - available: true, - reason: null, - effects: { - mutates_artifact: false, - launches_process: false, - }, - }, - ], - analysis_run: { - run_id: expect.stringMatching( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u, - ), - process_lineage: { - status: "snapshots", - snapshots: [ - { - provider: { id: "fixture", name: "Fixture", version: "1" }, - observation: { - status: "verified", - observed_at: "2026-07-22T10:00:00.000Z", - schema_version: 1, - launcher_pid: 100, - launcher_parent_pid: 1, - process_group_id: 100, - descendants: [ - { pid: 101, parent_pid: 100, process_group_id: 100 }, - ], - }, - }, - ], - }, - }, - analysis_activity: { - status: "timed_out_busy", - providers: [ - { - provider: { id: "fixture", name: "Fixture", version: "1" }, - active: { - request_id: 7, - operation: "analyze_function", - elapsed_ms: 31_000, - timeout_ms: 30_000, - caller_state: "timed_out", - }, - queued_requests: 2, - }, - ], - }, - }); - expect(operations).toEqual(["health", "address_name"]); - await session.close(); - }); - - it("allocates one fresh run identity per provider client lifetime", async () => { - const [first, second] = await targets(); - const provider = cacheProvider([]); - const createClient = provider.createClient.bind(provider); - const runIds: string[] = []; - provider.createClient = (target, profile, context) => { - if (context === undefined) - throw new Error("missing analysis run context"); - runIds.push(context.runId); - return createClient(target, profile, context); - }; - const session = new BinarySession(provider); - - expect((await session.open(first)).ok).toBe(true); - expect((await session.open(first)).ok).toBe(true); - expect((await session.open(second)).ok).toBe(true); - - expect(runIds).toHaveLength(2); - expect(new Set(runIds).size).toBe(2); - await session.close(); - }); - - it("replays exact immutable calls from a matching provider-neutral snapshot", async () => { - const [first, second] = await targets(); - const initialCalls: string[] = []; - const initial = new BinarySession(cacheProvider(initialCalls)); - expect((await initial.open(first)).ok).toBe(true); - expect( - ( - await initial.execute("address_name", { - address: "0x1000", - document: "first", - }) - ).ok, - ).toBe(true); - const snapshot = initial.exportAnalysisSnapshot(); - expect(snapshot.ok).toBe(true); - if (!snapshot.ok) return; - expect(snapshot.value.entries).toHaveLength(1); - await initial.close(); - - const replayCalls: string[] = []; - const replay = new BinarySession(cacheProvider(replayCalls)); - expect(replay.importAnalysisSnapshot(snapshot.value)).toEqual({ - ok: true, - value: 1, - }); - expect((await replay.open(first)).ok).toBe(true); - const cached = await replay.execute("address_name", { - address: "0x1000", - document: "first", - }); - expect(cached.ok).toBe(true); - if (cached.ok) - expect(cached.value.limitations).toContainEqual( - expect.stringContaining("local REA analysis snapshot"), - ); - expect( - ( - await replay.execute("set_address_name", { - address: "0x1000", - name: "renamed", - document: "first", - }) - ).ok, - ).toBe(true); - expect( - ( - await replay.execute("address_name", { - address: "0x1000", - document: "first", - }) - ).ok, - ).toBe(true); - expect(replayCalls).toEqual(["health", "set_address_name", "address_name"]); - expect(replay.exportAnalysisSnapshot().ok).toBe(false); - expect((await replay.open(first)).ok).toBe(true); - expect(replay.exportAnalysisSnapshot()).toMatchObject({ - ok: true, - value: { entries: [{ operation: "address_name" }] }, - }); - await replay.close(); - - const mismatch = new BinarySession(cacheProvider([])); - expect(mismatch.importAnalysisSnapshot(snapshot.value).ok).toBe(true); - const opened = await mismatch.open(second); - expect(opened.ok).toBe(false); - if (!opened.ok) expect(opened.error._tag).toBe("EvidenceIntegrityError"); - await mismatch.close(); - - const profileMismatchCalls: string[] = []; - const profileMismatchProvider = cacheProvider(profileMismatchCalls); - const identity = profileMismatchProvider.identity(); - profileMismatchProvider.resolveAnalysisProfile = () => - Promise.resolve( - resultOk({ - profile: createAnalysisProfile( - { - id: identity.id, - name: identity.name, - version: identity.version ?? "fixture-unresolved", - }, - 1, - { fixture: "different-profile" }, - ), - compatibility: {}, - }), - ); - const profileMismatch = new BinarySession(profileMismatchProvider); - expect(profileMismatch.importAnalysisSnapshot(snapshot.value).ok).toBe( - true, - ); - const profileOpened = await profileMismatch.open(first); - expect(profileOpened.ok).toBe(false); - if (!profileOpened.ok) { - expect(profileOpened.error._tag).toBe("EvidenceIntegrityError"); - expect(profileOpened.error.message).toContain("profile_mismatch"); - } - expect(profileMismatchCalls).toEqual([]); - await profileMismatch.close(); - - const activeProfileMismatch = new BinarySession(profileMismatchProvider); - expect((await activeProfileMismatch.open(first)).ok).toBe(true); - const activeImport = activeProfileMismatch.importAnalysisSnapshot( - snapshot.value, - ); - expect(activeImport.ok).toBe(false); - if (!activeImport.ok) - expect(activeImport.error.message).toContain("profile_mismatch"); - await activeProfileMismatch.close(); - }); - - it("does not snapshot reads that depend on the provider cursor", async () => { - const [first] = await targets(); - const calls: string[] = []; - const session = new BinarySession(cacheProvider(calls)); - expect((await session.open(first)).ok).toBe(true); - expect((await session.execute("address_name", {})).ok).toBe(true); - expect((await session.execute("address_name", {})).ok).toBe(true); - expect(calls).toEqual(["health", "address_name", "address_name"]); - expect(session.exportAnalysisSnapshot()).toMatchObject({ - ok: true, - value: { entries: [] }, - }); - await session.close(); - }); - - it("publishes provider-health availability changes and resets on target switch", async () => { - const [first, second] = await targets(); - const provider = cacheProvider([]); - provider.createClient = () => ({ - execute: (operation) => - Promise.resolve( - operation === "health" - ? ok(null) - : err(new ProviderAdapterError("fixture", operation)), - ), - close: () => Promise.resolve(), - }); - const session = new BinarySession(provider); - let changes = 0; - session.onAvailabilityChanged(() => { - changes += 1; - }); - expect((await session.open(first)).ok).toBe(true); - expect((await session.execute("address_name", {})).ok).toBe(false); - expect(session.status()).toMatchObject({ - capabilities: expect.arrayContaining([ - expect.objectContaining({ - operation: "address_name", - available: false, - reason: "Provider became unavailable during this session.", - }), - ]), - }); - expect(changes).toBe(1); - expect((await session.open(second)).ok).toBe(true); - expect(session.status()).toMatchObject({ - capabilities: expect.arrayContaining([ - expect.objectContaining({ - operation: "address_name", - available: true, - reason: null, - }), - ]), - }); - expect(changes).toBe(2); - }); - - it("isolates availability observers from execution results and session state", async () => { - const [first] = await targets(); - const provider = cacheProvider([]); - let providerCalls = 0; - provider.createClient = () => ({ - execute: (operation) => { - if (operation === "health") return Promise.resolve(ok(null)); - providerCalls += 1; - return Promise.resolve( - providerCalls === 1 - ? err(new ProviderAdapterError("fixture", operation)) - : ok(operation), - ); - }, - close: () => Promise.resolve(), - }); - const session = new BinarySession(provider); - expect((await session.open(first)).ok).toBe(true); - const input = { address: "0x1000", document: "first" }; - expect((await session.execute("address_name", input)).ok).toBe(false); - - session.onAvailabilityChanged(() => { - throw new Error("external observer failed"); - }); - session.onAvailabilityChanged(() => - Promise.reject(new Error("async external observer failed")), - ); - let delivered = 0; - session.onAvailabilityChanged(() => { - delivered += 1; - }); - - await expect(session.execute("address_name", input)).resolves.toMatchObject( - { ok: true }, - ); - expect(delivered).toBe(1); - expect(session.status()).toMatchObject({ - capabilities: expect.arrayContaining([ - expect.objectContaining({ - operation: "address_name", - available: true, - reason: null, - }), - ]), - }); - expect((await session.execute("address_name", input)).ok).toBe(true); - expect(providerCalls).toBe(2); - }); - - it("does not replay operations with filesystem side effects", async () => { - const [first] = await targets(); - const calls: string[] = []; - const session = new BinarySession(cacheProvider(calls, true)); - expect((await session.open(first)).ok).toBe(true); - const input = { address: "0x1000", document: "first" }; - expect((await session.execute("address_name", input)).ok).toBe(true); - expect((await session.execute("address_name", input)).ok).toBe(true); - expect(calls).toEqual(["health", "address_name", "address_name"]); - await session.close(); - }); - - it("returns typed unavailability without dispatching a partial provider", async () => { - const [first] = await targets(); - const operations: string[] = []; - const provider: AnalysisProvider = { - identity: () => ({ id: "partial", name: "Partial", version: "1" }), - capabilities: () => [ - { - provider: { id: "partial", name: "Partial", version: "1" }, - operation: "address_name", - inputContractVersion: 1, - outputContractVersion: 1, - available: false, - reason: "fixture intentionally omits symbol lookup", - pagination: "none", - exhaustive: false, - effects: { - mutatesArtifact: false, - launchesProcess: false, - mayShowUi: false, - mayAccessNetwork: false, - mayWriteFilesystem: false, - changesPermissions: false, - requiresRoot: false, - }, - limits: { - maxResults: null, - maxPayloadBytes: null, - timeoutMs: null, - }, - limitations: ["No symbol lookup implementation."], - }, - ], - createClient: () => ({ - execute: (operation) => { - operations.push(operation); - return Promise.resolve(ok(operation)); - }, - close: () => Promise.resolve(), - }), - }; - const session = new BinarySession(provider); - expect((await session.open(first)).ok).toBe(true); - const result = await session.execute("address_name", {}); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error).toMatchObject({ - _tag: "AnalysisCapabilityUnavailableError", - providerId: "partial", - operation: "address_name", - reason: "fixture intentionally omits symbol lookup", - }); - } - expect(operations).toEqual(["health"]); - await session.close(); - }); - - it("requires an open binary and closes idempotently", async () => { - const session = new BinarySession(() => client()); - expect((await session.execute("binary_overview", {})).ok).toBe(false); - expect(await session.close()).toEqual({ ok: true, value: null }); - }); - - it("keeps the active client when a switch fails", async () => { - directory = await createTestTempDirectory("bb-session-"); - const first = join(directory, "first.hop"); - const second = join(directory, "second.hop"); - await writeFile(first, "one"); - await writeFile(second, "two"); - let created = 0; - const session = new BinarySession(() => client(created++ === 1)); - expect((await session.open(first)).ok).toBe(true); - expect((await session.open(second)).ok).toBe(false); - expect(session.status()).toMatchObject({ - open: true, - sha256: - "7692c3ad3540bb803c020b3aee66cd8887123234ea0c6e7143c0add73ff431ed", - architecture: null, - }); - expect(JSON.stringify(session.status())).toContain("first.hop"); - expect(created).toBe(3); - }); - - it("serializes concurrent opens and leaves the last target active", async () => { - const [first, second] = await targets(); - const clients: TestClient[] = []; - const session = new BinarySession(() => { - const value = new TestClient(); - clients.push(value); - return value; - }); - const one = session.open(first); - const two = session.open(second); - expect((await one).ok).toBe(true); - expect((await two).ok).toBe(true); - expect(session.status()).toMatchObject({ open: true }); - expect(JSON.stringify(session.status())).toContain("second.hop"); - expect(clients[0]?.closed).toBe(1); - }); - - it("replaces the active client when a canonical path changes contents", async () => { - directory = await createTestTempDirectory("bb-session-"); - const path = join(directory, "mutable.hop"); - await writeFile(path, "one"); - const clients: Array<{ - readonly targetSha256: string; - readonly calls: string[]; - closed: number; - }> = []; - const session = new BinarySession((target) => { - const state = { - targetSha256: target.sha256, - calls: [] as string[], - closed: 0, - }; - clients.push(state); - return { - execute: (operation) => { - state.calls.push(operation); - return Promise.resolve( - ok(operation === "health" ? null : target.sha256), - ); - }, - close: () => { - state.closed += 1; - return Promise.resolve(); - }, - }; - }); - - const first = await session.open(path); - expect(first.ok).toBe(true); - await writeFile(path, "two"); - const second = await session.open(path); - expect(second.ok).toBe(true); - if (!first.ok || !second.ok) return; - - expect(second.value.sha256).not.toBe(first.value.sha256); - expect(session.activeTarget()?.sha256).toBe(second.value.sha256); - expect(await session.execute("binary_overview", {})).toMatchObject({ - ok: true, - value: { result: second.value.sha256 }, - }); - expect(clients).toMatchObject([ - { - targetSha256: first.value.sha256, - calls: ["health"], - closed: 1, - }, - { - targetSha256: second.value.sha256, - calls: ["health", "binary_overview"], - closed: 0, - }, - ]); - await session.close(); - }); - - it("waits for an active call before closing its client during a switch", async () => { - const [first, second] = await targets(); - const active = deferred>(); - const clients: TestClient[] = []; - const session = new BinarySession(() => { - const value = new TestClient( - undefined, - false, - clients.length === 0 ? active.promise : undefined, - ); - clients.push(value); - return value; - }); - await session.open(first); - const call = session.execute("procedure_pseudo_code", {}); - const switching = session.open(second); - await Promise.resolve(); - expect(clients[0]?.closed).toBe(0); - active.resolve(ok(null)); - await call; - await switching; - expect(clients[0]?.closed).toBe(1); - }); - - it("cancels an open queued behind another transition without creating a client", async () => { - const [first, second] = await targets(); - const health = deferred>(); - let created = 0; - const session = new BinarySession(() => { - created += 1; - return new TestClient(created === 1 ? health.promise : undefined); - }); - const opening = session.open(first); - const controller = new AbortController(); - const queued = session.open(second, { signal: controller.signal }); - controller.abort(); - health.resolve(ok(null)); - await opening; - const result = await queued; - expect(result.ok).toBe(false); - if (!result.ok) expect(result.error._tag).toBe("AnalysisCancelledError"); - expect(created).toBe(1); - }); - - it("cancels legacy profile resolution even when the provider ignores its signal", async () => { - const [first] = await targets(); - const provider = cacheProvider([]); - let observedSignal: AbortSignal | undefined; - let created = 0; - provider.resolveAnalysisProfile = (_target, options) => { - observedSignal = options?.signal; - return new Promise(() => undefined); - }; - provider.createClient = () => { - created += 1; - return new TestClient(); - }; - const session = new BinarySession(provider); - const controller = new AbortController(); - const opening = session.open(first, { signal: controller.signal }); - while (observedSignal === undefined) - await new Promise((resolve) => setImmediate(resolve)); - expect(observedSignal).toBe(controller.signal); - - controller.abort(); - - await expect(opening).resolves.toMatchObject({ - ok: false, - error: { _tag: "AnalysisCancelledError", operation: "open_binary" }, - }); - expect(created).toBe(0); - }); - - it("cancels a call while it waits for a transition", async () => { - const [first] = await targets(); - const health = deferred>(); - const session = new BinarySession(() => new TestClient(health.promise)); - const opening = session.open(first); - const controller = new AbortController(); - const call = session.execute( - "binary_overview", - {}, - { - signal: controller.signal, - }, - ); - controller.abort(); - const result = await call; - expect(result.ok).toBe(false); - if (!result.ok) expect(result.error._tag).toBe("AnalysisCancelledError"); - health.resolve(ok(null)); - await opening; - }); - - it("closes a failed candidate and reopens the previous target", async () => { - const [first, second] = await targets(); - const clients: TestClient[] = []; - const session = new BinarySession(() => { - const value = new TestClient(undefined, clients.length === 1); - clients.push(value); - return value; - }); - await session.open(first); - await session.open(second); - expect(clients[1]?.closed).toBe(1); - expect(clients[0]?.closed).toBe(1); - expect(clients[2]?.closed).toBe(0); - expect(JSON.stringify(session.status())).toContain("first.hop"); - await session.close(); - await session.close(); - expect(clients[2]?.closed).toBe(1); - }); - - it("closes the active bridge before starting a replacement", async () => { - const [first, second] = await targets(); - let liveClients = 0; - let overlapped = false; - const session = new BinarySession(() => ({ - execute: () => { - if (liveClients > 0) overlapped = true; - liveClients += 1; - return Promise.resolve(ok(null)); - }, - close: () => { - liveClients -= 1; - return Promise.resolve(); - }, - })); - await session.open(first); - await session.open(second); - expect(overlapped).toBe(false); - await session.close(); - }); - - it("clears session state while preserving a typed provider cleanup failure", async () => { - const [first] = await targets(); - const cleanupError = new ProviderCleanupError( - "fixture", - ["fixture-document"], - { reason: "shutdown acknowledgement missing" }, - ); - const session = new BinarySession(() => ({ - execute: () => Promise.resolve(ok(null)), - closeWithOutcome: () => Promise.resolve(err(cleanupError)), - close: () => Promise.resolve(), - })); - expect((await session.open(first)).ok).toBe(true); - - expect(await session.close()).toEqual(err(cleanupError)); - expect(session.status()).toMatchObject({ - open: false, - analysis_activity: { status: "not_observed", providers: [] }, - }); - }); - - it("does not start a replacement after the active provider cleanup is unconfirmed", async () => { - const [first, second] = await targets(); - const cleanupError = new ProviderCleanupError( - "fixture", - ["fixture-document"], - { reason: "shutdown acknowledgement missing" }, - ); - let created = 0; - const session = new BinarySession(() => { - created += 1; - return { - execute: () => Promise.resolve(ok(null)), - closeWithOutcome: () => Promise.resolve(err(cleanupError)), - close: () => Promise.resolve(), - }; - }); - expect((await session.open(first)).ok).toBe(true); - - expect(await session.open(second)).toEqual(err(cleanupError)); - expect(created).toBe(1); - expect(session.status()).toMatchObject({ open: false }); - }); -}); - -const client = (fail = false): AnalysisClient => ({ - execute: () => Promise.resolve(fail ? err(new HopperStartError()) : ok(null)), - close: () => Promise.resolve(), -}); - -const cacheProvider = ( - calls: string[], - mayWriteFilesystem = false, -): AnalysisProvider => { - const identity = { - id: "fixture", - name: "Fixture analysis provider", - version: "1", - } as const; - return { - identity: () => identity, - resolveAnalysisProfile: () => - Promise.resolve( - resultOk({ - profile: createAnalysisProfile(identity, 1, { fixture: true }), - compatibility: {}, - }), - ), - capabilities: () => [ - cacheCapability(identity, "address_name", false, mayWriteFilesystem), - cacheCapability(identity, "set_address_name", true), - ], - createClient: () => ({ - execute: (operation) => { - calls.push(operation); - return Promise.resolve(ok(operation)); - }, - close: () => Promise.resolve(), - }), - }; -}; - -const cacheCapability = ( - provider: CapabilityDescriptor["provider"], - operation: "address_name" | "set_address_name", - mutatesArtifact: boolean, - mayWriteFilesystem = false, -): CapabilityDescriptor => ({ - provider, - operation, - inputContractVersion: 1, - outputContractVersion: 1, - available: true, - reason: null, - pagination: "none", - exhaustive: true, - effects: { - mutatesArtifact, - launchesProcess: false, - mayShowUi: false, - mayAccessNetwork: false, - mayWriteFilesystem, - changesPermissions: false, - requiresRoot: false, - }, - limits: { - maxResults: null, - maxPayloadBytes: null, - timeoutMs: null, - }, - limitations: [], -}); - -class TestClient implements AnalysisClient { - closed = 0; - constructor( - readonly pendingHealth?: Promise>, - readonly failHealth = false, - readonly pendingCall?: Promise>, - ) {} - execute(name: string) { - if (name === "health") - return this.failHealth - ? Promise.resolve(err(new HopperStartError())) - : (this.pendingHealth ?? Promise.resolve(ok(null))); - return this.pendingCall ?? Promise.resolve(ok(null)); - } - close(): Promise { - this.closed += 1; - return Promise.resolve(); - } -} - -const targets = async (): Promise => { - directory ??= await createTestTempDirectory("bb-session-"); - const first = join(directory, "first.hop"); - const second = join(directory, "second.hop"); - await writeFile(first, "one"); - await writeFile(second, "two"); - return [first, second]; -}; - -const deferred = (): { - readonly promise: Promise; - resolve(value: T): void; -} => { - let resolvePromise: ((value: T) => void) | undefined; - const promise = new Promise((resolve) => { - resolvePromise = resolve; - }); - return { - promise, - resolve(value) { - resolvePromise?.(value); - }, - }; -}; diff --git a/tests/browserProcessStartup.test.ts b/tests/boundary/browser/browserProcessStartup.test.ts similarity index 95% rename from tests/browserProcessStartup.test.ts rename to tests/boundary/browser/browserProcessStartup.test.ts index 0e02dae19..093d02076 100644 --- a/tests/browserProcessStartup.test.ts +++ b/tests/boundary/browser/browserProcessStartup.test.ts @@ -4,12 +4,12 @@ import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { BrowserStartupError, waitForBrowserDevtoolsPort, -} from "../src/browser/BrowserProcessStartup.js"; +} from "../../../src/browser/BrowserProcessStartup.js"; const roots: string[] = []; diff --git a/tests/boundary/browser/cdpBrowserProvider.discovery_authorization.test.ts b/tests/boundary/browser/cdpBrowserProvider.discovery_authorization.test.ts new file mode 100644 index 000000000..b0c813d18 --- /dev/null +++ b/tests/boundary/browser/cdpBrowserProvider.discovery_authorization.test.ts @@ -0,0 +1,216 @@ +import { expect, it } from "vitest"; + +import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; +import { + inspectWebPageInputSchema, + listBrowserTargetsInputSchema, +} from "../../../src/domain/browserObservation.js"; +import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; +import { describeBrowser, trackBrowser } from "./cdpBrowserProvider.support.js"; + +describeBrowser("CdpBrowserProvider: discovery authorization 1", () => { + it("lists only exact-origin pages and sanitizes URLs", async () => { + const browser = await startFakeCdpBrowser(); + trackBrowser(browser); + const provider = new CdpBrowserProvider(); + const result = await provider.listTargets( + listBrowserTargetsInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + approved: true, + }), + ); + + if (!result.ok) throw result.error; + expect(result.value.targets.items).toEqual([ + expect.objectContaining({ + target_id: "allowed-page", + origin: browser.allowedOrigin, + url: `${browser.allowedOrigin}/app?token=%5BREDACTED%5D`, + }), + ]); + expect(result.value.excluded).toEqual({ + disallowed_origin: 1, + unsupported_url: 1, + non_page: 1, + }); + expect(JSON.stringify(result.value)).not.toContain("forbidden"); + expect(JSON.stringify(result.value)).not.toContain("page-secret"); + }); + + it.each([ + ["absolute", true, ""], + ["host/path", "host-path", ""], + ["root-relative", "root-relative", ""], + ["prefixed host/path", "prefixed", "Loading "], + ] as const)( + "sanitizes %s transitional target titles", + async (_label, urlShapedAllowedTitle, prefix) => { + const browser = await startFakeCdpBrowser({ urlShapedAllowedTitle }); + trackBrowser(browser); + const provider = new CdpBrowserProvider(); + const input = { + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + approved: true as const, + target_id: "allowed-page", + }; + const expected = `${prefix}${browser.allowedOrigin}/app?startup=%5BREDACTED%5D`; + const listed = await provider.listTargets( + listBrowserTargetsInputSchema.parse(input), + ); + if (!listed.ok) throw listed.error; + expect(listed.value.targets.items[0]?.title).toBe(expected); + + const inspected = await provider.inspectPage( + inspectWebPageInputSchema.parse({ ...input, observation_ms: 0 }), + ); + if (!inspected.ok) throw inspected.error; + expect(inspected.value.target.title).toBe(expected); + expect(JSON.stringify({ listed, inspected })).not.toContain( + "title-secret", + ); + }, + ); + + it("uses page-scoped discovery sockets as direct target transports", async () => { + const browser = await startFakeCdpBrowser({ + pageScopedVersionWebSocket: true, + omitTargetWebSocket: true, + additionalPageWithWebSocket: true, + additionalPageWithoutWebSocket: true, + }); + trackBrowser(browser); + const provider = new CdpBrowserProvider(); + const listed = await provider.listTargets( + listBrowserTargetsInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + approved: true, + }), + ); + if (!listed.ok) throw listed.error; + expect( + listed.value.targets.items.map(({ target_id }) => target_id), + ).toEqual(["allowed-page", "allowed-page-with-socket"]); + expect(listed.value.limitations).toContain( + "1 otherwise allowed page target(s) lacked a validated direct CDP WebSocket and were excluded.", + ); + + const inspected = await provider.inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 0, + }), + ); + if (!inspected.ok) throw inspected.error; + expect(inspected.value.target.target_id).toBe("allowed-page"); + expect(inspected.value.network.requests).toHaveLength(1); + + const second = await provider.inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page-with-socket", + approved: true, + observation_ms: 0, + }), + ); + if (!second.ok) throw second.error; + expect(second.value.target.target_id).toBe("allowed-page-with-socket"); + const methods = browser.commands.map(({ method }) => method); + expect(methods).not.toContain("Target.attachToTarget"); + expect(methods).not.toContain("Target.detachFromTarget"); + expect( + browser.commands.every(({ sessionId }) => sessionId === undefined), + ).toBe(true); + }); +}); + +describeBrowser("CdpBrowserProvider: discovery authorization 2", () => { + it("rejects empty browser attachment session identifiers", async () => { + const browser = await startFakeCdpBrowser({ invalidAttachedSession: true }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 0, + }), + ); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "BrowserObservationError", reason: "protocol_error" }, + }); + expect(browser.commands.map(({ method }) => method)).toEqual([ + "Target.attachToTarget", + ]); + }); + + it("rejects missing and disallowed page targets before attaching", async () => { + const browser = await startFakeCdpBrowser(); + trackBrowser(browser); + const provider = new CdpBrowserProvider(); + for (const targetId of ["missing", "disallowed-page"]) { + const result = await provider.inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: targetId, + approved: true, + observation_ms: 0, + }), + ); + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.error).toMatchObject({ + _tag: "BrowserObservationError", + reason: + targetId === "missing" ? "target_not_found" : "target_not_allowed", + }); + } + expect(browser.commands).toHaveLength(0); + }); + + it("returns typed errors for malformed discovery and disconnects", async () => { + const malformed = await startFakeCdpBrowser({ malformedDiscovery: true }); + trackBrowser(malformed); + const malformedResult = await new CdpBrowserProvider().listTargets( + listBrowserTargetsInputSchema.parse({ + cdp_endpoint: malformed.endpoint, + allowed_origins: [malformed.allowedOrigin], + approved: true, + }), + ); + expect(malformedResult).toMatchObject({ + ok: false, + error: { + _tag: "BrowserObservationError", + reason: "invalid_endpoint_response", + }, + }); + + const disconnecting = await startFakeCdpBrowser({ + closeOnMethod: "Page.getFrameTree", + }); + trackBrowser(disconnecting); + const disconnectedResult = await new CdpBrowserProvider().inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: disconnecting.endpoint, + allowed_origins: [disconnecting.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 0, + }), + ); + expect(disconnectedResult).toMatchObject({ + ok: false, + error: { _tag: "BrowserObservationError", reason: "disconnected" }, + }); + }); +}); diff --git a/tests/boundary/browser/cdpBrowserProvider.document_script.test.ts b/tests/boundary/browser/cdpBrowserProvider.document_script.test.ts new file mode 100644 index 000000000..803a24da4 --- /dev/null +++ b/tests/boundary/browser/cdpBrowserProvider.document_script.test.ts @@ -0,0 +1,441 @@ +import { expect, it } from "vitest"; + +import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; +import { inspectWebPageInputSchema } from "../../../src/domain/browserObservation.js"; +import { analyzeWebBundleInputSchema } from "../../../src/domain/webBundleAnalysis.js"; +import { discoverWebMcpToolsInputSchema } from "../../../src/domain/webMcpDiscovery.js"; +import { captureWebScreenshotInputSchema } from "../../../src/domain/webScreenshot.js"; +import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; +import { describeBrowser, trackBrowser } from "./cdpBrowserProvider.support.js"; + +describeBrowser("CdpBrowserProvider: document script 1", () => { + it("captures bounded accessibility text only after independent approval", async () => { + const browser = await startFakeCdpBrowser(); + trackBrowser(browser); + const result = await new CdpBrowserProvider().inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 0, + include_accessibility_text: true, + limits: { + max_ax_text_field_bytes: 6, + max_total_ax_text_bytes: 6, + }, + }), + ); + + if (!result.ok) throw result.error; + expect(result.value.accessibility).toMatchObject({ + text_capture: { + status: "truncated", + retained_bytes: 6, + truncated_fields: 2, + }, + nodes: [expect.objectContaining({ name: "Submit" })], + }); + expect(result.value.completeness.truncated_sections).toContain( + "accessibility", + ); + }); + + it("includes allowed script source only after explicit approval", async () => { + const browser = await startFakeCdpBrowser(); + trackBrowser(browser); + const provider = new CdpBrowserProvider(); + const result = await provider.inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 0, + include_script_sources: true, + }), + ); + + if (!result.ok) throw result.error; + expect(result.value.scripts.items[0]?.source).toEqual( + expect.objectContaining({ + included: true, + artifact: expect.objectContaining({ + text: "export const observed = 'source-secret';", + bytes: 40, + uri: expect.stringMatching(/^rea:\/\/web-content\/sha256\//u), + }), + }), + ); + expect(browser.commands.map((command) => command.method)).toContain( + "Debugger.getScriptSource", + ); + }); + + it("fetches and validates source maps only after separate approval", async () => { + const browser = await startFakeCdpBrowser({ + sourceMapBody: JSON.stringify({ + version: 3, + names: [], + sources: ["src/main.ts"], + sourcesContent: ["export const original = true;"], + mappings: "AAAA", + }), + }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().analyzeBundle( + analyzeWebBundleInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + source_capture_approved: true, + observation_ms: 0, + fetch_source_maps: true, + source_map_fetch_approved: true, + }), + ); + + if (!result.ok) throw result.error; + expect(result.value.observations.source_maps).toMatchObject({ + status: "included", + items: [ + { + status: "included", + original_sources: [ + expect.objectContaining({ + source: `${browser.allowedOrigin}/src/main.ts`, + }), + ], + mappings: [expect.any(Object)], + }, + ], + }); + const sourceMapRequest = browser.httpRequests.find(({ url }) => + url.startsWith("/app.js.map"), + ); + expect(sourceMapRequest).toEqual({ + url: "/app.js.map?token=map-secret", + authorization: undefined, + cookie: undefined, + referer: undefined, + }); + }); +}); + +describeBrowser("CdpBrowserProvider: document script 2", () => { + it("discovers untrusted WebMCP declarations without registering or invoking them", async () => { + const browser = await startFakeCdpBrowser({ webMcpTools: true }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().discoverWebMcpTools( + discoverWebMcpToolsInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 0, + }), + ); + + if (!result.ok) throw result.error; + expect(result.value).toMatchObject({ + status: "available", + tools: { + total: 1, + items: [ + { + name: "search_orders", + description: "Search orders; authorization=[REDACTED]", + declaration_kind: "declarative", + owner_origin: browser.allowedOrigin, + annotations: { + read_only: true, + untrusted_content: true, + autosubmit: false, + }, + trust: "page-declared-untrusted", + registration_source: { + url: `${browser.allowedOrigin}/app.js?token=%5BREDACTED%5D`, + line: 12, + column: 4, + }, + }, + ], + }, + }); + const serialized = JSON.stringify(result.value); + for (const secret of [ + "tool-secret", + "schema-secret", + "tool-source-secret", + "private-tool-secret", + ]) + expect(serialized).not.toContain(secret); + const methods = browser.commands.map(({ method }) => method); + expect(methods).toContain("WebMCP.enable"); + expect(methods).not.toContain("WebMCP.invokeTool"); + expect(methods).not.toContain("Runtime.evaluate"); + }); + + it("reports WebMCP unavailable when the experimental domain is absent", async () => { + const browser = await startFakeCdpBrowser({ + unsupportedMethods: ["WebMCP.enable"], + }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().discoverWebMcpTools( + discoverWebMcpToolsInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 0, + }), + ); + + if (!result.ok) throw result.error; + expect(result.value.status).toBe("unavailable"); + expect(result.value.tools.items).toEqual([]); + expect(result.value.completeness.unavailable_sections).toContain( + "webmcp_tools", + ); + }); + + it("rejects WebMCP evidence when the page leaves its approved origin", async () => { + const browser = await startFakeCdpBrowser({ + webMcpTools: true, + frameUrlAfterFirstRead: "https://private.example.test/tools", + }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().discoverWebMcpTools( + discoverWebMcpToolsInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 0, + }), + ); + + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "BrowserObservationError", + operation: "discover_webmcp_tools", + reason: "target_not_allowed", + }, + }); + expect(browser.commands.map(({ method }) => method)).not.toContain( + "WebMCP.invokeTool", + ); + }); + + it("bounds WebMCP registration replay and reports dropped declarations", async () => { + const browser = await startFakeCdpBrowser({ + webMcpTools: true, + extraCollections: true, + }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().discoverWebMcpTools( + discoverWebMcpToolsInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 0, + max_tools: 1, + }), + ); + + if (!result.ok) throw result.error; + expect(result.value.tools).toMatchObject({ + total: 2, + items: [expect.any(Object)], + }); + expect(result.value.completeness).toMatchObject({ + status: "truncated", + dropped_events: { webmcp_tools: 1 }, + }); + }); +}); + +describeBrowser("CdpBrowserProvider: document script 3", () => { + it("removes WebMCP declarations after their child frame leaves scope", async () => { + const browser = await startFakeCdpBrowser({ + webMcpTools: true, + extraCollections: true, + webMcpChildLeavesScope: true, + }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().discoverWebMcpTools( + discoverWebMcpToolsInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 0, + }), + ); + + if (!result.ok) throw result.error; + const names = result.value.tools.items.map(({ name }) => name); + expect(names).toEqual( + expect.arrayContaining(["search_orders", "update_order"]), + ); + expect(names).not.toEqual( + expect.arrayContaining(["child_tool", "escaped_child_tool"]), + ); + expect(result.value.completeness.policy_filtered_sections).toContain( + "webmcp_tools", + ); + expect(JSON.stringify(result.value)).not.toContain( + "cross-origin-child-secret", + ); + }); + + it("captures an explicitly approved content-addressed viewport screenshot", async () => { + const browser = await startFakeCdpBrowser(); + trackBrowser(browser); + const result = await new CdpBrowserProvider().captureScreenshot( + captureWebScreenshotInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + screenshot_approved: true, + }), + ); + + if (!result.ok) throw result.error; + expect(result.value).toMatchObject({ + viewport: { width: 1, height: 1 }, + artifact: { + uri: expect.stringMatching(/^rea:\/\/web-screenshot\/sha256\//u), + bytes: 70, + media_type: "image/png", + }, + }); + expect(browser.commands.map(({ method }) => method)).toContain( + "Page.captureScreenshot", + ); + }); + + it("discards screenshot pixels when the main frame navigates during capture", async () => { + const browser = await startFakeCdpBrowser({ + navigateDuringScreenshotUrl: "https://private.example.test/screenshot", + }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().captureScreenshot( + captureWebScreenshotInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + screenshot_approved: true, + }), + ); + + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "BrowserObservationError", + operation: "capture_web_screenshot", + reason: "target_not_allowed", + }, + }); + }); + + it("reports configured truncation without over-reading DOM or script source", async () => { + const browser = await startFakeCdpBrowser(); + trackBrowser(browser); + const result = await new CdpBrowserProvider().inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 0, + include_script_sources: true, + limits: { + max_frames: 1, + max_dom_nodes: 1, + max_ax_nodes: 1, + max_scripts: 1, + max_resources: 1, + max_workers: 1, + max_storage_keys: 1, + max_script_source_bytes: 10, + max_total_script_source_bytes: 10, + max_network_events: 1, + max_console_events: 1, + max_websocket_events: 1, + }, + }), + ); + if (!result.ok) throw result.error; + expect(result.value.completeness).toMatchObject({ + status: "truncated", + truncated_sections: ["dom", "script_sources"], + }); + expect(result.value.dom).toMatchObject({ total_nodes: 2 }); + expect(result.value.dom.nodes).toHaveLength(1); + expect(result.value.scripts.items[0]?.source).toMatchObject({ + included: false, + reason: "declared script length exceeds per-script limit", + }); + expect(browser.commands.map(({ method }) => method)).not.toContain( + "Debugger.getScriptSource", + ); + }); +}); + +describeBrowser("CdpBrowserProvider: document script 4", () => { + it("bounds frames, resources, workers, accessibility, and storage inventories", async () => { + const browser = await startFakeCdpBrowser({ extraCollections: true }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 0, + include_storage_keys: true, + limits: { + max_frames: 1, + max_dom_nodes: 2, + max_ax_nodes: 1, + max_scripts: 1, + max_resources: 1, + max_workers: 1, + max_storage_keys: 1, + max_script_source_bytes: 10, + max_total_script_source_bytes: 10, + max_network_events: 1, + max_console_events: 1, + max_websocket_events: 1, + }, + }), + ); + if (!result.ok) throw result.error; + expect(result.value.completeness).toMatchObject({ + status: "truncated", + truncated_sections: [ + "accessibility", + "frames", + "resources", + "storage_keys", + "workers", + ], + }); + expect(result.value.frames).toHaveLength(1); + expect(result.value.resources).toHaveLength(1); + expect(result.value.workers).toHaveLength(1); + expect(result.value.accessibility).toMatchObject({ total_nodes: 2 }); + expect(result.value.accessibility.nodes).toHaveLength(1); + expect(result.value.storage.local_storage_keys).toHaveLength(1); + expect(result.value.storage.session_storage_keys).toHaveLength(1); + expect(result.value.storage.indexed_db_names).toHaveLength(1); + expect(result.value.storage.cache_names).toHaveLength(1); + expect(result.value.storage.content_fingerprints).toEqual([]); + }); +}); diff --git a/tests/boundary/browser/cdpBrowserProvider.lifecycle_failures.test.ts b/tests/boundary/browser/cdpBrowserProvider.lifecycle_failures.test.ts new file mode 100644 index 000000000..ccd2f9737 --- /dev/null +++ b/tests/boundary/browser/cdpBrowserProvider.lifecycle_failures.test.ts @@ -0,0 +1,484 @@ +import { expect, it } from "vitest"; + +import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; +import { + inspectWebPageInputSchema, + listBrowserTargetsInputSchema, +} from "../../../src/domain/browserObservation.js"; +import { analyzeWebBundleInputSchema } from "../../../src/domain/webBundleAnalysis.js"; +import { observeWebSessionInputSchema } from "../../../src/domain/browserSession.js"; +import { + startFakeCdpBrowser, + type FakeCdpBrowser, +} from "../../fixtures/fakeCdpBrowser.js"; +import { describeBrowser, trackBrowser } from "./cdpBrowserProvider.support.js"; + +describeBrowser("CdpBrowserProvider: lifecycle failures 1", () => { + it("projects direct-session cancellation onto the requested operation", async () => { + const browser = await startFakeCdpBrowser({ + pageScopedVersionWebSocket: true, + omitTargetWebSocket: true, + }); + trackBrowser(browser); + const controller = new AbortController(); + const result = await new CdpBrowserProvider().observeSession( + observeWebSessionInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 1_000, + }), + { + signal: controller.signal, + progress: { + report(update) { + if (update.completed === 1) controller.abort(); + return Promise.resolve(); + }, + }, + }, + ); + + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCancelledError", + operation: "observe_web_session", + }, + }); + const methods = browser.commands.map(({ method }) => method); + expect(methods).not.toContain("Target.attachToTarget"); + expect(methods).not.toContain("Target.detachFromTarget"); + expect(methods).not.toContain("Target.closeTarget"); + expect(methods).not.toContain("Browser.close"); + }); + + it("projects capture-window cancellation onto bundle analysis", async () => { + const browser = await startFakeCdpBrowser({ + pageScopedVersionWebSocket: true, + omitTargetWebSocket: true, + }); + trackBrowser(browser); + const controller = new AbortController(); + const result = await new CdpBrowserProvider().analyzeBundle( + analyzeWebBundleInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + source_capture_approved: true, + observation_ms: 1_000, + }), + { + signal: controller.signal, + progress: { + report(update) { + if (update.completed === 2) controller.abort(); + return Promise.resolve(); + }, + }, + }, + ); + + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCancelledError", + operation: "analyze_web_bundle", + }, + }); + }); + + it("observes user-driven reload, SPA, redirect, failure, and lifecycle events", async () => { + const browser = await startFakeCdpBrowser({ + sessionTimeline: "same_origin", + }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().observeSession( + observeWebSessionInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 20, + }), + ); + + if (!result.ok) throw result.error; + expect(result.value.window.end_reason).toBe("window_elapsed"); + expect(result.value.timeline.map(({ type }) => type)).toEqual([ + "navigation_requested", + "same_origin_reload", + "same_document_navigation", + "redirect", + "load_failed", + "lifecycle", + ]); + expect(result.value.timeline).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "same_document_navigation", + url: `${browser.allowedOrigin}/reloaded?token=%5BREDACTED%5D`, + }), + expect.objectContaining({ + type: "load_failed", + detail: "net::ERR_CONNECTION_REFUSED", + }), + ]), + ); + expect(result.value.completeness.status).toBe("attach_limited"); + expect(JSON.stringify(result.value)).not.toContain("session-secret"); + expect(JSON.stringify(result.value)).not.toContain("redirect-secret"); + expect(browser.commands.map(({ method }) => method)).toContain( + "Target.detachFromTarget", + ); + }); +}); + +describeBrowser("CdpBrowserProvider: lifecycle failures 2", () => { + it("ends a browser session when its flat target session detaches", async () => { + const browser = await startFakeCdpBrowser({ + sessionTimeline: "target_detached", + }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().observeSession( + observeWebSessionInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 1_000, + }), + ); + + if (!result.ok) throw result.error; + expect(result.value.window.end_reason).toBe("target_terminated"); + expect(result.value.timeline.at(-1)).toMatchObject({ + type: "target_terminated", + detail: "target_terminated", + }); + }); + + it("ends a direct page session when its transport disconnects", async () => { + const browser = await startFakeCdpBrowser({ + pageScopedVersionWebSocket: true, + omitTargetWebSocket: true, + closeAfterMethod: "Page.setLifecycleEventsEnabled", + }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().observeSession( + observeWebSessionInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 1_000, + }), + ); + + if (!result.ok) throw result.error; + expect(result.value.window.end_reason).toBe("target_terminated"); + expect(result.value.timeline.at(-1)).toMatchObject({ + type: "target_terminated", + detail: "target_terminated", + }); + const methods = browser.commands.map(({ method }) => method); + expect(methods).not.toContain("Target.attachToTarget"); + expect(methods).not.toContain("Target.detachFromTarget"); + expect(methods).not.toContain("Target.closeTarget"); + }); + + it("ends immediately when a redirect leaves policy without exposing its URL", async () => { + const browser = await startFakeCdpBrowser({ + sessionTimeline: "outside_policy", + }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().observeSession( + observeWebSessionInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 1_000, + }), + ); + + if (!result.ok) throw result.error; + expect(result.value.window.end_reason).toBe("target_left_scope"); + expect(result.value.timeline.at(-1)).toMatchObject({ + type: "redirect", + url: null, + destination_scope: "outside_policy", + }); + expect(result.value.completeness).toMatchObject({ + status: "policy_filtered", + policy_filtered_sections: ["timeline"], + excluded: [ + { + section: "timeline", + reason: "out_of_target_scope", + count: 1, + }, + ], + }); + expect(JSON.stringify(result.value)).not.toContain("private.example.test"); + }); + + it("fails closed when the final session frame leaves policy without an event", async () => { + const browser = await startFakeCdpBrowser({ + frameUrlAfterFirstRead: "https://private.example.test/final", + }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().observeSession( + observeWebSessionInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 1, + }), + ); + + if (!result.ok) throw result.error; + expect(result.value.window.end_reason).toBe("target_left_scope"); + expect(result.value.target.final_url).toBeNull(); + expect(result.value.completeness).toMatchObject({ + status: "policy_filtered", + policy_filtered_sections: ["timeline"], + }); + expect(JSON.stringify(result.value)).not.toContain("private.example.test"); + }); +}); + +describeBrowser("CdpBrowserProvider: lifecycle failures 3", () => { + it("degrades optional domains but propagates protocol and payload failures", async () => { + const optional = await startFakeCdpBrowser({ + unsupportedMethods: ["Accessibility.getFullAXTree"], + }); + trackBrowser(optional); + const degraded = await new CdpBrowserProvider().inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: optional.endpoint, + allowed_origins: [optional.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 0, + }), + ); + if (!degraded.ok) throw degraded.error; + expect(degraded.value.accessibility.nodes).toEqual([]); + expect(degraded.value.accessibility.text_capture.status).toBe( + "unavailable", + ); + expect(degraded.value.completeness.unavailable_sections).toContain( + "accessibility", + ); + expect(degraded.value.limitations).toContain( + "Accessibility.getFullAXTree was unavailable from this browser target.", + ); + + for (const [options, reason] of [ + [{ oversizedDiscovery: true }, "payload_limit"], + [{ invalidBrowserWebSocket: true }, "invalid_endpoint_response"], + ] as const) { + const failed = await startFakeCdpBrowser(options); + trackBrowser(failed); + const result = await new CdpBrowserProvider().listTargets( + listBrowserTargetsInputSchema.parse({ + cdp_endpoint: failed.endpoint, + allowed_origins: [failed.allowedOrigin], + approved: true, + }), + ); + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.error).toMatchObject({ + _tag: "BrowserObservationError", + reason, + }); + } + + const malformed = await startFakeCdpBrowser({ + malformedMessageOnMethod: "Page.getFrameTree", + }); + trackBrowser(malformed); + const protocol = await new CdpBrowserProvider().inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: malformed.endpoint, + allowed_origins: [malformed.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 0, + }), + ); + expect(protocol).toMatchObject({ + ok: false, + error: { _tag: "BrowserObservationError", reason: "protocol_error" }, + }); + + const malformedEvent = await startFakeCdpBrowser({ + malformedEventOnMethod: "Debugger.enable", + }); + trackBrowser(malformedEvent); + const eventProtocol = await new CdpBrowserProvider().inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: malformedEvent.endpoint, + allowed_origins: [malformedEvent.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 0, + }), + ); + expect(eventProtocol).toMatchObject({ + ok: false, + error: { _tag: "BrowserObservationError", reason: "protocol_error" }, + }); + + const malformedEventShape = await startFakeCdpBrowser({ + malformedEventShapeOnMethod: "Debugger.enable", + }); + trackBrowser(malformedEventShape); + const eventShapeProtocol = await new CdpBrowserProvider().inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: malformedEventShape.endpoint, + allowed_origins: [malformedEventShape.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 0, + }), + ); + expect(eventShapeProtocol).toMatchObject({ + ok: false, + error: { _tag: "BrowserObservationError", reason: "protocol_error" }, + }); + }); + + it("cancels observation and still detaches without closing the page", async () => { + const browser = await startFakeCdpBrowser(); + trackBrowser(browser); + const controller = new AbortController(); + const pending = new CdpBrowserProvider().inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 1_000, + }), + { signal: controller.signal }, + ); + // Wait until the attach response has been consumed and capture has begun. + // Seeing the attach request alone races its response under worker load. + await waitForCommand(browser, "Page.getFrameTree"); + controller.abort(); + const result = await pending; + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCancelledError", + operation: "inspect_web_page", + }, + }); + const methods = browser.commands.map(({ method }) => method); + expect(methods).toContain("Target.detachFromTarget"); + expect(methods).not.toContain("Target.closeTarget"); + expect(methods).not.toContain("Browser.close"); + }); +}); + +describeBrowser("CdpBrowserProvider: lifecycle failures 4", () => { + it("waits for navigation commit and rechecks the attached main-frame origin", async () => { + const transitioning = await startFakeCdpBrowser({ + transitionalFrameReads: 2, + }); + trackBrowser(transitioning); + const input = (browser: FakeCdpBrowser) => + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 0, + }); + const committed = await new CdpBrowserProvider().inspectPage( + input(transitioning), + ); + if (!committed.ok) throw committed.error; + expect(committed.value.target.origin).toBe(transitioning.allowedOrigin); + expect( + transitioning.commands.filter( + ({ method }) => method === "Page.getFrameTree", + ), + ).toHaveLength(5); + + const navigated = await startFakeCdpBrowser({ + attachedFrameUrl: "https://unapproved.example.test/after-attach", + }); + trackBrowser(navigated); + const denied = await new CdpBrowserProvider().inspectPage(input(navigated)); + expect(denied).toMatchObject({ + ok: false, + error: { _tag: "BrowserObservationError", reason: "target_not_allowed" }, + }); + const methods = navigated.commands.map(({ method }) => method); + expect(methods).toContain("Target.detachFromTarget"); + expect(methods).not.toContain("DOMSnapshot.captureSnapshot"); + expect(methods).not.toContain("Storage.getUsageAndQuota"); + + const duringObservation = await startFakeCdpBrowser({ + navigateDuringObservationUrl: + "https://unapproved.example.test/during-observation", + }); + trackBrowser(duringObservation); + const interrupted = await new CdpBrowserProvider().inspectPage( + input(duringObservation), + ); + expect(interrupted).toMatchObject({ + ok: false, + error: { _tag: "BrowserObservationError", reason: "target_not_allowed" }, + }); + expect( + duringObservation.commands.map(({ method }) => method), + ).not.toContain("Accessibility.getFullAXTree"); + + const captureOptions: { navigateDuringCaptureUrl?: string } = {}; + const duringCapture = await startFakeCdpBrowser(captureOptions); + captureOptions.navigateDuringCaptureUrl = `${duringCapture.allowedOrigin}/changed`; + trackBrowser(duringCapture); + const discarded = await new CdpBrowserProvider().inspectPage( + input(duringCapture), + ); + expect(discarded).toMatchObject({ + ok: false, + error: { _tag: "BrowserObservationError", reason: "target_changed" }, + }); + expect(duringCapture.commands.map(({ method }) => method)).toContain( + "Target.detachFromTarget", + ); + + const crossOriginCapture = await startFakeCdpBrowser({ + navigateDuringCaptureUrl: + "https://unapproved.example.test/during-capture", + }); + trackBrowser(crossOriginCapture); + const rejectedCapture = await new CdpBrowserProvider().inspectPage( + input(crossOriginCapture), + ); + expect(rejectedCapture).toMatchObject({ + ok: false, + error: { _tag: "BrowserObservationError", reason: "target_not_allowed" }, + }); + }); +}); + +const waitForCommand = async ( + browser: FakeCdpBrowser, + method: string, +): Promise => { + for (let attempt = 0; attempt < 200; attempt += 1) { + if (browser.commands.some((command) => command.method === method)) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error(`Timed out waiting for fake CDP command ${method}`); +}; diff --git a/tests/boundary/browser/cdpBrowserProvider.network.test.ts b/tests/boundary/browser/cdpBrowserProvider.network.test.ts new file mode 100644 index 000000000..d16ce9c0e --- /dev/null +++ b/tests/boundary/browser/cdpBrowserProvider.network.test.ts @@ -0,0 +1,27 @@ +import { expect, it } from "vitest"; + +import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; +import { inspectWebPageInputSchema } from "../../../src/domain/browserObservation.js"; +import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; +import { describeBrowser, trackBrowser } from "./cdpBrowserProvider.support.js"; + +describeBrowser("CdpBrowserProvider: network 1", () => { + it("drops network evidence when a request redirects outside the approved origin", async () => { + const browser = await startFakeCdpBrowser({ + redirectToDisallowedOrigin: true, + }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 0, + }), + ); + + if (!result.ok) throw result.error; + expect(result.value.network.requests).toEqual([]); + }); +}); diff --git a/tests/boundary/browser/cdpBrowserProvider.sensitive_data.test.ts b/tests/boundary/browser/cdpBrowserProvider.sensitive_data.test.ts new file mode 100644 index 000000000..c9ee3e219 --- /dev/null +++ b/tests/boundary/browser/cdpBrowserProvider.sensitive_data.test.ts @@ -0,0 +1,387 @@ +import { expect, it } from "vitest"; + +import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; +import { + inspectWebPageInputSchema, + webPageInspectionSchema, +} from "../../../src/domain/browserObservation.js"; +import { + startFakeCdpBrowser, + type FakeCdpBrowser, +} from "../../fixtures/fakeCdpBrowser.js"; +import { describeBrowser, trackBrowser } from "./cdpBrowserProvider.support.js"; + +describeBrowser("CdpBrowserProvider: sensitive data 1", () => { + it("captures bounded passive evidence without retaining sensitive values", async () => { + const browser = await startFakeCdpBrowser({ + binaryWebSocketEvent: true, + foreignSessionEvents: true, + unrelatedWorker: true, + }); + trackBrowser(browser); + const provider = new CdpBrowserProvider(); + const result = await provider.inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 0, + include_storage_keys: true, + include_storage_fingerprints: true, + }), + ); + + if (!result.ok) throw result.error; + expect(() => webPageInspectionSchema.parse(result.value)).not.toThrow(); + expect(result.value.frames).toHaveLength(1); + expect(result.value.dom.nodes).toHaveLength(2); + expect(result.value.dom.nodes[1]?.attribute_names).toEqual([ + "token", + "href", + "rel", + ]); + expect(result.value.accessibility).toMatchObject({ + text_capture: { + status: "not_approved", + retained_bytes: 0, + }, + nodes: [expect.objectContaining({ name: null, description: null })], + }); + expect(result.value.scripts.items).toHaveLength(1); + expect(result.value.scripts.items[0]?.script_key).toMatch( + /^scr_[a-f0-9]{64}$/u, + ); + expect(result.value.scripts.items[0]?.frame_id).toBe("frame-main"); + expect(result.value.scripts.items[0]?.source_map_url).toBe( + `${browser.allowedOrigin}/app.js.map?token=%5BREDACTED%5D`, + ); + expect(result.value.scripts.items[0]?.resource_reconciliation).toEqual({ + status: "exact", + resource_key: result.value.resources[0]?.resource_key, + }); + expect(result.value.scripts.items[0]?.source).toEqual({ + included: false, + reason: "source capture was not approved", + }); + expect(result.value.resources).toHaveLength(1); + expect(result.value.network.requests).toHaveLength(1); + expect(result.value.network.requests[0]?.initiator).toEqual({ + type: "script", + url: `${browser.allowedOrigin}/app.js?caller=%5BREDACTED%5D`, + line: 3, + column: 5, + }); + expect(result.value.network.websocket_events).toEqual([ + { + request_id: "websocket-1", + direction: "sent", + opcode: 1, + payload_bytes: Buffer.byteLength("websocket-secret"), + payload_shape: null, + }, + { + request_id: "websocket-1", + direction: "received", + opcode: 2, + payload_bytes: 3, + payload_shape: null, + }, + ]); + expect(result.value.console.events[0]?.argument_types).toEqual(["string"]); + expect(result.value.workers).toHaveLength(1); + expect(result.value.workers[0]).toMatchObject({ + opener_target_id: "allowed-page", + parent_frame_id: null, + }); + expect(result.value.metadata).toMatchObject({ + headers_allowlisted: true, + responses: [ + { + content_length: 321, + content_encoding: "br", + csp: { nonce_count: 1, hash_count: 1 }, + policies: { + coop: "same-origin", + coep: "require-corp", + permissions_policy_features: ["camera", "geolocation"], + }, + }, + ], + dom_urls: [ + { + attribute: "href", + url: `${browser.allowedOrigin}/agent?token=%5BREDACTED%5D`, + destination_scope: "approved", + }, + ], + agent_hints: expect.arrayContaining([ + expect.objectContaining({ + mechanism: "link_rel", + declaration: "mcp service-desc", + }), + expect.objectContaining({ + mechanism: "dom_link_rel", + declaration: "mcp", + }), + expect.objectContaining({ + mechanism: "response_header", + declaration: "x-model-context", + }), + ]), + }); + expect(result.value.storage).toEqual( + expect.objectContaining({ + local_storage_keys: ["public-key"], + session_storage_keys: ["public-key"], + indexed_db_names: ["app-db"], + cache_names: ["assets-v1"], + content_fingerprints: expect.arrayContaining([ + expect.objectContaining({ scope: "cookie", complete: true }), + expect.objectContaining({ scope: "local_storage", complete: true }), + expect.objectContaining({ scope: "session_storage", complete: true }), + expect.objectContaining({ + scope: "indexed_db_schema", + complete: true, + }), + expect.objectContaining({ + scope: "indexed_db_record", + complete: true, + }), + expect.objectContaining({ scope: "cache_entry", complete: true }), + ]), + fingerprint_algorithm: "sha256", + fingerprints_complete: true, + values_redacted: true, + }), + ); + expectSensitiveValuesAbsent(result.value, browser); + }); +}); + +describeBrowser("CdpBrowserProvider: sensitive data 2", () => { + it("captures only approved redacted console text and value-free payload shapes", async () => { + const browser = await startFakeCdpBrowser({ + sensitiveShapes: true, + binaryWebSocketEvent: true, + }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 0, + include_console_text: true, + console_text_approved: true, + include_json_body_shapes: true, + json_body_schema_approved: true, + include_websocket_shapes: true, + websocket_shape_approved: true, + }), + ); + + if (!result.ok) throw result.error; + expect(result.value.console.events[0]?.text_capture).toEqual({ + status: "included", + values: [ + { argument_index: 0, type: "string", text: "authorization=[REDACTED]" }, + { argument_index: 1, type: "number", text: "42" }, + ], + retained_bytes: 26, + truncated_values: 0, + }); + expect(result.value.network.requests[0]?.body_shapes).toMatchObject({ + status: "included", + request: { + root_type: "object", + properties: expect.arrayContaining([ + expect.objectContaining({ path: "/token", types: ["string"] }), + expect.objectContaining({ + path: "/filters/active", + types: ["boolean"], + }), + ]), + }, + response: { + root_type: "object", + properties: expect.arrayContaining([ + expect.objectContaining({ + path: "/result/token", + types: ["string"], + }), + expect.objectContaining({ path: "/items/*/id", types: ["number"] }), + ]), + }, + }); + expect(result.value.network.websocket_events).toEqual([ + expect.objectContaining({ + opcode: 1, + payload_shape: expect.objectContaining({ + format: "json", + json_shape: expect.objectContaining({ + properties: expect.arrayContaining([ + expect.objectContaining({ path: "/token", types: ["string"] }), + ]), + }), + }), + }), + expect.objectContaining({ + opcode: 2, + payload_shape: { + format: "binary", + json_shape: null, + truncated: false, + }, + }), + ]); + const serialized = JSON.stringify(result.value); + for (const secret of [ + "request-body-secret", + "response-body-secret", + "websocket-secret", + "console-secret", + "object-secret", + ]) + expect(serialized).not.toContain(secret); + const methods = browser.commands.map(({ method }) => method); + expect(methods).toContain("Network.getResponseBody"); + expect(methods).not.toContain("Runtime.getProperties"); + expect(methods).not.toContain("Runtime.callFunctionOn"); + }); +}); + +describeBrowser("CdpBrowserProvider: sensitive data 3", () => { + it("reports independent sensitive-capture truncation at aggregate byte limits", async () => { + const browser = await startFakeCdpBrowser({ sensitiveShapes: true }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 0, + include_console_text: true, + console_text_approved: true, + include_json_body_shapes: true, + json_body_schema_approved: true, + include_websocket_shapes: true, + websocket_shape_approved: true, + limits: { + max_console_text_field_bytes: 5, + max_total_console_text_bytes: 5, + max_json_body_bytes: 10, + max_total_json_body_bytes: 10, + max_websocket_shape_bytes: 5, + max_total_websocket_shape_bytes: 5, + }, + }), + ); + + if (!result.ok) throw result.error; + expect(result.value.console.events[0]?.text_capture).toEqual({ + status: "truncated", + values: [{ argument_index: 0, type: "string", text: "autho" }], + retained_bytes: 5, + truncated_values: 2, + }); + expect(result.value.network.requests[0]?.body_shapes.status).toBe( + "truncated", + ); + expect(result.value.network.websocket_events[0]?.payload_shape).toEqual({ + format: "text", + json_shape: null, + truncated: true, + }); + expect(result.value.completeness.truncated_sections).toEqual( + expect.arrayContaining([ + "console_text", + "json_body_shapes", + "websocket_shapes", + ]), + ); + }); + + it("fails closed on malformed approved response and binary payload encodings", async () => { + const browser = await startFakeCdpBrowser({ + invalidResponseBodyBase64: true, + binaryWebSocketEvent: true, + invalidBinaryWebSocketEvent: true, + }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 0, + include_json_body_shapes: true, + json_body_schema_approved: true, + include_websocket_shapes: true, + websocket_shape_approved: true, + }), + ); + + if (!result.ok) throw result.error; + expect(result.value.network.requests[0]?.body_shapes).toMatchObject({ + status: "partial", + request: expect.any(Object), + response: null, + }); + expect(result.value.network.websocket_events[1]).toMatchObject({ + opcode: 2, + payload_bytes: 0, + payload_shape: { format: "binary", json_shape: null }, + }); + expect(result.value.completeness.unavailable_sections).toEqual( + expect.arrayContaining(["json_body_shapes", "websocket_frames"]), + ); + }); +}); + +const expectSensitiveValuesAbsent = ( + inspection: ReturnType, + browser: FakeCdpBrowser, +): void => { + const serialized = JSON.stringify(inspection); + for (const secret of [ + "page-secret", + "frame-secret", + "dom-secret", + "forbidden", + "resource-secret", + "script-secret", + "inline-secret", + "map-secret", + "network-secret", + "request-secret", + "request-body-secret", + "response-secret", + "response-body-secret", + "websocket-secret", + "websocket-url-secret", + "console-secret", + "unknown-origin-console-secret", + "unknown-console-value-secret", + "storage-secret", + "cookie-secret", + "indexed-db-secret", + "cache-body-secret", + "secret-id", + "dom-url-secret", + "link-secret", + "csp-secret", + "hash-secret", + "header-secret", + ]) + expect(serialized).not.toContain(secret); + const methods = browser.commands.map((command) => command.method); + expect(methods).toContain("Target.detachFromTarget"); + expect(methods).not.toContain("Browser.close"); + expect(methods).not.toContain("Target.closeTarget"); + expect(methods).not.toContain("Runtime.evaluate"); + expect(methods).not.toContain("Network.getResponseBody"); +}; diff --git a/tests/boundary/browser/cdpBrowserProvider.support.ts b/tests/boundary/browser/cdpBrowserProvider.support.ts new file mode 100644 index 000000000..1afe9e074 --- /dev/null +++ b/tests/boundary/browser/cdpBrowserProvider.support.ts @@ -0,0 +1,15 @@ +import { afterEach, describe } from "vitest"; + +import type { FakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; + +const browsers: FakeCdpBrowser[] = []; + +afterEach(async () => { + await Promise.all(browsers.splice(0).map(async (browser) => browser.close())); +}); + +export const trackBrowser = (browser: FakeCdpBrowser): void => { + browsers.push(browser); +}; + +export const describeBrowser = describe; diff --git a/tests/cdpCaptureCompleteness.test.ts b/tests/boundary/browser/cdpCaptureCompleteness.test.ts similarity index 95% rename from tests/cdpCaptureCompleteness.test.ts rename to tests/boundary/browser/cdpCaptureCompleteness.test.ts index 7f6e522dc..72d551d9e 100644 --- a/tests/cdpCaptureCompleteness.test.ts +++ b/tests/boundary/browser/cdpCaptureCompleteness.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { CdpCaptureCompleteness } from "../src/browser/CdpCaptureCompleteness.js"; +import { CdpCaptureCompleteness } from "../../../src/browser/CdpCaptureCompleteness.js"; describe("CdpCaptureCompleteness", () => { it("reports attach-window limitations without claiming a complete history", () => { diff --git a/tests/cdpCaptureDocuments.test.ts b/tests/boundary/browser/cdpCaptureDocuments.test.ts similarity index 96% rename from tests/cdpCaptureDocuments.test.ts rename to tests/boundary/browser/cdpCaptureDocuments.test.ts index f83a02de9..fdbedfaea 100644 --- a/tests/cdpCaptureDocuments.test.ts +++ b/tests/boundary/browser/cdpCaptureDocuments.test.ts @@ -5,8 +5,8 @@ import { captureDom, captureFrames, captureResources, -} from "../src/browser/CdpCaptureDocuments.js"; -import { inspectWebPageInputSchema } from "../src/domain/browserObservation.js"; +} from "../../../src/browser/CdpCaptureDocuments.js"; +import { inspectWebPageInputSchema } from "../../../src/domain/browserObservation.js"; const origin = "https://app.example.test"; diff --git a/tests/cdpConnection.test.ts b/tests/boundary/browser/cdpConnection.test.ts similarity index 93% rename from tests/cdpConnection.test.ts rename to tests/boundary/browser/cdpConnection.test.ts index dc1c3e051..001fbf5ff 100644 --- a/tests/cdpConnection.test.ts +++ b/tests/boundary/browser/cdpConnection.test.ts @@ -1,10 +1,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { CdpConnection } from "../src/browser/CdpConnection.js"; +import { CdpConnection } from "../../../src/browser/CdpConnection.js"; import { startFakeCdpBrowser, type FakeCdpBrowser, -} from "./fixtures/fakeCdpBrowser.js"; +} from "../../fixtures/fakeCdpBrowser.js"; describe("CDP connection", () => { const browsers: FakeCdpBrowser[] = []; diff --git a/tests/cdpElectronProvider.test.ts b/tests/boundary/browser/cdpElectronProvider.test.ts similarity index 87% rename from tests/cdpElectronProvider.test.ts rename to tests/boundary/browser/cdpElectronProvider.test.ts index 0cb433376..b72e19933 100644 --- a/tests/cdpElectronProvider.test.ts +++ b/tests/boundary/browser/cdpElectronProvider.test.ts @@ -4,33 +4,31 @@ import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { CdpElectronProvider } from "../src/browser/CdpElectronProvider.js"; +import { CdpElectronProvider } from "../../../src/browser/CdpElectronProvider.js"; import { inspectElectronPageInputSchema, listElectronTargetsInputSchema, -} from "../src/domain/electronObservation.js"; +} from "../../../src/domain/electronObservation.js"; import { startFakeCdpBrowser, type FakeCdpBrowser, -} from "./fixtures/fakeCdpBrowser.js"; +} from "../../fixtures/fakeCdpBrowser.js"; -describe("CdpElectronProvider", () => { - const browsers: FakeCdpBrowser[] = []; - const temporary: string[] = []; +const browsers: FakeCdpBrowser[] = []; +const temporary: string[] = []; - afterEach(async () => { - await Promise.all( - browsers.splice(0).map(async (browser) => browser.close()), - ); - await Promise.all( - temporary - .splice(0) - .map(async (path) => rm(path, { recursive: true, force: true })), - ); - }); +afterEach(async () => { + await Promise.all(browsers.splice(0).map(async (browser) => browser.close())); + await Promise.all( + temporary + .splice(0) + .map(async (path) => rm(path, { recursive: true, force: true })), + ); +}); +describe("CdpElectronProvider target access", () => { it("lists and inspects only canonical file targets beneath approved roots", async () => { const root = await electronFixture(); const index = join(root, "index.html"); @@ -164,7 +162,9 @@ describe("CdpElectronProvider", () => { }, }); }); +}); +describe("CdpElectronProvider capture", () => { it("captures Electron script source only after separate approval", async () => { const root = await electronFixture(); const browser = await startFakeCdpBrowser({ @@ -250,13 +250,13 @@ describe("CdpElectronProvider", () => { }, }); }); - - const electronFixture = async (): Promise => { - const root = await createTestTempDirectory("rea-electron-provider-"); - temporary.push(root); - await writeFile(join(root, "index.html"), ""); - await writeFile(join(root, "app.js"), "export const app = true;"); - await writeFile(join(root, "child.html"), "
child
"); - return root; - }; }); + +const electronFixture = async (): Promise => { + const root = await createTestTempDirectory("rea-electron-provider-"); + temporary.push(root); + await writeFile(join(root, "index.html"), ""); + await writeFile(join(root, "app.js"), "export const app = true;"); + await writeFile(join(root, "child.html"), "
child
"); + return root; +}; diff --git a/tests/cdpSafeMetadata.test.ts b/tests/boundary/browser/cdpSafeMetadata.test.ts similarity index 97% rename from tests/cdpSafeMetadata.test.ts rename to tests/boundary/browser/cdpSafeMetadata.test.ts index 882a2ad1d..4ad0497c7 100644 --- a/tests/cdpSafeMetadata.test.ts +++ b/tests/boundary/browser/cdpSafeMetadata.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { safeResponseMetadata } from "../src/browser/CdpSafeMetadata.js"; +import { safeResponseMetadata } from "../../../src/browser/CdpSafeMetadata.js"; const origin = "https://app.example.test"; diff --git a/tests/customProtocolCapture.test.ts b/tests/boundary/browser/customProtocolCapture.test.ts similarity index 98% rename from tests/customProtocolCapture.test.ts rename to tests/boundary/browser/customProtocolCapture.test.ts index ad82b35a7..6ed1be11c 100644 --- a/tests/customProtocolCapture.test.ts +++ b/tests/boundary/browser/customProtocolCapture.test.ts @@ -15,7 +15,7 @@ import { type TokenLifecycleEvent, type CustomProtocolCapture, type ProtocolFrame, -} from "../src/domain/customProtocolCapture.js"; +} from "../../../src/domain/customProtocolCapture.js"; const sampleFrames: ProtocolFrame[] = [ { diff --git a/tests/javascriptRuntimeObservation.test.ts b/tests/boundary/browser/javascriptRuntimeObservation.test.ts similarity index 87% rename from tests/javascriptRuntimeObservation.test.ts rename to tests/boundary/browser/javascriptRuntimeObservation.test.ts index 90e74e76b..ae5d4fb60 100644 --- a/tests/javascriptRuntimeObservation.test.ts +++ b/tests/boundary/browser/javascriptRuntimeObservation.test.ts @@ -1,38 +1,30 @@ -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; -import { afterEach, describe, expect, test } from "vitest"; +import { describe, expect, test } from "vitest"; -import { createJavaScriptRuntimeObservationEvidence } from "../src/application/JavaScriptRuntimeObservationEvidence.js"; +import { createJavaScriptRuntimeObservationEvidence } from "../../../src/application/JavaScriptRuntimeObservationEvidence.js"; import { listJavaScriptRuntimeTargets, observeJavaScriptRuntime, -} from "../src/application/JavaScriptRuntimeObservationService.js"; -import { createPermissionAuthority } from "../src/application/PermissionAuthority.js"; -import { reconcileJavaScriptRuntimeEvidence } from "../src/application/JavaScriptRuntimeReconciliationService.js"; -import { V8_INSPECTOR_PROVIDER_IDENTITY } from "../src/browser/V8InspectorProvider.js"; -import { V8InspectorProvider } from "../src/browser/V8InspectorProvider.js"; -import { JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE } from "../src/contracts/javascriptRuntimeReconciliationExample.js"; +} from "../../../src/application/JavaScriptRuntimeObservationService.js"; +import { createPermissionAuthority } from "../../../src/application/PermissionAuthority.js"; +import { reconcileJavaScriptRuntimeEvidence } from "../../../src/application/JavaScriptRuntimeReconciliationService.js"; +import { V8_INSPECTOR_PROVIDER_IDENTITY } from "../../../src/browser/V8InspectorProvider.js"; +import { V8InspectorProvider } from "../../../src/browser/V8InspectorProvider.js"; +import { JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE } from "../../../src/contracts/javascriptRuntimeReconciliationExample.js"; import type { JavaScriptRuntimeObservation, ObserveJavaScriptRuntimeInput, -} from "../src/domain/javascriptRuntimeObservation.js"; -import { javascriptRuntimeReconciliationResultSchema } from "../src/domain/javascriptRuntimeReconciliationSchemas.js"; +} from "../../../src/domain/javascriptRuntimeObservation.js"; +import { javascriptRuntimeReconciliationResultSchema } from "../../../src/domain/javascriptRuntimeReconciliationSchemas.js"; import type { PermissionCeiling, PermissionGrant, -} from "../src/domain/permissionPolicy.js"; -import { startFakeV8Inspector } from "./fixtures/fakeV8Inspector.js"; - -const temporaryRoots: string[] = []; - -afterEach(async () => { - await Promise.all( - temporaryRoots.splice(0).map((path) => rm(path, { recursive: true })), - ); -}); +} from "../../../src/domain/permissionPolicy.js"; +import { startFakeV8Inspector } from "../../fixtures/fakeV8Inspector.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; describe("passive V8 Inspector provider", () => { test("filters target locations and never retains the excluded path", async () => { @@ -124,7 +116,9 @@ describe("passive V8 Inspector provider", () => { } }, ); +}); +describe("passive V8 Inspector evidence", () => { test("marks capture truncation instead of claiming complete coverage", async () => { const fixture = await runtimeFixture(); const second = join(fixture.root, "second.js"); @@ -246,16 +240,14 @@ describe("passive V8 Inspector provider", () => { }); const runtimeFixture = async () => { - const root = await mkdtemp(join(tmpdir(), "rea-v8-runtime-")); - temporaryRoots.push(root); + const root = await createTestTempDirectory("rea-v8-runtime-"); const entry = join(root, "entry.js"); await writeFile(entry, "export const value = 1;\n"); return { root, entry }; }; const temporaryFile = async (name: string): Promise => { - const root = await mkdtemp(join(tmpdir(), "rea-v8-outside-")); - temporaryRoots.push(root); + const root = await createTestTempDirectory("rea-v8-outside-"); const path = join(root, name); await writeFile(path, "export {};\n"); return path; diff --git a/tests/playwrightBrowserScenarioProvider.test.ts b/tests/boundary/browser/playwrightBrowserScenarioProvider.test.ts similarity index 95% rename from tests/playwrightBrowserScenarioProvider.test.ts rename to tests/boundary/browser/playwrightBrowserScenarioProvider.test.ts index 49bccde50..e8abcdb73 100644 --- a/tests/playwrightBrowserScenarioProvider.test.ts +++ b/tests/boundary/browser/playwrightBrowserScenarioProvider.test.ts @@ -1,19 +1,19 @@ import { describe, expect, it } from "vitest"; -import type { BrowserScenarioSessionPort } from "../src/browser/BrowserScenarioSessionPort.js"; -import { PlaywrightBrowserScenarioProvider } from "../src/browser/PlaywrightBrowserScenarioProvider.js"; -import { BrowserScenarioCaptureBudget } from "../src/browser/PlaywrightScenarioArtifacts.js"; -import { sanitizeBrowserUrl } from "../src/domain/browserObservation.js"; +import type { BrowserScenarioSessionPort } from "../../../src/browser/BrowserScenarioSessionPort.js"; +import { PlaywrightBrowserScenarioProvider } from "../../../src/browser/PlaywrightBrowserScenarioProvider.js"; +import { BrowserScenarioCaptureBudget } from "../../../src/browser/PlaywrightScenarioArtifacts.js"; +import { sanitizeBrowserUrl } from "../../../src/domain/browserObservation.js"; import { browserScenarioSchema, type BrowserScenario, type BrowserScenarioAction, -} from "../src/domain/browserScenario.js"; +} from "../../../src/domain/browserScenario.js"; import { browserStepArtifactsSchema, type BrowserScenarioEvent, type BrowserStepArtifacts, -} from "../src/domain/browserScenarioCapture.js"; +} from "../../../src/domain/browserScenarioCapture.js"; const scenario = ( options: { diff --git a/tests/playwrightExecutionBoundary.test.ts b/tests/boundary/browser/playwrightExecutionBoundary.test.ts similarity index 87% rename from tests/playwrightExecutionBoundary.test.ts rename to tests/boundary/browser/playwrightExecutionBoundary.test.ts index 6c31a6561..9825992e5 100644 --- a/tests/playwrightExecutionBoundary.test.ts +++ b/tests/boundary/browser/playwrightExecutionBoundary.test.ts @@ -1,10 +1,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { withPlaywrightExecutionBoundary } from "../src/browser/PlaywrightExecutionBoundary.js"; +import { withPlaywrightExecutionBoundary } from "../../../src/browser/PlaywrightExecutionBoundary.js"; import { AnalysisCancelledError, AnalysisTimeoutError, -} from "../src/domain/errors.js"; +} from "../../../src/domain/errors.js"; describe("withPlaywrightExecutionBoundary", () => { afterEach(() => vi.useRealTimers()); diff --git a/tests/playwrightScenarioEvents.test.ts b/tests/boundary/browser/playwrightScenarioEvents.test.ts similarity index 88% rename from tests/playwrightScenarioEvents.test.ts rename to tests/boundary/browser/playwrightScenarioEvents.test.ts index 82d9624e1..c5f86cfcb 100644 --- a/tests/playwrightScenarioEvents.test.ts +++ b/tests/boundary/browser/playwrightScenarioEvents.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import type { Page } from "playwright-core"; -import { BrowserScenarioSecrets } from "../src/browser/BrowserScenarioSecrets.js"; -import { BrowserScenarioCaptureBudget } from "../src/browser/PlaywrightScenarioArtifacts.js"; -import { PlaywrightScenarioEvents } from "../src/browser/PlaywrightScenarioEvents.js"; -import { browserScenarioSchema } from "../src/domain/browserScenario.js"; +import { BrowserScenarioSecrets } from "../../../src/browser/BrowserScenarioSecrets.js"; +import { BrowserScenarioCaptureBudget } from "../../../src/browser/PlaywrightScenarioArtifacts.js"; +import { PlaywrightScenarioEvents } from "../../../src/browser/PlaywrightScenarioEvents.js"; +import { browserScenarioSchema } from "../../../src/domain/browserScenario.js"; describe("PlaywrightScenarioEvents", () => { it("bounds oversized page errors before validating captured events", () => { diff --git a/tests/protocolCapture.test.ts b/tests/boundary/browser/protocolCapture.test.ts similarity index 99% rename from tests/protocolCapture.test.ts rename to tests/boundary/browser/protocolCapture.test.ts index 23dac0e3a..b40263acb 100644 --- a/tests/protocolCapture.test.ts +++ b/tests/boundary/browser/protocolCapture.test.ts @@ -10,7 +10,7 @@ import { protocolCaptureSchema, type ProtocolMessage, type ProtocolCapture, -} from "../src/domain/protocolCapture.js"; +} from "../../../src/domain/protocolCapture.js"; describe("protocol capture", () => { it("classifies gRPC from content type", () => { diff --git a/tests/webSourceMapFetcher.test.ts b/tests/boundary/browser/webSourceMapFetcher.test.ts similarity index 96% rename from tests/webSourceMapFetcher.test.ts rename to tests/boundary/browser/webSourceMapFetcher.test.ts index 0ec347157..680566904 100644 --- a/tests/webSourceMapFetcher.test.ts +++ b/tests/boundary/browser/webSourceMapFetcher.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { fetchWebSourceMaps } from "../src/browser/WebSourceMapFetcher.js"; -import { analyzeWebBundleInputSchema } from "../src/domain/webBundleAnalysis.js"; +import { fetchWebSourceMaps } from "../../../src/browser/WebSourceMapFetcher.js"; +import { analyzeWebBundleInputSchema } from "../../../src/domain/webBundleAnalysis.js"; const origin = "https://app.example.test"; const request = { @@ -10,7 +10,7 @@ const request = { fetchUrl: `${origin}/assets/app.js.map?token=secret`, }; -describe("web source-map fetcher", () => { +describe("web source-map fetching and validation", () => { it("fetches without credentials and derives mappings and original modules", async () => { const calls: Array<{ url: string; init: RequestInit | undefined }> = []; const map = JSON.stringify({ @@ -157,7 +157,9 @@ describe("web source-map fetcher", () => { ); } }); +}); +describe("web source-map aggregate limits", () => { it("bounds aggregate map count and reports omitted requests", async () => { const calls: string[] = []; const requests = [0, 1, 2].map((index) => ({ diff --git a/tests/browserCli.test.ts b/tests/boundary/cli/browserCli.test.ts similarity index 92% rename from tests/browserCli.test.ts rename to tests/boundary/cli/browserCli.test.ts index 47f9bc8c2..e4cffdae4 100644 --- a/tests/browserCli.test.ts +++ b/tests/boundary/cli/browserCli.test.ts @@ -6,7 +6,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { startFakeCdpBrowser, type FakeCdpBrowser, -} from "./fixtures/fakeCdpBrowser.js"; +} from "../../fixtures/fakeCdpBrowser.js"; const execute = promisify(execFile); const browsers: FakeCdpBrowser[] = []; @@ -117,6 +117,29 @@ describe("browser CLI parity", () => { alignment: { status: "aligned", aligned_steps: 2 }, }, }); + }, + INTEGRATION_TEST_TIMEOUT_MS, + ); +}); + +describe("browser CLI capture parity", () => { + it( + "returns the same analysis, session, WebMCP, screenshot, and policy contracts", + async () => { + const browser = await startFakeCdpBrowser({ + sessionTimeline: "same_origin", + webMcpTools: true, + sensitiveShapes: true, + }); + browsers.push(browser); + const environment = { + ...process.env, + REA_BROWSER_OBSERVE_ENABLED: "true", + REA_BROWSER_CDP_ENDPOINTS_JSON: JSON.stringify([browser.endpoint]), + REA_BROWSER_ALLOWED_ORIGINS_JSON: JSON.stringify([ + browser.allowedOrigin, + ]), + }; const analyzed = await runCli( [ "analyze-web-bundle", @@ -226,7 +249,9 @@ describe("browser CLI parity", () => { }, INTEGRATION_TEST_TIMEOUT_MS, ); +}); +describe("browser CLI approval", () => { it( "requires explicit approval before opening the CDP endpoint", async () => { diff --git a/tests/cliLogging.test.ts b/tests/boundary/cli/cliLogging.test.ts similarity index 91% rename from tests/cliLogging.test.ts rename to tests/boundary/cli/cliLogging.test.ts index d28e6724d..3a9bc6f6c 100644 --- a/tests/cliLogging.test.ts +++ b/tests/boundary/cli/cliLogging.test.ts @@ -1,7 +1,10 @@ import { afterEach, describe, expect, it } from "vitest"; -import { isCliOperationFailure, logCliCommand } from "../src/cliLogging.js"; -import { silentLogger } from "../src/logger.js"; +import { + isCliOperationFailure, + logCliCommand, +} from "../../../src/cliLogging.js"; +import { silentLogger } from "../../../src/logger.js"; const originalExitCode = process.exitCode; diff --git a/tests/cliOutput.test.ts b/tests/boundary/cli/cliOutput.test.ts similarity index 66% rename from tests/cliOutput.test.ts rename to tests/boundary/cli/cliOutput.test.ts index 58ca29945..7b60aed3c 100644 --- a/tests/cliOutput.test.ts +++ b/tests/boundary/cli/cliOutput.test.ts @@ -1,24 +1,21 @@ -import { execFile } from "node:child_process"; import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { promisify } from "node:util"; import { createPackageWithOptions } from "@electron/asar"; import { describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { fixtureTest } from "../../support/fixtures.js"; import { renderCliOutputArgumentError, sanitizeCliOutput, validateCliOutputArguments, -} from "../src/cliOutput.js"; +} from "../../../src/cliOutput.js"; -const execFileAsync = promisify(execFile); const CLI_INTEGRATION_TIMEOUT_MS = 60_000; const CLI_VARIANT_TIMEOUT_MS = 120_000; -describe("CLI output boundary", () => { +describe("CLI output argument and sanitization boundary", () => { it("rejects token windows that would corrupt structured output", () => { for (const format of ["json", "jsonl", "yaml"] as const) { const validation = validateCliOutputArguments([ @@ -61,20 +58,20 @@ describe("CLI output boundary", () => { ).toEqual({ ok: true }); }); - it("fails before emitting a truncated JSON document", async () => { - const execution = execFileAsync( - process.execPath, - ["scripts/rea.mjs", "providers", "--token-limit", "5", "--json"], - { cwd: process.cwd() }, - ); - const failure = await execution.catch((cause: unknown) => cause); - expect(failure).toMatchObject({ code: 1 }); - const { stdout } = failure as { readonly stdout: string }; - expect(JSON.parse(stdout)).toMatchObject({ - ok: false, - error: { code: "UNSUPPORTED_OUTPUT_COMBINATION" }, - }); - }); + fixtureTest( + "fails before emitting a truncated JSON document", + async ({ cli }) => { + const result = await cli.run({ + arguments: ["providers", "--token-limit", "5", "--json"], + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + ok: false, + error: { code: "UNSUPPORTED_OUTPUT_COMBINATION" }, + }); + }, + CLI_INTEGRATION_TIMEOUT_MS, + ); it("preserves normal output and sanitizes text and JSON validation errors", () => { expect(sanitizeCliOutput("result: ok\n")).toBe("result: ok\n"); @@ -125,34 +122,30 @@ describe("CLI output boundary", () => { meta: { command: "analyze" }, }); }); +}); - it( +describe("compiled CLI output boundary", () => { + fixtureTest( "sanitizes a real missing-argument dispatcher failure", - async () => { - await expect( - execFileAsync(process.execPath, ["scripts/rea.mjs", "analyze"], { - cwd: process.cwd(), - }), - ).rejects.toMatchObject({ + async ({ cli }) => { + const ordinary = await cli.run({ arguments: ["analyze"] }); + expect(ordinary).toMatchObject({ + exitCode: 1, stdout: 'code: VALIDATION_ERROR\nmessage: "REA could not read the command arguments. Run `rea --help`, correct the arguments, then try again."\n', }); - await expect( - execFileAsync( - process.execPath, - ["scripts/rea.mjs", "--full-output", "--json", "analyze"], - { cwd: process.cwd() }, - ), - ).rejects.toMatchObject({ + const json = await cli.run({ + arguments: ["--full-output", "--json", "analyze"], + }); + expect(json).toMatchObject({ + exitCode: 1, stdout: expect.not.stringContaining("fieldErrors"), }); - await expect( - execFileAsync( - process.execPath, - ["scripts/rea.mjs", "--full-output", "analyze"], - { cwd: process.cwd() }, - ), - ).rejects.toMatchObject({ + const yaml = await cli.run({ + arguments: ["--full-output", "analyze"], + }); + expect(yaml).toMatchObject({ + exitCode: 1, stdout: 'ok: false\nerror:\n code: VALIDATION_ERROR\n message: "REA could not read the command arguments. Run `rea --help`, correct the arguments, then try again."\n', }); @@ -160,28 +153,22 @@ describe("CLI output boundary", () => { CLI_INTEGRATION_TIMEOUT_MS, ); - it( + fixtureTest( "preserves artifact diagnostics in ordinary and full JSON output", - async () => { - const root = await createTestTempDirectory("rea-cli-diagnostics-"); - const source = join(root, "source"); + async ({ cli, workspace }) => { + const source = workspace.path("source"); await mkdir(source); await writeFile(join(source, "main.js"), "console.log('ok');\n"); - const archive = join(root, "fixture.asar"); + const archive = workspace.path("fixture.asar"); await createPackageWithOptions(source, archive, { unpack: "*.js" }); await writeFile(join(`${archive}.unpacked`, "main.js"), "changed();\n"); for (const flags of [["--json"], ["--full-output", "--json"]]) { - const execution = execFileAsync(process.execPath, [ - "scripts/rea.mjs", - ...flags, - "inventory-artifact", - archive, - ]); - const failure = await execution.catch((cause: unknown) => cause); - expect(failure).toMatchObject({ code: 1 }); - const { stdout } = failure as { readonly stdout: string }; - const output = JSON.stringify(JSON.parse(stdout) as unknown); + const result = await cli.run({ + arguments: [...flags, "inventory-artifact", archive], + }); + expect(result.exitCode).toBe(1); + const output = JSON.stringify(result.json); expect(output).toContain('"logical_path":"main.js"'); expect(output).toMatch(/"declared_sha256":"[a-f0-9]{64}"/u); expect(output).toMatch(/"calculated_sha256":"[a-f0-9]{64}"/u); @@ -191,9 +178,9 @@ describe("CLI output boundary", () => { CLI_INTEGRATION_TIMEOUT_MS, ); - it( + fixtureTest( "keeps operation failure status independent of output controls", - async () => { + async ({ cli }) => { const variants = [ ["--format", "toon"], ["--format", "json"], @@ -207,43 +194,42 @@ describe("CLI output boundary", () => { ]; for (const flags of variants) { - await expect( - execFileAsync(process.execPath, [ - "scripts/rea.mjs", + const result = await cli.run({ + arguments: [ ...flags, "investigate-versions", "/tmp/left", "/tmp/right", "/tmp/workspace.json", - ]), - ).rejects.toMatchObject({ code: 1, stdout: expect.any(String) }); + ], + }); + expect(result).toMatchObject({ + exitCode: 1, + stdout: expect.any(String), + }); } }, CLI_VARIANT_TIMEOUT_MS, ); - it( + fixtureTest( "keeps failure logs out of structured stdout", - async () => { - const execution = execFileAsync( - process.execPath, - [ - "scripts/rea.mjs", + async ({ cli }) => { + const result = await cli.run({ + arguments: [ "--json", "investigate-versions", "/tmp/left", "/tmp/right", "/tmp/workspace.json", ], - { env: { ...process.env, REA_LOG_LEVEL: "error" } }, - ); - const failure = await execution.catch((cause: unknown) => cause); - expect(failure).toMatchObject({ - code: 1, + environment: { REA_LOG_LEVEL: "error" }, + }); + expect(result).toMatchObject({ + exitCode: 1, stderr: expect.stringContaining('"status":"error"'), }); - const { stdout } = failure as { readonly stdout: string }; - expect(JSON.parse(stdout)).toMatchObject({ error: "ApprovalRequired" }); + expect(result.json).toMatchObject({ error: "ApprovalRequired" }); }, CLI_INTEGRATION_TIMEOUT_MS, ); diff --git a/tests/cliPolicyCommands.test.ts b/tests/boundary/cli/cliPolicyCommands.test.ts similarity index 95% rename from tests/cliPolicyCommands.test.ts rename to tests/boundary/cli/cliPolicyCommands.test.ts index 32a06f110..e224e4748 100644 --- a/tests/cliPolicyCommands.test.ts +++ b/tests/boundary/cli/cliPolicyCommands.test.ts @@ -5,13 +5,13 @@ import { promisify } from "node:util"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { readProjectPermissionStore, writeProjectPermissionStore, -} from "../src/application/ProjectPermissionStore.js"; -import { approvePolicyRevocation } from "../src/cliPolicyCommands.js"; +} from "../../../src/application/ProjectPermissionStore.js"; +import { approvePolicyRevocation } from "../../../src/cliPolicyCommands.js"; const execute = promisify(execFile); const roots: string[] = []; diff --git a/tests/cliSetup.test.ts b/tests/boundary/cli/cliSetup.test.ts similarity index 98% rename from tests/cliSetup.test.ts rename to tests/boundary/cli/cliSetup.test.ts index 5928acce4..389d776c6 100644 --- a/tests/cliSetup.test.ts +++ b/tests/boundary/cli/cliSetup.test.ts @@ -5,7 +5,7 @@ import { promisify } from "node:util"; import { spawn } from "@lydell/node-pty"; import { describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; const execute = promisify(execFile); const decisionMarker = "REA_SETUP_DECISION:"; @@ -136,7 +136,9 @@ describe("interactive setup journey", () => { expect(result.output).toContain("Cursor (detected)"); expect(result.decision.approved).toBe(false); }); +}); +describe("interactive setup selection and confirmation", () => { it("retains the bundled skill even when all agents are deselected (#399)", async () => { const result = await runJourney([ step("What should REA set up?", "\r"), @@ -241,7 +243,9 @@ describe("interactive setup journey", () => { expect(result.output).toContain("Nothing selected. No changes were made."); expect(result.decision.selectedActionIds).toEqual([]); }); +}); +describe("interactive setup completion", () => { it("frames completion around the capabilities that are now ready", async () => { const { stderr } = await execute( process.execPath, diff --git a/tests/boundary/cli/dispatcher.test.ts b/tests/boundary/cli/dispatcher.test.ts new file mode 100644 index 000000000..c6cf3778e --- /dev/null +++ b/tests/boundary/cli/dispatcher.test.ts @@ -0,0 +1,103 @@ +import { copyFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { describe, expect } from "vitest"; + +import { fixtureTest } from "../../support/fixtures.js"; + +describe("executable dispatcher", () => { + fixtureTest( + "reserves production MCP routing for bare mode arguments", + async ({ processes, workspace }) => { + const scripts = await workspace.mkdir("scripts"); + await workspace.mkdir("dist"); + await Promise.all([ + copyFile("scripts/rea.mjs", join(scripts, "rea.mjs")), + copyFile( + "scripts/package-runner-bootstrap.mjs", + join(scripts, "package-runner-bootstrap.mjs"), + ), + workspace.write( + "package.json", + JSON.stringify({ type: "module", version: "1.0.0" }), + ), + workspace.write( + "dist/main.js", + 'export const runEntrypoint = () => process.stdout.write(JSON.stringify({ adapter: "mcp" }));\n', + ), + workspace.write( + "dist/cli.js", + 'export const createCli = () => ({ serve: (args) => process.stdout.write(JSON.stringify({ adapter: "cli", args })) });\n', + ), + workspace.write( + "dist/cliOutput.js", + "export const sanitizeCliOutput = (output) => output; export const validateCliOutputArguments = () => ({ ok: true }); export const renderCliOutputArgumentError = () => '';\n", + ), + workspace.write( + "dist/mcpDoctor.js", + 'export const runProductionMcpDoctorCli = (args) => Promise.resolve({ output: JSON.stringify({ adapter: "mcp-doctor", args }), exitCode: 0 });\n', + ), + ]); + + const invoke = async (args: readonly string[]): Promise => { + const { exitCode, stdout } = await processes.run( + process.execPath, + [join(scripts, "rea.mjs"), ...args], + { cwd: workspace.root }, + ); + expect(exitCode).toBe(0); + return JSON.parse(stdout) as unknown; + }; + + await expect(invoke(["mcp"])).resolves.toEqual({ adapter: "mcp" }); + await expect(invoke(["--mcp"])).resolves.toEqual({ adapter: "mcp" }); + await expect(invoke(["mcp", "doctor", "--json"])).resolves.toEqual({ + adapter: "mcp-doctor", + args: ["--json"], + }); + await expect(invoke(["mcp", "add"])).resolves.toEqual({ + adapter: "cli", + args: ["mcp", "add"], + }); + await expect(invoke(["--mcp", "extra"])).resolves.toEqual({ + adapter: "cli", + args: ["--mcp", "extra"], + }); + }, + ); + + fixtureTest( + "explains how to restore a missing compiled runtime", + async ({ processes, workspace }) => { + const scripts = await workspace.mkdir("scripts"); + await Promise.all([ + copyFile("scripts/rea.mjs", join(scripts, "rea.mjs")), + copyFile( + "scripts/package-runner-bootstrap.mjs", + join(scripts, "package-runner-bootstrap.mjs"), + ), + workspace.write( + "package.json", + JSON.stringify({ type: "module", version: "1.0.0" }), + ), + ]); + + for (const args of [["mcp"], ["mcp", "doctor"], ["--help"]]) { + const result = await processes.run( + process.execPath, + [join(scripts, "rea.mjs"), ...args], + { cwd: workspace.root }, + ); + expect(result).toMatchObject({ + exitCode: 1, + stderr: expect.stringContaining( + "REA's compiled runtime is missing. Run `npm ci` in", + ), + }); + expect(result).toMatchObject({ + stderr: expect.not.stringContaining("ERR_MODULE_NOT_FOUND"), + }); + } + }, + ); +}); diff --git a/tests/doctor.test.ts b/tests/boundary/cli/doctor.test.ts similarity index 87% rename from tests/doctor.test.ts rename to tests/boundary/cli/doctor.test.ts index 6c7c2ebe7..2c34411b8 100644 --- a/tests/doctor.test.ts +++ b/tests/boundary/cli/doctor.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { runDoctor, type DoctorHost } from "../src/application/Doctor.js"; -import { CATALOG_IDENTITY } from "../src/catalogIdentity.js"; -import { PRODUCT_IDENTITY } from "../src/identity.js"; +import { runDoctor, type DoctorHost } from "../../../src/application/Doctor.js"; +import { CATALOG_IDENTITY } from "../../../src/catalogIdentity.js"; +import { PRODUCT_IDENTITY } from "../../../src/identity.js"; const host = (overrides: Partial = {}): DoctorHost => ({ platform: "darwin", @@ -167,7 +167,9 @@ describe("doctor", () => { "Unset or update HOPPER_LAUNCHER_PATH to an executable Hopper launcher.", }); }); +}); +describe("doctor installation identity", () => { it("distinguishes stale skills, path shadowing, and unobservable live state", async () => { const result = await runDoctor( undefined, @@ -242,7 +244,9 @@ describe("doctor", () => { }), ); }); +}); +describe("doctor scoped readiness", () => { it("scopes effective readiness to selected clients without hiding drift", async () => { const registrations = [ { @@ -322,6 +326,9 @@ describe("doctor", () => { }); expect(selectedGhidra.healthy).toBe(true); }); +}); + +describe("doctor Linux and Hopper discovery", () => { it("accepts an officially supported Linux distribution", async () => { const path = "/home/user/.local/share/rea/hopper/bin/Hopper"; const result = await runDoctor( @@ -445,64 +452,4 @@ describe("doctor", () => { expect(result.hopperPath).toBeUndefined(); expect(result.checks.find(({ name }) => name === "hopper")?.ok).toBe(false); }); - - it("reports a broken shadowed Node candidate without hiding the healthy launcher", async () => { - const result = await runDoctor( - undefined, - host({ - runtimeExecutables: () => - Promise.resolve({ - launcher_node: "/healthy/node", - candidates: [ - { - tool: "node", - lexical_path: "/healthy/node", - canonical_path: "/healthy/node", - path_index: null, - selection: "rea-launcher", - version: "v24.18.0", - healthy: true, - failure: null, - }, - { - tool: "node", - lexical_path: "/opt/homebrew/bin/node", - canonical_path: "/opt/homebrew/Cellar/node/25.2.1/bin/node", - path_index: 2, - selection: "path-shadowed", - version: null, - healthy: false, - failure: { - code: "runtime_dynamic_library_missing", - exit_code: 134, - signal: null, - dependency: - "/opt/homebrew/opt/simdjson/lib/libsimdjson.29.dylib", - stderr: "dyld: Library not loaded", - }, - }, - ], - }), - }), - ); - - expect(result.healthy).toBe(false); - expect(result.identity?.runtime_executables).toMatchObject({ - launcher_node: "/healthy/node", - candidates: [ - { healthy: true, selection: "rea-launcher" }, - { - healthy: false, - failure: { code: "runtime_dynamic_library_missing" }, - }, - ], - }); - expect( - result.checks.find(({ name }) => name === "node-toolchains"), - ).toMatchObject({ - ok: false, - classification: "missing_dependency", - remediation: expect.stringContaining("Do not create"), - }); - }); }); diff --git a/tests/boundary/cli/doctorRuntimeExecutables.test.ts b/tests/boundary/cli/doctorRuntimeExecutables.test.ts new file mode 100644 index 000000000..f36f05596 --- /dev/null +++ b/tests/boundary/cli/doctorRuntimeExecutables.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; + +import { runDoctor, type DoctorHost } from "../../../src/application/Doctor.js"; +import { CATALOG_IDENTITY } from "../../../src/catalogIdentity.js"; +import { PRODUCT_IDENTITY } from "../../../src/identity.js"; + +const host = (overrides: Partial = {}): DoctorHost => ({ + platform: "darwin", + architecture: "x64", + nodeVersion: "24.18.0", + macosVersion: () => Promise.resolve("12.0"), + linuxDistribution: () => Promise.resolve(undefined), + validTarget: (path) => Promise.resolve(path.includes("Hopper")), + executable: (path) => Promise.resolve(path.includes("Hopper")), + supportedLinuxHopper: () => Promise.resolve(true), + linuxDemoRuntimeCheck: () => + Promise.resolve({ + name: "hopper-demo-runtime", + ok: true, + classification: "healthy", + }), + brewHopperPath: () => Promise.resolve(undefined), + manualHopperPaths: () => Promise.resolve([]), + installedSkillIdentity: () => + Promise.resolve({ + version: PRODUCT_IDENTITY.skillVersion, + toolCount: CATALOG_IDENTITY.counts.mcp_tools, + catalogDigest: CATALOG_IDENTITY.digests.combined_sha256, + }), + ...overrides, +}); + +describe("doctor runtime executable diagnostics", () => { + it("reports a broken shadowed Node candidate without hiding the healthy launcher", async () => { + const result = await runDoctor( + undefined, + host({ + runtimeExecutables: () => + Promise.resolve({ + launcher_node: "/healthy/node", + candidates: [ + { + tool: "node", + lexical_path: "/healthy/node", + canonical_path: "/healthy/node", + path_index: null, + selection: "rea-launcher", + version: "v24.18.0", + healthy: true, + failure: null, + }, + { + tool: "node", + lexical_path: "/opt/homebrew/bin/node", + canonical_path: "/opt/homebrew/Cellar/node/25.2.1/bin/node", + path_index: 2, + selection: "path-shadowed", + version: null, + healthy: false, + failure: { + code: "runtime_dynamic_library_missing", + exit_code: 134, + signal: null, + dependency: + "/opt/homebrew/opt/simdjson/lib/libsimdjson.29.dylib", + stderr: "dyld: Library not loaded", + }, + }, + ], + }), + }), + ); + + expect(result.healthy).toBe(false); + expect(result.identity?.runtime_executables).toMatchObject({ + launcher_node: "/healthy/node", + candidates: [ + { healthy: true, selection: "rea-launcher" }, + { + healthy: false, + failure: { code: "runtime_dynamic_library_missing" }, + }, + ], + }); + expect( + result.checks.find(({ name }) => name === "node-toolchains"), + ).toMatchObject({ + ok: false, + classification: "missing_dependency", + remediation: expect.stringContaining("Do not create"), + }); + }); +}); diff --git a/tests/electronCli.test.ts b/tests/boundary/cli/electronCli.test.ts similarity index 90% rename from tests/electronCli.test.ts rename to tests/boundary/cli/electronCli.test.ts index c439ccdd6..20f6f99b5 100644 --- a/tests/electronCli.test.ts +++ b/tests/boundary/cli/electronCli.test.ts @@ -6,33 +6,30 @@ import { promisify } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { startFakeCdpBrowser, type FakeCdpBrowser, -} from "./fixtures/fakeCdpBrowser.js"; -import { writeElectronBoundaryFixture } from "./fixtures/electronBoundaryApplication.js"; -import { JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE } from "../src/contracts/javascriptRuntimeReconciliationExample.js"; +} from "../../fixtures/fakeCdpBrowser.js"; +import { writeElectronBoundaryFixture } from "../../fixtures/electronBoundaryApplication.js"; +import { JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE } from "../../../src/contracts/javascriptRuntimeReconciliationExample.js"; const execute = promisify(execFile); const INTEGRATION_TEST_TIMEOUT_MS = 20_000; +const browsers: FakeCdpBrowser[] = []; +const temporary: string[] = []; + +afterEach(async () => { + await Promise.all(browsers.splice(0).map(async (browser) => browser.close())); + await Promise.all( + temporary + .splice(0) + .map(async (path) => rm(path, { recursive: true, force: true })), + ); +}); describe("Electron CLI parity", () => { - const browsers: FakeCdpBrowser[] = []; - const temporary: string[] = []; - - afterEach(async () => { - await Promise.all( - browsers.splice(0).map(async (browser) => browser.close()), - ); - await Promise.all( - temporary - .splice(0) - .map(async (path) => rm(path, { recursive: true, force: true })), - ); - }); - it( "returns the same root-confined Electron Evidence contracts", async () => { @@ -117,7 +114,9 @@ describe("Electron CLI parity", () => { }, INTEGRATION_TEST_TIMEOUT_MS, ); +}); +describe("Electron CLI artifact behavior", () => { it( "keeps artifact format separate from structured output format", async () => { @@ -211,7 +210,9 @@ describe("Electron CLI parity", () => { }, INTEGRATION_TEST_TIMEOUT_MS, ); +}); +describe("Electron CLI runtime reconciliation", () => { it( "reads a file and returns the derived static/runtime reconciliation contract", async () => { diff --git a/tests/javascriptRuntimeObservationCli.test.ts b/tests/boundary/cli/javascriptRuntimeObservationCli.test.ts similarity index 89% rename from tests/javascriptRuntimeObservationCli.test.ts rename to tests/boundary/cli/javascriptRuntimeObservationCli.test.ts index 3cf460c54..a7e7fb1ec 100644 --- a/tests/javascriptRuntimeObservationCli.test.ts +++ b/tests/boundary/cli/javascriptRuntimeObservationCli.test.ts @@ -6,12 +6,12 @@ import { promisify } from "node:util"; import { afterEach, describe, expect, test } from "vitest"; -import { loadConfiguredPermissionAuthority } from "../src/application/PermissionConfiguration.js"; -import { observeJavaScriptRuntime } from "../src/application/JavaScriptRuntimeObservationService.js"; -import { V8InspectorProvider } from "../src/browser/V8InspectorProvider.js"; -import { parseConfig } from "../src/config.js"; -import { startFakeV8Inspector } from "./fixtures/fakeV8Inspector.js"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { loadConfiguredPermissionAuthority } from "../../../src/application/PermissionConfiguration.js"; +import { observeJavaScriptRuntime } from "../../../src/application/JavaScriptRuntimeObservationService.js"; +import { V8InspectorProvider } from "../../../src/browser/V8InspectorProvider.js"; +import { parseConfig } from "../../../src/config.js"; +import { startFakeV8Inspector } from "../../fixtures/fakeV8Inspector.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; const execute = promisify(execFile); const TIMEOUT_MS = 20_000; diff --git a/tests/packageRunnerBootstrap.test.ts b/tests/boundary/cli/packageRunnerBootstrap.test.ts similarity index 96% rename from tests/packageRunnerBootstrap.test.ts rename to tests/boundary/cli/packageRunnerBootstrap.test.ts index 6f03fe054..a8f4fd834 100644 --- a/tests/packageRunnerBootstrap.test.ts +++ b/tests/boundary/cli/packageRunnerBootstrap.test.ts @@ -6,8 +6,8 @@ import { describe, expect, it } from "vitest"; import { packageRunnerSetupPlan, runPackageRunnerSetupBootstrap, -} from "../scripts/package-runner-bootstrap.mjs"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +} from "../../../scripts/package-runner-bootstrap.mjs"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; describe("package-runner setup bootstrap", () => { it("reruns bare setup through the current release when npm selected a local package", () => { diff --git a/tests/processCli.test.ts b/tests/boundary/cli/processCli.test.ts similarity index 95% rename from tests/processCli.test.ts rename to tests/boundary/cli/processCli.test.ts index f682057b1..a5ecb0025 100644 --- a/tests/processCli.test.ts +++ b/tests/boundary/cli/processCli.test.ts @@ -6,15 +6,15 @@ import { promisify } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { captureProcessScenarioFile, compareProcessEvidenceFiles, projectProcessCliError, -} from "../src/application/ProcessCli.js"; -import { createEvidence } from "../src/domain/evidence.js"; -import { PROCESS_PROVIDER } from "../src/application/ProcessEvidence.js"; +} from "../../../src/application/ProcessCli.js"; +import { createEvidence } from "../../../src/domain/evidence.js"; +import { PROCESS_PROVIDER } from "../../../src/application/ProcessEvidence.js"; const roots: string[] = []; const execFileAsync = promisify(execFile); diff --git a/tests/productCatalog.test.ts b/tests/boundary/cli/productCatalog.test.ts similarity index 86% rename from tests/productCatalog.test.ts rename to tests/boundary/cli/productCatalog.test.ts index 63c21af90..4a015f260 100644 --- a/tests/productCatalog.test.ts +++ b/tests/boundary/cli/productCatalog.test.ts @@ -5,9 +5,9 @@ import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; import { z } from "zod"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { SUPPORTED_CLIENT_DEFINITIONS } from "../src/application/SupportedClients.js"; +import { SUPPORTED_CLIENT_DEFINITIONS } from "../../../src/application/SupportedClients.js"; import { ARTIFACT_GRAPH_PROVIDER, JAVASCRIPT_APPLICATION_PROVIDER, @@ -15,30 +15,30 @@ import { JAVASCRIPT_RUNTIME_RECONCILIATION_PROVIDER, MANAGED_STATIC_PROVIDER, MANAGED_WORKFLOW_PROVIDER, -} from "../src/application/InvestigationProviders.js"; -import { CDP_BROWSER_PROVIDER_IDENTITY } from "../src/browser/CdpBrowserProvider.js"; -import { CDP_ELECTRON_PROVIDER_IDENTITY } from "../src/browser/CdpElectronProvider.js"; -import { PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY } from "../src/browser/PlaywrightBrowserScenarioProvider.js"; -import { V8_INSPECTOR_PROVIDER_IDENTITY } from "../src/browser/V8InspectorProvider.js"; -import { CLI_COMMAND_NAMES } from "../src/cliCommandNames.js"; -import { createCli } from "../src/cli.js"; -import { TOOL_CONTRACTS } from "../src/contracts/toolContracts.js"; -import { analysisSnapshotSchema } from "../src/domain/analysisSnapshot.js"; -import { investigationWorkspaceSchema } from "../src/domain/investigationWorkspace.js"; +} from "../../../src/application/InvestigationProviders.js"; +import { CDP_BROWSER_PROVIDER_IDENTITY } from "../../../src/browser/CdpBrowserProvider.js"; +import { CDP_ELECTRON_PROVIDER_IDENTITY } from "../../../src/browser/CdpElectronProvider.js"; +import { PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY } from "../../../src/browser/PlaywrightBrowserScenarioProvider.js"; +import { V8_INSPECTOR_PROVIDER_IDENTITY } from "../../../src/browser/V8InspectorProvider.js"; +import { CLI_COMMAND_NAMES } from "../../../src/cliCommandNames.js"; +import { createCli } from "../../../src/cli.js"; +import { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js"; +import { analysisSnapshotSchema } from "../../../src/domain/analysisSnapshot.js"; +import { investigationWorkspaceSchema } from "../../../src/domain/investigationWorkspace.js"; import { HOPPER_PROVIDER_IDENTITY, HOPPER_PROVIDER_TOOL_CONTRACTS, -} from "../src/hopper/HopperProvider.js"; +} from "../../../src/hopper/HopperProvider.js"; import { GHIDRA_PROVIDER_IDENTITY, GHIDRA_PROVIDER_TOOL_CONTRACTS, -} from "../src/ghidra/GhidraProvider.js"; -import { NATIVE_MACOS_PROVIDER_IDENTITY } from "../src/native/NativeMacOSProvider.js"; +} from "../../../src/ghidra/GhidraProvider.js"; +import { NATIVE_MACOS_PROVIDER_IDENTITY } from "../../../src/native/NativeMacOSProvider.js"; import { assertDocumentationFacts, documentationFactIssues, -} from "../scripts/lib/docs-facts.mjs"; -import { ensureGeneratedFile } from "../scripts/lib/generated-file.mjs"; +} from "../../../scripts/lib/docs-facts.mjs"; +import { ensureGeneratedFile } from "../../../scripts/lib/generated-file.mjs"; import { createCliInventory, cliCommandDescriptionIssues, @@ -46,9 +46,9 @@ import { createProductCatalog, providerCatalogDigest, serializeProductCatalog, -} from "../scripts/lib/product-catalog.mjs"; +} from "../../../scripts/lib/product-catalog.mjs"; -const root = fileURLToPath(new URL("..", import.meta.url)); +const root = fileURLToPath(new URL("../../..", import.meta.url)); const temporaryRoots: string[] = []; afterEach(async () => { @@ -166,15 +166,17 @@ describe("canonical product catalog", () => { assertDocumentationFacts(root, catalog), ).resolves.toBeUndefined(); await expect(documentationFactIssues(root, catalog)).resolves.toEqual([]); - }, 15_000); + }, 60_000); +}); +describe("canonical CLI catalog", () => { it("uses the same primary command names as the actual Incur router", () => { const inventory = createCliInventory(createCli()); expect(inventory.primary).toEqual([...CLI_COMMAND_NAMES].sort()); expect(inventory.aliases).toEqual([ { name: "compare-bundles", target: "compare" }, ]); - }); + }, 30_000); it("describes every primary command argument and option", () => { expect(cliCommandDescriptionIssues(createCli())).toEqual([]); @@ -195,7 +197,9 @@ describe("canonical product catalog", () => { expect(cliCommandOptionNames(cli, name)).toContain("provider"); } }); +}); +describe("canonical product catalog drift", () => { it("reports tool-family, setup-client, and schema fact drift", async () => { const catalog = await createProductCatalog(root); const firstFamily = catalog.tools.families[0]; diff --git a/tests/reconstructionObligationLedgerCli.test.ts b/tests/boundary/cli/reconstructionObligationLedgerCli.test.ts similarity index 94% rename from tests/reconstructionObligationLedgerCli.test.ts rename to tests/boundary/cli/reconstructionObligationLedgerCli.test.ts index e4198d537..ef4f1d128 100644 --- a/tests/reconstructionObligationLedgerCli.test.ts +++ b/tests/boundary/cli/reconstructionObligationLedgerCli.test.ts @@ -3,7 +3,7 @@ import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; -import { createEvidenceBundle } from "../src/domain/evidenceBundle.js"; +import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; const execute = promisify(execFile); diff --git a/tests/reconstructionReadinessCli.test.ts b/tests/boundary/cli/reconstructionReadinessCli.test.ts similarity index 90% rename from tests/reconstructionReadinessCli.test.ts rename to tests/boundary/cli/reconstructionReadinessCli.test.ts index 665ad1a22..6654eaf7b 100644 --- a/tests/reconstructionReadinessCli.test.ts +++ b/tests/boundary/cli/reconstructionReadinessCli.test.ts @@ -3,7 +3,7 @@ import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; -import { RECONSTRUCTION_READINESS_EXAMPLE } from "../src/contracts/reconstructionReadinessExample.js"; +import { RECONSTRUCTION_READINESS_EXAMPLE } from "../../../src/contracts/reconstructionReadinessExample.js"; const execute = promisify(execFile); diff --git a/tests/integration/configIntegration.test.ts b/tests/boundary/filesystem/configIntegration.test.ts similarity index 98% rename from tests/integration/configIntegration.test.ts rename to tests/boundary/filesystem/configIntegration.test.ts index 135e87112..e3b62e132 100644 --- a/tests/integration/configIntegration.test.ts +++ b/tests/boundary/filesystem/configIntegration.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { parseConfig } from "../../src/config.js"; +import { parseConfig } from "../../../src/config.js"; describe("configuration parsing integration", () => { it("returns defaults when no environment variables are set", () => { diff --git a/tests/dependencyInstall.test.ts b/tests/boundary/filesystem/dependencyInstall.test.ts similarity index 97% rename from tests/dependencyInstall.test.ts rename to tests/boundary/filesystem/dependencyInstall.test.ts index 206036ca6..8e3d59955 100644 --- a/tests/dependencyInstall.test.ts +++ b/tests/boundary/filesystem/dependencyInstall.test.ts @@ -4,7 +4,7 @@ import { spawnSync } from "node:child_process"; import { afterEach, describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; const roots: string[] = []; const script = join(process.cwd(), "scripts", "check-dependency-install.mjs"); diff --git a/tests/evidenceBundleFiles.test.ts b/tests/boundary/filesystem/evidenceBundleFiles.test.ts similarity index 93% rename from tests/evidenceBundleFiles.test.ts rename to tests/boundary/filesystem/evidenceBundleFiles.test.ts index ba858f8ce..34cfbee55 100644 --- a/tests/evidenceBundleFiles.test.ts +++ b/tests/boundary/filesystem/evidenceBundleFiles.test.ts @@ -3,19 +3,19 @@ import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { readEvidenceBundle, writeEvidenceBundle, -} from "../src/application/EvidenceBundleFiles.js"; -import { compareEvidenceBundlesCommand } from "../src/application/EvidenceBundleCommands.js"; -import { createEvidence } from "../src/domain/evidence.js"; +} from "../../../src/application/EvidenceBundleFiles.js"; +import { compareEvidenceBundlesCommand } from "../../../src/application/EvidenceBundleCommands.js"; +import { createEvidence } from "../../../src/domain/evidence.js"; import { createEvidenceBundle, serializeEvidenceBundle, type EvidenceFilePolicy, -} from "../src/domain/evidenceBundle.js"; +} from "../../../src/domain/evidenceBundle.js"; let directory: string | undefined; diff --git a/tests/installScript.test.ts b/tests/boundary/filesystem/installScript.test.ts similarity index 99% rename from tests/installScript.test.ts rename to tests/boundary/filesystem/installScript.test.ts index f8a3a0304..00722ddbd 100644 --- a/tests/installScript.test.ts +++ b/tests/boundary/filesystem/installScript.test.ts @@ -13,7 +13,7 @@ import { promisify } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; const execFileAsync = promisify(execFile); const roots: string[] = []; diff --git a/tests/referenceSourceImport.test.ts b/tests/boundary/filesystem/referenceSourceImport.test.ts similarity index 93% rename from tests/referenceSourceImport.test.ts rename to tests/boundary/filesystem/referenceSourceImport.test.ts index 4545ecc97..3cb5175c1 100644 --- a/tests/referenceSourceImport.test.ts +++ b/tests/boundary/filesystem/referenceSourceImport.test.ts @@ -5,18 +5,18 @@ import { join } from "node:path"; import { add, commit, init } from "isomorphic-git"; import { describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { importReferenceSource, normalizeHistoricalSourceParseFailures, -} from "../src/application/ReferenceSourceImport.js"; -import { projectReferenceSourceEntryFailure } from "../src/application/ReferenceSourceImportEntries.js"; +} from "../../../src/application/ReferenceSourceImport.js"; +import { projectReferenceSourceEntryFailure } from "../../../src/application/ReferenceSourceImportEntries.js"; import { projectReferenceSourceImportError, type ReferenceSourceImportError, -} from "../src/application/ReferenceSourceImportTypes.js"; -import { createHistoricalSourceManifest } from "../src/domain/referenceSourceGraph.js"; +} from "../../../src/application/ReferenceSourceImportTypes.js"; +import { createHistoricalSourceManifest } from "../../../src/domain/referenceSourceGraph.js"; const limits = { maxBytes: 1024 * 1024, @@ -55,7 +55,7 @@ const importTree = ( ...(signal === undefined ? {} : { signal }), }); -describe("reference source import", () => { +describe("reference source import error projection", () => { it("deduplicates exact parse failures without collapsing distinct reasons", () => { const malformed = { path: "src/main.ts", @@ -122,7 +122,9 @@ describe("reference source import", () => { ); } }); +}); +describe("reference source import behavior", () => { it("is relocation-stable, resolves imports, and excludes secrets before capture", async () => { const parent = await createTestTempDirectory("rea-reference-import-"); try { diff --git a/tests/referenceSourceReader.test.ts b/tests/boundary/filesystem/referenceSourceReader.test.ts similarity index 96% rename from tests/referenceSourceReader.test.ts rename to tests/boundary/filesystem/referenceSourceReader.test.ts index 307b5d9be..62345d242 100644 --- a/tests/referenceSourceReader.test.ts +++ b/tests/boundary/filesystem/referenceSourceReader.test.ts @@ -3,9 +3,9 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { readReferenceSource } from "../src/reference/ReferenceSourceReader.js"; +import { readReferenceSource } from "../../../src/reference/ReferenceSourceReader.js"; const limits = { maxBytes: 1_024, @@ -14,7 +14,7 @@ const limits = { maxPathBytes: 100, } as const; -describe("readReferenceSource", () => { +describe("readReferenceSource entries", () => { it("returns explicit entries in canonical code-point path order", async () => { const root = await createTestTempDirectory("rea-reference-"); await mkdir(join(root, "nested")); @@ -148,7 +148,9 @@ describe("readReferenceSource", () => { expect(result.value.bytesRead).toBe(1); expect(result.value.truncated).toBe(true); }); +}); +describe("readReferenceSource failures and exclusions", () => { it("applies exclusions to normalized paths before reading entries", async () => { const root = await createTestTempDirectory("rea-reference-"); await mkdir(join(root, "ignored")); diff --git a/tests/safeOutputTree.test.ts b/tests/boundary/filesystem/safeOutputTree.test.ts similarity index 93% rename from tests/safeOutputTree.test.ts rename to tests/boundary/filesystem/safeOutputTree.test.ts index 6981f7f23..33afa6e2f 100644 --- a/tests/safeOutputTree.test.ts +++ b/tests/boundary/filesystem/safeOutputTree.test.ts @@ -5,9 +5,9 @@ import { Readable } from "node:stream"; import { describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { SafeOutputTree } from "../src/artifacts/SafeOutputTree.js"; +import { SafeOutputTree } from "../../../src/artifacts/SafeOutputTree.js"; const LIMITS = { maxEntries: 10, diff --git a/tests/temporaryDirectory.test.ts b/tests/boundary/filesystem/temporaryDirectory.test.ts similarity index 93% rename from tests/temporaryDirectory.test.ts rename to tests/boundary/filesystem/temporaryDirectory.test.ts index 93ceb9a22..3ef251dbb 100644 --- a/tests/temporaryDirectory.test.ts +++ b/tests/boundary/filesystem/temporaryDirectory.test.ts @@ -4,7 +4,7 @@ import { dirname, join } from "node:path"; import { describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; describe.sequential("test temporary directory ownership", () => { let completedDirectory: string | undefined; diff --git a/tests/boundary/mcp/artifactMcp.test.ts b/tests/boundary/mcp/artifactMcp.test.ts new file mode 100644 index 000000000..9de365ac5 --- /dev/null +++ b/tests/boundary/mcp/artifactMcp.test.ts @@ -0,0 +1,467 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { createPackageWithOptions } from "@electron/asar"; +import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; +import { expect, it } from "vitest"; +import { z } from "zod"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +import { + composeBinarySessionFromFactory, + composeBinarySessionFromProvider, +} from "../../../src/application/BinarySessionComposition.js"; +import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js"; +import { ARTIFACT_COMPARISON_EXAMPLE } from "../../../src/contracts/artifactComparisonExample.js"; +import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { observed } from "../../fixtures/analysisExecution.js"; + +it.each([ + { unpacked: false, label: "embedded" }, + { unpacked: true, label: "unpacked" }, +])( + "returns actionable local details for an $label ASAR integrity error", + async ({ unpacked }) => { + const root = await createTestTempDirectory("rea-asar-integrity-mcp-"); + const source = join(root, "source"); + const original = "console.log('ok');\n"; + const changed = "console.log('no');\n"; + await mkdir(source); + await writeFile(join(source, "main.js"), original); + const archive = join(root, "fixture.asar"); + await createPackageWithOptions( + source, + archive, + unpacked ? { unpack: "*.js" } : {}, + ); + if (unpacked) { + await writeFile(join(`${archive}.unpacked`, "main.js"), changed); + } else { + const bytes = await readFile(archive); + const contentOffset = bytes.indexOf(original); + expect(contentOffset).toBeGreaterThanOrEqual(0); + bytes.write(changed, contentOffset, "utf8"); + await writeFile(archive, bytes); + } + + const session = composeBinarySessionFromProvider(new ArtifactProvider()); + const server = createServer(session, session); + const client = new Client({ name: "asar-integrity-test", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const opened = await client.callTool({ + name: "open_binary", + arguments: { path: archive }, + }); + expect(opened.isError).not.toBe(true); + + const result = await client.callTool({ + name: "inventory_artifact", + arguments: {}, + }); + expect(result.isError).toBe(true); + expect(result.structuredContent).toEqual({ + error: { + code: "artifact_integrity_mismatch", + category: "integrity_mismatch", + message: + "Artifact is invalid or has changed. Get a fresh copy and try again.", + details: { + logical_path: "main.js", + declared_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u), + calculated_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u), + unpacked, + }, + retryable: false, + remediation: { + action: + "Artifact is invalid or has changed. Get a fresh copy and try again.", + restart_required: false, + }, + }, + }); + } finally { + await Promise.allSettled([ + client.close(), + server.close(), + session.close(), + ]); + } + }, + 20_000, +); + +const artifactInventoryResultSchema = z.object({ + occurrences: z.object({ + items: z.array( + z.object({ logical_path: z.string(), hash_status: z.string() }), + ), + }), + integrity_contradictions: z.array( + z.object({ + logical_path: z.string(), + declared_sha256: z.string(), + observed_sha256: z.string(), + trust: z.literal("observed-untrusted"), + }), + ), +}); + +it("records an approved mismatch, preserves verified siblings, and never reports equivalence", async () => { + const root = await createTestTempDirectory("rea-asar-continue-mcp-"); + const source = join(root, "source"); + await mkdir(source); + const original = "console.log('ok');\n", + changed = "console.log('no');\n", + secondOriginal = "console.log('up');\n", + secondChanged = "console.log('dn');\n"; + await writeFile(join(source, "main.js"), original); + await writeFile(join(source, "second.js"), secondOriginal); + await writeFile(join(source, "sibling.js"), "console.log('sibling');\n"); + const archive = join(root, "fixture.asar"); + await createPackageWithOptions(source, archive, {}); + const bytes = await readFile(archive); + const contentOffset = bytes.indexOf(original); + const secondOffset = bytes.indexOf(secondOriginal); + expect(contentOffset).toBeGreaterThanOrEqual(0); + expect(secondOffset).toBeGreaterThanOrEqual(0); + bytes.write(changed, contentOffset, "utf8"); + bytes.write(secondChanged, secondOffset, "utf8"); + await writeFile(archive, bytes); + + const session = composeBinarySessionFromProvider( + new ArtifactProvider(false, true), + ); + const server = createServer(session, session); + const client = new Client({ name: "asar-continue-test", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + await client.callTool({ + name: "open_binary", + arguments: { path: archive }, + }); + const limited = await client.callTool({ + name: "inventory_artifact", + arguments: { + integrity_policy: "record-and-continue", + integrity_continue_approved: true, + max_integrity_mismatches: 1, + }, + }); + expect(limited).toMatchObject({ + isError: true, + structuredContent: { error: { code: "truncated" } }, + }); + const result = await client.callTool({ + name: "inventory_artifact", + arguments: { + integrity_policy: "record-and-continue", + integrity_continue_approved: true, + }, + }); + expect(result.isError).not.toBe(true); + const compact = compactResult(result.structuredContent); + const evidence = session.evidenceById(compact.evidence_id); + if (evidence === undefined) throw new Error("missing inventory Evidence"); + const inventory = artifactInventoryResultSchema.parse(compact.result); + expect(inventory.integrity_contradictions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + logical_path: "main.js", + trust: "observed-untrusted", + declared_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u), + observed_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u), + }), + expect.objectContaining({ logical_path: "second.js" }), + ]), + ); + expect(inventory.integrity_contradictions).toHaveLength(2); + expect(inventory.occurrences.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + logical_path: "main.js", + hash_status: "mismatched", + }), + expect.objectContaining({ + logical_path: "sibling.js", + hash_status: "verified", + }), + expect.objectContaining({ + logical_path: "second.js", + hash_status: "mismatched", + }), + ]), + ); + const inspected = await client.callTool({ + name: "inspect_artifact", + arguments: { + integrity_policy: "record-and-continue", + integrity_continue_approved: true, + }, + }); + expect(inspected.isError).not.toBe(true); + expect(compactResult(inspected.structuredContent).result).toMatchObject({ + contradictions: [ + expect.objectContaining({ occurrence_id: expect.any(String) }), + expect.objectContaining({ occurrence_id: expect.any(String) }), + ], + substeps: [ + expect.objectContaining({ + operation: "inventory_artifact", + status: "completed", + }), + ], + }); + const compared = await client.callTool({ + name: "compare_artifacts", + arguments: { + left_evidence_ids: [evidence.evidence_id], + right_evidence_ids: [evidence.evidence_id], + }, + }); + expect(compared.isError).not.toBe(true); + expect(compactResult(compared.structuredContent).result).toMatchObject({ + status: "contradiction", + summary: { contradiction: 2 }, + changes: { + items: expect.arrayContaining([ + expect.objectContaining({ + logical_path: "main.js", + classification: "contradiction", + dimensions: ["integrity"], + }), + expect.objectContaining({ + logical_path: "second.js", + classification: "contradiction", + }), + ]), + }, + }); + } finally { + await Promise.allSettled([client.close(), server.close(), session.close()]); + } +}); + +it("rejects altered payloads that reuse session Evidence IDs", async () => { + const session = composeBinarySessionFromFactory(() => ({ + health: () => Promise.resolve(), + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(), + })); + session.recordEvidence(ARTIFACT_COMPARISON_EXAMPLE.left); + session.recordEvidence(ARTIFACT_COMPARISON_EXAMPLE.right); + const server = createServer(session, session); + const client = new Client({ + name: "artifact-authority-test", + version: "1", + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const result = await client.callTool({ + name: "compare_artifacts", + arguments: { + ...ARTIFACT_COMPARISON_EXAMPLE, + left: { + ...ARTIFACT_COMPARISON_EXAMPLE.left, + limitations: ["caller altered this record"], + }, + }, + }); + expect(result.isError).toBe(true); + expect(session.exportEvidenceBundle().records).toHaveLength(2); + } finally { + await Promise.allSettled([client.close(), server.close(), session.close()]); + } +}); + +it("rejects comparison Evidence that is not owned by the session", async () => { + const session = composeBinarySessionFromFactory(() => ({ + health: () => Promise.resolve(), + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(), + })); + const server = createServer(session, session); + const client = new Client({ + name: "artifact-ownership-test", + version: "1", + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const result = await client.callTool({ + name: "compare_artifacts", + arguments: ARTIFACT_COMPARISON_EXAMPLE, + }); + expect(result.isError).toBe(true); + expect(session.exportEvidenceBundle().records).toEqual([]); + } finally { + await Promise.allSettled([client.close(), server.close(), session.close()]); + } +}); + +it("opens archives without Hopper, compares them, and exports linked evidence", async () => { + const directory = await createTestTempDirectory("rea-artifact-mcp-"); + const archive = join(directory, "fixture.ipa"); + const changedArchive = join(directory, "changed.ipa"); + const writer = new ZipWriter(new Uint8ArrayWriter()); + await writer.add("Payload/Fixture.app/main.js", new TextReader("main();")); + await writeFile(archive, await writer.close()); + const changedWriter = new ZipWriter(new Uint8ArrayWriter()); + await changedWriter.add( + "Payload/Fixture.app/main.js", + new TextReader("changed();"), + ); + await writeFile(changedArchive, await changedWriter.close()); + const session = composeBinarySessionFromProvider(new ArtifactProvider()); + const server = createServer(session, session); + const client = new Client({ name: "artifact-mcp-test", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const opened = await client.callTool({ + name: "open_binary", + arguments: { path: archive }, + }); + expect(opened.isError).not.toBe(true); + const inspected = await client.callTool({ + name: "inspect_artifact", + arguments: { max_observations: 1, max_relationships: 1 }, + }); + expect(inspected.isError).not.toBe(true); + const inspectionResult = compactResult(inspected.structuredContent); + const inspectionEvidence = session.evidenceById( + inspectionResult.evidence_id, + ); + if (inspectionEvidence === undefined) + throw new Error("missing inspection Evidence"); + const inspection = z + .object({ + substeps: z.array( + z.object({ + evidence_id: z.string(), + status: z.literal("completed"), + }), + ), + coverage: z.object({ + status: z.literal("truncated"), + substeps_completed: z.literal(1), + }), + unexplored_branches: z.array(z.object({ reason: z.string() })), + }) + .parse(inspectionResult.result); + expect(inspection.substeps).toHaveLength(1); + expect( + session.evidenceById(inspection.substeps[0]?.evidence_id ?? ""), + ).toMatchObject({ operation: "inventory_artifact" }); + expect(inspectionEvidence.evidence_links).toEqual([ + inspection.substeps[0]?.evidence_id, + ]); + expect(inspection.unexplored_branches).toEqual( + expect.arrayContaining([ + expect.objectContaining({ reason: "page-limit" }), + ]), + ); + const inventory = await client.callTool({ + name: "inventory_artifact", + arguments: {}, + }); + expect(inventory.isError).not.toBe(true); + const inventoryResult = compactResult(inventory.structuredContent); + const evidence = session.evidenceById(inventoryResult.evidence_id); + if (evidence === undefined) throw new Error("missing inventory Evidence"); + expect(evidence).toMatchObject({ + provider: { id: "rea-artifact-graph" }, + subject: { format: "ipa" }, + }); + expect(inventoryResult.result).toMatchObject({ + manifest: { root_format: "ipa" }, + }); + const openedChanged = await client.callTool({ + name: "open_binary", + arguments: { path: changedArchive }, + }); + expect(openedChanged.isError).not.toBe(true); + const changedInventory = await client.callTool({ + name: "inventory_artifact", + arguments: {}, + }); + const changedResult = compactResult(changedInventory.structuredContent); + const changedEvidence = session.evidenceById(changedResult.evidence_id); + if (changedEvidence === undefined) + throw new Error("missing changed Evidence"); + const compared = await client.callTool({ + name: "compare_artifacts", + arguments: { + left_evidence_ids: [evidence.evidence_id], + right_evidence_ids: [changedEvidence.evidence_id], + unknown_registry_approved: true, + }, + }); + expect(compared.isError).not.toBe(true); + expect(compactResult(compared.structuredContent)).toMatchObject({ + result: { status: "changed" }, + evidence_id: expect.stringMatching(/^ev_/u), + }); + const unknowns = await client.callTool({ + name: "list_unknowns", + arguments: { domain: "artifact-comparison" }, + }); + expect(unknowns.structuredContent).toMatchObject({ + result: { + items: [ + expect.objectContaining({ + unknown: expect.objectContaining({ + domain: "artifact-comparison", + }), + }), + ], + }, + }); + const snapshotted = await client.callTool({ + name: "snapshot_evidence_bundle", + arguments: {}, + }); + const bundleUri = z + .object({ result: z.object({ bundle_uri: z.string() }) }) + .parse(snapshotted.structuredContent).result.bundle_uri; + const bundleResource = await client.readResource({ uri: bundleUri }); + const bundle = evidenceBundleSchema.parse( + JSON.parse( + z.object({ text: z.string() }).parse(bundleResource.contents[0]).text, + ), + ); + expect(bundle.records).toHaveLength(6); + expect(bundle.artifacts).toContainEqual({ + digest: { sha256: evidence.subject?.digest.sha256 }, + format: "ipa", + architecture: null, + }); + } finally { + await Promise.allSettled([client.close(), server.close(), session.close()]); + } +}); + +const compactResult = (value: unknown) => + z + .object({ + result: z.unknown(), + evidence_id: z.string(), + evidence_uri: z.string(), + }) + .parse(value); diff --git a/tests/boundary/mcp/browserMcp.test.ts b/tests/boundary/mcp/browserMcp.test.ts new file mode 100644 index 000000000..6d1dfa299 --- /dev/null +++ b/tests/boundary/mcp/browserMcp.test.ts @@ -0,0 +1,330 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { afterEach, expect, it } from "vitest"; + +import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js"; +import type { BinarySession } from "../../../src/application/BinarySession.js"; +import { loadConfiguredPermissionAuthority } from "../../../src/application/PermissionConfiguration.js"; +import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; +import { parseConfig } from "../../../src/config.js"; +import { JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE } from "../../../src/contracts/javascriptRuntimeReconciliationExample.js"; +import { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { observed } from "../../fixtures/analysisExecution.js"; +import { + startFakeCdpBrowser, + type FakeCdpBrowser, +} from "../../fixtures/fakeCdpBrowser.js"; + +const resources: Array<{ close(): Promise }> = []; +const browsers: FakeCdpBrowser[] = []; +const INTEGRATION_TEST_TIMEOUT_MS = 20_000; + +afterEach(async () => { + await Promise.all( + resources.splice(0).map(async (resource) => resource.close()), + ); + await Promise.all(browsers.splice(0).map(async (browser) => browser.close())); +}); + +it( + "exposes CLI-equivalent Evidence v2 and session resources", + async () => { + const browser = await startFakeCdpBrowser({ + sessionTimeline: "same_origin", + webMcpTools: true, + sensitiveShapes: true, + }); + browsers.push(browser); + const connected = await connectBrowser(browser); + + const tools = await connected.client.listTools(); + expect(tools.tools.length).toBeLessThan(TOOL_CONTRACTS.length); + expect(tools.tools.map(({ name }) => name)).toEqual( + expect.arrayContaining([ + "list_browser_targets", + "inspect_web_page", + "analyze_web_bundle", + "observe_web_session", + "discover_webmcp_tools", + "compare_web_captures", + "capture_web_screenshot", + "compare_web_screenshots", + ]), + ); + const status = await connected.client.callTool({ + name: "binary_session", + arguments: { detail: "full" }, + }); + expect(status.structuredContent).toMatchObject({ + result: { + tool_availability: expect.arrayContaining([ + expect.objectContaining({ + name: "inspect_web_page", + available: true, + reason: "available", + }), + ]), + }, + }); + const listed = await connected.client.callTool({ + name: "list_browser_targets", + arguments: { + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + approved: true, + }, + }); + expect(listed.isError).not.toBe(true); + expect(listed.structuredContent).toMatchObject({ + result: { + targets: { items: [{ target_id: "allowed-page" }] }, + }, + }); + const inspected = await connected.client.callTool({ + name: "inspect_web_page", + arguments: { + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 0, + include_console_text: true, + console_text_approved: true, + include_json_body_shapes: true, + json_body_schema_approved: true, + include_websocket_shapes: true, + websocket_shape_approved: true, + include_script_sources: true, + }, + }); + expect(inspected.isError).not.toBe(true); + expect(inspected.structuredContent).toMatchObject({ + result: { + target: { target_id: "allowed-page" }, + console: { prior_activity_available: false }, + network: { + prior_activity_available: false, + requests: [ + expect.objectContaining({ + body_shapes: expect.objectContaining({ status: "included" }), + }), + ], + }, + }, + }); + const reconciled = await connected.client.callTool({ + name: "reconcile_javascript_runtime", + arguments: { + static_layers: JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE.static_layers, + runtime_observations: [ + evidenceFor(connected.session, inspected.structuredContent), + ], + }, + }); + expect(reconciled.isError).not.toBe(true); + expect(reconciled.structuredContent).toMatchObject({ + result: { summary: { runtime_scripts: 1 } }, + }); + expect(JSON.stringify(reconciled.structuredContent)).not.toContain( + "source-secret", + ); + const analyzed = await connected.client.callTool({ + name: "analyze_web_bundle", + arguments: { + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + source_capture_approved: true, + observation_ms: 0, + }, + }); + expect(analyzed.isError).not.toBe(true); + expect(analyzed.structuredContent).toMatchObject({ + result: { + capture: { scripts_analyzed: 1 }, + observations: { source_maps: { status: "not_requested" } }, + }, + }); + await verifySessionAndComparisonTools(connected, browser, inspected); + }, + INTEGRATION_TEST_TIMEOUT_MS, +); + +const verifySessionAndComparisonTools = async ( + connected: Awaited>, + browser: FakeCdpBrowser, + inspected: Awaited>, +): Promise => { + const observedSession = await connected.client.callTool({ + name: "observe_web_session", + arguments: { + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 5, + }, + }); + expect(observedSession.isError).not.toBe(true); + expect(observedSession.structuredContent).toMatchObject({ + result: { + window: { end_reason: "window_elapsed" }, + timeline: expect.arrayContaining([ + expect.objectContaining({ type: "same_origin_reload" }), + ]), + }, + }); + const webMcp = await connected.client.callTool({ + name: "discover_webmcp_tools", + arguments: { + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 0, + }, + }); + expect(webMcp.isError).not.toBe(true); + expect(webMcp.structuredContent).toMatchObject({ + result: { + tools: { + items: [expect.objectContaining({ name: "search_orders" })], + }, + }, + }); + const capture = normalizedResultOf(inspected.structuredContent); + const compared = await connected.client.callTool({ + name: "compare_web_captures", + arguments: { + before: { inspection: capture }, + after: { inspection: capture }, + }, + }); + expect(compared.isError).not.toBe(true); + expect(compared.structuredContent).toMatchObject({ + result: { overall_status: "unknown" }, + }); + const screenshot = await connected.client.callTool({ + name: "capture_web_screenshot", + arguments: { + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + screenshot_approved: true, + }, + }); + expect(screenshot.isError).not.toBe(true); + const screenshotArtifact = artifactOf(screenshot.structuredContent); + const visual = await connected.client.callTool({ + name: "compare_web_screenshots", + arguments: { + before: screenshotArtifact, + after: screenshotArtifact, + }, + }); + expect(visual.isError).not.toBe(true); + expect(visual.structuredContent).toMatchObject({ + result: { status: "identical", changed_pixels: 0 }, + }); + const evidenceId = evidenceIdOf(inspected.structuredContent); + const evidence = await connected.client.readResource({ + uri: `rea://evidence/${evidenceId}`, + }); + expect(evidence.contents[0]).toEqual( + expect.objectContaining({ text: expect.stringContaining(evidenceId) }), + ); +}; + +it("denies origins outside the administrator ceiling before CDP attach", async () => { + const browser = await startFakeCdpBrowser(); + browsers.push(browser); + const connected = await connectBrowser(browser); + const denied = await connected.client.callTool({ + name: "inspect_web_page", + arguments: { + cdp_endpoint: browser.endpoint, + allowed_origins: ["https://unapproved.example.test"], + target_id: "allowed-page", + approved: true, + observation_ms: 0, + }, + }); + expect(denied.isError).toBe(true); + expect(denied.structuredContent).toMatchObject({ + error: { + code: "permission_required", + details: { + missing: { origins: ["https://unapproved.example.test"] }, + }, + }, + }); + expect(browser.commands).toHaveLength(0); +}); + +const connectBrowser = async (browser: FakeCdpBrowser) => { + const config = parseConfig({ + REA_BROWSER_OBSERVE_ENABLED: "true", + REA_BROWSER_CDP_ENDPOINTS_JSON: JSON.stringify([browser.endpoint]), + REA_BROWSER_ALLOWED_ORIGINS_JSON: JSON.stringify([browser.allowedOrigin]), + }); + if (!config.ok) throw config.error; + const authority = await loadConfiguredPermissionAuthority(config.value); + if (!authority.ok) throw authority.error; + const session = composeBinarySessionFromFactory(() => ({ + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(), + })); + const server = createServer(session, session, { + browserObservation: new CdpBrowserProvider(), + permissionAuthority: authority.value, + availabilityPolicy: () => ({ + processCaptureEnabled: false, + evidenceFileRoots: 0, + investigationInputRoots: 0, + browserObservationEnabled: true, + }), + }); + const client = new Client({ name: "browser-mcp-test", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(client, server, session); + await server.connect(serverTransport); + await client.connect(clientTransport); + return { client, session }; +}; + +const evidenceIdOf = (value: unknown): string => { + if ( + typeof value !== "object" || + value === null || + !("evidence_id" in value) || + typeof value.evidence_id !== "string" + ) + throw new TypeError("Missing browser evidence ID"); + return value.evidence_id; +}; + +const normalizedResultOf = (value: unknown): unknown => { + if (typeof value !== "object" || value === null || !("result" in value)) + throw new TypeError("Missing normalized browser result"); + return value.result; +}; + +const evidenceFor = (session: BinarySession, value: unknown) => { + const evidence = session.evidenceById(evidenceIdOf(value)); + if (evidence === undefined) throw new TypeError("Missing session Evidence"); + return evidence; +}; + +const artifactOf = (value: unknown): unknown => { + const normalized = normalizedResultOf(value); + if ( + typeof normalized !== "object" || + normalized === null || + !("artifact" in normalized) + ) + throw new TypeError("Missing screenshot artifact"); + return normalized.artifact; +}; diff --git a/tests/browserScenarioMcp.test.ts b/tests/boundary/mcp/browserScenarioMcp.test.ts similarity index 88% rename from tests/browserScenarioMcp.test.ts rename to tests/boundary/mcp/browserScenarioMcp.test.ts index a3ed95497..9431b67ff 100644 --- a/tests/browserScenarioMcp.test.ts +++ b/tests/boundary/mcp/browserScenarioMcp.test.ts @@ -2,25 +2,25 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { dirname } from "node:path"; import { afterEach, describe, expect, test } from "vitest"; -import type { BrowserScenarioCapturePort } from "../src/application/BrowserScenarioCapturePort.js"; -import { BinarySession } from "../src/application/BinarySession.js"; -import { loadConfiguredPermissionAuthority } from "../src/application/PermissionConfiguration.js"; +import type { BrowserScenarioCapturePort } from "../../../src/application/BrowserScenarioCapturePort.js"; +import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js"; +import { loadConfiguredPermissionAuthority } from "../../../src/application/PermissionConfiguration.js"; import type { ExecutionOptions, ProviderIdentity, -} from "../src/application/AnalysisProvider.js"; -import { CdpBrowserProvider } from "../src/browser/CdpBrowserProvider.js"; -import { parseConfig } from "../src/config.js"; -import { sanitizeBrowserUrl } from "../src/domain/browserObservation.js"; -import type { BrowserScenario } from "../src/domain/browserScenario.js"; +} from "../../../src/application/AnalysisProvider.js"; +import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; +import { parseConfig } from "../../../src/config.js"; +import { sanitizeBrowserUrl } from "../../../src/domain/browserObservation.js"; +import type { BrowserScenario } from "../../../src/domain/browserScenario.js"; import { browserScenarioCaptureSchema, type BrowserScenarioCapture, -} from "../src/domain/browserScenarioCapture.js"; -import type { AnalysisError } from "../src/domain/errors.js"; -import { ok, type Result } from "../src/domain/result.js"; -import { createServer } from "../src/server/createServer.js"; -import { observed } from "./fixtures/analysisExecution.js"; +} from "../../../src/domain/browserScenarioCapture.js"; +import type { AnalysisError } from "../../../src/domain/errors.js"; +import { ok, type Result } from "../../../src/domain/result.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { observed } from "../../fixtures/analysisExecution.js"; const providerIdentity: ProviderIdentity = { id: "test-browser-scenario", @@ -189,7 +189,7 @@ describe("browser scenario MCP tool", () => { const authority = await loadConfiguredPermissionAuthority(configured.value); if (!authority.ok) throw authority.error; const provider = new FakeBrowserScenarioProvider(); - const session = new BinarySession(() => ({ + const session = composeBinarySessionFromFactory(() => ({ execute: () => Promise.resolve(observed(null)), close: () => Promise.resolve(), })); diff --git a/tests/bundleComparisonMcp.test.ts b/tests/boundary/mcp/bundleComparisonMcp.test.ts similarity index 90% rename from tests/bundleComparisonMcp.test.ts rename to tests/boundary/mcp/bundleComparisonMcp.test.ts index efb1c46e7..cbedd3753 100644 --- a/tests/bundleComparisonMcp.test.ts +++ b/tests/boundary/mcp/bundleComparisonMcp.test.ts @@ -4,16 +4,16 @@ import { join } from "node:path"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { afterEach, describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { BinarySession } from "../src/application/BinarySession.js"; -import { createEvidence } from "../src/domain/evidence.js"; +import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js"; +import { createEvidence } from "../../../src/domain/evidence.js"; import { createEvidenceBundle, serializeEvidenceBundle, -} from "../src/domain/evidenceBundle.js"; -import { createServer } from "../src/server/createServer.js"; -import { observed } from "./fixtures/analysisExecution.js"; +} from "../../../src/domain/evidenceBundle.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { observed } from "../../fixtures/analysisExecution.js"; const roots: string[] = []; afterEach(async () => { @@ -137,7 +137,7 @@ describe("bundle comparison MCP integration", () => { }); const connect = async (root: string, maxBytes = 1024 * 1024) => { - const session = new BinarySession(() => ({ + const session = composeBinarySessionFromFactory(() => ({ health: () => Promise.resolve(), execute: () => Promise.resolve(observed(null)), close: () => Promise.resolve(), diff --git a/tests/createServer.test.ts b/tests/boundary/mcp/createServer.test.ts similarity index 51% rename from tests/createServer.test.ts rename to tests/boundary/mcp/createServer.test.ts index 219a2e1a0..5abaee2c6 100644 --- a/tests/createServer.test.ts +++ b/tests/boundary/mcp/createServer.test.ts @@ -1,8 +1,8 @@ -import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { describe, expect, it } from "vitest"; -import { observed as ok } from "./fixtures/analysisExecution.js"; -import { createServer } from "../src/server/createServer.js"; +import { observed as ok } from "../../fixtures/analysisExecution.js"; +import { mcpTest } from "../../support/mcp/mcpFixture.js"; +import { createServer } from "../../../src/server/createServer.js"; const analysis = { execute: () => Promise.resolve(ok(null)), @@ -13,18 +13,15 @@ describe("MCP server composition", () => { expect(createServer(analysis)).not.toBe(createServer(analysis)); }); - it("does not advertise unavailable session tools", async () => { + mcpTest("does not advertise unavailable session tools", async ({ mcp }) => { const server = createServer(analysis); - const client = new Client({ name: "composition-test", version: "1.0.0" }); - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - await server.connect(serverTransport); - await client.connect(clientTransport); + const client = await mcp.connect(server, { + name: "composition-test", + version: "1.0.0", + }); expect(client.getInstructions()).not.toContain("open_binary"); const names = (await client.listTools()).tools.map(({ name }) => name); expect(names).not.toContain("compare_managed_members"); expect(names).not.toContain("plan_managed_runtime_correlation"); - await client.close(); - await server.close(); }); }); diff --git a/tests/derivedOperation.test.ts b/tests/boundary/mcp/derivedOperation.test.ts similarity index 96% rename from tests/derivedOperation.test.ts rename to tests/boundary/mcp/derivedOperation.test.ts index ece61cab3..c4328bf11 100644 --- a/tests/derivedOperation.test.ts +++ b/tests/boundary/mcp/derivedOperation.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { runDerivedOperation } from "../src/server/runDerivedOperation.js"; +import { runDerivedOperation } from "../../../src/server/runDerivedOperation.js"; const context = ( controller: AbortController, diff --git a/tests/boundary/mcp/electronMcp.test.ts b/tests/boundary/mcp/electronMcp.test.ts new file mode 100644 index 000000000..dc2451e75 --- /dev/null +++ b/tests/boundary/mcp/electronMcp.test.ts @@ -0,0 +1,351 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { afterEach, expect, it } from "vitest"; +import { z } from "zod"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js"; +import type { BinarySession } from "../../../src/application/BinarySession.js"; +import { loadConfiguredPermissionAuthority } from "../../../src/application/PermissionConfiguration.js"; +import { CdpElectronProvider } from "../../../src/browser/CdpElectronProvider.js"; +import { parseConfig } from "../../../src/config.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { observed } from "../../fixtures/analysisExecution.js"; +import { + startFakeCdpBrowser, + type FakeCdpBrowser, +} from "../../fixtures/fakeCdpBrowser.js"; +import { writeElectronBoundaryFixture } from "../../fixtures/electronBoundaryApplication.js"; + +const browsers: FakeCdpBrowser[] = []; +const resources: Array<{ close(): Promise }> = []; +const temporary: string[] = []; + +afterEach(async () => { + await Promise.all(resources.splice(0).map(async (item) => item.close())); + await Promise.all(browsers.splice(0).map(async (browser) => browser.close())); + await Promise.all( + temporary + .splice(0) + .map(async (path) => rm(path, { recursive: true, force: true })), + ); +}); + +it("exposes root-confined Electron discovery and inspection as Evidence v2", async () => { + const root = await createTestTempDirectory("rea-electron-mcp-"); + temporary.push(root); + await writeFile(join(root, "index.html"), ""); + await writeFile( + join(root, "app.js"), + "export const observed = 'source-secret';", + ); + await writeFile( + join(root, "package.json"), + JSON.stringify({ name: "runtime-fixture", renderer: "index.html" }), + ); + await writeFile(join(root, "worker.js"), "self.onmessage = () => {};\n"); + const browser = await startFakeCdpBrowser({ + electronFileUrl: pathToFileURL(join(root, "index.html")).href, + }); + browsers.push(browser); + const config = parseConfig({ + REA_ELECTRON_OBSERVE_ENABLED: "true", + REA_ELECTRON_CDP_ENDPOINTS_JSON: JSON.stringify([browser.endpoint]), + REA_ELECTRON_FILE_ROOTS_JSON: JSON.stringify([root]), + REA_INVESTIGATION_INPUT_ROOTS_JSON: JSON.stringify([root]), + }); + if (!config.ok) throw config.error; + const authority = await loadConfiguredPermissionAuthority(config.value); + if (!authority.ok) throw authority.error; + const session = composeBinarySessionFromFactory(() => ({ + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(), + })); + const server = createServer(session, session, { + electronObservation: new CdpElectronProvider(), + permissionAuthority: authority.value, + availabilityPolicy: () => ({ + processCaptureEnabled: false, + evidenceFileRoots: 0, + investigationInputRoots: 1, + electronObservationEnabled: true, + }), + }); + const client = new Client({ name: "electron-mcp-test", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(client, server, session); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const listed = await client.callTool({ + name: "list_electron_targets", + arguments: { + cdp_endpoint: browser.endpoint, + allowed_file_roots: [root], + approved: true, + }, + }); + expect(listed.isError).not.toBe(true); + expect(listed.structuredContent).toMatchObject({ + result: { + targets: { items: [{ target_id: "electron-page" }] }, + }, + }); + const inspected = await client.callTool({ + name: "inspect_electron_page", + arguments: { + cdp_endpoint: browser.endpoint, + allowed_file_roots: [root], + target_id: "electron-page", + approved: true, + observation_ms: 0, + include_script_sources: true, + source_capture_approved: true, + }, + }); + const analyzed = await client.callTool({ + name: "analyze_javascript_application", + arguments: { input_path: root, approved: true, detail: "full" }, + }); + expect(analyzed.isError).not.toBe(true); + expect(analyzed.structuredContent).toMatchObject({ + result: { graph: { nodes: expect.any(Array), edges: expect.any(Array) } }, + }); + const analysisText = analyzed.content.find(({ type }) => type === "text"); + expect(analysisText).toBeDefined(); + if (analysisText?.type !== "text") + throw new TypeError("Missing JavaScript analysis text projection"); + expect(analysisText.text).not.toContain('"nodes":['); + const reconciled = await client.callTool({ + name: "reconcile_javascript_runtime", + arguments: { + static_layers: [ + { + role: "application", + analysis: evidenceFor(session, analyzed.structuredContent), + }, + ], + runtime_observations: [evidenceFor(session, inspected.structuredContent)], + }, + }); + expect(reconciled.isError).not.toBe(true); + expect(reconciled.structuredContent).toMatchObject({ + result: { + summary: { runtime_scripts: 1, matched: expect.any(Number) }, + source_map_authority: { used_for_primary_matching: false }, + }, + }); + expect(inspected.isError).not.toBe(true); + expect(inspected.structuredContent).toMatchObject({ + result: { + target: { file_path: expect.stringMatching(/\/index\.html$/u) }, + scripts: { + items: [ + expect.objectContaining({ + frame_id: "frame-main", + file_path: expect.stringMatching(/\/app\.js$/u), + }), + ], + }, + workers: [ + expect.objectContaining({ + target_id: "electron-worker", + opener_target_id: "electron-page", + file_path: expect.stringMatching(/\/worker\.js$/u), + }), + ], + }, + }); + const outside = join(root, "..", `outside-${Date.now().toString(16)}`); + await mkdir(outside); + temporary.push(outside); + const commandsBeforeDenial = browser.commands.length; + const denied = await client.callTool({ + name: "list_electron_targets", + arguments: { + cdp_endpoint: browser.endpoint, + allowed_file_roots: [outside], + approved: true, + }, + }); + expect(denied.isError).toBe(true); + expect(denied.structuredContent).toMatchObject({ + error: { + code: "permission_required", + details: { missing: { roots: [expect.stringMatching(/outside-/u)] } }, + }, + }); + expect(browser.commands).toHaveLength(commandsBeforeDenial); +}, 20_000); + +it("exposes the target-free static JavaScript application workflow", async () => { + const root = await createTestTempDirectory("rea-electron-static-mcp-"); + temporary.push(root); + await writeElectronBoundaryFixture(root); + const config = parseConfig({ + REA_INVESTIGATION_INPUT_ROOTS_JSON: JSON.stringify([root]), + }); + if (!config.ok) throw config.error; + const authority = await loadConfiguredPermissionAuthority(config.value); + if (!authority.ok) throw authority.error; + const session = composeBinarySessionFromFactory(() => ({ + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(), + })); + const server = createServer(session, session, { + permissionAuthority: authority.value, + availabilityPolicy: () => ({ + processCaptureEnabled: false, + evidenceFileRoots: 0, + investigationInputRoots: 1, + }), + }); + const client = new Client({ + name: "electron-static-mcp-test", + version: "1", + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(client, server, session); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const analyzed = await client.callTool({ + name: "analyze_javascript_application", + arguments: { input_path: root, approved: true }, + }); + + expect(analyzed.isError).not.toBe(true); + const projected = z + .object({ + evidence_id: z.string(), + result: z.object({ + schema_version: z.literal(2), + input_path: z.string(), + unknowns: z.array(z.string()), + summary: z.object({ + browser_windows: z.number(), + context_bridge_apis: z.number(), + ipc: z.object({ paired_renderer_transmissions: z.number() }), + }), + graph: z.object({ + graph_id: z.string(), + node_count: z.number(), + edge_count: z.number(), + top_findings: z.array(z.unknown()), + pages: z.object({ nodes: z.string(), edges: z.string() }), + }), + semantic_graph: z.object({ + graph_id: z.string(), + nodes: z.number(), + relations: z.number(), + unknown_frontiers: z.number(), + query_tool: z.literal("trace_javascript_semantics"), + }), + }), + }) + .parse(analyzed.structuredContent); + expect(projected.result).toMatchObject({ + input_path: expect.stringMatching(/rea-electron-static-mcp-/u), + summary: { + browser_windows: 3, + context_bridge_apis: 2, + ipc: { paired_renderer_transmissions: 4 }, + }, + }); + expect(projected.result.graph.node_count).toBeGreaterThan(0); + expect(projected.result.semantic_graph.nodes).toBeGreaterThan(0); + expect(JSON.stringify(analyzed.structuredContent)).not.toContain('"nodes":['); + + const nodePage = await client.readResource({ + uri: projected.result.graph.pages.nodes, + }); + const nodeContent = nodePage.contents[0]; + if (nodeContent === undefined || !("text" in nodeContent)) + throw new TypeError("Missing JavaScript application graph node page"); + expect(JSON.parse(nodeContent.text)).toMatchObject({ + evidence_id: projected.evidence_id, + graph_id: projected.result.graph.graph_id, + collection: "nodes", + items: expect.any(Array), + offset: 0, + limit: 100, + }); +}); + +it("reports an unconfigured investigation ceiling before static analysis", async () => { + const root = await createTestTempDirectory("rea-electron-denied-mcp-"); + temporary.push(root); + await writeElectronBoundaryFixture(root); + const config = parseConfig({}); + if (!config.ok) throw config.error; + const authority = await loadConfiguredPermissionAuthority(config.value); + if (!authority.ok) throw authority.error; + const session = composeBinarySessionFromFactory(() => ({ + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(), + })); + const server = createServer(session, session, { + permissionAuthority: authority.value, + }); + const client = new Client({ + name: "electron-denied-mcp-test", + version: "1", + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(client, server, session); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const status = await client.callTool({ + name: "binary_session", + arguments: { + detail: "capabilities", + capability_family: "electron-provider", + limit: 100, + }, + }); + expect(status.structuredContent).toMatchObject({ + result: { + capabilities: { + items: expect.arrayContaining([ + expect.objectContaining({ + name: "analyze_javascript_application", + available: false, + reason: "policy_disabled", + }), + expect.objectContaining({ + name: "reconcile_javascript_runtime", + available: true, + reason: "available", + }), + ]), + }, + }, + }); + + expect( + (await client.listTools()).tools.some( + ({ name }) => name === "analyze_javascript_application", + ), + ).toBe(false); +}); + +const evidenceFor = (session: BinarySession, value: unknown) => { + if ( + typeof value !== "object" || + value === null || + !("evidence_id" in value) || + typeof value.evidence_id !== "string" + ) + throw new TypeError("Missing compact Evidence ID"); + const evidence = session.evidenceById(value.evidence_id); + if (evidence === undefined) throw new TypeError("Missing session Evidence"); + return evidence; +}; diff --git a/tests/boundary/mcp/enhancedTools.01-lists-the-complete-target-open.test.ts b/tests/boundary/mcp/enhancedTools.01-lists-the-complete-target-open.test.ts new file mode 100644 index 000000000..fe74d02ed --- /dev/null +++ b/tests/boundary/mcp/enhancedTools.01-lists-the-complete-target-open.test.ts @@ -0,0 +1,315 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import type { CallToolResult } from "@modelcontextprotocol/server"; +import { afterEach, describe, expect, it } from "vitest"; + +import type { AnalysisOperationPort } from "../../../src/application/AnalysisProvider.js"; +import { MANAGED_WORKFLOW_TOOL_CONTRACTS } from "../../../src/contracts/managedWorkflowToolContracts.js"; +import { + ENHANCED_TOOL_CONTRACTS, + SESSION_TOOL_CONTRACTS, + TOOL_CONTRACTS, +} from "../../../src/contracts/toolContracts.js"; +import { + jsonValueSchema, + type JsonValue, +} from "../../../src/domain/jsonValue.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { observed as ok } from "../../fixtures/analysisExecution.js"; + +const PROCEDURES = { + "0x1": "_TtC7Fixture5Class", + "0x2": "_TtV7Fixture6Struct", + "0x3": "_TtP7Fixture8Protocol", + "0x4": "_TtO7Fixture4Enum", + "0x5": "_TtE7Fixture9Extension", + "0x6": "prefix_TtOther", +}; + +const page = (values: Readonly>) => ({ + items: Object.entries(values).map(([address, value]) => ({ address, value })), + offset: 0, + limit: 100, + total: Object.keys(values).length, + next_offset: null, + has_more: false, +}); + +const fixturePort = (): AnalysisOperationPort => ({ + execute: (name, arguments_) => { + switch (name) { + case "list_procedures": + return Promise.resolve(ok(page(PROCEDURES))); + case "list_names": + return Promise.resolve( + ok( + page({ + "0x10": "_OBJC_CLASS_$_Fixture", + "0x11": "_OBJC_CLASS_$_Fixture", + "0x12": "_OBJC_PROTOCOL_$_FixtureDelegate", + "0x13": "entry", + }), + ), + ); + case "procedure_pseudo_code": { + const procedure = arguments_.procedure; + return Promise.resolve( + ok(typeof procedure === "string" ? `pseudo:${procedure}` : "invalid"), + ); + } + case "procedure_callees": { + const procedure = arguments_.procedure; + return Promise.resolve( + ok( + procedure === "0x1" + ? ["0x2", "0x3"] + : procedure === "0x2" + ? ["0x1"] + : [], + ), + ); + } + case "procedure_callers": + return Promise.resolve(ok(["0x9"])); + case "address_name": + return Promise.resolve(ok(arguments_.address ?? null)); + case "xrefs": + return Promise.resolve(ok(["0x20", "0x21"])); + case "resolve_containing_procedure": + return Promise.resolve( + ok({ + query_address: + typeof arguments_.address === "string" + ? arguments_.address + : "0x0", + found: false, + procedure: null, + reason: "not_in_procedure", + }), + ); + case "list_segments": + return Promise.resolve( + ok([{ name: "__TEXT", start: "0x1000", end: "0x2000" }]), + ); + case "list_documents": + return Promise.resolve(ok(["fixture"])); + case "list_strings": + return Promise.resolve(ok(page({ "0x30": "hello" }))); + case "analyze_function": + return Promise.resolve( + ok({ + procedure: { + address: "0x1", + name: "entry", + signature: null, + locals: [], + }, + pseudocode: { + text: "return 0;", + total_chars: 9, + returned_chars: 9, + truncated: false, + next_offset: null, + }, + assembly: { + items: [], + total: 0, + returned: 0, + truncated: false, + next_offset: null, + }, + comments: emptyBounded(), + callers: emptyBounded(), + callees: emptyBounded(), + incoming_references: emptyBounded(), + outgoing_references: emptyBounded(), + referenced_strings: emptyBounded(), + referenced_names: emptyBounded(), + basic_blocks: emptyBounded(), + instruction_scan: { scanned: 0, truncated: false }, + }), + ); + default: + return Promise.resolve(ok(null)); + } + }, +}); + +const emptyBounded = () => ({ + items: [], + total: 0, + returned: 0, + truncated: false, + next_offset: null, +}); + +const resources: Array<{ close(): Promise }> = []; + +afterEach(async () => { + await Promise.all( + resources.splice(0).map(async (resource) => resource.close()), + ); +}); + +const connect = async (analysis: AnalysisOperationPort = fixturePort()) => { + const server = createServer(analysis); + const client = new Client({ name: "enhanced-test", version: "1.0.0" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(client, server); + await server.connect(serverTransport); + await client.connect(clientTransport); + return client; +}; + +const jsonResult = (result: CallToolResult): JsonValue => { + if (result.structuredContent === undefined) + throw new Error("Tool result omitted structured content"); + const structured = jsonValueSchema.safeParse(result.structuredContent); + if (!structured.success) + throw new Error("Tool structured result was not JSON"); + if ( + typeof structured.data === "object" && + structured.data !== null && + !Array.isArray(structured.data) && + "normalized_result" in structured.data + ) { + return structured.data.normalized_result ?? null; + } + if ( + typeof structured.data === "object" && + structured.data !== null && + !Array.isArray(structured.data) && + "evidence_id" in structured.data && + "result" in structured.data + ) + return structured.data.result ?? null; + const text = result.content.find((item) => item.type === "text"); + if (text?.type !== "text") + throw new Error("Tool result omitted text content"); + const decoded: unknown = JSON.parse(text.text); + const parsed = jsonValueSchema.safeParse(decoded); + if (!parsed.success) throw new Error("Tool result was not JSON"); + return parsed.data; +}; + +describe("enhanced MCP tools", () => { + it("lists the complete target-open analysis surface", async () => { + const client = await connect(); + const listed = await client.listTools(); + expect(listed.tools).toHaveLength( + TOOL_CONTRACTS.length - + SESSION_TOOL_CONTRACTS.length - + MANAGED_WORKFLOW_TOOL_CONTRACTS.length, + ); + expect( + listed.tools + .map(({ name }) => name) + .filter((name) => + ENHANCED_TOOL_CONTRACTS.some((tool) => tool.name === name), + ) + .sort(), + ).toEqual(ENHANCED_TOOL_CONTRACTS.map(({ name }) => name).sort()); + }); + + it("executes all thirteen tools through production registration", async () => { + const client = await connect(); + const calls = [ + ["swift_classes", { pattern: "Fixture" }], + ["get_objc_classes", { pattern: "Fixture" }], + ["get_objc_protocols", {}], + ["batch_decompile", { addresses: ["0x1", "0x2"] }], + ["get_call_graph", { address: "0x1", direction: "forward", depth: 2 }], + ["analyze_swift_types", {}], + ["find_xrefs_to_name", { name: "entry" }], + ["binary_overview", {}], + ["analyze_function", { procedure: "0x1" }], + ["inspect_native_api", { procedure: "0x1" }], + ["trace_feature", { query: "hello", max_operations: 10 }], + ["find_code_for_string", { query: "hello", max_operations: 10 }], + ["trace_call_path", { start: "0x1", goal: "0x2", max_operations: 10 }], + ] as const; + const results = await Promise.all( + calls.map(async ([name, arguments_]) => + jsonResult(await client.callTool({ name, arguments: arguments_ })), + ), + ); + expect(results[0]).toMatchObject({ count: 1 }); + expect(results[1]).toMatchObject({ count: 1 }); + expect(results[2]).toMatchObject({ count: 1 }); + expect(results[3]).toEqual({ + items: [ + { address: "0x1", status: "ok", pseudocode: "pseudo:0x1" }, + { address: "0x2", status: "ok", pseudocode: "pseudo:0x2" }, + ], + total: 2, + succeeded: 2, + failed: 0, + }); + expect(results[4]).toEqual({ + "0": [{ address: "0x1", status: "ok", calls: ["0x2", "0x3"] }], + "1": [ + { address: "0x2", status: "ok", calls: ["0x1"] }, + { address: "0x3", status: "ok", calls: [] }, + ], + }); + expect(results[5]).toMatchObject({ total: 6 }); + expect(results[6]).toEqual({ + status: "resolved", + name: "entry", + address: "0x13", + xrefs: ["0x20", "0x21"], + }); + expect(results[7]).toMatchObject({ + document: "fixture", + segment_count: 1, + procedure_count: 6, + string_count: 1, + }); + expect(results[8]).toMatchObject({ + procedure: { address: "0x1", name: "entry" }, + pseudocode: { text: "return 0;" }, + }); + expect(results[9]).toMatchObject({ + procedure: { address: "0x1", name: "entry" }, + boundary: { available: false }, + unsupported_branches: [ + "structured-boundary-types", + "jump-table-data-mapping", + ], + residual_unknowns: expect.arrayContaining([ + expect.stringContaining("boundary types"), + ]), + substeps: [ + { operation: "analyze_function", status: "completed" }, + { operation: "project_native_api_boundary", status: "unsupported" }, + { operation: "preserve_residual_unknowns", status: "completed" }, + ], + }); + expect(results[10]).toMatchObject({ + query: "hello", + search_mode: "literal", + truncated: false, + references: [ + { target_address: "0x30", source_address: "0x20" }, + { target_address: "0x30", source_address: "0x21" }, + ], + }); + expect(results[11]).toMatchObject({ + query: "hello", + matches: [{ type: "string", address: "0x30", value: "hello" }], + truncated: false, + }); + expect(results[12]).toMatchObject({ + start: "0x1", + goal: "0x2", + direction: "forward", + goal_status: "reached", + traversal_path: ["0x1", "0x2"], + nodes: [ + { address: "0x1", depth: 0 }, + { address: "0x2", depth: 1 }, + ], + truncated: false, + }); + }); +}); diff --git a/tests/boundary/mcp/enhancedTools.02-returns-evidence-ids-and-explicit.test.ts b/tests/boundary/mcp/enhancedTools.02-returns-evidence-ids-and-explicit.test.ts new file mode 100644 index 000000000..f5e192b91 --- /dev/null +++ b/tests/boundary/mcp/enhancedTools.02-returns-evidence-ids-and-explicit.test.ts @@ -0,0 +1,262 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { afterEach, describe, expect, it } from "vitest"; + +import type { AnalysisOperationPort } from "../../../src/application/AnalysisProvider.js"; +import { EnhancedTools } from "../../../src/application/EnhancedTools.js"; +import { AnalysisOutputError } from "../../../src/domain/errors.js"; +import { err } from "../../../src/domain/result.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { observed as ok } from "../../fixtures/analysisExecution.js"; + +const PROCEDURES = { + "0x1": "_TtC7Fixture5Class", + "0x2": "_TtV7Fixture6Struct", + "0x3": "_TtP7Fixture8Protocol", + "0x4": "_TtO7Fixture4Enum", + "0x5": "_TtE7Fixture9Extension", + "0x6": "prefix_TtOther", +}; + +const page = (values: Readonly>) => ({ + items: Object.entries(values).map(([address, value]) => ({ address, value })), + offset: 0, + limit: 100, + total: Object.keys(values).length, + next_offset: null, + has_more: false, +}); + +const fixturePort = (): AnalysisOperationPort => ({ + execute: (name, arguments_) => { + switch (name) { + case "list_procedures": + return Promise.resolve(ok(page(PROCEDURES))); + case "list_names": + return Promise.resolve( + ok( + page({ + "0x10": "_OBJC_CLASS_$_Fixture", + "0x11": "_OBJC_CLASS_$_Fixture", + "0x12": "_OBJC_PROTOCOL_$_FixtureDelegate", + "0x13": "entry", + }), + ), + ); + case "procedure_pseudo_code": { + const procedure = arguments_.procedure; + return Promise.resolve( + ok(typeof procedure === "string" ? `pseudo:${procedure}` : "invalid"), + ); + } + case "procedure_callees": { + const procedure = arguments_.procedure; + return Promise.resolve( + ok( + procedure === "0x1" + ? ["0x2", "0x3"] + : procedure === "0x2" + ? ["0x1"] + : [], + ), + ); + } + case "procedure_callers": + return Promise.resolve(ok(["0x9"])); + case "address_name": + return Promise.resolve(ok(arguments_.address ?? null)); + case "xrefs": + return Promise.resolve(ok(["0x20", "0x21"])); + case "resolve_containing_procedure": + return Promise.resolve( + ok({ + query_address: + typeof arguments_.address === "string" + ? arguments_.address + : "0x0", + found: false, + procedure: null, + reason: "not_in_procedure", + }), + ); + case "list_segments": + return Promise.resolve( + ok([{ name: "__TEXT", start: "0x1000", end: "0x2000" }]), + ); + case "list_documents": + return Promise.resolve(ok(["fixture"])); + case "list_strings": + return Promise.resolve(ok(page({ "0x30": "hello" }))); + case "analyze_function": + return Promise.resolve( + ok({ + procedure: { + address: "0x1", + name: "entry", + signature: null, + locals: [], + }, + pseudocode: { + text: "return 0;", + total_chars: 9, + returned_chars: 9, + truncated: false, + next_offset: null, + }, + assembly: { + items: [], + total: 0, + returned: 0, + truncated: false, + next_offset: null, + }, + comments: emptyBounded(), + callers: emptyBounded(), + callees: emptyBounded(), + incoming_references: emptyBounded(), + outgoing_references: emptyBounded(), + referenced_strings: emptyBounded(), + referenced_names: emptyBounded(), + basic_blocks: emptyBounded(), + instruction_scan: { scanned: 0, truncated: false }, + }), + ); + default: + return Promise.resolve(ok(null)); + } + }, +}); + +const emptyBounded = () => ({ + items: [], + total: 0, + returned: 0, + truncated: false, + next_offset: null, +}); + +const resources: Array<{ close(): Promise }> = []; + +afterEach(async () => { + await Promise.all( + resources.splice(0).map(async (resource) => resource.close()), + ); +}); + +const connect = async (analysis: AnalysisOperationPort = fixturePort()) => { + const server = createServer(analysis); + const client = new Client({ name: "enhanced-test", version: "1.0.0" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(client, server); + await server.connect(serverTransport); + await client.connect(clientTransport); + return client; +}; + +describe("enhanced MCP tools", () => { + it("returns Evidence IDs and explicit call-path frontiers at limits", async () => { + const client = await connect(); + const result = await client.callTool({ + name: "trace_call_path", + arguments: { + start: "0x1", + goal: "0x3", + max_nodes: 1, + max_operations: 10, + }, + }); + + expect(result.isError).not.toBe(true); + expect(result.structuredContent).toMatchObject({ + evidence_id: expect.stringMatching(/^ev_[a-f0-9]{64}$/u), + result: { + goal_status: "not_reached", + nodes: [{ address: "0x1", depth: 0 }], + frontier: ["0x2"], + truncated: true, + residual_unknowns: expect.arrayContaining([ + expect.stringContaining("reached the node limit"), + expect.stringContaining("reached the edge limit"), + ]), + limits: { + max_nodes: 1, + nodes_visited: 1, + operations_used: 1, + }, + }, + }); + }); + + it("bounds batch concurrency at the parsed maximum of 20", async () => { + let active = 0; + let maximum = 0; + const analysis: AnalysisOperationPort = { + execute: async (name) => { + if (name !== "procedure_pseudo_code") return ok(null); + active += 1; + maximum = Math.max(maximum, active); + await new Promise((resolve) => setTimeout(resolve, 2)); + active -= 1; + return ok("pseudo"); + }, + }; + const client = await connect(analysis); + const result = await client.callTool({ + name: "batch_decompile", + arguments: { + addresses: Array.from( + { length: 20 }, + (_, index) => `0x${String(index)}`, + ), + }, + }); + expect(result.isError).not.toBe(true); + expect(maximum).toBe(20); + }); + + it("returns ordered typed batch failures and zero counts for empty input", async () => { + const tools = new EnhancedTools({ + execute: (_name, arguments_) => + arguments_.procedure === "0x2" + ? Promise.resolve(err(new AnalysisOutputError("decompile", "failed"))) + : Promise.resolve(ok("pseudo")), + }); + + const result = await tools.execute("batch_decompile", { + addresses: ["0x1", "0x2"], + }); + const empty = await tools.execute("batch_decompile", { addresses: [] }); + + expect(result).toEqual({ + ok: true, + value: { + items: [ + { address: "0x1", status: "ok", pseudocode: "pseudo" }, + { + address: "0x2", + status: "error", + error: { + code: "unreadable_output", + category: "execution_failure", + message: + "Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.", + retryable: false, + remediation: { + action: + "Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.", + restart_required: false, + }, + }, + }, + ], + total: 2, + succeeded: 1, + failed: 1, + }, + }); + expect(empty).toEqual({ + ok: true, + value: { items: [], total: 0, succeeded: 0, failed: 0 }, + }); + }); +}); diff --git a/tests/boundary/mcp/enhancedTools.03-returns-typed-graph-failures-and.test.ts b/tests/boundary/mcp/enhancedTools.03-returns-typed-graph-failures-and.test.ts new file mode 100644 index 000000000..d23006a17 --- /dev/null +++ b/tests/boundary/mcp/enhancedTools.03-returns-typed-graph-failures-and.test.ts @@ -0,0 +1,297 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import type { CallToolResult } from "@modelcontextprotocol/server"; +import { afterEach, describe, expect, it } from "vitest"; + +import type { AnalysisOperationPort } from "../../../src/application/AnalysisProvider.js"; +import { EnhancedTools } from "../../../src/application/EnhancedTools.js"; +import { AnalysisOutputError } from "../../../src/domain/errors.js"; +import { + jsonValueSchema, + type JsonValue, +} from "../../../src/domain/jsonValue.js"; +import { err } from "../../../src/domain/result.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { observed as ok } from "../../fixtures/analysisExecution.js"; + +const PROCEDURES = { + "0x1": "_TtC7Fixture5Class", + "0x2": "_TtV7Fixture6Struct", + "0x3": "_TtP7Fixture8Protocol", + "0x4": "_TtO7Fixture4Enum", + "0x5": "_TtE7Fixture9Extension", + "0x6": "prefix_TtOther", +}; + +const page = (values: Readonly>) => ({ + items: Object.entries(values).map(([address, value]) => ({ address, value })), + offset: 0, + limit: 100, + total: Object.keys(values).length, + next_offset: null, + has_more: false, +}); + +const fixturePort = (): AnalysisOperationPort => ({ + execute: (name, arguments_) => { + switch (name) { + case "list_procedures": + return Promise.resolve(ok(page(PROCEDURES))); + case "list_names": + return Promise.resolve( + ok( + page({ + "0x10": "_OBJC_CLASS_$_Fixture", + "0x11": "_OBJC_CLASS_$_Fixture", + "0x12": "_OBJC_PROTOCOL_$_FixtureDelegate", + "0x13": "entry", + }), + ), + ); + case "procedure_pseudo_code": { + const procedure = arguments_.procedure; + return Promise.resolve( + ok(typeof procedure === "string" ? `pseudo:${procedure}` : "invalid"), + ); + } + case "procedure_callees": { + const procedure = arguments_.procedure; + return Promise.resolve( + ok( + procedure === "0x1" + ? ["0x2", "0x3"] + : procedure === "0x2" + ? ["0x1"] + : [], + ), + ); + } + case "procedure_callers": + return Promise.resolve(ok(["0x9"])); + case "address_name": + return Promise.resolve(ok(arguments_.address ?? null)); + case "xrefs": + return Promise.resolve(ok(["0x20", "0x21"])); + case "resolve_containing_procedure": + return Promise.resolve( + ok({ + query_address: + typeof arguments_.address === "string" + ? arguments_.address + : "0x0", + found: false, + procedure: null, + reason: "not_in_procedure", + }), + ); + case "list_segments": + return Promise.resolve( + ok([{ name: "__TEXT", start: "0x1000", end: "0x2000" }]), + ); + case "list_documents": + return Promise.resolve(ok(["fixture"])); + case "list_strings": + return Promise.resolve(ok(page({ "0x30": "hello" }))); + case "analyze_function": + return Promise.resolve( + ok({ + procedure: { + address: "0x1", + name: "entry", + signature: null, + locals: [], + }, + pseudocode: { + text: "return 0;", + total_chars: 9, + returned_chars: 9, + truncated: false, + next_offset: null, + }, + assembly: { + items: [], + total: 0, + returned: 0, + truncated: false, + next_offset: null, + }, + comments: emptyBounded(), + callers: emptyBounded(), + callees: emptyBounded(), + incoming_references: emptyBounded(), + outgoing_references: emptyBounded(), + referenced_strings: emptyBounded(), + referenced_names: emptyBounded(), + basic_blocks: emptyBounded(), + instruction_scan: { scanned: 0, truncated: false }, + }), + ); + default: + return Promise.resolve(ok(null)); + } + }, +}); + +const emptyBounded = () => ({ + items: [], + total: 0, + returned: 0, + truncated: false, + next_offset: null, +}); + +const resources: Array<{ close(): Promise }> = []; + +afterEach(async () => { + await Promise.all( + resources.splice(0).map(async (resource) => resource.close()), + ); +}); + +const connect = async (analysis: AnalysisOperationPort = fixturePort()) => { + const server = createServer(analysis); + const client = new Client({ name: "enhanced-test", version: "1.0.0" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(client, server); + await server.connect(serverTransport); + await client.connect(clientTransport); + return client; +}; + +const jsonResult = (result: CallToolResult): JsonValue => { + if (result.structuredContent === undefined) + throw new Error("Tool result omitted structured content"); + const structured = jsonValueSchema.safeParse(result.structuredContent); + if (!structured.success) + throw new Error("Tool structured result was not JSON"); + if ( + typeof structured.data === "object" && + structured.data !== null && + !Array.isArray(structured.data) && + "normalized_result" in structured.data + ) { + return structured.data.normalized_result ?? null; + } + if ( + typeof structured.data === "object" && + structured.data !== null && + !Array.isArray(structured.data) && + "evidence_id" in structured.data && + "result" in structured.data + ) + return structured.data.result ?? null; + const text = result.content.find((item) => item.type === "text"); + if (text?.type !== "text") + throw new Error("Tool result omitted text content"); + const decoded: unknown = JSON.parse(text.text); + const parsed = jsonValueSchema.safeParse(decoded); + if (!parsed.success) throw new Error("Tool result was not JSON"); + return parsed.data; +}; + +describe("enhanced MCP tools", () => { + it("returns typed graph failures and stable unresolved-name results", async () => { + const tools = new EnhancedTools({ + execute: (name) => + name === "procedure_callees" + ? Promise.resolve(err(new AnalysisOutputError(name, "failed"))) + : Promise.resolve(ok(page({}))), + }); + + const graph = await tools.execute("get_call_graph", { + address: "0x1", + direction: "forward", + depth: 1, + }); + const unresolved = await tools.execute("find_xrefs_to_name", { + name: "missing", + }); + + expect(graph).toMatchObject({ + ok: true, + value: { + "0": [ + { + address: "0x1", + status: "error", + error: { + category: "execution_failure", + message: + "Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.", + }, + }, + ], + }, + }); + expect(unresolved).toEqual({ + ok: true, + value: { + status: "unresolved", + name: "missing", + reason: "name_not_found", + }, + }); + }); + + it("follows procedure pagination for whole-binary workflows", async () => { + const offsets: number[] = []; + const client = await connect({ + execute: (_name, arguments_) => { + const offset = arguments_.offset; + offsets.push(typeof offset === "number" ? offset : 0); + return Promise.resolve( + ok({ + items: [ + { + address: offset === 500 ? "0x2" : "0x1", + value: offset === 500 ? "_TtC4Last" : "_TtC5First", + }, + ], + offset: typeof offset === "number" ? offset : 0, + limit: 500, + total: 2, + next_offset: offset === 500 ? null : 500, + has_more: offset !== 500, + }), + ); + }, + }); + const result = jsonResult( + await client.callTool({ name: "swift_classes", arguments: {} }), + ); + expect(offsets).toEqual([0, 500]); + expect(result).toMatchObject({ count: 2 }); + }); + + it("does not start another page after cancellation", async () => { + const controller = new AbortController(); + let calls = 0; + const analysis: AnalysisOperationPort = { + execute: () => { + calls += 1; + controller.abort(); + return Promise.resolve( + ok({ + items: [{ address: "0x1", value: "_TtC5First" }], + offset: 0, + limit: 500, + total: 2, + next_offset: 500, + has_more: true, + }), + ); + }, + }; + + const result = await new EnhancedTools(analysis).execute( + "swift_classes", + {}, + controller.signal, + ); + + expect(calls).toBe(1); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected cancellation"); + expect(result.error._tag).toBe("AnalysisCancelledError"); + }); +}); diff --git a/tests/boundary/mcp/enhancedTools.04-follows-name-pagination-for-exhaustive.test.ts b/tests/boundary/mcp/enhancedTools.04-follows-name-pagination-for-exhaustive.test.ts new file mode 100644 index 000000000..4e679af52 --- /dev/null +++ b/tests/boundary/mcp/enhancedTools.04-follows-name-pagination-for-exhaustive.test.ts @@ -0,0 +1,316 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import type { CallToolResult } from "@modelcontextprotocol/server"; +import { afterEach, describe, expect, it } from "vitest"; + +import type { AnalysisOperationPort } from "../../../src/application/AnalysisProvider.js"; +import { + jsonValueSchema, + type JsonValue, +} from "../../../src/domain/jsonValue.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { observed as ok } from "../../fixtures/analysisExecution.js"; + +const PROCEDURES = { + "0x1": "_TtC7Fixture5Class", + "0x2": "_TtV7Fixture6Struct", + "0x3": "_TtP7Fixture8Protocol", + "0x4": "_TtO7Fixture4Enum", + "0x5": "_TtE7Fixture9Extension", + "0x6": "prefix_TtOther", +}; + +const page = (values: Readonly>) => ({ + items: Object.entries(values).map(([address, value]) => ({ address, value })), + offset: 0, + limit: 100, + total: Object.keys(values).length, + next_offset: null, + has_more: false, +}); + +const fixturePort = (): AnalysisOperationPort => ({ + execute: (name, arguments_) => { + switch (name) { + case "list_procedures": + return Promise.resolve(ok(page(PROCEDURES))); + case "list_names": + return Promise.resolve( + ok( + page({ + "0x10": "_OBJC_CLASS_$_Fixture", + "0x11": "_OBJC_CLASS_$_Fixture", + "0x12": "_OBJC_PROTOCOL_$_FixtureDelegate", + "0x13": "entry", + }), + ), + ); + case "procedure_pseudo_code": { + const procedure = arguments_.procedure; + return Promise.resolve( + ok(typeof procedure === "string" ? `pseudo:${procedure}` : "invalid"), + ); + } + case "procedure_callees": { + const procedure = arguments_.procedure; + return Promise.resolve( + ok( + procedure === "0x1" + ? ["0x2", "0x3"] + : procedure === "0x2" + ? ["0x1"] + : [], + ), + ); + } + case "procedure_callers": + return Promise.resolve(ok(["0x9"])); + case "address_name": + return Promise.resolve(ok(arguments_.address ?? null)); + case "xrefs": + return Promise.resolve(ok(["0x20", "0x21"])); + case "resolve_containing_procedure": + return Promise.resolve( + ok({ + query_address: + typeof arguments_.address === "string" + ? arguments_.address + : "0x0", + found: false, + procedure: null, + reason: "not_in_procedure", + }), + ); + case "list_segments": + return Promise.resolve( + ok([{ name: "__TEXT", start: "0x1000", end: "0x2000" }]), + ); + case "list_documents": + return Promise.resolve(ok(["fixture"])); + case "list_strings": + return Promise.resolve(ok(page({ "0x30": "hello" }))); + case "analyze_function": + return Promise.resolve( + ok({ + procedure: { + address: "0x1", + name: "entry", + signature: null, + locals: [], + }, + pseudocode: { + text: "return 0;", + total_chars: 9, + returned_chars: 9, + truncated: false, + next_offset: null, + }, + assembly: { + items: [], + total: 0, + returned: 0, + truncated: false, + next_offset: null, + }, + comments: emptyBounded(), + callers: emptyBounded(), + callees: emptyBounded(), + incoming_references: emptyBounded(), + outgoing_references: emptyBounded(), + referenced_strings: emptyBounded(), + referenced_names: emptyBounded(), + basic_blocks: emptyBounded(), + instruction_scan: { scanned: 0, truncated: false }, + }), + ); + default: + return Promise.resolve(ok(null)); + } + }, +}); + +const emptyBounded = () => ({ + items: [], + total: 0, + returned: 0, + truncated: false, + next_offset: null, +}); + +const resources: Array<{ close(): Promise }> = []; + +afterEach(async () => { + await Promise.all( + resources.splice(0).map(async (resource) => resource.close()), + ); +}); + +const connect = async (analysis: AnalysisOperationPort = fixturePort()) => { + const server = createServer(analysis); + const client = new Client({ name: "enhanced-test", version: "1.0.0" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(client, server); + await server.connect(serverTransport); + await client.connect(clientTransport); + return client; +}; + +const jsonResult = (result: CallToolResult): JsonValue => { + if (result.structuredContent === undefined) + throw new Error("Tool result omitted structured content"); + const structured = jsonValueSchema.safeParse(result.structuredContent); + if (!structured.success) + throw new Error("Tool structured result was not JSON"); + if ( + typeof structured.data === "object" && + structured.data !== null && + !Array.isArray(structured.data) && + "normalized_result" in structured.data + ) { + return structured.data.normalized_result ?? null; + } + if ( + typeof structured.data === "object" && + structured.data !== null && + !Array.isArray(structured.data) && + "evidence_id" in structured.data && + "result" in structured.data + ) + return structured.data.result ?? null; + const text = result.content.find((item) => item.type === "text"); + if (text?.type !== "text") + throw new Error("Tool result omitted text content"); + const decoded: unknown = JSON.parse(text.text); + const parsed = jsonValueSchema.safeParse(decoded); + if (!parsed.success) throw new Error("Tool result was not JSON"); + return parsed.data; +}; + +describe("enhanced MCP tools", () => { + it("follows name pagination for exhaustive Objective-C discovery", async () => { + const offsets: number[] = []; + const client = await connect({ + execute: (name, arguments_) => { + expect(name).toBe("list_names"); + const offset = + typeof arguments_.offset === "number" ? arguments_.offset : 0; + offsets.push(offset); + return Promise.resolve( + ok({ + items: [ + { + address: offset === 500 ? "0x2" : "0x1", + value: + offset === 500 ? "_OBJC_CLASS_$_Last" : "_OBJC_CLASS_$_First", + }, + ], + offset, + limit: 500, + total: 2, + next_offset: offset === 500 ? null : 500, + has_more: offset !== 500, + }), + ); + }, + }); + const result = jsonResult( + await client.callTool({ name: "get_objc_classes", arguments: {} }), + ); + expect(offsets).toEqual([0, 500]); + expect(result).toMatchObject({ count: 2 }); + }); + + it("honors overview detail and limit while reporting exhaustive totals", async () => { + const procedureOffsets: number[] = []; + const client = await connect({ + execute: (name, arguments_) => { + switch (name) { + case "list_segments": + return Promise.resolve( + ok([ + { name: "__TEXT", start: "0x1000", end: "0x1800" }, + { name: "__DATA", start: "0x1800", end: "0x2000" }, + ]), + ); + case "list_documents": + return Promise.resolve(ok(["fixture"])); + case "list_strings": + return Promise.resolve( + ok({ + items: [{ address: "0x30", value: "first page only" }], + offset: 0, + limit: 100, + total: 700, + next_offset: 100, + has_more: true, + }), + ); + case "list_procedures": { + const offset = + typeof arguments_.offset === "number" ? arguments_.offset : 0; + procedureOffsets.push(offset); + return Promise.resolve( + ok({ + items: [ + { + address: offset === 500 ? "0x2" : "0x1", + value: offset === 500 ? "last" : "first", + }, + ], + offset, + limit: 500, + total: 2, + next_offset: offset === 500 ? null : 500, + has_more: offset !== 500, + }), + ); + } + default: + return Promise.resolve(ok(null)); + } + }, + }); + const result = jsonResult( + await client.callTool({ + name: "binary_overview", + arguments: { detail: "detailed", limit: 1 }, + }), + ); + expect(procedureOffsets).toEqual([0]); + expect(result).toEqual({ + document: "fixture", + detail: "detailed", + segments: [ + { name: "__TEXT", start: "0x1000", end: "0x1800", length: 2048 }, + ], + segment_count: 2, + procedure_count: 2, + string_count: 700, + }); + }); + + it("rejects non-advancing pagination metadata", async () => { + const client = await connect({ + execute: () => + Promise.resolve( + ok({ + items: [{ address: "0x1", value: "_TtC5First" }], + offset: 0, + limit: 500, + total: 2, + next_offset: 0, + has_more: true, + }), + ), + }); + const result = await client.callTool({ + name: "swift_classes", + arguments: {}, + }); + expect(result.isError).toBe(true); + const text = result.content.find((item) => item.type === "text"); + expect(text?.type === "text" ? text.text : "").toBe( + JSON.stringify(result.structuredContent), + ); + }); +}); diff --git a/tests/boundary/mcp/enhancedTools.05-stops-trace-feature-at-its.test.ts b/tests/boundary/mcp/enhancedTools.05-stops-trace-feature-at-its.test.ts new file mode 100644 index 000000000..09986feba --- /dev/null +++ b/tests/boundary/mcp/enhancedTools.05-stops-trace-feature-at-its.test.ts @@ -0,0 +1,304 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import type { CallToolResult } from "@modelcontextprotocol/server"; +import { afterEach, describe, expect, it } from "vitest"; + +import type { AnalysisOperationPort } from "../../../src/application/AnalysisProvider.js"; +import { + jsonValueSchema, + type JsonValue, +} from "../../../src/domain/jsonValue.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { observed as ok } from "../../fixtures/analysisExecution.js"; + +const PROCEDURES = { + "0x1": "_TtC7Fixture5Class", + "0x2": "_TtV7Fixture6Struct", + "0x3": "_TtP7Fixture8Protocol", + "0x4": "_TtO7Fixture4Enum", + "0x5": "_TtE7Fixture9Extension", + "0x6": "prefix_TtOther", +}; + +const page = (values: Readonly>) => ({ + items: Object.entries(values).map(([address, value]) => ({ address, value })), + offset: 0, + limit: 100, + total: Object.keys(values).length, + next_offset: null, + has_more: false, +}); + +const fixturePort = (): AnalysisOperationPort => ({ + execute: (name, arguments_) => { + switch (name) { + case "list_procedures": + return Promise.resolve(ok(page(PROCEDURES))); + case "list_names": + return Promise.resolve( + ok( + page({ + "0x10": "_OBJC_CLASS_$_Fixture", + "0x11": "_OBJC_CLASS_$_Fixture", + "0x12": "_OBJC_PROTOCOL_$_FixtureDelegate", + "0x13": "entry", + }), + ), + ); + case "procedure_pseudo_code": { + const procedure = arguments_.procedure; + return Promise.resolve( + ok(typeof procedure === "string" ? `pseudo:${procedure}` : "invalid"), + ); + } + case "procedure_callees": { + const procedure = arguments_.procedure; + return Promise.resolve( + ok( + procedure === "0x1" + ? ["0x2", "0x3"] + : procedure === "0x2" + ? ["0x1"] + : [], + ), + ); + } + case "procedure_callers": + return Promise.resolve(ok(["0x9"])); + case "address_name": + return Promise.resolve(ok(arguments_.address ?? null)); + case "xrefs": + return Promise.resolve(ok(["0x20", "0x21"])); + case "resolve_containing_procedure": + return Promise.resolve( + ok({ + query_address: + typeof arguments_.address === "string" + ? arguments_.address + : "0x0", + found: false, + procedure: null, + reason: "not_in_procedure", + }), + ); + case "list_segments": + return Promise.resolve( + ok([{ name: "__TEXT", start: "0x1000", end: "0x2000" }]), + ); + case "list_documents": + return Promise.resolve(ok(["fixture"])); + case "list_strings": + return Promise.resolve(ok(page({ "0x30": "hello" }))); + case "analyze_function": + return Promise.resolve( + ok({ + procedure: { + address: "0x1", + name: "entry", + signature: null, + locals: [], + }, + pseudocode: { + text: "return 0;", + total_chars: 9, + returned_chars: 9, + truncated: false, + next_offset: null, + }, + assembly: { + items: [], + total: 0, + returned: 0, + truncated: false, + next_offset: null, + }, + comments: emptyBounded(), + callers: emptyBounded(), + callees: emptyBounded(), + incoming_references: emptyBounded(), + outgoing_references: emptyBounded(), + referenced_strings: emptyBounded(), + referenced_names: emptyBounded(), + basic_blocks: emptyBounded(), + instruction_scan: { scanned: 0, truncated: false }, + }), + ); + default: + return Promise.resolve(ok(null)); + } + }, +}); + +const emptyBounded = () => ({ + items: [], + total: 0, + returned: 0, + truncated: false, + next_offset: null, +}); + +const resources: Array<{ close(): Promise }> = []; + +afterEach(async () => { + await Promise.all( + resources.splice(0).map(async (resource) => resource.close()), + ); +}); + +const connect = async (analysis: AnalysisOperationPort = fixturePort()) => { + const server = createServer(analysis); + const client = new Client({ name: "enhanced-test", version: "1.0.0" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(client, server); + await server.connect(serverTransport); + await client.connect(clientTransport); + return client; +}; + +const jsonResult = (result: CallToolResult): JsonValue => { + if (result.structuredContent === undefined) + throw new Error("Tool result omitted structured content"); + const structured = jsonValueSchema.safeParse(result.structuredContent); + if (!structured.success) + throw new Error("Tool structured result was not JSON"); + if ( + typeof structured.data === "object" && + structured.data !== null && + !Array.isArray(structured.data) && + "normalized_result" in structured.data + ) { + return structured.data.normalized_result ?? null; + } + if ( + typeof structured.data === "object" && + structured.data !== null && + !Array.isArray(structured.data) && + "evidence_id" in structured.data && + "result" in structured.data + ) + return structured.data.result ?? null; + const text = result.content.find((item) => item.type === "text"); + if (text?.type !== "text") + throw new Error("Tool result omitted text content"); + const decoded: unknown = JSON.parse(text.text); + const parsed = jsonValueSchema.safeParse(decoded); + if (!parsed.success) throw new Error("Tool result was not JSON"); + return parsed.data; +}; + +describe("enhanced MCP tools", () => { + it("stops trace_feature at its explicit operation budget", async () => { + let operations = 0; + const client = await connect({ + execute: () => { + operations += 1; + return Promise.resolve( + ok({ + items: [{ address: "0x1", value: "needle" }], + offset: 0, + limit: 500, + total: 2, + next_offset: 1, + has_more: true, + }), + ); + }, + }); + const result = jsonResult( + await client.callTool({ + name: "trace_feature", + arguments: { query: "needle", max_operations: 1 }, + }), + ); + expect(operations).toBe(1); + expect(result).toMatchObject({ + operations_used: 1, + operation_budget: 1, + truncated: true, + }); + expect(JSON.stringify(result)).toContain("operation budget"); + }); + + it("returns correction-only structured validation issues", async () => { + const client = await connect(); + const valid = await client.callTool({ + name: "trace_feature", + arguments: { query: "needle", max_operations: 1 }, + }); + expect(valid.isError).not.toBe(true); + const misspelled = await client.callTool({ + name: "trace_feature", + arguments: { query: "needle", max_operatons: 1 }, + }); + expect(misspelled).toMatchObject({ + isError: true, + structuredContent: { + error: { + code: "invalid_request", + retryable: true, + details: { + issues: [{ path: ["max_operatons"], reason: "unknown_argument" }], + }, + }, + }, + }); + const nestedMisspelled = await client.callTool({ + name: "analyze_function", + arguments: { + procedure: "0x1", + collection_offset: { commentz: 1 }, + }, + }); + expect(nestedMisspelled).toMatchObject({ + isError: true, + structuredContent: { + error: { + details: { + issues: [ + { + path: ["collection_offset", "commentz"], + reason: "unknown_argument", + }, + ], + }, + }, + }, + }); + const bounded = await client.callTool({ + name: "trace_feature", + arguments: { query: "needle", max_operations: 101 }, + }); + expect(bounded.structuredContent).toMatchObject({ + error: { + details: { + issues: [ + { + path: ["max_operations"], + reason: "out_of_range", + maximum: 100, + }, + ], + }, + }, + }); + expect(bounded.content[0]).toEqual({ + type: "text", + text: JSON.stringify(bounded.structuredContent), + }); + }); + + it("returns a typed tool error for malformed Hopper boundary values", async () => { + const client = await connect({ + execute: () => Promise.resolve(ok(["not", "a", "procedure", "map"])), + }); + const result = await client.callTool({ + name: "swift_classes", + arguments: {}, + }); + expect(result.isError).toBe(true); + const text = result.content.find((item) => item.type === "text"); + expect(text?.type === "text" ? text.text : "").toBe( + JSON.stringify(result.structuredContent), + ); + }); +}); diff --git a/tests/boundary/mcp/enhancedTools.06-rejects-incomplete-function-dossiers-at.test.ts b/tests/boundary/mcp/enhancedTools.06-rejects-incomplete-function-dossiers-at.test.ts new file mode 100644 index 000000000..bdecd0750 --- /dev/null +++ b/tests/boundary/mcp/enhancedTools.06-rejects-incomplete-function-dossiers-at.test.ts @@ -0,0 +1,242 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { afterEach, describe, expect, it } from "vitest"; + +import type { AnalysisOperationPort } from "../../../src/application/AnalysisProvider.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { observed as ok } from "../../fixtures/analysisExecution.js"; + +const PROCEDURES = { + "0x1": "_TtC7Fixture5Class", + "0x2": "_TtV7Fixture6Struct", + "0x3": "_TtP7Fixture8Protocol", + "0x4": "_TtO7Fixture4Enum", + "0x5": "_TtE7Fixture9Extension", + "0x6": "prefix_TtOther", +}; + +const page = (values: Readonly>) => ({ + items: Object.entries(values).map(([address, value]) => ({ address, value })), + offset: 0, + limit: 100, + total: Object.keys(values).length, + next_offset: null, + has_more: false, +}); + +const fixturePort = (): AnalysisOperationPort => ({ + execute: (name, arguments_) => { + switch (name) { + case "list_procedures": + return Promise.resolve(ok(page(PROCEDURES))); + case "list_names": + return Promise.resolve( + ok( + page({ + "0x10": "_OBJC_CLASS_$_Fixture", + "0x11": "_OBJC_CLASS_$_Fixture", + "0x12": "_OBJC_PROTOCOL_$_FixtureDelegate", + "0x13": "entry", + }), + ), + ); + case "procedure_pseudo_code": { + const procedure = arguments_.procedure; + return Promise.resolve( + ok(typeof procedure === "string" ? `pseudo:${procedure}` : "invalid"), + ); + } + case "procedure_callees": { + const procedure = arguments_.procedure; + return Promise.resolve( + ok( + procedure === "0x1" + ? ["0x2", "0x3"] + : procedure === "0x2" + ? ["0x1"] + : [], + ), + ); + } + case "procedure_callers": + return Promise.resolve(ok(["0x9"])); + case "address_name": + return Promise.resolve(ok(arguments_.address ?? null)); + case "xrefs": + return Promise.resolve(ok(["0x20", "0x21"])); + case "resolve_containing_procedure": + return Promise.resolve( + ok({ + query_address: + typeof arguments_.address === "string" + ? arguments_.address + : "0x0", + found: false, + procedure: null, + reason: "not_in_procedure", + }), + ); + case "list_segments": + return Promise.resolve( + ok([{ name: "__TEXT", start: "0x1000", end: "0x2000" }]), + ); + case "list_documents": + return Promise.resolve(ok(["fixture"])); + case "list_strings": + return Promise.resolve(ok(page({ "0x30": "hello" }))); + case "analyze_function": + return Promise.resolve( + ok({ + procedure: { + address: "0x1", + name: "entry", + signature: null, + locals: [], + }, + pseudocode: { + text: "return 0;", + total_chars: 9, + returned_chars: 9, + truncated: false, + next_offset: null, + }, + assembly: { + items: [], + total: 0, + returned: 0, + truncated: false, + next_offset: null, + }, + comments: emptyBounded(), + callers: emptyBounded(), + callees: emptyBounded(), + incoming_references: emptyBounded(), + outgoing_references: emptyBounded(), + referenced_strings: emptyBounded(), + referenced_names: emptyBounded(), + basic_blocks: emptyBounded(), + instruction_scan: { scanned: 0, truncated: false }, + }), + ); + default: + return Promise.resolve(ok(null)); + } + }, +}); + +const emptyBounded = () => ({ + items: [], + total: 0, + returned: 0, + truncated: false, + next_offset: null, +}); + +const resources: Array<{ close(): Promise }> = []; + +afterEach(async () => { + await Promise.all( + resources.splice(0).map(async (resource) => resource.close()), + ); +}); + +const connect = async (analysis: AnalysisOperationPort = fixturePort()) => { + const server = createServer(analysis); + const client = new Client({ name: "enhanced-test", version: "1.0.0" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(client, server); + await server.connect(serverTransport); + await client.connect(clientTransport); + return client; +}; + +describe("enhanced MCP tools", () => { + it("rejects incomplete function dossiers at the application boundary", async () => { + const client = await connect({ + execute: () => + Promise.resolve( + ok({ + procedure: { address: "0x1", name: "entry" }, + pseudocode: { text: "plausible but incomplete" }, + }), + ), + }); + const result = await client.callTool({ + name: "analyze_function", + arguments: { procedure: "0x1" }, + }); + expect(result.isError).toBe(true); + const text = result.content.find((item) => item.type === "text"); + expect(text?.type === "text" ? text.text : "").toBe( + JSON.stringify(result.structuredContent), + ); + }); + + it("rejects deceptive function dossier collection metadata", async () => { + const malformedPort = fixturePort(); + const client = await connect({ + execute: async (name, arguments_, options) => { + const result = await malformedPort.execute(name, arguments_, options); + if (!result.ok || name !== "analyze_function") return result; + const dossier = result.value.result; + if ( + typeof dossier !== "object" || + dossier === null || + Array.isArray(dossier) + ) + return result; + return ok({ + ...dossier, + comments: { + items: [], + total: 0, + returned: 1, + truncated: false, + next_offset: null, + }, + }); + }, + }); + const result = await client.callTool({ + name: "analyze_function", + arguments: { procedure: "0x1" }, + }); + expect(result.isError).toBe(true); + const text = result.content.find((item) => item.type === "text"); + expect(text?.type === "text" ? text.text : "").toBe( + JSON.stringify(result.structuredContent), + ); + }); + + it("accepts a final dossier page whose total exceeds returned", async () => { + const malformedPort = fixturePort(); + const client = await connect({ + execute: async (name, arguments_, options) => { + const result = await malformedPort.execute(name, arguments_, options); + if (!result.ok || name !== "analyze_function") return result; + const dossier = result.value.result; + if ( + typeof dossier !== "object" || + dossier === null || + Array.isArray(dossier) + ) + return result; + return ok({ + ...dossier, + callers: { + items: [], + total: 1, + returned: 0, + truncated: false, + next_offset: null, + }, + }); + }, + }); + const result = await client.callTool({ + name: "analyze_function", + arguments: { procedure: "0x1" }, + }); + expect(result.isError).not.toBe(true); + }); +}); diff --git a/tests/boundary/mcp/evidenceResources.test.ts b/tests/boundary/mcp/evidenceResources.test.ts new file mode 100644 index 000000000..3d076b873 --- /dev/null +++ b/tests/boundary/mcp/evidenceResources.test.ts @@ -0,0 +1,269 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js"; +import type { BinarySession } from "../../../src/application/BinarySession.js"; +import { createEvidence } from "../../../src/domain/evidence.js"; +import { observed } from "../../fixtures/analysisExecution.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; +import { createInvestigationWorkspace } from "../../../src/domain/investigationWorkspace.js"; + +const provider = { id: "fixture", name: "Fixture", version: "1" }; + +interface ResourceFixture { + readonly evidence: ReturnType; + readonly capture: ReturnType; + readonly workspace: ReturnType; + readonly unknown: ReturnType; +} + +function seedResourceFixture(session: BinarySession): ResourceFixture { + const evidence = createEvidence(undefined, provider, { + operation: "resource_probe", + parameters: {}, + result: { observed: true }, + }); + session.recordEvidence(evidence); + const capture = createEvidence(undefined, provider, { + operation: "capture_process_scenario", + parameters: {}, + result: { + frames: [{ sequence: 0, at_ms: 0, data: "ready" }], + protocol_events: [], + replay_transitions: [], + }, + }); + session.recordEvidence(capture); + const workspace = createInvestigationWorkspace( + "resource test", + createEvidenceBundle([]), + [], + ); + session.retainInvestigationWorkspace(workspace); + const unknown = session.recordUnknown({ + approved: true, + question: "Was every branch observed?", + severity: "medium", + domain: "control-flow", + supporting_evidence_ids: [evidence.evidence_id], + contradicting_evidence_ids: [], + required_authority: "controlled-replay", + required_confidence: "observed", + required_environment: null, + recommended_probes: [], + relationships: [], + }); + expect(unknown.ok).toBe(true); + return { evidence, capture, workspace, unknown }; +} + +async function verifyResourceProtocol( + client: Client, + fixture: ResourceFixture, +): Promise { + const { evidence, capture, workspace, unknown } = fixture; + const templates = await client.listResourceTemplates(); + expect(templates.resourceTemplates).toContainEqual( + expect.objectContaining({ uriTemplate: "rea://evidence/{evidenceId}" }), + ); + expect( + templates.resourceTemplates.map(({ uriTemplate }) => uriTemplate), + ).toEqual( + expect.arrayContaining([ + "rea://artifact/{manifestId}/{collection}", + "rea://function/{targetSha256}/{address}", + "rea://evidence-bundle/{bundleDigest}", + "rea://workspace/{workspaceId}/revision/{revision}", + ]), + ); + expect(templates.resourceTemplates).toContainEqual( + expect.objectContaining({ + uriTemplate: "rea://evidence/{evidenceId}/section/{section}", + }), + ); + const listed = await client.listResources(); + expect(listed.resources).toContainEqual( + expect.objectContaining({ uri: "rea://snapshot/current" }), + ); + expect(listed.resources).not.toContainEqual( + expect.objectContaining({ + uri: `rea://evidence/${evidence.evidence_id}`, + }), + ); + const read = await client.readResource({ + uri: `rea://evidence/${evidence.evidence_id}`, + }); + expect(read.contents).toEqual([ + expect.objectContaining({ + uri: `rea://evidence/${evidence.evidence_id}`, + mimeType: "application/json", + text: expect.stringContaining(evidence.evidence_id), + }), + ]); + const firstSnapshot = await client.callTool({ + name: "snapshot_evidence_bundle", + arguments: {}, + }); + const secondSnapshot = await client.callTool({ + name: "snapshot_evidence_bundle", + arguments: {}, + }); + expect(secondSnapshot.structuredContent).toEqual( + firstSnapshot.structuredContent, + ); + expect(firstSnapshot.content).toContainEqual( + expect.objectContaining({ + type: "text", + text: expect.stringContaining("Copy the opaque URI exactly"), + }), + ); + const bundleUri = z + .object({ result: z.object({ bundle_uri: z.string() }) }) + .parse(firstSnapshot.structuredContent).result.bundle_uri; + const retainedBundle = await client.readResource({ uri: bundleUri }); + expect(retainedBundle.contents[0]).toEqual( + expect.objectContaining({ + uri: bundleUri, + text: expect.stringContaining(evidence.evidence_id), + }), + ); + const terminal = await client.readResource({ + uri: `rea://evidence/${capture.evidence_id}/section/terminal`, + }); + const workspaceResource = await client.readResource({ + uri: `rea://workspace/${workspace.workspace_id}/revision/${String(workspace.revision)}`, + }); + expect(workspaceResource.contents[0]).toEqual( + expect.objectContaining({ + text: expect.stringContaining(workspace.revision_digest), + }), + ); + expect(terminal.contents[0]).toEqual( + expect.objectContaining({ text: expect.stringContaining("ready") }), + ); + if (unknown.ok) { + const unknownResource = await client.readResource({ + uri: `rea://unknown/${unknown.value.unknown_id}`, + }); + expect(unknownResource.contents[0]).toEqual( + expect.objectContaining({ + text: expect.stringContaining(unknown.value.revision_digest), + }), + ); + } + await expect( + client.readResource({ uri: `rea://evidence/ev_${"0".repeat(64)}` }), + ).rejects.toThrow(/not found/iu); +} + +describe("evidence MCP resources", () => { + it("publishes mutable snapshot changes for every Evidence mutation", () => { + const session = composeBinarySessionFromFactory(() => ({ + health: () => Promise.resolve(), + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(), + })); + let updates = 0; + session.onAnalysisSnapshotChanged(() => { + updates += 1; + }); + const first = createEvidence(undefined, provider, { + operation: "notification_probe", + parameters: {}, + result: 1, + }); + expect(session.recordEvidence(first)).toEqual({ ok: true, value: "added" }); + expect(session.recordEvidence(first)).toEqual({ + ok: true, + value: "duplicate", + }); + const imported = createEvidence(undefined, provider, { + operation: "import_notification_probe", + parameters: {}, + result: 2, + }); + expect( + session.importEvidenceBundle(createEvidenceBundle([imported])), + ).toEqual({ ok: true, value: 1 }); + expect( + session.recordUnknown({ + approved: true, + question: "Does mutation publish a snapshot update?", + severity: "low", + domain: "notifications", + supporting_evidence_ids: [first.evidence_id], + contradicting_evidence_ids: [], + required_authority: "controlled-replay", + required_confidence: "observed", + required_environment: null, + recommended_probes: [], + relationships: [], + }).ok, + ).toBe(true); + expect(updates).toBe(3); + }); + + it("retains bundles without eviction and rejects a seventeenth digest", () => { + const session = composeBinarySessionFromFactory(() => ({ + health: () => Promise.resolve(), + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(), + })); + const first = session.snapshotEvidenceBundle(); + expect(first.ok).toBe(true); + for (let index = 0; index < 15; index += 1) { + session.recordEvidence( + createEvidence(undefined, provider, { + operation: `retention_probe_${String(index)}`, + parameters: {}, + result: index, + }), + ); + expect(session.snapshotEvidenceBundle().ok).toBe(true); + } + session.recordEvidence( + createEvidence(undefined, provider, { + operation: "retention_probe_overflow", + parameters: {}, + result: true, + }), + ); + expect(session.snapshotEvidenceBundle()).toMatchObject({ + ok: false, + error: { _tag: "EvidenceLimitError", limit: "records", maximum: 16 }, + }); + if (first.ok) + expect(session.retainedEvidenceBundle(first.value.bundleDigest)).toEqual( + expect.any(String), + ); + }); + + it("lists and reads only evidence owned by the current session", async () => { + const session = composeBinarySessionFromFactory(() => ({ + health: () => Promise.resolve(), + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(), + })); + const fixture = seedResourceFixture(session); + const server = createServer(session, session); + const client = new Client({ + name: "evidence-resource-test", + version: "1", + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + await verifyResourceProtocol(client, fixture); + } finally { + await Promise.allSettled([ + client.close(), + server.close(), + session.close(), + ]); + } + }, 10_000); +}); diff --git a/tests/functionComparisonMcp.test.ts b/tests/boundary/mcp/functionComparisonMcp.test.ts similarity index 89% rename from tests/functionComparisonMcp.test.ts rename to tests/boundary/mcp/functionComparisonMcp.test.ts index 7fc5d7753..f4d4fcabd 100644 --- a/tests/functionComparisonMcp.test.ts +++ b/tests/boundary/mcp/functionComparisonMcp.test.ts @@ -1,15 +1,15 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { describe, expect, it } from "vitest"; -import { BinarySession } from "../src/application/BinarySession.js"; -import { FUNCTION_COMPARISON_EXAMPLE } from "../src/contracts/functionComparisonExample.js"; -import { createEvidence } from "../src/domain/evidence.js"; -import { createServer } from "../src/server/createServer.js"; -import { observed } from "./fixtures/analysisExecution.js"; +import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js"; +import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js"; +import { createEvidence } from "../../../src/domain/evidence.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { observed } from "../../fixtures/analysisExecution.js"; describe("function comparison MCP integration", () => { it("records linked comparison Evidence and an approved residual unknown", async () => { - const session = new BinarySession(() => ({ + const session = composeBinarySessionFromFactory(() => ({ health: () => Promise.resolve(), execute: () => Promise.resolve(observed(null)), close: () => Promise.resolve(), @@ -75,7 +75,7 @@ describe("function comparison MCP integration", () => { }); it("rejects dangling Evidence links before recording output", async () => { - const session = new BinarySession(() => ({ + const session = composeBinarySessionFromFactory(() => ({ health: () => Promise.resolve(), execute: () => Promise.resolve(observed(null)), close: () => Promise.resolve(), @@ -106,7 +106,7 @@ describe("function comparison MCP integration", () => { }); it("rejects Evidence IDs with the wrong operation", async () => { - const session = new BinarySession(() => ({ + const session = composeBinarySessionFromFactory(() => ({ health: () => Promise.resolve(), execute: () => Promise.resolve(observed(null)), close: () => Promise.resolve(), diff --git a/tests/boundary/mcp/ghidraEvidenceMcp.test.ts b/tests/boundary/mcp/ghidraEvidenceMcp.test.ts new file mode 100644 index 000000000..786441482 --- /dev/null +++ b/tests/boundary/mcp/ghidraEvidenceMcp.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; +import { EnhancedTools } from "../../../src/application/EnhancedTools.js"; +import { connectGhidraMcp, sessionEvidence } from "./ghidraMcpHarness.js"; + +describe("Ghidra MCP evidence parity", () => { + it("preserves provider evidence, composed parity, and capability routing", async () => { + const harness = await connectGhidraMcp("ghidra-parity"); + const { mcp, session } = harness; + try { + const listed = sessionEvidence( + session, + ( + await mcp.callTool({ + name: "list_procedures", + arguments: {}, + }) + ).structuredContent, + ); + expect(listed).toMatchObject({ + operation: "list_procedures", + provider: { id: "ghidra", name: "Ghidra", version: "12.1.2" }, + analysis_profile: { + provider: { id: "ghidra", version: "12.1.2" }, + parameters: { + import_mode: "ephemeral-read-only", + analyzer_preset: "ghidra-default", + }, + }, + normalized_result: { + items: [ + { + address: "0x401000", + value: "fixture_main", + procedure: { + external: false, + thunk: false, + thunk_target: null, + }, + }, + ], + total: 1, + }, + raw_result: { + items: [{ value_truncated: false }], + }, + }); + + const mcpOverview = sessionEvidence( + session, + ( + await mcp.callTool({ + name: "binary_overview", + arguments: { detail: "detailed", limit: 5 }, + }) + ).structuredContent, + ); + const directOverview = await new EnhancedTools(session).execute( + "binary_overview", + { detail: "detailed", limit: 5 }, + ); + expect(directOverview.ok).toBe(true); + if (!directOverview.ok) return; + expect(mcpOverview.normalized_result).toEqual(directOverview.value); + expect(mcpOverview).toMatchObject({ + provider: { id: "rea-workflow" }, + confidence: "derived", + normalized_result: { + document: "fixture", + segment_count: 1, + procedure_count: 1, + string_count: 2, + segments: [{ name: ".text", length: 256 }], + }, + }); + + const pseudocode = sessionEvidence( + session, + ( + await mcp.callTool({ + name: "procedure_pseudo_code", + arguments: { procedure: "fixture_main" }, + }) + ).structuredContent, + ); + expect(pseudocode).toMatchObject({ + operation: "procedure_pseudo_code", + provider: { id: "ghidra", version: "12.1.2" }, + normalized_result: expect.stringContaining("return 42"), + limitations: expect.arrayContaining([ + expect.stringContaining("not text-equivalent to Hopper"), + expect.stringContaining("30-second native deadline"), + ]), + }); + + const analyzed = sessionEvidence( + session, + ( + await mcp.callTool({ + name: "analyze_function", + arguments: { procedure: "fixture_main", include_assembly: true }, + }) + ).structuredContent, + ); + expect(analyzed).toMatchObject({ + operation: "analyze_function", + provider: { id: "rea-workflow", version: "1" }, + normalized_result: { + procedure: { + address: "0x401000", + name: "fixture_main", + classification: { + external: false, + thunk: false, + provenance: "ghidra-function-manager", + }, + }, + pseudocode: { truncated: false }, + outgoing_references: { + items: [ + { + kind: { + available: true, + provenance: "ghidra-reference-manager", + }, + }, + ], + }, + limitations: expect.arrayContaining([ + expect.stringContaining("indirect flows without target addresses"), + expect.stringContaining( + "not original source or Hopper-equivalent text", + ), + ]), + }, + limitations: [ + "Derived by an REA workflow from one or more provider observations.", + ], + }); + const directAnalyzed = await new EnhancedTools(session).execute( + "analyze_function", + { procedure: "fixture_main", include_assembly: true }, + ); + expect(directAnalyzed.ok).toBe(true); + if (!directAnalyzed.ok) return; + expect(analyzed.normalized_result).toEqual(directAnalyzed.value); + } finally { + await harness.close(); + } + }, 30_000); +}); diff --git a/tests/boundary/mcp/ghidraMcpHarness.ts b/tests/boundary/mcp/ghidraMcpHarness.ts new file mode 100644 index 000000000..6a718cafc --- /dev/null +++ b/tests/boundary/mcp/ghidraMcpHarness.ts @@ -0,0 +1,299 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { composeBinarySession } from "../../../src/application/BinarySessionComposition.js"; +import type { BinarySession } from "../../../src/application/BinarySession.js"; +import { AnalysisProviderRegistry } from "../../../src/application/AnalysisProviderRegistry.js"; +import { SessionProviderRouter } from "../../../src/application/SessionProviderRouter.js"; +import { parseConfig } from "../../../src/config.js"; +import type { JsonValue } from "../../../src/domain/jsonValue.js"; +import { ok } from "../../../src/domain/result.js"; +import type { GhidraInstallationHost } from "../../../src/ghidra/GhidraInstallation.js"; +import type { GhidraOperation } from "../../../src/ghidra/GhidraClient.js"; +import { + GhidraProvider, + type GhidraProviderClientFactory, +} from "../../../src/ghidra/GhidraProvider.js"; +import { GHIDRA_SESSION_CAPABILITIES } from "../../../src/ghidra/GhidraSessionValues.js"; +import { silentLogger } from "../../../src/logger.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { + ghidraBounded, + ghidraFunctionClassification, + ghidraFunctionDossier, + ghidraFunctionIdentity, + ghidraReferenceEdge, +} from "../../fixtures/ghidraFunction.js"; + +const INSTALL = "/opt/ghidra_12.1.2_PUBLIC"; + +export const connectGhidraMcp = async (name: string) => { + const calls: GhidraOperation[] = []; + const factory: GhidraProviderClientFactory = (options) => ({ + start: () => + Promise.resolve( + ok(sessionInfo(options.profileDigest, options.targetSha256)), + ), + callTool: (operation, input) => { + calls.push(operation); + return Promise.resolve(ok(resultFor(operation, input))); + }, + close: () => Promise.resolve(), + }); + const config = parseConfig({ GHIDRA_INSTALL_DIR: INSTALL }); + if (!config.ok) throw config.error; + const provider = new GhidraProvider( + config.value, + silentLogger, + installationHost(), + factory, + ); + const session = composeBinarySession( + SessionProviderRouter.selectable( + new AnalysisProviderRegistry([provider]), + [], + ), + ); + const server = createServer(session, session, { logger: silentLogger }); + const mcp = new Client({ name, version: "1.0.0" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const close = async () => { + await Promise.allSettled([mcp.close(), server.close()]); + await session.close(); + }; + try { + await server.connect(serverTransport); + await mcp.connect(clientTransport); + const opened = await mcp.callTool({ + name: "open_binary", + arguments: { path: process.execPath }, + }); + if (opened.isError === true) + throw new Error("The Ghidra MCP harness could not open its target"); + } catch (cause: unknown) { + await close(); + throw cause; + } + return { + calls, + mcp, + session, + close, + }; +}; + +export const sessionEvidence = (session: BinarySession, value: unknown) => { + if ( + typeof value !== "object" || + value === null || + !("evidence_id" in value) || + typeof value.evidence_id !== "string" + ) + throw new TypeError( + `Missing compact Evidence ID: ${JSON.stringify(value)}`, + ); + const evidence = session.evidenceById(value.evidence_id); + if (evidence === undefined) throw new TypeError("Missing session Evidence"); + return evidence; +}; + +const installationHost = (): GhidraInstallationHost => ({ + platform: "linux", + architecture: "x64", + readText: () => "application.version=12.1.2\n", + executable: () => true, + probeJava: () => ({ + version: "21.0.11", + major: 21, + home: "/usr/lib/jvm/jdk-21", + bits: 64, + jdk: true, + }), +}); + +const sessionInfo = (profileDigest: string, targetSha256: string) => ({ + name: "REA Ghidra bridge" as const, + bridge_version: 6 as const, + run_id: "11111111-1111-4111-8111-111111111111", + profile_digest: profileDigest, + provider: { id: "ghidra" as const, version: "12.1.2" }, + read_only: true as const, + analysis_complete: true, + analysis_timed_out: false, + capabilities: [...GHIDRA_SESSION_CAPABILITIES], + target: { + name: "fixture", + language_id: "x86:LE:64:default", + compiler_spec_id: "gcc", + image_base: "0x400000", + default_address_space: "ram", + sha256: targetSha256, + }, +}); + +type GhidraResultBuilder = ( + input: Readonly>, + limit: number, +) => JsonValue; + +const resultBuilders = new Map([ + ["list_documents", () => ["fixture"]], + [ + "list_procedures", + (_input, limit) => + page( + [ + { + address: "0x401000", + value: "fixture_main", + value_truncated: false, + procedure: { + external: false, + thunk: false, + thunk_target: null, + }, + }, + ], + limit, + ), + ], + [ + "list_strings", + (_input, limit) => + page( + [ + stringItem("0x402000", "inventory fixture"), + stringItem("0x402020", "external fixture"), + ], + limit, + ), + ], + [ + "list_segments", + () => [ + { + name: ".text", + start: "0x401000", + end: "0x401100", + readable: true, + writable: false, + executable: true, + permissions: { available: true, source: "ghidra-memory-block" }, + provenance: "ghidra-memory-block", + address_space: "ram", + image_base: "0x400000", + initialized: true, + overlay: false, + sections: [], + }, + ], + ], + [ + "list_names", + (_input, limit) => + page( + [ + { + address: "0x401000", + value: "fixture_main", + value_truncated: false, + symbol: { + primary: true, + dynamic: false, + external: false, + type: "function", + source: "user_defined", + }, + }, + ], + limit, + ), + ], + ["search_procedures", (_input, limit) => page([], limit)], + ["search_strings", (_input, limit) => page([], limit)], + ["address_name", () => "fixture_main"], + ["procedure_address", () => "0x401000"], + [ + "resolve_containing_procedure", + () => ({ + query_address: "0x401001", + found: true, + procedure: ghidraFunctionIdentity(), + }), + ], + ["procedure_assembly", () => "0x401000: CALL 0x401020\n0x401005: RET"], + ["procedure_callees", () => []], + ["procedure_callers", () => []], + [ + "procedure_info", + () => ({ + name: "fixture_main", + entrypoint: "0x401000", + basicblock_count: 1, + length: 6, + signature: "int fixture_main(void)", + locals: [], + classification: ghidraFunctionClassification(), + }), + ], + ["procedure_pseudo_code", () => "int fixture_main(void) { return 42; }"], + [ + "read_function_instructions", + () => ({ + procedure: ghidraFunctionIdentity(), + instructions: ghidraBounded(["0x401000: CALL 0x401020", "0x401005: RET"]), + instructions_scanned: 2, + instruction_scan_truncated: false, + limitations: ["Ghidra-specific instruction text."], + }), + ], + [ + "procedure_references", + (input, _limit) => ({ + procedure: ghidraFunctionIdentity(), + direction: input.direction ?? "outgoing", + references: ghidraBounded([ghidraReferenceEdge()]), + instructions_scanned: 2, + instruction_scan_truncated: false, + }), + ], + ["xrefs", () => ["0x401001"]], + [ + "analyze_function", + (input, _limit) => + ghidraFunctionDossier( + input.include_assembly === true, + input.procedure === "fixture_truncated", + ), + ], +]); + +const resultFor = ( + operation: GhidraOperation, + input: Readonly>, +): JsonValue => { + const limit = typeof input.limit === "number" ? input.limit : 100; + const builder = resultBuilders.get(operation); + if (builder === undefined) + throw new TypeError(`Unexpected Ghidra operation: ${operation}`); + return builder(input, limit); +}; + +const page = (items: readonly JsonValue[], limit: number): JsonValue => ({ + items: items.slice(0, limit), + offset: 0, + limit, + total: items.length, + next_offset: items.length > limit ? limit : null, + has_more: items.length > limit, +}); + +const stringItem = (address: string, value: string): JsonValue => ({ + address, + value, + value_truncated: false, + string: { + encoding: "UTF-8", + termination: "present_or_not_required", + byte_length: value.length + 1, + }, +}); diff --git a/tests/boundary/mcp/ghidraNativeApiMcp.test.ts b/tests/boundary/mcp/ghidraNativeApiMcp.test.ts new file mode 100644 index 000000000..524b3f105 --- /dev/null +++ b/tests/boundary/mcp/ghidraNativeApiMcp.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { connectGhidraMcp, sessionEvidence } from "./ghidraMcpHarness.js"; + +describe("Ghidra MCP native API evidence", () => { + it("records approved native API truncation once and links its evidence", async () => { + const harness = await connectGhidraMcp("ghidra-native-api"); + const { mcp, session } = harness; + try { + const unapprovedInspection = sessionEvidence( + session, + ( + await mcp.callTool({ + name: "inspect_native_api", + arguments: { procedure: "fixture_truncated" }, + }) + ).structuredContent, + ); + expect(unapprovedInspection.normalized_result).toMatchObject({ + boundary: { + available: true, + jump_tables: [{ mappings_truncated: true }], + }, + residual_unknowns: [ + expect.stringContaining("additional data sources or targets"), + ], + }); + expect( + ( + await mcp.callTool({ + name: "list_unknowns", + arguments: {}, + }) + ).structuredContent, + ).toMatchObject({ result: { items: [] } }); + + const approvedInspection = sessionEvidence( + session, + ( + await mcp.callTool({ + name: "inspect_native_api", + arguments: { + procedure: "fixture_truncated", + unknown_registry_approved: true, + }, + }) + ).structuredContent, + ); + await mcp.callTool({ + name: "inspect_native_api", + arguments: { + procedure: "fixture_truncated", + unknown_registry_approved: true, + }, + }); + expect( + ( + await mcp.callTool({ + name: "list_unknowns", + arguments: {}, + }) + ).structuredContent, + ).toMatchObject({ + result: { + items: [ + { + unknown: { + status: "open", + domain: "native-api", + question: expect.stringContaining( + "additional data sources or targets", + ), + supporting_evidence_ids: [approvedInspection.evidence_id], + recommended_probes: [ + { + operation: "inspect_native_api", + rationale: expect.stringContaining("ABI probe"), + }, + ], + }, + }, + ], + }, + }); + } finally { + await harness.close(); + } + }, 30_000); +}); diff --git a/tests/boundary/mcp/ghidraRoutingMcp.test.ts b/tests/boundary/mcp/ghidraRoutingMcp.test.ts new file mode 100644 index 000000000..a71bda29c --- /dev/null +++ b/tests/boundary/mcp/ghidraRoutingMcp.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { connectGhidraMcp } from "./ghidraMcpHarness.js"; + +describe("Ghidra MCP capability routing", () => { + it("routes composed and capability tools", async () => { + const harness = await connectGhidraMcp("ghidra-routing"); + const { calls, mcp } = harness; + try { + const batch = await mcp.callTool({ + name: "batch_decompile", + arguments: { addresses: ["fixture_main"] }, + }); + expect(batch.isError).not.toBe(true); + expect(batch.structuredContent).toMatchObject({ + result: { + succeeded: 1, + failed: 0, + items: [{ status: "ok" }], + }, + }); + + const availableNames = (await mcp.listTools()).tools.map( + ({ name }) => name, + ); + expect(availableNames).not.toContain("set_comment"); + expect(calls).not.toContain("set_comment"); + + const status = await mcp.callTool({ + name: "binary_session", + arguments: { detail: "full" }, + }); + expect(status.structuredContent).toMatchObject({ + result: { + open: true, + analysis_provider_binding: { + provider: { id: "ghidra", version: "12.1.2" }, + }, + tool_availability: expect.arrayContaining([ + expect.objectContaining({ + name: "binary_overview", + available: true, + }), + expect.objectContaining({ name: "swift_classes", available: true }), + expect.objectContaining({ + name: "get_objc_classes", + available: true, + }), + expect.objectContaining({ + name: "batch_decompile", + available: true, + }), + expect.objectContaining({ + name: "trace_feature", + available: true, + }), + expect.objectContaining({ + name: "get_call_graph", + available: true, + }), + expect.objectContaining({ + name: "find_xrefs_to_name", + available: true, + }), + expect.objectContaining({ + name: "set_comment", + available: false, + }), + ]), + }, + }); + } finally { + await harness.close(); + } + }, 30_000); +}); diff --git a/tests/boundary/mcp/investigationAnalysisMcp.test.ts b/tests/boundary/mcp/investigationAnalysisMcp.test.ts new file mode 100644 index 000000000..b1f266e5a --- /dev/null +++ b/tests/boundary/mcp/investigationAnalysisMcp.test.ts @@ -0,0 +1,348 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { expect, it } from "vitest"; +import { z } from "zod"; + +import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js"; +import type { BinarySession } from "../../../src/application/BinarySession.js"; +import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js"; +import { INVESTIGATION_EXAMPLES } from "../../../src/contracts/investigationExamples.js"; +import { + FUNCTION_COMPARISON_EVIDENCE, + PROCESS_CAPTURE_RECONSTRUCTION, + PROCESS_CAPTURE_REFERENCE, + PROCESS_COMPARISON_EVIDENCE, +} from "../../../src/contracts/investigationExamples.js"; +import { createEvidence } from "../../../src/domain/evidence.js"; +import { + jsonObjectSchema, + jsonValueSchema, +} from "../../../src/domain/jsonValue.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { observed } from "../../fixtures/analysisExecution.js"; +import type { EvidenceFilePolicy } from "../../../src/domain/evidenceBundle.js"; +import type { PermissionAuthority } from "../../../src/application/PermissionAuthority.js"; + +it("aggregates comparison Evidence and records an approved runtime gap", async () => { + const { session, server, client } = await connected(); + const comparison = + INVESTIGATION_EXAMPLES.find_changed_behavior.comparisons[0]; + if (comparison === undefined) throw new Error("missing comparison fixture"); + for (const evidence of [ + FUNCTION_COMPARISON_EXAMPLE.left, + FUNCTION_COMPARISON_EXAMPLE.right, + comparison, + ]) + expect(session.recordEvidence(evidence).ok).toBe(true); + try { + const response = await client.callTool({ + name: "find_changed_behavior", + arguments: { + comparisons: [comparison], + unknown_registry_approved: true, + }, + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + const evidence = sessionEvidence(session, response.structuredContent); + expect(evidence).toMatchObject({ + provider: { id: "rea-changed-behavior" }, + normalized_result: { behavior_status: "unknown" }, + }); + expect(session.listUnknowns({ domain: "changed-behavior" })).toMatchObject([ + { + question: + "Did both versions behave the same under a complete controlled replay?", + }, + ]); + } finally { + await close(session, server, client); + } +}, 10_000); + +it("builds a zero-hop exact-address path with dossier citations", async () => { + const { session, server, client } = await connected(); + expect(session.recordEvidence(FUNCTION_COMPARISON_EXAMPLE.left).ok).toBe( + true, + ); + try { + const response = await client.callTool({ + name: "build_call_path", + arguments: INVESTIGATION_EXAMPLES.build_call_path, + }); + expect(response.isError).not.toBe(true); + const evidence = sessionEvidence(session, response.structuredContent); + expect(evidence).toMatchObject({ + provider: { id: "rea-call-path" }, + normalized_result: { + status: "found", + shortest_hops: 0, + paths: { total: 1 }, + }, + evidence_links: [FUNCTION_COMPARISON_EXAMPLE.left.evidence_id], + }); + } finally { + await close(session, server, client); + } +}); + +it("records a safe unresolved call-path question", async () => { + const { session, server, client } = await connected(); + const base = FUNCTION_COMPARISON_EXAMPLE.left; + if (base.subject === null) throw new Error("missing dossier subject"); + const dossier = createEvidence( + { + path: base.subject.local_path, + sha256: base.subject.digest.sha256, + format: base.subject.format, + ...(base.subject.architecture === null + ? {} + : { architecture: base.subject.architecture }), + }, + base.provider, + { + predicateType: base.predicate_type, + operation: base.operation, + parameters: base.parameters, + result: jsonValueSchema.parse({ + ...jsonObjectSchema.parse(base.normalized_result), + callees: { + items: [{ address: "0x2000", name: "next" }], + total: 1, + returned: 1, + truncated: false, + next_offset: null, + }, + }), + rawResult: base.raw_result, + confidence: base.confidence, + authority: base.authority, + limitations: base.limitations, + locations: base.locations, + evidenceLinks: base.evidence_links, + }, + ); + expect(session.recordEvidence(dossier).ok).toBe(true); + try { + const response = await client.callTool({ + name: "build_call_path", + arguments: { + ...INVESTIGATION_EXAMPLES.build_call_path, + functions: [dossier], + start: { address: "0x1000" }, + goal: { address: "0x3000" }, + unknown_registry_approved: true, + }, + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + expect(session.listUnknowns({ domain: "call-path" })).toMatchObject([ + { + question: + "Can the requested call path be established from complete analysis?", + }, + ]); + } finally { + await close(session, server, client); + } +}); + +it("records an explicit non-causal static/runtime hypothesis", async () => { + const { session, server, client } = await connected(); + for (const evidence of [ + FUNCTION_COMPARISON_EXAMPLE.left, + FUNCTION_COMPARISON_EXAMPLE.right, + FUNCTION_COMPARISON_EVIDENCE, + PROCESS_CAPTURE_REFERENCE, + PROCESS_CAPTURE_RECONSTRUCTION, + PROCESS_COMPARISON_EVIDENCE, + ]) + expect(session.recordEvidence(evidence).ok).toBe(true); + try { + const response = await client.callTool({ + name: "correlate_static_and_runtime", + arguments: INVESTIGATION_EXAMPLES.correlate_static_and_runtime, + }); + expect(response.isError).not.toBe(true); + const evidence = sessionEvidence(session, response.structuredContent); + expect(evidence).toMatchObject({ + provider: { id: "rea-static-runtime-correlation" }, + confidence: "inferred", + normalized_result: { + status: "correlated", + summary: { hypotheses: 1 }, + }, + }); + } finally { + await close(session, server, client); + } +}); + +it("records a safe unresolved static/runtime question", async () => { + const { session, server, client } = await connected(); + for (const evidence of [ + FUNCTION_COMPARISON_EXAMPLE.left, + FUNCTION_COMPARISON_EXAMPLE.right, + FUNCTION_COMPARISON_EVIDENCE, + PROCESS_CAPTURE_REFERENCE, + PROCESS_CAPTURE_RECONSTRUCTION, + PROCESS_COMPARISON_EVIDENCE, + ]) + expect(session.recordEvidence(evidence).ok).toBe(true); + const mapping = + INVESTIGATION_EXAMPLES.correlate_static_and_runtime.mappings[0]; + if (mapping === undefined) throw new Error("missing correlation fixture"); + try { + const response = await client.callTool({ + name: "correlate_static_and_runtime", + arguments: { + ...INVESTIGATION_EXAMPLES.correlate_static_and_runtime, + mappings: [ + { + ...mapping, + static: { + ...mapping.static, + selector: { kind: "function", dimension: "assembly" }, + }, + }, + ], + unknown_registry_approved: true, + }, + }); + expect(response.isError).not.toBe(true); + expect( + session.listUnknowns({ domain: "static-runtime-correlation" }), + ).toMatchObject([ + { + question: "Does runtime behavior match the available static analysis?", + }, + ]); + } finally { + await close(session, server, client); + } +}); + +it("passes only the finite declared reconstruction specification", async () => { + const { session, server, client } = await connected(); + for (const evidence of [ + PROCESS_CAPTURE_REFERENCE, + PROCESS_CAPTURE_RECONSTRUCTION, + PROCESS_COMPARISON_EVIDENCE, + ]) + expect(session.recordEvidence(evidence).ok).toBe(true); + try { + const response = await client.callTool({ + name: "verify_reconstruction", + arguments: { + ...INVESTIGATION_EXAMPLES.verify_reconstruction, + unknown_registry_approved: true, + }, + }); + expect(response.isError).not.toBe(true); + const evidence = sessionEvidence(session, response.structuredContent); + expect(evidence).toMatchObject({ + provider: { id: "rea-reconstruction-verifier" }, + normalized_result: { status: "pass", summary: { passed: 1 } }, + }); + expect(evidence.limitations).toContain( + "Pass means every declared claim passed; it does not establish global implementation equivalence.", + ); + } finally { + await close(session, server, client); + } +}); + +it("cannot omit a session-owned active unknown from reconstruction input", async () => { + const { session, server, client } = await connected(); + for (const evidence of [ + PROCESS_CAPTURE_REFERENCE, + PROCESS_CAPTURE_RECONSTRUCTION, + PROCESS_COMPARISON_EVIDENCE, + ]) + expect(session.recordEvidence(evidence).ok).toBe(true); + expect( + session.recordUnknown({ + approved: true, + question: "Was terminal equivalence reproduced independently?", + severity: "high", + domain: "reconstruction-verification", + supporting_evidence_ids: [PROCESS_COMPARISON_EVIDENCE.evidence_id], + contradicting_evidence_ids: [], + required_authority: "controlled-replay", + required_confidence: "observed", + required_environment: null, + recommended_probes: [ + { + operation: "capture_process_scenario", + rationale: "Repeat both sides under one controlled environment.", + }, + ], + relationships: [], + }).ok, + ).toBe(true); + try { + const response = await client.callTool({ + name: "verify_reconstruction", + arguments: { + ...INVESTIGATION_EXAMPLES.verify_reconstruction, + unknown_registry_approved: true, + }, + }); + expect(response.isError).not.toBe(true); + const evidence = sessionEvidence(session, response.structuredContent); + expect(evidence.normalized_result).toMatchObject({ + status: "unknown", + summary: { unknown: 1 }, + }); + expect( + session.listUnknowns({ domain: "reconstruction-verification" }), + ).toContainEqual( + expect.objectContaining({ + question: "Does the reconstruction satisfy every declared claim?", + }), + ); + } finally { + await close(session, server, client); + } +}); + +const connected = async ( + evidenceFilePolicy?: EvidenceFilePolicy, + investigationInputRoots: readonly string[] = evidenceFilePolicy?.roots ?? [], + permissionAuthority?: PermissionAuthority, +) => { + const session = composeBinarySessionFromFactory(() => ({ + health: () => Promise.resolve(), + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(), + })); + const server = createServer(session, session, { + ...(evidenceFilePolicy === undefined + ? {} + : { + evidenceFilePolicy, + investigationInputRoots, + }), + ...(permissionAuthority === undefined ? {} : { permissionAuthority }), + }); + const client = new Client({ name: "investigation-test", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + return { session, server, client }; +}; + +const sessionEvidence = (session: BinarySession, value: unknown) => { + const parsed = z + .object({ evidence_id: z.string().regex(/^ev_[a-f0-9]{64}$/u) }) + .parse(value); + const evidence = session.evidenceById(parsed.evidence_id); + if (evidence === undefined) throw new TypeError("Missing session Evidence"); + return evidence; +}; + +const close = async ( + session: BinarySession, + server: Awaited>, + client: Client, +) => { + await Promise.allSettled([client.close(), server.close(), session.close()]); +}; diff --git a/tests/boundary/mcp/investigationWorkspaceMcp.test.ts b/tests/boundary/mcp/investigationWorkspaceMcp.test.ts new file mode 100644 index 000000000..ecc733be2 --- /dev/null +++ b/tests/boundary/mcp/investigationWorkspaceMcp.test.ts @@ -0,0 +1,395 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { z } from "zod"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js"; +import type { BinarySession } from "../../../src/application/BinarySession.js"; +import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js"; +import { INVESTIGATION_EXAMPLES } from "../../../src/contracts/investigationExamples.js"; +import { + FUNCTION_COMPARISON_EVIDENCE, + PROCESS_CAPTURE_RECONSTRUCTION, + PROCESS_CAPTURE_REFERENCE, + PROCESS_COMPARISON_EVIDENCE, +} from "../../../src/contracts/investigationExamples.js"; +import { changedBehaviorResultSchema } from "../../../src/domain/changedBehavior.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { observed } from "../../fixtures/analysisExecution.js"; +import { readInvestigationWorkspace } from "../../../src/application/InvestigationWorkspaceStore.js"; +import type { EvidenceFilePolicy } from "../../../src/domain/evidenceBundle.js"; +import type { PermissionAuthority } from "../../../src/application/PermissionAuthority.js"; +import { permissionAuthorityForRoot } from "../../fixtures/permissionAuthority.js"; + +it("never retains derived evidence after request cancellation", async () => { + const { session, server, client } = await connected(); + const inputs = [ + { + name: "find_changed_behavior", + arguments: INVESTIGATION_EXAMPLES.find_changed_behavior, + }, + { + name: "build_call_path", + arguments: INVESTIGATION_EXAMPLES.build_call_path, + }, + { + name: "correlate_static_and_runtime", + arguments: INVESTIGATION_EXAMPLES.correlate_static_and_runtime, + }, + ] as const; + for (const evidence of [ + FUNCTION_COMPARISON_EXAMPLE.left, + FUNCTION_COMPARISON_EXAMPLE.right, + FUNCTION_COMPARISON_EVIDENCE, + PROCESS_CAPTURE_REFERENCE, + PROCESS_CAPTURE_RECONSTRUCTION, + PROCESS_COMPARISON_EVIDENCE, + ]) + expect(session.recordEvidence(evidence).ok).toBe(true); + const initialCount = session.exportEvidenceBundle().records.length; + try { + for (const input of inputs) { + const controller = new AbortController(); + const request = client.callTool(input, { + signal: controller.signal, + onprogress: () => controller.abort(), + }); + await expect(request).rejects.toThrow(/abort/iu); + } + await expect + .poll(() => session.exportEvidenceBundle().records.length) + .toBe(initialCount); + } finally { + await close(session, server, client); + } +}, 10_000); + +it("runs and reuses a persistent cross-version workspace", async () => { + const directory = await createTestTempDirectory("rea-investigation-mcp-"); + const left = join(directory, "left"); + const right = join(directory, "right"); + const workspace = join(directory, "workspace.json"); + await Promise.all([mkdir(left), mkdir(right)]); + await Promise.all([ + writeFile(join(left, "feature.txt"), "old\n"), + writeFile(join(right, "feature.txt"), "new\n"), + ]); + const filePolicy = evidencePolicy(directory); + const { session, server, client } = await connected(filePolicy); + try { + const arguments_ = { + investigation_run: { + approved: true, + workspace_path: workspace, + workspace_name: "mcp-release-diff", + left_path: left, + right_path: right, + options: { page_size: 500, change_limit: 100 }, + }, + }; + const first = await client.callTool({ + name: "find_changed_behavior", + arguments: arguments_, + }); + expect(first.isError).not.toBe(true); + const firstEvidence = sessionEvidence(session, first.structuredContent); + expect(firstEvidence.normalized_result).toMatchObject({ + behavior_status: "unknown", + summary: { static_candidates: 2 }, + investigation_run: { inventory_evidence_count: 2 }, + }); + expect(session.hasEvidence(firstEvidence.evidence_id)).toBe(true); + const loaded = await readInvestigationWorkspace(workspace, filePolicy); + expect(loaded).toMatchObject({ + ok: true, + value: { revision: 3, runs: [{ status: "complete" }] }, + }); + expect(first.content).toContainEqual( + expect.objectContaining({ + type: "resource_link", + uri: expect.stringMatching( + /^rea:\/\/workspace\/ws_[a-f0-9]{64}\/revision\/3$/u, + ), + }), + ); + const retainedWorkspace = session.investigationWorkspaces()[0]; + expect(retainedWorkspace).toBeDefined(); + if (retainedWorkspace !== undefined) { + const resource = await client.readResource({ + uri: `rea://workspace/${retainedWorkspace.workspace_id}/revision/${String(retainedWorkspace.revision)}`, + }); + expect(resource.contents[0]).toEqual( + expect.objectContaining({ + text: expect.stringContaining(retainedWorkspace.revision_digest), + }), + ); + } + + const second = await client.callTool({ + name: "find_changed_behavior", + arguments: arguments_, + }); + expect(second.isError, JSON.stringify(second)).not.toBe(true); + const secondEvidence = sessionEvidence(session, second.structuredContent); + expect(secondEvidence.evidence_id).toBe(firstEvidence.evidence_id); + expect( + await readInvestigationWorkspace(workspace, filePolicy), + ).toMatchObject({ ok: true, value: { revision: 3 } }); + } finally { + await close(session, server, client); + await rm(directory, { recursive: true, force: true }); + } +}); + +it("replays a complete workspace without write authority", async () => { + const directory = await createTestTempDirectory("rea-investigation-read-"); + const left = join(directory, "left"); + const right = join(directory, "right"); + const workspace = join(directory, "workspace.json"); + await Promise.all([mkdir(left), mkdir(right)]); + await Promise.all([ + writeFile(join(left, "feature.txt"), "old\n"), + writeFile(join(right, "feature.txt"), "new\n"), + ]); + const filePolicy = evidencePolicy(directory); + const arguments_ = { + investigation_run: { + approved: true, + workspace_path: workspace, + workspace_name: "read-only-replay", + left_path: left, + right_path: right, + options: { page_size: 500, change_limit: 100 }, + }, + }; + const writer = await investigationAuthority(directory, true); + const initial = await connected(filePolicy, [directory], writer); + try { + const created = await initial.client.callTool({ + name: "find_changed_behavior", + arguments: arguments_, + }); + expect(created.isError, JSON.stringify(created)).not.toBe(true); + } finally { + await close(initial.session, initial.server, initial.client); + } + + const reader = await investigationAuthority(directory, false); + const replay = await connected(filePolicy, [directory], reader); + try { + const cached = await replay.client.callTool({ + name: "find_changed_behavior", + arguments: arguments_, + }); + expect(cached.isError, JSON.stringify(cached)).not.toBe(true); + expect( + await readInvestigationWorkspace(workspace, filePolicy), + ).toMatchObject({ ok: true, value: { revision: 3 } }); + + await writeFile(join(right, "feature.txt"), "changed again\n"); + const denied = await replay.client.callTool({ + name: "find_changed_behavior", + arguments: arguments_, + }); + expect(denied).toMatchObject({ + isError: true, + structuredContent: { + error: { + code: "permission_required", + details: { capability: "investigation_workspace_write" }, + }, + }, + }); + } finally { + await close(replay.session, replay.server, replay.client); + await rm(directory, { recursive: true, force: true }); + } +}); + +it("defers input permission for an explicit replay with deleted inputs", async () => { + const directory = await createTestTempDirectory("rea-investigation-replay-"); + const left = join(directory, "left"); + const right = join(directory, "right"); + const workspace = join(directory, "workspace.json"); + await Promise.all([mkdir(left), mkdir(right)]); + await Promise.all([ + writeFile(join(left, "feature.txt"), "old\n"), + writeFile(join(right, "feature.txt"), "new\n"), + ]); + const filePolicy = evidencePolicy(directory); + const investigationRun = { + approved: true, + workspace_path: workspace, + workspace_name: "selected-replay", + left_path: left, + right_path: right, + options: { page_size: 500, change_limit: 100 }, + }; + const writer = await investigationAuthority(directory, true); + const initial = await connected(filePolicy, [directory], writer); + let runId: string | undefined; + try { + const created = await initial.client.callTool({ + name: "find_changed_behavior", + arguments: { investigation_run: investigationRun }, + }); + expect(created.isError, JSON.stringify(created)).not.toBe(true); + const evidence = sessionEvidence( + initial.session, + created.structuredContent, + ); + const result = changedBehaviorResultSchema.parse( + evidence.normalized_result, + ); + runId = result.investigation_run?.run_id; + } finally { + await close(initial.session, initial.server, initial.client); + } + if (runId === undefined) throw new Error("missing completed run ID"); + + await Promise.all([ + rm(left, { recursive: true, force: true }), + rm(right, { recursive: true, force: true }), + ]); + const workspaceOnly = await permissionAuthorityForRoot( + directory, + [ + "investigation_workspace_read", + "investigation_workspace_write", + "investigation_input", + ], + ["investigation_workspace_read"], + ); + const replay = await connected(filePolicy, [], workspaceOnly); + try { + const cached = await replay.client.callTool({ + name: "find_changed_behavior", + arguments: { + investigation_run: { + ...investigationRun, + replay_run_id: runId, + }, + }, + }); + expect(cached.isError, JSON.stringify(cached)).not.toBe(true); + expect( + await readInvestigationWorkspace(workspace, filePolicy), + ).toMatchObject({ ok: true, value: { revision: 3 } }); + } finally { + await close(replay.session, replay.server, replay.client); + await rm(directory, { recursive: true, force: true }); + } +}); + +it("refuses automatic artifact reads outside operator-approved roots", async () => { + const directory = await createTestTempDirectory("rea-investigation-mcp-"); + const approvedInputs = join(directory, "approved-inputs"); + const outsideInputs = join(directory, "outside-inputs"); + const evidenceRoot = join(directory, "evidence"); + await Promise.all([ + mkdir(approvedInputs), + mkdir(outsideInputs), + mkdir(evidenceRoot), + ]); + const left = join(outsideInputs, "left"); + const right = join(outsideInputs, "right"); + await Promise.all([mkdir(left), mkdir(right)]); + const { session, server, client } = await connected( + evidencePolicy(evidenceRoot), + [approvedInputs], + ); + try { + const response = await client.callTool({ + name: "find_changed_behavior", + arguments: { + investigation_run: { + approved: true, + workspace_path: join(evidenceRoot, "workspace.json"), + left_path: left, + right_path: right, + }, + }, + }); + expect(response.isError).toBe(true); + expect(response.content[0]).toEqual({ + type: "text", + text: JSON.stringify(response.structuredContent), + }); + } finally { + await close(session, server, client); + await rm(directory, { recursive: true, force: true }); + } +}); + +const connected = async ( + evidenceFilePolicy?: EvidenceFilePolicy, + investigationInputRoots: readonly string[] = evidenceFilePolicy?.roots ?? [], + permissionAuthority?: PermissionAuthority, +) => { + const session = composeBinarySessionFromFactory(() => ({ + health: () => Promise.resolve(), + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(), + })); + const server = createServer(session, session, { + ...(evidenceFilePolicy === undefined + ? {} + : { + evidenceFilePolicy, + investigationInputRoots, + }), + ...(permissionAuthority === undefined ? {} : { permissionAuthority }), + }); + const client = new Client({ name: "investigation-test", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + return { session, server, client }; +}; + +const evidencePolicy = (root: string): EvidenceFilePolicy => ({ + roots: [root], + maxBytes: 64 * 1024 * 1024, + maxDepth: 64, + maxStringLength: 1024 * 1024, + maxNodes: 1_000_000, +}); + +const sessionEvidence = (session: BinarySession, value: unknown) => { + const parsed = z + .object({ evidence_id: z.string().regex(/^ev_[a-f0-9]{64}$/u) }) + .parse(value); + const evidence = session.evidenceById(parsed.evidence_id); + if (evidence === undefined) throw new TypeError("Missing session Evidence"); + return evidence; +}; + +const investigationAuthority = async ( + root: string, + includeWrite: boolean, +): Promise => + permissionAuthorityForRoot( + root, + [ + "investigation_workspace_read", + "investigation_workspace_write", + "investigation_input", + ], + [ + "investigation_workspace_read", + "investigation_input", + ...(includeWrite ? (["investigation_workspace_write"] as const) : []), + ], + ); + +const close = async ( + session: BinarySession, + server: Awaited>, + client: Client, +) => { + await Promise.allSettled([client.close(), server.close(), session.close()]); +}; diff --git a/tests/javascriptRuntimeObservationMcp.test.ts b/tests/boundary/mcp/javascriptRuntimeObservationMcp.test.ts similarity index 85% rename from tests/javascriptRuntimeObservationMcp.test.ts rename to tests/boundary/mcp/javascriptRuntimeObservationMcp.test.ts index fa9735960..ffa53b3c4 100644 --- a/tests/javascriptRuntimeObservationMcp.test.ts +++ b/tests/boundary/mcp/javascriptRuntimeObservationMcp.test.ts @@ -5,15 +5,15 @@ import { pathToFileURL } from "node:url"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { afterEach, describe, expect, test } from "vitest"; -import { BinarySession } from "../src/application/BinarySession.js"; -import { loadConfiguredPermissionAuthority } from "../src/application/PermissionConfiguration.js"; -import { observeJavaScriptRuntime } from "../src/application/JavaScriptRuntimeObservationService.js"; -import { V8InspectorProvider } from "../src/browser/V8InspectorProvider.js"; -import { parseConfig } from "../src/config.js"; -import { createServer } from "../src/server/createServer.js"; -import { observed } from "./fixtures/analysisExecution.js"; -import { startFakeV8Inspector } from "./fixtures/fakeV8Inspector.js"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js"; +import { loadConfiguredPermissionAuthority } from "../../../src/application/PermissionConfiguration.js"; +import { observeJavaScriptRuntime } from "../../../src/application/JavaScriptRuntimeObservationService.js"; +import { V8InspectorProvider } from "../../../src/browser/V8InspectorProvider.js"; +import { parseConfig } from "../../../src/config.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { observed } from "../../fixtures/analysisExecution.js"; +import { startFakeV8Inspector } from "../../fixtures/fakeV8Inspector.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; describe("JavaScript runtime observation MCP tools", () => { const resources: Array<{ close(): Promise }> = []; @@ -45,7 +45,7 @@ describe("JavaScript runtime observation MCP tools", () => { if (!config.ok) throw config.error; const authority = await loadConfiguredPermissionAuthority(config.value); if (!authority.ok) throw authority.error; - const session = new BinarySession(() => ({ + const session = composeBinarySessionFromFactory(() => ({ execute: () => Promise.resolve(observed(null)), close: () => Promise.resolve(), })); diff --git a/tests/mainErrors.test.ts b/tests/boundary/mcp/mainErrors.test.ts similarity index 98% rename from tests/mainErrors.test.ts rename to tests/boundary/mcp/mainErrors.test.ts index c39f55b3c..574b64e96 100644 --- a/tests/mainErrors.test.ts +++ b/tests/boundary/mcp/mainErrors.test.ts @@ -1,7 +1,7 @@ import type { ServeStdioOptions } from "@modelcontextprotocol/server/stdio"; import { describe, expect, it } from "vitest"; -import { run, runEntrypoint } from "../src/main.js"; +import { run, runEntrypoint } from "../../../src/main.js"; type RuntimeDependencies = NonNullable[0]>; diff --git a/tests/mainReload.test.ts b/tests/boundary/mcp/mainReload.test.ts similarity index 93% rename from tests/mainReload.test.ts rename to tests/boundary/mcp/mainReload.test.ts index ccba50fdd..e624e5b75 100644 --- a/tests/mainReload.test.ts +++ b/tests/boundary/mcp/mainReload.test.ts @@ -2,26 +2,25 @@ import { createHash } from "node:crypto"; import { mkdir, rm } from "node:fs/promises"; import { join } from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import type { PermissionAuthority } from "../src/application/PermissionAuthority.js"; -import type { ProjectPermissionStore } from "../src/application/ProjectPermissionStore.js"; -import type { PermissionGrant } from "../src/domain/permissionPolicy.js"; -import { ok } from "../src/domain/result.js"; -import { run } from "../src/main.js"; +import type { PermissionAuthority } from "../../../src/application/PermissionAuthority.js"; +import type { ProjectPermissionStore } from "../../../src/application/ProjectPermissionStore.js"; +import type { PermissionGrant } from "../../../src/domain/permissionPolicy.js"; +import { ok } from "../../../src/domain/result.js"; +import { run } from "../../../src/main.js"; import { createServer, type CreateServerOptions, -} from "../src/server/createServer.js"; +} from "../../../src/server/createServer.js"; type RuntimeDependencies = NonNullable[0]>; const directories: string[] = []; afterEach(async () => { - vi.restoreAllMocks(); await Promise.all( directories .splice(0) @@ -198,12 +197,12 @@ const startRuntime = async ( createServer: (analysis, session, received) => { options = received; const server = createServer(analysis, session, received); - vi.spyOn(server, "sendToolListChanged").mockImplementation(() => { + server.sendToolListChanged = () => { notificationCount += 1; notification.resolve(undefined); notification = deferred(); waiters.get(notificationCount)?.resolve(undefined); - }); + }; return server; }, }); diff --git a/tests/boundary/mcp/managedMcp.test.ts b/tests/boundary/mcp/managedMcp.test.ts new file mode 100644 index 000000000..9c0846157 --- /dev/null +++ b/tests/boundary/mcp/managedMcp.test.ts @@ -0,0 +1,400 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import type { CallToolResult } from "@modelcontextprotocol/server"; +import { expect, it } from "vitest"; +import { z } from "zod"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +import { AnalysisProviderRegistry } from "../../../src/application/AnalysisProviderRegistry.js"; +import { composeBinarySession } from "../../../src/application/BinarySessionComposition.js"; +import type { BinarySession } from "../../../src/application/BinarySession.js"; +import { createPermissionAuthority } from "../../../src/application/PermissionAuthority.js"; +import { SessionProviderRouter } from "../../../src/application/SessionProviderRouter.js"; +import { MANAGED_NATIVE_VERIFICATION_EXAMPLE } from "../../../src/contracts/managedWorkflowExamples.js"; +import type { PermissionCeiling } from "../../../src/domain/permissionPolicy.js"; +import { ManagedStaticProvider } from "../../../src/dotnet/ManagedStaticProvider.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { buildManagedPeFixture } from "../../fixtures/managedPe.js"; + +it("opens a managed PE and executes the managed static provider through MCP", async () => { + const directory = await createTestTempDirectory("rea-managed-mcp-"); + const path = join(directory, "fixture.exe"); + const rightPath = join(directory, "fixture-renamed.exe"); + const runtimePath = join(directory, "dotnet"); + await writeFile(path, buildManagedPeFixture()); + await writeFile(runtimePath, "#!/bin/sh\n"); + await writeFile(rightPath, buildManagedPeFixture({ methodName: "Renamed" })); + const session = composeBinarySession( + SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), [ + new ManagedStaticProvider(), + ]), + ); + const runtimeCeiling: PermissionCeiling = { + capability: "managed_runtime", + roots: [directory], + executables: [runtimePath], + environment_names: [], + network: "none", + mount: false, + }; + const authority = await createPermissionAuthority( + [runtimeCeiling], + [ + { + ...runtimeCeiling, + grant_id: "administrator:managed_runtime", + lifetime: "administrator", + operation_identity: null, + expires_at: null, + }, + ], + ); + if (!authority.ok) throw authority.error; + const server = createServer(session, session, { + permissionAuthority: authority.value, + managedRuntimePolicy: { + enabled: true, + roots: [directory], + executablePath: runtimePath, + }, + availabilityPolicy: () => ({ + processCaptureEnabled: false, + evidenceFileRoots: 0, + investigationInputRoots: 0, + browserObservationEnabled: false, + electronObservationEnabled: false, + javascriptReplayEnabled: false, + managedRuntimeEnabled: true, + }), + }); + const client = new Client({ name: "managed-mcp-test", version: "1.0.0" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + await verifyManagedCatalogAndNativeWorkflow(client, session); + const members = await inspectManagedStaticWorkflow(client, session, path); + await verifyManagedComparisonAndReconstruction( + client, + session, + members, + rightPath, + ); + } finally { + await Promise.all([client.close(), server.close()]); + await session.close(); + } +}, 30_000); + +const verifyManagedCatalogAndNativeWorkflow = async ( + client: Client, + session: BinarySession, +): Promise => { + const names = (await client.listTools()).tools.map(({ name }) => name); + expect(names).toEqual( + expect.arrayContaining([ + "inspect_managed_artifact", + "inspect_managed_members", + "inspect_managed_native_boundaries", + "compare_managed_members", + "import_managed_reconstruction", + "verify_managed_native_boundaries", + "plan_managed_runtime_correlation", + ]), + ); + for (const evidence of [ + MANAGED_NATIVE_VERIFICATION_EXAMPLE.managed_boundaries, + ...MANAGED_NATIVE_VERIFICATION_EXAMPLE.native_observations, + ]) + expect(session.recordEvidence(evidence)).toMatchObject({ ok: true }); + const verified = structured( + await client.callTool({ + name: "verify_managed_native_boundaries", + arguments: { + managed_boundaries_evidence_id: + MANAGED_NATIVE_VERIFICATION_EXAMPLE.managed_boundaries.evidence_id, + native_observation_evidence_ids: + MANAGED_NATIVE_VERIFICATION_EXAMPLE.native_observations.map( + ({ evidence_id: evidenceId }) => evidenceId, + ), + limits: MANAGED_NATIVE_VERIFICATION_EXAMPLE.limits, + }, + }), + ); + expect(verified).toMatchObject({ + evidence_id: expect.stringMatching(/^ev_[a-f0-9]{64}$/u), + evidence_uri: expect.stringMatching(/^rea:\/\/evidence\/ev_/u), + result: { + summary: { verified: 1 }, + algorithm: { token_to_address_mapping: "not-inferred" }, + }, + }); + const native = MANAGED_NATIVE_VERIFICATION_EXAMPLE.native_observations[0]; + if (native === undefined) throw new Error("missing native Evidence"); + const wrong = await client.callTool({ + name: "verify_managed_native_boundaries", + arguments: { + managed_boundaries_evidence_id: native.evidence_id, + native_observation_evidence_ids: [ + MANAGED_NATIVE_VERIFICATION_EXAMPLE.managed_boundaries.evidence_id, + ], + }, + }); + expect(wrong).toMatchObject({ + isError: true, + structuredContent: { + error: { + code: "evidence_integrity_mismatch", + details: { reason: "wrong_operation", actual: "inspect_macho" }, + }, + }, + }); +}; + +const inspectManagedStaticWorkflow = async ( + client: Client, + session: BinarySession, + path: string, +): Promise> => { + const inspected = structured( + await client.callTool({ + name: "inspect_managed_artifact", + arguments: { path, reference_limit: 1 }, + }), + ); + expect(inspected).toMatchObject({ + result: { + classification: { status: "managed", runtime_family: "modern-dotnet" }, + references: { limit: 1 }, + }, + }); + const members = sessionEvidence( + session, + structured( + await client.callTool({ + name: "inspect_managed_members", + arguments: { method_limit: 1 }, + }), + ), + ); + expect(members).toMatchObject({ + operation: "inspect_managed_members", + provider: { id: "rea-dotnet-static" }, + subject: { local_path: path, format: "pe" }, + normalized_result: { + identity_scope: { token_identity: "build-local" }, + methods: { total: 1, returned: 1 }, + call_edges: { total: 1 }, + field_accesses: { total: 1 }, + }, + }); + const boundaries = structured( + await client.callTool({ + name: "inspect_managed_native_boundaries", + arguments: { import_limit: 1 }, + }), + ); + expect(boundaries).toMatchObject({ + result: { + identity_scope: { token_identity: "build-local" }, + pinvoke_imports: { total: 0, limit: 1 }, + native_implementations: { total: 0 }, + }, + }); + return members; +}; + +const methodFrom = (members: Record) => + z + .object({ + items: z.array( + z.object({ + token: z.string(), + signature: z.object({ raw_sha256: z.string() }), + body: z.object({ normalized_il_sha256: z.string().nullable() }), + }), + ), + }) + .parse( + z.record(z.string(), z.unknown()).parse(members.normalized_result) + .methods, + ).items[0]; + +const verifyManagedComparisonAndReconstruction = async ( + client: Client, + session: BinarySession, + members: Record, + rightPath: string, +): Promise => { + await client.callTool({ + name: "open_binary", + arguments: { path: rightPath }, + }); + const right = sessionEvidence( + session, + structured( + await client.callTool({ + name: "inspect_managed_members", + arguments: { method_limit: 1 }, + }), + ), + ); + const compared = sessionEvidence( + session, + structured( + await client.callTool({ + name: "compare_managed_members", + arguments: { + left_evidence_id: members.evidence_id, + right_evidence_id: right.evidence_id, + limits: { + max_method_matches: 100, + max_field_matches: 100, + max_candidates: 10, + }, + }, + }), + ), + ); + expect(compared).toMatchObject({ + operation: "compare_managed_members", + provider: { id: "rea-dotnet-workflows" }, + confidence: "inferred", + normalized_result: { + algorithm: { name_matching: "not-used" }, + matching: { exact_il_signature: 1 }, + }, + }); + const method = methodFrom(members); + expect(method).toBeDefined(); + if (method === undefined) return; + await verifyImport(client, session, members, method); + await verifyRuntimePlan(client, session, members, method); +}; + +type ManagedMethod = NonNullable>; + +const verifyImport = async ( + client: Client, + session: BinarySession, + members: Record, + method: ManagedMethod, +): Promise => { + const imported = sessionEvidence( + session, + structured( + await client.callTool({ + name: "import_managed_reconstruction", + arguments: { + static_members_evidence_id: members.evidence_id, + decompiler: { + name: "ilspycmd", + version: "9.1.0.7988", + family: "ilspy", + executable_sha256: null, + options: ["--type", "Example.Program"], + }, + methods: [ + { + token: method.token, + signature_sha256: method.signature.raw_sha256, + normalized_il_sha256: method.body.normalized_il_sha256, + reconstruction: { + kind: "decompiled-csharp", + language: "csharp", + text: "internal static void Main() { }", + }, + }, + ], + notes: ["synthetic MCP import"], + }, + }), + ), + ); + expect(imported).toMatchObject({ + operation: "import_managed_reconstruction", + provider: { id: "rea-dotnet-workflows" }, + confidence: "inferred", + normalized_result: { + executed: false, + summary: { imported_methods: 1, decompiled_csharp_methods: 1 }, + methods: [ + { + token: method.token, + validation: { canonical_observation: false }, + }, + ], + }, + }); +}; + +const verifyRuntimePlan = async ( + client: Client, + session: BinarySession, + members: Record, + method: ManagedMethod, +): Promise => { + const planned = sessionEvidence( + session, + structured( + await client.callTool({ + name: "plan_managed_runtime_correlation", + arguments: { + static_members_evidence_id: members.evidence_id, + method: { + token: method.token, + signature_sha256: method.signature.raw_sha256, + normalized_il_sha256: method.body.normalized_il_sha256, + }, + requested_effect: "attach", + host: { + os: "linux", + clr_family: "dotnet", + architecture: "x86_64", + }, + bounds: { + timeout_ms: 5000, + max_threads: 32, + max_output_bytes: 65536, + allow_network: false, + allow_ui: false, + }, + }, + }), + ), + ); + expect(planned).toMatchObject({ + operation: "plan_managed_runtime_correlation", + provider: { id: "rea-dotnet-workflows" }, + confidence: "derived", + normalized_result: { + executed: false, + authority_model: { capability: "managed_runtime" }, + requested_runtime: { effect: "attach", network: "none" }, + effect_taxonomy: { attaches_process: true }, + }, + }); +}; + +const structured = (result: CallToolResult): Record => { + if ( + typeof result.structuredContent !== "object" || + result.structuredContent === null + ) + throw new Error("missing structured result"); + return z.record(z.string(), z.unknown()).parse(result.structuredContent); +}; + +const sessionEvidence = ( + session: BinarySession, + result: Readonly>, +): Record => { + const evidenceId = z.string().parse(result.evidence_id); + const evidence = session.evidenceById(evidenceId); + if (evidence === undefined) throw new Error("missing session Evidence"); + return z.record(z.string(), z.unknown()).parse(evidence); +}; diff --git a/tests/mcpDoctor.test.ts b/tests/boundary/mcp/mcpDoctor.test.ts similarity index 93% rename from tests/mcpDoctor.test.ts rename to tests/boundary/mcp/mcpDoctor.test.ts index 3035bb907..6af366f8f 100644 --- a/tests/mcpDoctor.test.ts +++ b/tests/boundary/mcp/mcpDoctor.test.ts @@ -3,10 +3,13 @@ import { join, resolve } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { compareInventory, runProductionMcpDoctor } from "../src/mcpDoctor.js"; -import { CATALOG_IDENTITY } from "../src/catalogIdentity.js"; +import { + compareInventory, + runProductionMcpDoctor, +} from "../../../src/mcpDoctor.js"; +import { CATALOG_IDENTITY } from "../../../src/catalogIdentity.js"; const temporary: string[] = []; diff --git a/tests/mcpStartupPolicy.test.ts b/tests/boundary/mcp/mcpStartupPolicy.test.ts similarity index 93% rename from tests/mcpStartupPolicy.test.ts rename to tests/boundary/mcp/mcpStartupPolicy.test.ts index c0621d7b2..c0d148a3c 100644 --- a/tests/mcpStartupPolicy.test.ts +++ b/tests/boundary/mcp/mcpStartupPolicy.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; -import { MCP_STARTUP_POLICY } from "../src/mcpStartupPolicy.js"; +import { MCP_STARTUP_POLICY } from "../../../src/mcpStartupPolicy.js"; import { createEsmModuleProfileCollector, parseLinuxVmRssBytes, -} from "../scripts/lib/mcp-startup-probe.mjs"; +} from "../../../scripts/lib/mcp-startup-probe.mjs"; describe("MCP startup policy", () => { it("preserves headroom beneath the configured Codex timeout", () => { diff --git a/tests/officialTools.test.ts b/tests/boundary/mcp/officialTools.test.ts similarity index 93% rename from tests/officialTools.test.ts rename to tests/boundary/mcp/officialTools.test.ts index 991ac8e14..ebd3959de 100644 --- a/tests/officialTools.test.ts +++ b/tests/boundary/mcp/officialTools.test.ts @@ -1,13 +1,13 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { afterEach, describe, expect, it } from "vitest"; -import type { AnalysisOperationPort } from "../src/application/AnalysisProvider.js"; -import { OFFICIAL_TOOL_CONTRACTS } from "../src/contracts/toolContracts.js"; -import { HopperRemoteError } from "../src/domain/errors.js"; -import { err } from "../src/domain/result.js"; -import { observed as ok } from "./fixtures/analysisExecution.js"; -import type { JsonValue } from "../src/domain/jsonValue.js"; -import { createServer } from "../src/server/createServer.js"; +import type { AnalysisOperationPort } from "../../../src/application/AnalysisProvider.js"; +import { OFFICIAL_TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js"; +import { HopperRemoteError } from "../../../src/domain/errors.js"; +import { err } from "../../../src/domain/result.js"; +import { observed as ok } from "../../fixtures/analysisExecution.js"; +import type { JsonValue } from "../../../src/domain/jsonValue.js"; +import { createServer } from "../../../src/server/createServer.js"; const VALID_INPUTS: Readonly< Record>> diff --git a/tests/permissionMcp.test.ts b/tests/boundary/mcp/permissionMcp.test.ts similarity index 84% rename from tests/permissionMcp.test.ts rename to tests/boundary/mcp/permissionMcp.test.ts index 639e7c87d..b84bc47ac 100644 --- a/tests/permissionMcp.test.ts +++ b/tests/boundary/mcp/permissionMcp.test.ts @@ -3,10 +3,10 @@ import { realpath } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import type { AnalysisOperationPort } from "../src/application/AnalysisProvider.js"; -import { PermissionAuthority } from "../src/application/PermissionAuthority.js"; -import { createPermissionPolicy } from "../src/domain/permissionPolicy.js"; -import { createServer } from "../src/server/createServer.js"; +import type { AnalysisOperationPort } from "../../../src/application/AnalysisProvider.js"; +import { PermissionAuthority } from "../../../src/application/PermissionAuthority.js"; +import { createPermissionPolicy } from "../../../src/domain/permissionPolicy.js"; +import { createServer } from "../../../src/server/createServer.js"; describe("MCP permission preflight", () => { it("denies extraction before dispatch and returns exact typed remediation", async () => { diff --git a/tests/boundary/mcp/processCaptureElicitation.test.ts b/tests/boundary/mcp/processCaptureElicitation.test.ts new file mode 100644 index 000000000..bdcae68e7 --- /dev/null +++ b/tests/boundary/mcp/processCaptureElicitation.test.ts @@ -0,0 +1,89 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { serveStdio } from "@modelcontextprotocol/server/stdio"; +import { rm } from "node:fs/promises"; +import { dirname } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js"; +import { PermissionAuthority } from "../../../src/application/PermissionAuthority.js"; +import { createPermissionPolicy } from "../../../src/domain/permissionPolicy.js"; +import { silentLogger } from "../../../src/logger.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { observed } from "../../fixtures/analysisExecution.js"; +import { PROCESS_CAPTURE_ELICITATION_POLICY } from "../../../src/server/ProcessCaptureElicitation.js"; +describe("process-capture MCP elicitation handshake", () => { + it("completes signed consent through the modern MCP client and server", async () => { + const root = await createTestTempDirectory("rea-elicit-mcp-"); + const authority = new PermissionAuthority( + createPermissionPolicy([ + { + capability: "process_capture", + roots: [root], + executables: [process.execPath], + environment_names: [], + network: "external", + mount: false, + }, + ]), + ); + const session = composeBinarySessionFromFactory(() => ({ + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(), + })); + const createTestServer = () => + createServer(session, session, { + logger: silentLogger, + permissionAuthority: authority, + processPolicy: { + enabled: true, + executableRoots: [dirname(process.execPath)], + workingRoots: [root], + allowedEnvironment: [], + allowExternalNetwork: true, + }, + }); + const client = new Client( + { name: "process-elicit", version: "1" }, + { + capabilities: { elicitation: { form: {} } }, + versionNegotiation: { + mode: { + pin: PROCESS_CAPTURE_ELICITATION_POLICY.protocolVersions[0], + }, + }, + inputRequired: { autoFulfill: true, maxRounds: 3 }, + }, + ); + let prompts = 0; + client.setRequestHandler("elicitation/create", () => { + prompts += 1; + return Promise.resolve({ + action: "accept" as const, + content: { lifetime: "session" }, + }); + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const server = serveStdio(createTestServer, { + transport: serverTransport, + legacy: "reject", + }); + try { + await client.connect(clientTransport); + const captured = await client.callTool({ + name: "capture_process_scenario", + arguments: { + approved: true, + executable: process.execPath, + arguments: ["-e", "process.exit(0)"], + working_directory: root, + }, + }); + expect(captured.isError).not.toBe(true); + expect(prompts).toBe(1); + } finally { + await Promise.allSettled([client.close(), server.close()]); + await rm(root, { recursive: true, force: true }); + } + }, 20000); +}); diff --git a/tests/boundary/mcp/processCaptureElicitationGrants.test.ts b/tests/boundary/mcp/processCaptureElicitationGrants.test.ts new file mode 100644 index 000000000..c1997c0ee --- /dev/null +++ b/tests/boundary/mcp/processCaptureElicitationGrants.test.ts @@ -0,0 +1,149 @@ +import { isInputRequiredResult } from "@modelcontextprotocol/server"; +import { describe, expect, it } from "vitest"; +import { PermissionAuthority } from "../../../src/application/PermissionAuthority.js"; +import { createPermissionPolicy } from "../../../src/domain/permissionPolicy.js"; +import { + authorizeProcessCaptureWithElicitation, + type ProcessCaptureElicitationState, +} from "../../../src/server/ProcessCaptureElicitation.js"; +const request = { + capability: "process_capture" as const, + roots: ["/tmp"], + executables: [process.execPath], + environment_names: ["PATH"], + network: "external" as const, + mount: false, + operation_identity: "capture:test", +}; +const now = Date.parse("2026-07-18T00:00:00.000Z"); +const ceilingOnlyAuthority = ( + origins?: readonly string[], +): PermissionAuthority => + new PermissionAuthority( + createPermissionPolicy([ + { + capability: "process_capture", + roots: request.roots, + executables: request.executables, + environment_names: request.environment_names, + ...(origins === undefined ? {} : { origins }), + network: request.network, + mount: request.mount, + }, + ]), + ); +const requestContext = ( + state?: ProcessCaptureElicitationState, + response?: Record, +) => ({ + mcpReq: { + requestState: () => state, + ...(response === undefined + ? {} + : { inputResponses: { process_capture_grant: response } }), + }, +}); +const verifiedState = ( + state: ProcessCaptureElicitationState | undefined, +): Promise => { + if (state === undefined) throw new Error("state was not minted"); + return Promise.resolve(state); +}; +describe("process-capture MCP elicitation grants", () => { + it("grants and reuses a signed session scope on the modern protocol", async () => { + const authority = ceilingOnlyAuthority(); + let state: ProcessCaptureElicitationState | undefined; + const elicitation = { + stateCodec: { + mint: (value: ProcessCaptureElicitationState) => { + state = value; + return Promise.resolve("signed-state"); + }, + verify: () => verifiedState(state), + }, + supported: () => true, + now: () => now, + consumedNonces: new Map(), + }; + const first = await authorizeProcessCaptureWithElicitation( + authority, + request, + requestContext(), + elicitation, + ); + expect(isInputRequiredResult(first)).toBe(true); + expect(first).toMatchObject({ requestState: "signed-state" }); + expect(first).toMatchObject({ + inputRequests: { + process_capture_grant: { + params: { + message: expect.stringContaining("Mount access: no"), + }, + }, + }, + }); + expect(state?.request).toEqual(request); + const accepted = await authorizeProcessCaptureWithElicitation( + authority, + request, + requestContext(state, { + action: "accept", + content: { lifetime: "session" }, + }), + elicitation, + ); + expect(isInputRequiredResult(accepted)).toBe(false); + if (isInputRequiredResult(accepted)) return; + expect(accepted.ok).toBe(true); + const reused = await authorizeProcessCaptureWithElicitation( + authority, + request, + requestContext(), + elicitation, + ); + expect(isInputRequiredResult(reused)).toBe(false); + if (!isInputRequiredResult(reused)) expect(reused.ok).toBe(true); + }); + it("fails closed for legacy clients and altered continuations", async () => { + const authority = ceilingOnlyAuthority(); + let state: ProcessCaptureElicitationState | undefined; + const elicitation = { + stateCodec: { + mint: (value: ProcessCaptureElicitationState) => { + state = value; + return Promise.resolve("signed-state"); + }, + verify: () => verifiedState(state), + }, + supported: () => false, + now: () => now, + consumedNonces: new Map(), + }; + const legacy = await authorizeProcessCaptureWithElicitation( + authority, + request, + requestContext(), + elicitation, + ); + expect(isInputRequiredResult(legacy)).toBe(false); + if (!isInputRequiredResult(legacy)) expect(legacy.ok).toBe(false); + const modern = { ...elicitation, supported: () => true }; + await authorizeProcessCaptureWithElicitation( + authority, + request, + requestContext(), + modern, + ); + const altered = await authorizeProcessCaptureWithElicitation( + authority, + { ...request, operation_identity: "capture:altered" }, + requestContext(state, { + action: "accept", + content: { lifetime: "session" }, + }), + modern, + ); + expect(isInputRequiredResult(altered)).toBe(false); + if (!isInputRequiredResult(altered)) expect(altered.ok).toBe(false); + }); +}); diff --git a/tests/boundary/mcp/processCaptureElicitationGrantsPart2.test.ts b/tests/boundary/mcp/processCaptureElicitationGrantsPart2.test.ts new file mode 100644 index 000000000..943314c1d --- /dev/null +++ b/tests/boundary/mcp/processCaptureElicitationGrantsPart2.test.ts @@ -0,0 +1,167 @@ +import { isInputRequiredResult } from "@modelcontextprotocol/server"; +import { describe, expect, it } from "vitest"; +import { PermissionAuthority } from "../../../src/application/PermissionAuthority.js"; +import { createPermissionPolicy } from "../../../src/domain/permissionPolicy.js"; +import { + authorizeProcessCaptureWithElicitation, + type ProcessCaptureElicitationState, +} from "../../../src/server/ProcessCaptureElicitation.js"; +const request = { + capability: "process_capture" as const, + roots: ["/tmp"], + executables: [process.execPath], + environment_names: ["PATH"], + network: "external" as const, + mount: false, + operation_identity: "capture:test", +}; +const now = Date.parse("2026-07-18T00:00:00.000Z"); +const ceilingOnlyAuthority = ( + origins?: readonly string[], +): PermissionAuthority => + new PermissionAuthority( + createPermissionPolicy([ + { + capability: "process_capture", + roots: request.roots, + executables: request.executables, + environment_names: request.environment_names, + ...(origins === undefined ? {} : { origins }), + network: request.network, + mount: request.mount, + }, + ]), + ); +const requestContext = ( + state?: ProcessCaptureElicitationState, + response?: Record, +) => ({ + mcpReq: { + requestState: () => state, + ...(response === undefined + ? {} + : { inputResponses: { process_capture_grant: response } }), + }, +}); +const verifiedState = ( + state: ProcessCaptureElicitationState | undefined, +): Promise => { + if (state === undefined) throw new Error("state was not minted"); + return Promise.resolve(state); +}; +describe("process-capture MCP elicitation grants", () => { + it("binds signed continuation state to exact origins", async () => { + const authority = ceilingOnlyAuthority([ + "https://example.test", + "https://other.test", + ]); + let state: ProcessCaptureElicitationState | undefined; + const elicitation = { + stateCodec: { + mint: (value: ProcessCaptureElicitationState) => { + state = value; + return Promise.resolve("signed-state"); + }, + verify: () => verifiedState(state), + }, + supported: () => true, + now: () => now, + consumedNonces: new Map(), + }; + const scoped = { ...request, origins: ["https://example.test"] }; + const prompted = await authorizeProcessCaptureWithElicitation( + authority, + scoped, + requestContext(), + elicitation, + ); + expect(prompted).toMatchObject({ + inputRequests: { + process_capture_grant: { + params: { + message: expect.stringContaining("Origins: https://example.test"), + }, + }, + }, + }); + const altered = await authorizeProcessCaptureWithElicitation( + authority, + { ...scoped, origins: ["https://other.test"] }, + requestContext(state, { + action: "accept", + content: { lifetime: "session" }, + }), + elicitation, + ); + expect(isInputRequiredResult(altered)).toBe(false); + if (!isInputRequiredResult(altered)) expect(altered.ok).toBe(false); + }); + it("consumes once grants and rejects continuation replay", async () => { + const authority = ceilingOnlyAuthority(); + let state: ProcessCaptureElicitationState | undefined; + const elicitation = { + stateCodec: { + mint: (value: ProcessCaptureElicitationState) => { + state = value; + return Promise.resolve("signed-state"); + }, + verify: () => verifiedState(state), + }, + supported: () => true, + now: () => now, + consumedNonces: new Map(), + }; + await authorizeProcessCaptureWithElicitation( + authority, + request, + requestContext(), + elicitation, + ); + const response = { + action: "accept", + content: { lifetime: "once" }, + }; + const accepted = await authorizeProcessCaptureWithElicitation( + authority, + request, + requestContext(state, response), + elicitation, + ); + if (isInputRequiredResult(accepted)) throw new Error("grant was not used"); + expect(accepted.ok).toBe(true); + const replayed = await authorizeProcessCaptureWithElicitation( + authority, + request, + requestContext(state, response), + elicitation, + ); + expect(isInputRequiredResult(replayed)).toBe(false); + if (!isInputRequiredResult(replayed)) expect(replayed.ok).toBe(false); + const next = await authorizeProcessCaptureWithElicitation( + authority, + request, + requestContext(), + elicitation, + ); + expect(isInputRequiredResult(next)).toBe(true); + const declined = await authorizeProcessCaptureWithElicitation( + authority, + request, + requestContext(state, { action: "decline" }), + elicitation, + ); + expect(isInputRequiredResult(declined)).toBe(false); + const acceptedAfterDecline = await authorizeProcessCaptureWithElicitation( + authority, + request, + requestContext(state, { + action: "accept", + content: { lifetime: "session" }, + }), + elicitation, + ); + expect(isInputRequiredResult(acceptedAfterDecline)).toBe(false); + if (!isInputRequiredResult(acceptedAfterDecline)) + expect(acceptedAfterDecline.ok).toBe(false); + }); +}); diff --git a/tests/boundary/mcp/processCaptureElicitationGrantsPart3.test.ts b/tests/boundary/mcp/processCaptureElicitationGrantsPart3.test.ts new file mode 100644 index 000000000..e34e51ee5 --- /dev/null +++ b/tests/boundary/mcp/processCaptureElicitationGrantsPart3.test.ts @@ -0,0 +1,182 @@ +import { isInputRequiredResult } from "@modelcontextprotocol/server"; +import { mkdir, rm } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { PermissionAuthority } from "../../../src/application/PermissionAuthority.js"; +import { createPermissionPolicy } from "../../../src/domain/permissionPolicy.js"; +import { + authorizeProcessCaptureWithElicitation, + type ProcessCaptureElicitationState, +} from "../../../src/server/ProcessCaptureElicitation.js"; +const request = { + capability: "process_capture" as const, + roots: ["/tmp"], + executables: [process.execPath], + environment_names: ["PATH"], + network: "external" as const, + mount: false, + operation_identity: "capture:test", +}; +const now = Date.parse("2026-07-18T00:00:00.000Z"); +const ceilingOnlyAuthority = ( + origins?: readonly string[], +): PermissionAuthority => + new PermissionAuthority( + createPermissionPolicy([ + { + capability: "process_capture", + roots: request.roots, + executables: request.executables, + environment_names: request.environment_names, + ...(origins === undefined ? {} : { origins }), + network: request.network, + mount: request.mount, + }, + ]), + ); +const requestContext = ( + state?: ProcessCaptureElicitationState, + response?: Record, +) => ({ + mcpReq: { + requestState: () => state, + ...(response === undefined + ? {} + : { inputResponses: { process_capture_grant: response } }), + }, +}); +const verifiedState = ( + state: ProcessCaptureElicitationState | undefined, +): Promise => { + if (state === undefined) throw new Error("state was not minted"); + return Promise.resolve(state); +}; +describe("process-capture MCP elicitation grants", () => { + it("bounds replay state and prunes entries at the signed-state TTL", async () => { + const authority = ceilingOnlyAuthority(); + let state: ProcessCaptureElicitationState | undefined; + const consumedNonces = new Map(); + const elicitation = { + stateCodec: { + mint: (value: ProcessCaptureElicitationState) => { + state = value; + return Promise.resolve("signed-state"); + }, + verify: () => verifiedState(state), + }, + supported: () => true, + now: () => now, + consumedNonces, + }; + await authorizeProcessCaptureWithElicitation( + authority, + request, + requestContext(), + elicitation, + ); + const future = now + 600000; + for (let index = 0; index < 4096; index += 1) + consumedNonces.set(`occupied-${String(index)}`, future); + const rejectedAtCapacity = await authorizeProcessCaptureWithElicitation( + authority, + request, + requestContext(state, { + action: "accept", + content: { lifetime: "session" }, + }), + elicitation, + ); + expect(isInputRequiredResult(rejectedAtCapacity)).toBe(false); + if (!isInputRequiredResult(rejectedAtCapacity)) + expect(rejectedAtCapacity.ok).toBe(false); + consumedNonces.set("occupied-0", now - 1); + const acceptedAfterPrune = await authorizeProcessCaptureWithElicitation( + authority, + request, + requestContext(state, { + action: "accept", + content: { lifetime: "session" }, + }), + elicitation, + ); + expect(isInputRequiredResult(acceptedAfterPrune)).toBe(false); + if (!isInputRequiredResult(acceptedAfterPrune)) + expect(acceptedAfterPrune.ok).toBe(true); + expect(consumedNonces.has("occupied-0")).toBe(false); + expect(consumedNonces.size).toBe(4096); + }); + it.each(["result", "throw"] as const)( + "revokes an elicited grant when final authorization ends with %s", + async (failure) => { + const root = await createTestTempDirectory("rea-elicit-rollback-"); + const scoped = { ...request, roots: [root] }; + const policy = createPermissionPolicy([ + { + capability: "process_capture", + roots: scoped.roots, + executables: scoped.executables, + environment_names: scoped.environment_names, + network: scoped.network, + mount: scoped.mount, + }, + ]); + class FailingFinalAuthority extends PermissionAuthority { + private authorizationCount = 0; + override async authorize( + ...arguments_: Parameters + ): ReturnType { + this.authorizationCount += 1; + const result = await super.authorize(...arguments_); + if (failure === "result" && this.authorizationCount === 2) + await rm(root, { recursive: true, force: true }); + if (failure === "throw" && this.authorizationCount === 3) + throw new Error("synthetic final authorization rejection"); + return result; + } + } + const authority = new FailingFinalAuthority(policy); + let state: ProcessCaptureElicitationState | undefined; + const elicitation = { + stateCodec: { + mint: (value: ProcessCaptureElicitationState) => { + state = value; + return Promise.resolve("signed-state"); + }, + verify: () => verifiedState(state), + }, + supported: () => true, + now: () => now, + consumedNonces: new Map(), + }; + await authorizeProcessCaptureWithElicitation( + authority, + scoped, + requestContext(), + elicitation, + ); + const finalAuthorization = authorizeProcessCaptureWithElicitation( + authority, + scoped, + requestContext(state, { + action: "accept", + content: { lifetime: "session" }, + }), + elicitation, + ); + if (failure === "throw") + await expect(finalAuthorization).rejects.toThrow( + "synthetic final authorization rejection", + ); + else { + const failed = await finalAuthorization; + expect(isInputRequiredResult(failed)).toBe(false); + if (!isInputRequiredResult(failed)) expect(failed.ok).toBe(false); + await mkdir(root); + } + const retry = await authority.authorize(scoped, "read", { + elicitationSupported: true, + }); + expect(retry.ok).toBe(false); + }, + ); +}); diff --git a/tests/boundary/mcp/processCaptureElicitationPart2.test.ts b/tests/boundary/mcp/processCaptureElicitationPart2.test.ts new file mode 100644 index 000000000..989051ee3 --- /dev/null +++ b/tests/boundary/mcp/processCaptureElicitationPart2.test.ts @@ -0,0 +1,141 @@ +import { isInputRequiredResult } from "@modelcontextprotocol/server"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { serveStdio } from "@modelcontextprotocol/server/stdio"; +import { rm } from "node:fs/promises"; +import { dirname } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js"; +import { PermissionAuthority } from "../../../src/application/PermissionAuthority.js"; +import { createPermissionPolicy } from "../../../src/domain/permissionPolicy.js"; +import { silentLogger } from "../../../src/logger.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { observed } from "../../fixtures/analysisExecution.js"; +import { PROCESS_CAPTURE_ELICITATION_POLICY } from "../../../src/server/ProcessCaptureElicitation.js"; +describe("process-capture MCP elicitation handshake", () => { + it.each(["tampered", "expired"] as const)( + "rejects %s signed state through the modern MCP client and server", + async (failure) => { + const startedAt = Date.parse("2026-07-23T00:00:00.000Z"); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(startedAt); + const root = await createTestTempDirectory("rea-elicit-state-"); + const authority = new PermissionAuthority( + createPermissionPolicy([ + { + capability: "process_capture", + roots: [root], + executables: [process.execPath], + environment_names: [], + network: "external", + mount: false, + }, + ]), + ); + const session = composeBinarySessionFromFactory(() => ({ + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(), + })); + const client = new Client( + { name: "process-elicit-state", version: "1" }, + { + capabilities: { elicitation: { form: {} } }, + versionNegotiation: { + mode: { + pin: PROCESS_CAPTURE_ELICITATION_POLICY.protocolVersions[0], + }, + }, + inputRequired: { autoFulfill: false, maxRounds: 3 }, + }, + ); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const server = serveStdio( + () => + createServer(session, session, { + logger: silentLogger, + permissionAuthority: authority, + processPolicy: { + enabled: true, + executableRoots: [dirname(process.execPath)], + workingRoots: [root], + allowedEnvironment: [], + allowExternalNetwork: true, + }, + }), + { transport: serverTransport, legacy: "reject" }, + ); + const call = { + name: "capture_process_scenario", + arguments: { + approved: true, + executable: process.execPath, + arguments: ["-e", "process.exit(0)"], + working_directory: root, + }, + }; + try { + await client.connect(clientTransport); + const required = await client.callTool(call, { + allowInputRequired: true, + }); + expect(isInputRequiredResult(required)).toBe(true); + if (!isInputRequiredResult(required)) return; + const requestState = required.requestState; + expect(requestState).toEqual(expect.any(String)); + if (requestState === undefined) return; + if (failure === "expired") + vi.setSystemTime( + startedAt + + (PROCESS_CAPTURE_ELICITATION_POLICY.stateTtlSeconds + 1) * 1000, + ); + const echoedState = + failure === "tampered" + ? `${requestState.startsWith("A") ? "B" : "A"}${requestState.slice(1)}` + : requestState; + const retryMethod: string = "tools/call"; + await expect( + client.request( + { + method: retryMethod, + params: { + ...call, + requestState: echoedState, + inputResponses: { + process_capture_grant: { + action: "accept", + content: { lifetime: "session" }, + }, + }, + }, + }, + z.unknown(), + ), + ).rejects.toMatchObject({ + code: -32602, + data: { reason: "invalid_request_state" }, + }); + expect( + await authority.authorize( + { + capability: "process_capture", + roots: [root], + executables: [process.execPath], + environment_names: [], + network: "external", + mount: false, + operation_identity: `capture_process_scenario:${process.execPath}`, + }, + "read", + ), + ).toMatchObject({ ok: false }); + } finally { + await Promise.allSettled([client.close(), server.close()]); + await rm(root, { recursive: true, force: true }); + vi.useRealTimers(); + } + }, + 20000, + ); +}); diff --git a/tests/boundary/mcp/processTraceAdaptersParity.test.ts b/tests/boundary/mcp/processTraceAdaptersParity.test.ts new file mode 100644 index 000000000..ee2fc62cb --- /dev/null +++ b/tests/boundary/mcp/processTraceAdaptersParity.test.ts @@ -0,0 +1,163 @@ +import { rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { afterEach, describe, expect, it } from "vitest"; + +import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js"; +import type { AnalysisClient } from "../../../src/application/AnalysisProvider.js"; +import { compareProcessEvidenceFiles } from "../../../src/application/ProcessCli.js"; +import { PROCESS_PROVIDER } from "../../../src/application/ProcessEvidence.js"; +import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../../../src/contracts/processCaptureExample.js"; +import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; +import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; +import type { ProcessTraceSpecification } from "../../../src/domain/processTraceComparison.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { observed as ok } from "../../fixtures/analysisExecution.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const resources: Array<{ close(): Promise }> = []; +const temporaryRoots: string[] = []; +const beforeCheckpoint = { + name: "before", + at_ms: 0, + files: [], + effects: [], + truncated: false, +}; +const afterCheckpoint = { + name: "after_settlement", + at_ms: 50, + files: [], + effects: [], + truncated: false, +}; +const capture = { + ...EMPTY_PROCESS_CAPTURE_EXAMPLE, + event_journal: [ + { capture_order: 0, collection: "filesystem_checkpoints", index: 0 }, + { capture_order: 1, collection: "lifecycle", index: 0 }, + { capture_order: 2, collection: "lifecycle", index: 1 }, + { capture_order: 3, collection: "filesystem_checkpoints", index: 1 }, + ], +} as const; +const traceSpecification: ProcessTraceSpecification = { + version: 1, + events: [ + { + id: "before", + source: "filesystem", + exact: beforeCheckpoint, + cardinality: { kind: "required" }, + }, + { + id: "exit", + source: "lifecycle", + exact: { event: "exit", ...capture.exit }, + cardinality: { kind: "required" }, + }, + { + id: "settlement", + source: "lifecycle", + exact: { event: "settlement", ...capture.settlement }, + cardinality: { kind: "required" }, + }, + { + id: "after", + source: "filesystem", + exact: afterCheckpoint, + cardinality: { kind: "required" }, + }, + ], + language: { + kind: "finite_traces", + variants: [ + { + id: "normal", + trace: ["before", "exit", "settlement", "after"], + }, + ], + }, +}; + +const captureEvidence = (side: "left" | "right") => + createEvidence(undefined, PROCESS_PROVIDER, { + predicateType: "rea.process-capture/v4", + operation: "capture_process_scenario", + parameters: { side }, + result: jsonValueSchema.parse(capture), + confidence: "observed", + authority: "controlled-replay", + locations: [{ kind: "artifact-path", path: `/fixture/${side}` }], + }); + +afterEach(async () => { + await Promise.all(resources.splice(0).map((resource) => resource.close())); + await Promise.all( + temporaryRoots + .splice(0) + .map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +describe("declared trace adapter parity", () => { + it("returns identical CLI and MCP results and Evidence identity", async () => { + const left = captureEvidence("left"); + const right = captureEvidence("right"); + const root = await createTestTempDirectory("rea-trace-adapters-"); + temporaryRoots.push(root); + const leftPath = join(root, "left.json"); + const rightPath = join(root, "right.json"); + const specificationPath = join(root, "trace.json"); + await Promise.all([ + writeFile(leftPath, JSON.stringify(left)), + writeFile(rightPath, JSON.stringify(right)), + writeFile(specificationPath, JSON.stringify(traceSpecification)), + ]); + const cliEvidence = parseEvidence( + await compareProcessEvidenceFiles(leftPath, rightPath, specificationPath), + ); + const session = composeBinarySessionFromFactory( + (_path) => + ({ + execute: () => Promise.resolve(ok(null)), + close: () => Promise.resolve(), + }) satisfies AnalysisClient, + ); + expect(session.recordEvidence(left).ok).toBe(true); + expect(session.recordEvidence(right).ok).toBe(true); + const server = createServer( + { execute: () => Promise.resolve(ok(null)) }, + session, + ); + const client = new Client({ name: "trace-parity", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(client, server); + await server.connect(serverTransport); + await client.connect(clientTransport); + const response = await client.callTool({ + name: "compare_process_captures", + arguments: { + left_evidence_id: left.evidence_id, + right_evidence_id: right.evidence_id, + trace_spec: traceSpecification, + }, + }); + expect(response.isError).not.toBe(true); + expect(response.structuredContent).toMatchObject({ + result: { trace: { verdict: "equivalent" } }, + evidence_id: cliEvidence.evidence_id, + }); + const structuredResult = + typeof response.structuredContent === "object" && + response.structuredContent !== null + ? Reflect.get(response.structuredContent, "result") + : undefined; + expect(structuredResult).toEqual(cliEvidence.normalized_result); + expect(cliEvidence.locations).toEqual([ + { kind: "artifact-path", path: "/fixture/left" }, + { kind: "artifact-path", path: "/fixture/right" }, + ]); + }); +}); diff --git a/tests/progressMcp.test.ts b/tests/boundary/mcp/progressMcp.test.ts similarity index 88% rename from tests/progressMcp.test.ts rename to tests/boundary/mcp/progressMcp.test.ts index 2a7fadd98..7337fc00b 100644 --- a/tests/progressMcp.test.ts +++ b/tests/boundary/mcp/progressMcp.test.ts @@ -1,11 +1,11 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { describe, expect, it } from "vitest"; -import type { AnalysisOperationPort } from "../src/application/AnalysisProvider.js"; -import { AnalysisCancelledError } from "../src/domain/errors.js"; -import { err } from "../src/domain/result.js"; -import { createServer } from "../src/server/createServer.js"; -import { observed } from "./fixtures/analysisExecution.js"; +import type { AnalysisOperationPort } from "../../../src/application/AnalysisProvider.js"; +import { AnalysisCancelledError } from "../../../src/domain/errors.js"; +import { err } from "../../../src/domain/result.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { observed } from "../../fixtures/analysisExecution.js"; describe("ordinary MCP progress and cancellation", () => { it("emits bounded progress for a normal tool request", async () => { diff --git a/tests/promptCompletion.test.ts b/tests/boundary/mcp/promptCompletion.test.ts similarity index 89% rename from tests/promptCompletion.test.ts rename to tests/boundary/mcp/promptCompletion.test.ts index c5acf849c..f8f890b42 100644 --- a/tests/promptCompletion.test.ts +++ b/tests/boundary/mcp/promptCompletion.test.ts @@ -2,15 +2,15 @@ import { rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import type { AnalysisClient } from "../src/application/AnalysisProvider.js"; -import { BinarySession } from "../src/application/BinarySession.js"; -import { ARTIFACT_COMPARISON_EXAMPLE } from "../src/contracts/artifactComparisonExample.js"; -import { PROCESS_CAPTURE_REFERENCE } from "../src/contracts/investigationExamples.js"; -import { createEvidence } from "../src/domain/evidence.js"; -import { createPromptCompletionSource } from "../src/server/promptCompletion.js"; -import { observed } from "./fixtures/analysisExecution.js"; +import type { AnalysisClient } from "../../../src/application/AnalysisProvider.js"; +import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js"; +import { ARTIFACT_COMPARISON_EXAMPLE } from "../../../src/contracts/artifactComparisonExample.js"; +import { PROCESS_CAPTURE_REFERENCE } from "../../../src/contracts/investigationExamples.js"; +import { createEvidence } from "../../../src/domain/evidence.js"; +import { createPromptCompletionSource } from "../../../src/server/promptCompletion.js"; +import { observed } from "../../fixtures/analysisExecution.js"; let directory: string | undefined; @@ -20,10 +20,10 @@ afterEach(async () => { directory = undefined; }); -describe("guided prompt completion", () => { +describe("guided prompt completion from live analysis", () => { it("reads live documents and paged procedures without ambiguous names", async () => { const requests: Array>> = []; - const session = new BinarySession(() => client(requests)); + const session = composeBinarySessionFromFactory(() => client(requests)); directory = await createTestTempDirectory("rea-prompt-completion-"); const target = join(directory, "fixture.hop"); await writeFile(target, "fixture"); @@ -54,9 +54,11 @@ describe("guided prompt completion", () => { expect(await completion.complete("document", "")).toEqual([]); expect(await completion.complete("procedure", "")).toEqual([]); }); +}); +describe("guided prompt completion from investigation records", () => { it("projects only validated typed identifiers from the evidence ledger", async () => { - const session = new BinarySession(() => client([])); + const session = composeBinarySessionFromFactory(() => client([])); const invalidCapture = createEvidence(undefined, fixtureProvider, { operation: "capture_process_scenario", parameters: {}, @@ -135,7 +137,7 @@ describe("guided prompt completion", () => { }); it("deduplicates, case-folds, and returns deterministic live evidence IDs", async () => { - const session = new BinarySession(() => client([])); + const session = composeBinarySessionFromFactory(() => client([])); for (let index = 149; index >= 0; index -= 1) { const evidence = createEvidence(undefined, fixtureProvider, { operation: `fixture-${String(index)}`, @@ -152,7 +154,9 @@ describe("guided prompt completion", () => { expect(values).toHaveLength(150); expect(values).toEqual([...values].sort()); }); +}); +describe("guided prompt completion validation and limits", () => { it("does not offer procedure names from an incomplete discovery", async () => { let calls = 0; const completion = createPromptCompletionSource({ diff --git a/tests/promptMcp.test.ts b/tests/boundary/mcp/promptMcp.test.ts similarity index 93% rename from tests/promptMcp.test.ts rename to tests/boundary/mcp/promptMcp.test.ts index 47d8bcf94..018fd671a 100644 --- a/tests/promptMcp.test.ts +++ b/tests/boundary/mcp/promptMcp.test.ts @@ -5,15 +5,16 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { McpServer } from "@modelcontextprotocol/server"; import { afterEach, describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import type { AnalysisClient } from "../src/application/AnalysisProvider.js"; -import { BinarySession } from "../src/application/BinarySession.js"; -import { PROMPT_CONTRACTS } from "../src/contracts/promptContracts.js"; -import { createEvidence } from "../src/domain/evidence.js"; -import { createServer } from "../src/server/createServer.js"; -import { registerGuidedPrompts } from "../src/server/registerPrompts.js"; -import { observed } from "./fixtures/analysisExecution.js"; +import type { AnalysisClient } from "../../../src/application/AnalysisProvider.js"; +import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js"; +import type { BinarySession } from "../../../src/application/BinarySession.js"; +import { PROMPT_CONTRACTS } from "../../../src/contracts/promptContracts.js"; +import { createEvidence } from "../../../src/domain/evidence.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { registerGuidedPrompts } from "../../../src/server/registerPrompts.js"; +import { observed } from "../../fixtures/analysisExecution.js"; const resources: Array<{ close(): Promise }> = []; const temporaryDirectories: string[] = []; @@ -158,7 +159,9 @@ describe("guided prompts over MCP", () => { }); expect(executions).toBe(0); }); +}); +describe("guided prompt completion registry", () => { it("applies the MCP 100-value limit while reporting total and hasMore", async () => { const session = fixtureSession(); for (let index = 0; index < 150; index += 1) @@ -224,7 +227,9 @@ describe("guided prompts over MCP", () => { expect(prompts.prompts[0]?.title).toBe("Investigate a feature (updated)"); expect(await complete(client, "document", "uni")).toEqual(["unidentified"]); }); +}); +describe("guided prompt completion lifecycle", () => { it("survives a cancelled completion and ignores its late result", async () => { let calls = 0; let markStarted = (): void => undefined; @@ -279,7 +284,7 @@ describe("guided prompts over MCP", () => { const second = join(directory, "second.hop"); await Promise.all([writeFile(first, "first"), writeFile(second, "second")]); const procedureRequests: Array>> = []; - const session = new BinarySession((target) => + const session = composeBinarySessionFromFactory((target) => lifecycleClient(basename(target.path, ".hop"), procedureRequests), ); const client = await connect(createServer(session, session)); @@ -327,7 +332,7 @@ describe("guided prompts over MCP", () => { ).not.toBe(true); expect(await complete(client, "document", "")).toEqual([]); expect(await complete(client, "procedure", "")).toEqual([]); - }); + }, 10_000); }); const fixtureProvider = { id: "fixture", name: "Fixture", version: "1" }; @@ -335,7 +340,7 @@ const fixtureProvider = { id: "fixture", name: "Fixture", version: "1" }; const fixtureSession = ( onExecute: () => void = () => undefined, ): BinarySession => - new BinarySession(() => ({ + composeBinarySessionFromFactory(() => ({ execute: () => { onExecute(); return Promise.resolve(observed(null)); diff --git a/tests/providerSelectionMcp.test.ts b/tests/boundary/mcp/providerSelectionMcp.test.ts similarity index 90% rename from tests/providerSelectionMcp.test.ts rename to tests/boundary/mcp/providerSelectionMcp.test.ts index fd0b00cb4..d1833c0ef 100644 --- a/tests/providerSelectionMcp.test.ts +++ b/tests/boundary/mcp/providerSelectionMcp.test.ts @@ -6,21 +6,21 @@ import type { CallToolResult } from "@modelcontextprotocol/server"; import { afterEach, describe, expect, it } from "vitest"; import { z } from "zod"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { createAnalysisExecution, type AnalysisProviderCandidate, type CapabilityDescriptor, type ProviderIdentity, -} from "../src/application/AnalysisProvider.js"; -import { AnalysisProviderRegistry } from "../src/application/AnalysisProviderRegistry.js"; -import { BinarySession } from "../src/application/BinarySession.js"; -import { SessionProviderRouter } from "../src/application/SessionProviderRouter.js"; -import { createAnalysisProfile } from "../src/domain/analysisProfile.js"; -import { ok } from "../src/domain/result.js"; -import { silentLogger } from "../src/logger.js"; -import { createServer } from "../src/server/createServer.js"; +} from "../../../src/application/AnalysisProvider.js"; +import { AnalysisProviderRegistry } from "../../../src/application/AnalysisProviderRegistry.js"; +import { composeBinarySession } from "../../../src/application/BinarySessionComposition.js"; +import { SessionProviderRouter } from "../../../src/application/SessionProviderRouter.js"; +import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; +import { ok } from "../../../src/domain/result.js"; +import { silentLogger } from "../../../src/logger.js"; +import { createServer } from "../../../src/server/createServer.js"; const resources: Array<{ close(): Promise }> = []; let directory: string | undefined; @@ -37,7 +37,7 @@ describe("provider selection over MCP", () => { const target = join(directory, "fixture.hop"); await writeFile(target, "fixture"); const starts: string[] = []; - const session = new BinarySession( + const session = composeBinarySession( SessionProviderRouter.selectable( new AnalysisProviderRegistry([ candidate("beta", starts), diff --git a/tests/reconstructionCoverageMcp.test.ts b/tests/boundary/mcp/reconstructionCoverageMcp.test.ts similarity index 90% rename from tests/reconstructionCoverageMcp.test.ts rename to tests/boundary/mcp/reconstructionCoverageMcp.test.ts index 2c2503b62..695207985 100644 --- a/tests/reconstructionCoverageMcp.test.ts +++ b/tests/boundary/mcp/reconstructionCoverageMcp.test.ts @@ -4,19 +4,19 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { BinarySession } from "../src/application/BinarySession.js"; -import { INVESTIGATION_EXAMPLES } from "../src/contracts/investigationExamples.js"; -import { createEvidence } from "../src/domain/evidence.js"; -import { createEvidenceBundle } from "../src/domain/evidenceBundle.js"; +import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js"; +import { INVESTIGATION_EXAMPLES } from "../../../src/contracts/investigationExamples.js"; +import { createEvidence } from "../../../src/domain/evidence.js"; +import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; import { createReconstructionCoverageWorkspace, createReconstructionVerifierContract, -} from "../src/domain/reconstructionCoverage.js"; -import { createServer } from "../src/server/createServer.js"; -import { observed } from "./fixtures/analysisExecution.js"; -import { permissionAuthorityForRoot } from "./fixtures/permissionAuthority.js"; +} from "../../../src/domain/reconstructionCoverage.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { observed } from "../../fixtures/analysisExecution.js"; +import { permissionAuthorityForRoot } from "../../fixtures/permissionAuthority.js"; const digest = (character: string): string => character.repeat(64); @@ -36,7 +36,7 @@ describe("reconstruction coverage MCP parity", () => { ["investigation_workspace_read", "investigation_workspace_write"], ["investigation_workspace_read", "investigation_workspace_write"], ); - const session = new BinarySession(() => ({ + const session = composeBinarySessionFromFactory(() => ({ execute: () => Promise.resolve(observed(null)), close: () => Promise.resolve(), })); diff --git a/tests/reconstructionObligationLedgerMcp.test.ts b/tests/boundary/mcp/reconstructionObligationLedgerMcp.test.ts similarity index 86% rename from tests/reconstructionObligationLedgerMcp.test.ts rename to tests/boundary/mcp/reconstructionObligationLedgerMcp.test.ts index bab69c6fb..7bc559209 100644 --- a/tests/reconstructionObligationLedgerMcp.test.ts +++ b/tests/boundary/mcp/reconstructionObligationLedgerMcp.test.ts @@ -1,14 +1,14 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { describe, expect, it } from "vitest"; -import { BinarySession } from "../src/application/BinarySession.js"; -import { createEvidenceBundle } from "../src/domain/evidenceBundle.js"; -import { createServer } from "../src/server/createServer.js"; -import { observed } from "./fixtures/analysisExecution.js"; +import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js"; +import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { observed } from "../../fixtures/analysisExecution.js"; describe("reconstruction obligation ledger MCP parity", () => { it("builds an Evidence-backed page and exposes the retained resource", async () => { - const session = new BinarySession(() => ({ + const session = composeBinarySessionFromFactory(() => ({ execute: () => Promise.resolve(observed(null)), close: () => Promise.resolve(), })); diff --git a/tests/reconstructionReadinessMcp.test.ts b/tests/boundary/mcp/reconstructionReadinessMcp.test.ts similarity index 84% rename from tests/reconstructionReadinessMcp.test.ts rename to tests/boundary/mcp/reconstructionReadinessMcp.test.ts index 4f5007711..79601691b 100644 --- a/tests/reconstructionReadinessMcp.test.ts +++ b/tests/boundary/mcp/reconstructionReadinessMcp.test.ts @@ -1,14 +1,14 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { describe, expect, it } from "vitest"; -import { BinarySession } from "../src/application/BinarySession.js"; -import { RECONSTRUCTION_READINESS_EXAMPLE } from "../src/contracts/reconstructionReadinessExample.js"; -import { createServer } from "../src/server/createServer.js"; -import { observed } from "./fixtures/analysisExecution.js"; +import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js"; +import { RECONSTRUCTION_READINESS_EXAMPLE } from "../../../src/contracts/reconstructionReadinessExample.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { observed } from "../../fixtures/analysisExecution.js"; describe("reconstruction readiness MCP parity", () => { it("returns a passing compact result and retains the full report resource", async () => { - const session = new BinarySession(() => ({ + const session = composeBinarySessionFromFactory(() => ({ execute: () => Promise.resolve(observed(null)), close: () => Promise.resolve(), })); diff --git a/tests/replayMachineMcp.test.ts b/tests/boundary/mcp/replayMachineMcp.test.ts similarity index 84% rename from tests/replayMachineMcp.test.ts rename to tests/boundary/mcp/replayMachineMcp.test.ts index caac29ef3..b6f13ca47 100644 --- a/tests/replayMachineMcp.test.ts +++ b/tests/boundary/mcp/replayMachineMcp.test.ts @@ -1,14 +1,14 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { describe, expect, it } from "vitest"; -import { BinarySession } from "../src/application/BinarySession.js"; -import { REPLAY_MACHINE_RUN_EXAMPLE } from "../src/contracts/replayMachineExample.js"; -import { createServer } from "../src/server/createServer.js"; -import { observed } from "./fixtures/analysisExecution.js"; +import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js"; +import { REPLAY_MACHINE_RUN_EXAMPLE } from "../../../src/contracts/replayMachineExample.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { observed } from "../../fixtures/analysisExecution.js"; describe("direct replay machine MCP parity", () => { it("returns typed journal output and safe validation failures", async () => { - const session = new BinarySession(() => ({ + const session = composeBinarySessionFromFactory(() => ({ execute: () => Promise.resolve(observed(null)), close: () => Promise.resolve(), })); diff --git a/tests/serverIdentity.test.ts b/tests/boundary/mcp/serverIdentity.test.ts similarity index 51% rename from tests/serverIdentity.test.ts rename to tests/boundary/mcp/serverIdentity.test.ts index 780d5607a..1749d039f 100644 --- a/tests/serverIdentity.test.ts +++ b/tests/boundary/mcp/serverIdentity.test.ts @@ -3,20 +3,23 @@ import { readFile } from "node:fs/promises"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { describe, expect, it } from "vitest"; -import { BinarySession } from "../src/application/BinarySession.js"; -import { CATALOG_IDENTITY, CLI_COMMAND_NAMES } from "../src/catalogIdentity.js"; -import { PACKAGE_METADATA } from "../src/generatedPackageMetadata.js"; -import { PRODUCT_IDENTITY, SDK_IDENTITY } from "../src/identity.js"; -import { TOOL_CONTRACTS } from "../src/contracts/toolContracts.js"; -import { TOOL_KINDS } from "../src/contracts/toolContractTypes.js"; -import { createServer } from "../src/server/createServer.js"; -import { createServerIdentity } from "../src/serverIdentity.js"; -import { observed } from "./fixtures/analysisExecution.js"; -import { buildCapabilityInventory } from "../src/application/CapabilityInventory.js"; +import { composeBinarySessionFromProvider } from "../../../src/application/BinarySessionComposition.js"; +import { + CATALOG_IDENTITY, + CLI_COMMAND_NAMES, +} from "../../../src/catalogIdentity.js"; +import { PACKAGE_METADATA } from "../../../src/generatedPackageMetadata.js"; +import { PRODUCT_IDENTITY, SDK_IDENTITY } from "../../../src/identity.js"; +import { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js"; +import { TOOL_KINDS } from "../../../src/contracts/toolContractTypes.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createServerIdentity } from "../../../src/serverIdentity.js"; +import { observed } from "../../fixtures/analysisExecution.js"; +import { buildCapabilityInventory } from "../../../src/application/CapabilityInventory.js"; import type { AnalysisProvider, CapabilityDescriptor, -} from "../src/application/AnalysisProvider.js"; +} from "../../../src/application/AnalysisProvider.js"; const availabilityProvider = (): AnalysisProvider => { const identity = { id: "fixture", name: "Fixture", version: "1" }; @@ -178,9 +181,11 @@ describe("server and catalog identity", () => { }), ); }); +}); +describe("live server identity over MCP", () => { it("exposes live identity, labeled inventories, availability, and list changes", async () => { - const session = new BinarySession(availabilityProvider()); + const session = composeBinarySessionFromProvider(availabilityProvider()); const server = createServer(session, session); const client = new Client( { name: "identity-test", version: "9" }, @@ -199,159 +204,9 @@ describe("server and catalog identity", () => { try { await server.connect(serverTransport); await client.connect(clientTransport); - const instructions = client.getInstructions(); - expect(instructions?.length).toBeLessThanOrEqual(640); - expect(instructions).toContain( - "ASAR/JavaScript -> analyze_javascript_application", - ); - expect(instructions).toContain( - "native binary/database -> open_binary, then binary_overview", - ); - expect(instructions).toContain("Never repeat identical analysis"); - expect(instructions).toContain("cite Evidence IDs"); - const resource = await client.readResource({ - uri: "rea://server/identity", - }); - const content = resource.contents[0]; - expect(content).toBeDefined(); - if (content === undefined || !("text" in content)) return; - const live = JSON.parse(content.text); - expect(live).toMatchObject({ - package: { version: PRODUCT_IDENTITY.packageVersion }, - server: { version: PRODUCT_IDENTITY.packageVersion }, - sdk: { - server: SDK_IDENTITY.server, - client_test: PACKAGE_METADATA.clientSdkVersion, - }, - client: { name: "identity-test", version: "9" }, - alignment: { state: "unknown" }, - }); - const status = await client.callTool({ - name: "binary_session", - arguments: { - detail: "full", - expected_package_version: "1.2.0", - }, - }); - expect(status.structuredContent).toMatchObject({ - result: { - server_identity: { - catalog: { - counts: { - mcp_tools: TOOL_CONTRACTS.length, - cli_commands: CLI_COMMAND_NAMES.length, - }, - }, - alignment: { state: "mcp_server_restart_required" }, - }, - tool_availability: expect.arrayContaining([ - expect.objectContaining({ - name: "current_address", - available: false, - reason: "target_required", - }), - expect.objectContaining({ - name: "capture_process_scenario", - available: false, - reason: "policy_disabled", - client_requirements: { - required: [], - optional: ["elicitation_form"], - missing_required: [], - missing_optional: [], - }, - }), - expect.objectContaining({ - name: "inspect_web_page", - available: false, - reason: "policy_disabled", - }), - expect.objectContaining({ - name: "analyze_javascript_application", - available: false, - reason: "policy_disabled", - remediation: expect.stringContaining( - "REA_INVESTIGATION_INPUT_ROOTS_JSON", - ), - }), - ]), - client_features: { - elicitation_form: true, - elicitation_url: false, - roots: false, - sampling: false, - }, - }, - }); - const summary = await client.callTool({ - name: "binary_session", - arguments: { expected_package_version: "1.2.0" }, - }); - expect(summary.structuredContent).toMatchObject({ - result: { - view: "summary", - open: false, - target: null, - alignment: { state: "mcp_server_restart_required" }, - recommended_actions: expect.arrayContaining([ - "For a supplied target, route by format: ASAR/JavaScript to analyze_javascript_application; archive/package to open_binary(path), then inspect_artifact or inventory_artifact on the active target; managed PE/CLI to inspect_managed_artifact; browser/Electron runtimes to their list-target tools; native binaries to open_binary.", - ]), - }, - }); - expect(JSON.stringify(summary.structuredContent)).not.toContain( - "tool_availability", - ); - const capabilities = await client.callTool({ - name: "binary_session", - arguments: { - detail: "capabilities", - capability_family: "browser-provider", - cursor: 0, - limit: 1, - }, - }); - expect(capabilities.structuredContent).toMatchObject({ - result: { - view: "capabilities", - capability_family: "browser-provider", - capabilities: { - items: [expect.objectContaining({ surface: "browser-provider" })], - cursor: 0, - limit: 1, - total: expect.any(Number), - next_cursor: expect.any(Number), - has_more: true, - }, - client_features: { - elicitation_form: true, - elicitation_url: false, - roots: false, - sampling: false, - }, - }, - }); - for (const capabilityFamily of TOOL_KINDS) { - const familyCapabilities = await client.callTool({ - name: "binary_session", - arguments: { - detail: "capabilities", - capability_family: capabilityFamily, - limit: 100, - }, - }); - expect(familyCapabilities.structuredContent).toMatchObject({ - result: { - view: "capabilities", - capability_family: capabilityFamily, - capabilities: { - items: expect.arrayContaining([ - expect.objectContaining({ surface: capabilityFamily }), - ]), - total: expect.any(Number), - }, - }, - }); - } + await assertLiveIdentity(client); + await assertSessionIdentity(client); + await assertCapabilityViews(client); expect( (await client.listTools()).tools.map(({ name }) => name), ).not.toContain("current_address"); @@ -372,3 +227,160 @@ describe("server and catalog identity", () => { } }, 10_000); }); + +const assertLiveIdentity = async (client: Client): Promise => { + const instructions = client.getInstructions(); + expect(instructions?.length).toBeLessThanOrEqual(640); + expect(instructions).toContain( + "ASAR/JavaScript -> analyze_javascript_application", + ); + expect(instructions).toContain( + "native binary/database -> open_binary, then binary_overview", + ); + expect(instructions).toContain("Never repeat identical analysis"); + expect(instructions).toContain("cite Evidence IDs"); + const resource = await client.readResource({ uri: "rea://server/identity" }); + const content = resource.contents[0]; + expect(content).toBeDefined(); + if (content === undefined || !("text" in content)) + throw new Error("missing identity resource text"); + expect(JSON.parse(content.text)).toMatchObject({ + package: { version: PRODUCT_IDENTITY.packageVersion }, + server: { version: PRODUCT_IDENTITY.packageVersion }, + sdk: { + server: SDK_IDENTITY.server, + client_test: PACKAGE_METADATA.clientSdkVersion, + }, + client: { name: "identity-test", version: "9" }, + alignment: { state: "unknown" }, + }); +}; + +const assertSessionIdentity = async (client: Client): Promise => { + const status = await client.callTool({ + name: "binary_session", + arguments: { detail: "full", expected_package_version: "1.2.0" }, + }); + expect(status.structuredContent).toMatchObject({ + result: { + server_identity: { + catalog: { + counts: { + mcp_tools: TOOL_CONTRACTS.length, + cli_commands: CLI_COMMAND_NAMES.length, + }, + }, + alignment: { state: "mcp_server_restart_required" }, + }, + tool_availability: expect.arrayContaining([ + expect.objectContaining({ + name: "current_address", + available: false, + reason: "target_required", + }), + expect.objectContaining({ + name: "capture_process_scenario", + available: false, + reason: "policy_disabled", + client_requirements: { + required: [], + optional: ["elicitation_form"], + missing_required: [], + missing_optional: [], + }, + }), + expect.objectContaining({ + name: "inspect_web_page", + available: false, + reason: "policy_disabled", + }), + expect.objectContaining({ + name: "analyze_javascript_application", + available: false, + reason: "policy_disabled", + remediation: expect.stringContaining( + "REA_INVESTIGATION_INPUT_ROOTS_JSON", + ), + }), + ]), + client_features: { + elicitation_form: true, + elicitation_url: false, + roots: false, + sampling: false, + }, + }, + }); + const summary = await client.callTool({ + name: "binary_session", + arguments: { expected_package_version: "1.2.0" }, + }); + expect(summary.structuredContent).toMatchObject({ + result: { + view: "summary", + open: false, + target: null, + alignment: { state: "mcp_server_restart_required" }, + recommended_actions: expect.arrayContaining([ + "For a supplied target, route by format: ASAR/JavaScript to analyze_javascript_application; archive/package to open_binary(path), then inspect_artifact or inventory_artifact on the active target; managed PE/CLI to inspect_managed_artifact; browser/Electron runtimes to their list-target tools; native binaries to open_binary.", + ]), + }, + }); + expect(JSON.stringify(summary.structuredContent)).not.toContain( + "tool_availability", + ); +}; + +const assertCapabilityViews = async (client: Client): Promise => { + const capabilities = await client.callTool({ + name: "binary_session", + arguments: { + detail: "capabilities", + capability_family: "browser-provider", + cursor: 0, + limit: 1, + }, + }); + expect(capabilities.structuredContent).toMatchObject({ + result: { + view: "capabilities", + capability_family: "browser-provider", + capabilities: { + items: [expect.objectContaining({ surface: "browser-provider" })], + cursor: 0, + limit: 1, + total: expect.any(Number), + next_cursor: expect.any(Number), + has_more: true, + }, + client_features: { + elicitation_form: true, + elicitation_url: false, + roots: false, + sampling: false, + }, + }, + }); + for (const capabilityFamily of TOOL_KINDS) { + const familyCapabilities = await client.callTool({ + name: "binary_session", + arguments: { + detail: "capabilities", + capability_family: capabilityFamily, + limit: 100, + }, + }); + expect(familyCapabilities.structuredContent).toMatchObject({ + result: { + view: "capabilities", + capability_family: capabilityFamily, + capabilities: { + items: expect.arrayContaining([ + expect.objectContaining({ surface: capabilityFamily }), + ]), + total: expect.any(Number), + }, + }, + }); + } +}; diff --git a/tests/boundary/mcp/serverLifecycleMcp.test.ts b/tests/boundary/mcp/serverLifecycleMcp.test.ts new file mode 100644 index 000000000..fc4633be2 --- /dev/null +++ b/tests/boundary/mcp/serverLifecycleMcp.test.ts @@ -0,0 +1,119 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import type { CallToolResult } from "@modelcontextprotocol/server"; +import { afterEach, expect, it } from "vitest"; + +import type { AnalysisOperationPort } from "../../../src/application/AnalysisProvider.js"; +import { HopperRemoteError } from "../../../src/domain/errors.js"; +import { err } from "../../../src/domain/result.js"; +import { observed as ok } from "../../fixtures/analysisExecution.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { + SESSION_TOOL_CONTRACTS, + TOOL_CONTRACTS, +} from "../../../src/contracts/toolContracts.js"; +import { MANAGED_WORKFLOW_TOOL_CONTRACTS } from "../../../src/contracts/managedWorkflowToolContracts.js"; + +const resources: Array<{ close(): Promise }> = []; + +afterEach(async () => { + await Promise.all( + resources.splice(0).map(async (resource) => resource.close()), + ); +}); + +const connect = async (analysis: AnalysisOperationPort) => { + const server = createServer(analysis); + const client = new Client({ + name: "integration-test", + version: "1.0.0", + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(client, server); + await server.connect(serverTransport); + await client.connect(clientTransport); + return client; +}; + +const text = (result: CallToolResult): string => { + const content = result.content.find((item) => item.type === "text"); + if (content?.type !== "text") throw new Error("missing text result"); + return content.text; +}; + +const structured = (result: CallToolResult): Record => { + if ( + typeof result.structuredContent !== "object" || + result.structuredContent === null + ) + throw new Error("missing structured result"); + return Object.fromEntries(Object.entries(result.structuredContent)); +}; + +it("preserves the target-open tool inventory without a session", async () => { + const client = await connect({ + execute: () => Promise.resolve(ok(null)), + }); + const listed = await client.listTools(); + expect(listed.tools).toHaveLength( + TOOL_CONTRACTS.length - + SESSION_TOOL_CONTRACTS.length - + MANAGED_WORKFLOW_TOOL_CONTRACTS.length, + ); + const names = listed.tools.map((t) => t.name); + expect(names).toContain("binary_overview"); + expect(names).toContain("batch_decompile"); + expect(names).not.toContain("open_binary"); +}); + +it("projects remote failures without provider or bridge details", async () => { + const client = await connect({ + execute: () => + Promise.resolve(err(new HopperRemoteError(-32000, "bridge timeout"))), + }); + + const result = await client.callTool({ + name: "list_documents", + arguments: {}, + }); + expect(result.isError).toBe(true); + expect(structured(result)).toMatchObject({ + error: { + category: "execution_failure", + }, + }); + expect(text(result)).toBe(JSON.stringify(result.structuredContent)); +}); + +it("handles concurrent tool calls without corruption", async () => { + const invocations: string[] = []; + const client = await connect({ + execute: (name) => { + invocations.push(name); + return Promise.resolve( + ok( + ["list_procedures", "list_strings"].includes(name) + ? { + items: [], + offset: 0, + limit: 100, + total: 0, + next_offset: null, + has_more: false, + } + : [], + ), + ); + }, + }); + + const results = await Promise.all([ + client.callTool({ name: "list_procedures", arguments: {} }), + client.callTool({ name: "list_segments", arguments: {} }), + client.callTool({ name: "list_strings", arguments: {} }), + ]); + + expect(results.every((r) => !r.isError)).toBe(true); + expect(invocations).toHaveLength(3); + expect(new Set(invocations).size).toBe(3); +}); diff --git a/tests/boundary/mcp/serverUnknownsMcp.test.ts b/tests/boundary/mcp/serverUnknownsMcp.test.ts new file mode 100644 index 000000000..4ba7b00bc --- /dev/null +++ b/tests/boundary/mcp/serverUnknownsMcp.test.ts @@ -0,0 +1,307 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import type { CallToolResult } from "@modelcontextprotocol/server"; +import { afterEach, expect, it } from "vitest"; + +import { + composeBinarySessionFromFactory, + composeBinarySessionFromProvider, +} from "../../../src/application/BinarySessionComposition.js"; +import type { + AnalysisClient, + AnalysisOperationPort, + AnalysisProvider, + CapabilityDescriptor, +} from "../../../src/application/AnalysisProvider.js"; +import { AnalysisCapabilityUnavailableError } from "../../../src/domain/errors.js"; +import { err } from "../../../src/domain/result.js"; +import { observed as ok } from "../../fixtures/analysisExecution.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createEvidence } from "../../../src/domain/evidence.js"; +import { processCaptureSchema } from "../../../src/domain/processCapture.js"; +import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../../../src/contracts/processCaptureExample.js"; +import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; +import { PROCESS_PROVIDER } from "../../../src/server/sessionToolPolicies.js"; + +const resources: Array<{ close(): Promise }> = []; + +afterEach(async () => { + await Promise.all( + resources.splice(0).map(async (resource) => resource.close()), + ); +}); + +const providerWithCapabilities = ( + operations: readonly CapabilityDescriptor["operation"][], +): AnalysisProvider => { + const identity = { id: "fixture", name: "Fixture", version: "1" }; + const capabilities: readonly CapabilityDescriptor[] = operations.map( + (operation) => ({ + provider: identity, + operation, + inputContractVersion: 1, + outputContractVersion: 1, + available: true, + reason: null, + pagination: "none", + exhaustive: true, + effects: { + mutatesArtifact: false, + launchesProcess: false, + mayShowUi: false, + mayAccessNetwork: false, + mayWriteFilesystem: false, + changesPermissions: false, + requiresRoot: false, + }, + limits: { + maxResults: null, + maxPayloadBytes: null, + timeoutMs: null, + }, + limitations: [], + }), + ); + return { + identity: () => identity, + capabilities: () => capabilities, + createClient: () => ({ + execute: () => Promise.resolve(ok(null)), + close: () => Promise.resolve(), + }), + }; +}; + +const structured = (result: CallToolResult): Record => { + if ( + typeof result.structuredContent !== "object" || + result.structuredContent === null + ) + throw new Error("missing structured result"); + return Object.fromEntries(Object.entries(result.structuredContent)); +}; + +it("records approved trace truncation as a deduplicated residual unknown", async () => { + const analysis: AnalysisOperationPort = { + execute: () => + Promise.resolve( + ok({ + items: [], + offset: 0, + limit: 500, + total: 0, + next_offset: null, + has_more: false, + }), + ), + }; + const session = composeBinarySessionFromProvider( + providerWithCapabilities([ + "list_strings", + "list_procedures", + "xrefs", + "resolve_containing_procedure", + ]), + ); + const server = createServer(analysis, session); + const client = new Client({ name: "unknown-workflow", version: "1.0.0" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(client, server); + await server.connect(serverTransport); + await client.connect(clientTransport); + await client.callTool({ + name: "open_binary", + arguments: { path: process.execPath }, + }); + + for (let attempt = 0; attempt < 2; attempt += 1) { + const traced = await client.callTool({ + name: "trace_feature", + arguments: { + query: "license", + max_operations: 1, + unknown_registry_approved: true, + }, + }); + expect(traced.isError).not.toBe(true); + } + const listed = structured( + await client.callTool({ name: "list_unknowns", arguments: {} }), + ); + expect(listed).toMatchObject({ + result: { + items: [ + { + unknown: { + status: "open", + domain: "control-flow", + question: "Investigation reached the operation budget.", + }, + }, + ], + }, + }); +}, 10_000); + +it("records approved typed capability unavailability without forwarding the flag", async () => { + const received: Array>> = []; + const analysis: AnalysisOperationPort = { + execute: (name, arguments_) => { + received.push(arguments_); + return Promise.resolve( + err( + new AnalysisCapabilityUnavailableError( + "partial", + name, + "Decompiler is not installed.", + ), + ), + ); + }, + }; + const session = composeBinarySessionFromProvider( + providerWithCapabilities(["procedure_pseudo_code"]), + ); + const server = createServer(analysis, session); + const client = new Client({ name: "unavailable-unknown", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(client, server); + await server.connect(serverTransport); + await client.connect(clientTransport); + await client.callTool({ + name: "open_binary", + arguments: { path: process.execPath }, + }); + + const unavailable = await client.callTool({ + name: "procedure_pseudo_code", + arguments: { + procedure: "main", + unknown_registry_approved: true, + }, + }); + expect(unavailable.isError).toBe(true); + expect(structured(unavailable)).toMatchObject({ + error: { + category: "unsupported_provider", + }, + }); + expect(received[0]).not.toHaveProperty("unknown_registry_approved"); + expect( + structured(await client.callTool({ name: "list_unknowns", arguments: {} })), + ).toMatchObject({ + result: { + items: [ + { + unknown: { + domain: "analysis-capability", + question: + "The requested analysis is unavailable for the current target.", + recommended_probes: [ + { + rationale: + "Choose another analysis or target that can answer this question.", + }, + ], + }, + }, + ], + }, + }); +}); + +it("records approved capture disagreement as a contradicted unknown", async () => { + const session = composeBinarySessionFromFactory( + () => + ({ + execute: () => Promise.resolve(ok(null)), + close: () => Promise.resolve(), + }) satisfies AnalysisClient, + ); + const left = processCaptureSchema.parse(EMPTY_PROCESS_CAPTURE_EXAMPLE); + const right = processCaptureSchema.parse({ + ...EMPTY_PROCESS_CAPTURE_EXAMPLE, + interaction_events: [ + { + sequence: 0, + scheduled_at_ms: 0, + dispatched_at_ms: 0, + type: "input", + data: "fixture", + outcome: "dispatched", + }, + ], + shim_events: [ + { + sequence: 0, + at_ms: 0, + command: "fixture", + route_index: null, + arguments: [], + working_directory: "/tmp", + outcome: "unmatched", + }, + ], + }); + const captureEvidence = (capture: typeof left) => + createEvidence(undefined, PROCESS_PROVIDER, { + predicateType: "rea.process-capture/v4", + operation: "capture_process_scenario", + parameters: {}, + result: jsonValueSchema.parse(capture), + confidence: "observed", + authority: "controlled-replay", + }); + const leftEvidence = captureEvidence(left); + const rightEvidence = captureEvidence(right); + expect(session.recordEvidence(leftEvidence).ok).toBe(true); + expect(session.recordEvidence(rightEvidence).ok).toBe(true); + const server = createServer( + { execute: () => Promise.resolve(ok(null)) }, + session, + ); + const client = new Client({ name: "comparison-unknown", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(client, server); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const mismatched = await client.callTool({ + name: "compare_process_captures", + arguments: { + left_evidence_id: leftEvidence.evidence_id, + left: right, + right_evidence_id: rightEvidence.evidence_id, + right, + }, + }); + expect(mismatched.isError).toBe(true); + + const compared = await client.callTool({ + name: "compare_process_captures", + arguments: { + left_evidence_id: leftEvidence.evidence_id, + right_evidence_id: rightEvidence.evidence_id, + unknown_registry_approved: true, + }, + }); + expect(compared.isError).not.toBe(true); + expect( + structured(await client.callTool({ name: "list_unknowns", arguments: {} })), + ).toMatchObject({ + result: { + items: [ + { + unknown: { + status: "contradicted", + domain: "process-comparison", + question: "Process captures disagree across: interaction, shim", + contradicting_evidence_ids: [rightEvidence.evidence_id], + }, + }, + ], + }, + }); +}); diff --git a/tests/boundary/mcp/serverWorkflowMcp.test.ts b/tests/boundary/mcp/serverWorkflowMcp.test.ts new file mode 100644 index 000000000..abd54e0a2 --- /dev/null +++ b/tests/boundary/mcp/serverWorkflowMcp.test.ts @@ -0,0 +1,236 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import type { CallToolResult } from "@modelcontextprotocol/server"; +import { afterEach, expect, it } from "vitest"; +import { z } from "zod"; + +import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js"; +import type { + AnalysisClient, + AnalysisOperationPort, +} from "../../../src/application/AnalysisProvider.js"; +import { observed as ok } from "../../fixtures/analysisExecution.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js"; + +const resources: Array<{ close(): Promise }> = []; + +afterEach(async () => { + await Promise.all( + resources.splice(0).map(async (resource) => resource.close()), + ); +}); + +const connect = async (analysis: AnalysisOperationPort) => { + const server = createServer(analysis); + const client = new Client({ + name: "integration-test", + version: "1.0.0", + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(client, server); + await server.connect(serverTransport); + await client.connect(clientTransport); + return client; +}; + +const structured = (result: CallToolResult): Record => { + if ( + typeof result.structuredContent !== "object" || + result.structuredContent === null + ) + throw new Error("missing structured result"); + return Object.fromEntries(Object.entries(result.structuredContent)); +}; + +const objectEntries = (value: unknown): readonly [string, unknown][] => + typeof value === "object" && value !== null && !Array.isArray(value) + ? Object.entries(value) + : []; + +const referencedSchema = (schema: unknown, root: unknown): unknown => { + const object = Object.fromEntries(objectEntries(schema)); + const reference = object.$ref; + if (typeof reference !== "string" || !reference.startsWith("#/$defs/")) + return schema; + return Object.fromEntries(objectEntries(root)).$defs instanceof Object + ? Object.fromEntries( + objectEntries(Object.fromEntries(objectEntries(root)).$defs), + )[reference.slice(8)] + : schema; +}; + +const assertDescribedStrictObjects = ( + schema: unknown, + root = schema, + path = "$", +): void => { + for (const [key, value] of objectEntries(schema)) { + if (key === "examples") continue; + if (key === "properties") { + const properties = objectEntries(value); + for (const [property, propertySchema] of properties) { + const resolved = referencedSchema(propertySchema, root); + const propertyObject = Object.fromEntries( + objectEntries(propertySchema), + ); + expect( + propertyObject.description ?? + Object.fromEntries(objectEntries(resolved)).description, + `${path}.properties.${property}`, + ).toEqual(expect.any(String)); + assertDescribedStrictObjects( + propertySchema, + root, + `${path}.properties.${property}`, + ); + } + expect( + Object.fromEntries(objectEntries(schema)).additionalProperties, + ).toBe(false); + continue; + } + assertDescribedStrictObjects(value, root, `${path}.${key}`); + } + if (Array.isArray(schema)) + for (const [index, value] of schema.entries()) + assertDescribedStrictObjects(value, root, `${path}[${String(index)}]`); +}; + +const assertDescribedRootObject = (schema: unknown, path: string): void => { + const object = Object.fromEntries(objectEntries(schema)); + expect(object.additionalProperties, path).toBe(false); + for (const [property, propertySchema] of objectEntries(object.properties)) + expect( + Object.fromEntries(objectEntries(propertySchema)).description, + `${path}.properties.${property}`, + ).toEqual(expect.any(String)); +}; + +it("executes a realistic workflow: list methods, decompile selected, get xrefs", async () => { + const client = await connect({ + execute: (name, args) => { + switch (name) { + case "list_procedures": + return Promise.resolve( + ok({ + items: [ + { address: "0x1000", value: "main" }, + { address: "0x2000", value: "helper" }, + ], + offset: 0, + limit: 100, + total: 2, + next_offset: null, + has_more: false, + }), + ); + case "procedure_pseudo_code": + return Promise.resolve( + ok(`pseudo for ${(args as { procedure: string }).procedure}`), + ); + case "xrefs": + return Promise.resolve(ok(["0x1000"])); + default: + return Promise.resolve(ok(null)); + } + }, + }); + + const listResult = await client.callTool({ + name: "list_procedures", + arguments: {}, + }); + expect(listResult.isError).not.toBe(true); + expect(structured(listResult)).toMatchObject({ + result: { + items: [ + { address: "0x1000", value: "main" }, + { address: "0x2000", value: "helper" }, + ], + total: 2, + has_more: false, + }, + }); + + const decompileResult = await client.callTool({ + name: "procedure_pseudo_code", + arguments: { procedure: "0x1000" }, + }); + expect(structured(decompileResult)).toMatchObject({ + result: "pseudo for 0x1000", + }); + + const xrefResult = await client.callTool({ + name: "xrefs", + arguments: {}, + }); + expect(structured(xrefResult)).toMatchObject({ + result: ["0x1000"], + }); +}); + +it("advertises the complete currently available inventory with a session", async () => { + const session = composeBinarySessionFromFactory( + (_path) => + ({ + execute: () => Promise.resolve(ok(null)), + close: () => Promise.resolve(), + }) satisfies AnalysisClient, + ); + const server = createServer( + { execute: () => Promise.resolve(ok(null)) }, + session, + ); + const client = new Client({ + name: "integration-test", + version: "1.0.0", + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(client, server); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const listed = await client.listTools(); + const names = listed.tools.map((t) => t.name); + expect(names).toContain("open_binary"); + expect(names).toContain("close_binary"); + expect(names).toContain("binary_session"); + expect(names).not.toContain("binary_overview"); + expect(names).not.toContain("batch_decompile"); + const status = structured( + await client.callTool({ + name: "binary_session", + arguments: { detail: "full" }, + }), + ); + const available = new Set( + z + .object({ + result: z.object({ + tool_availability: z.array( + z.object({ name: z.string(), available: z.boolean() }), + ), + }), + }) + .parse(status) + .result.tool_availability.filter((item) => item.available) + .map(({ name }) => name), + ); + expect(new Set(names)).toEqual(available); + + const contracts = new Map( + TOOL_CONTRACTS.map((contract) => [contract.name, contract]), + ); + for (const tool of listed.tools) { + const contract = contracts.get(tool.name); + expect(contract, tool.name).toBeDefined(); + expect(tool.title, tool.name).toBe(contract?.title); + expect(tool.description, tool.name).toBe(contract?.description); + expect(tool.annotations, tool.name).toEqual(contract?.annotations); + expect(tool.outputSchema, tool.name).toBeDefined(); + assertDescribedStrictObjects(tool.inputSchema, tool.inputSchema, tool.name); + assertDescribedRootObject(tool.outputSchema, `${tool.name}.output`); + } +}, 10_000); diff --git a/tests/sessionMcp.test.ts b/tests/boundary/mcp/sessionMcp.test.ts similarity index 64% rename from tests/sessionMcp.test.ts rename to tests/boundary/mcp/sessionMcp.test.ts index 40e532761..e8fbf96e9 100644 --- a/tests/sessionMcp.test.ts +++ b/tests/boundary/mcp/sessionMcp.test.ts @@ -6,25 +6,27 @@ import type { CallToolResult } from "@modelcontextprotocol/server"; import { afterEach, describe, expect, it } from "vitest"; import { z } from "zod"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { BinarySession } from "../src/application/BinarySession.js"; +import { + composeBinarySessionFromFactory, + composeBinarySessionFromProvider, +} from "../../../src/application/BinarySessionComposition.js"; import type { AnalysisClient, AnalysisProvider, CapabilityDescriptor, -} from "../src/application/AnalysisProvider.js"; -import { probeProcessCaptureCapability } from "../src/application/ProcessHarness.js"; -import { observed as ok } from "./fixtures/analysisExecution.js"; -import { createServer } from "../src/server/createServer.js"; +} from "../../../src/application/AnalysisProvider.js"; +import { probeProcessCaptureCapability } from "../../../src/application/ProcessHarness.js"; +import { observed as ok } from "../../fixtures/analysisExecution.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { silentLogger } from "../../../src/logger.js"; +import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; +import { ok as resultOk } from "../../../src/domain/result.js"; import { - createEvidenceBundle, - evidenceBundleSchema, -} from "../src/domain/evidenceBundle.js"; -import { createEvidence } from "../src/domain/evidence.js"; -import { silentLogger } from "../src/logger.js"; -import { createAnalysisProfile } from "../src/domain/analysisProfile.js"; -import { ok as resultOk } from "../src/domain/result.js"; + createSessionMcpHarness, + snapshotAndRecordUnknown, +} from "./sessionMcpHarness.js"; const SNAPSHOT_PROFILE = createAnalysisProfile( { id: "fixture", name: "Fixture analysis provider", version: "1" }, @@ -34,7 +36,7 @@ const SNAPSHOT_PROFILE = createAnalysisProfile( const resources: Array<{ close(): Promise }> = []; const processFixture = fileURLToPath( - new URL("./fixtures/processFidelity.mjs", import.meta.url), + new URL("../../fixtures/processFidelity.mjs", import.meta.url), ); let directory: string | undefined; afterEach(async () => { @@ -50,7 +52,7 @@ describe("target-free MCP lifecycle", () => { const targetPath = join(directory, "mutable.hop"); await writeFile(targetPath, "first"); const closed: string[] = []; - const session = new BinarySession(provider(closed), { + const session = composeBinarySessionFromProvider(provider(closed), { resolveAnalysisProfile: () => Promise.resolve( resultOk({ profile: SNAPSHOT_PROFILE, compatibility: {} }), @@ -102,53 +104,18 @@ describe("target-free MCP lifecycle", () => { path: canonicalTargetPath, sha256: z.object({ sha256: z.string() }).parse(second).sha256, }); - }, 10_000); + }, 30_000); +}); +describe("target-free MCP workflow", () => { it("reports no-target, opens, analyzes, switches, reports status, and closes", async () => { directory = await createTestTempDirectory("rea-mcp-session-"); - const first = join(directory, "first.hop"); - const second = join(directory, "second.hop"); - await writeFile(first, "one"); - await writeFile(second, "two"); - const closed: string[] = []; - const session = new BinarySession(provider(closed), { - resolveAnalysisProfile: () => - Promise.resolve( - resultOk({ profile: SNAPSHOT_PROFILE, compatibility: {} }), - ), - }); - const server = createServer(session, session, { - logger: silentLogger, - evidenceFilePolicy: { - roots: [directory], - maxBytes: 1024 * 1024, - maxDepth: 68, - maxStringLength: 1024, - maxNodes: 10_000, - }, - analysisSnapshotFilePolicy: { - roots: [directory], - maxBytes: 1024 * 1024, - maxDepth: 68, - maxStringLength: 1024, - maxNodes: 10_000, - }, - }); - const mcp = new Client({ name: "session-test", version: "1.0.0" }); - let resourceListChanges = 0; - let unknownResourceUpdates = 0; - mcp.setNotificationHandler("notifications/resources/list_changed", () => { - resourceListChanges += 1; - }); - mcp.setNotificationHandler("notifications/resources/updated", (notice) => { - if (notice.params.uri.startsWith("rea://unknown/")) - unknownResourceUpdates += 1; - }); - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - resources.push(mcp, server); - await server.connect(serverTransport); - await mcp.connect(clientTransport); + const lifecycle = await createSessionMcpHarness( + directory, + provider, + resources, + ); + const { mcp, first, second, closed } = lifecycle; const beforeTools = (await mcp.listTools()).tools; const beforeNames = beforeTools.map(({ name }) => name); @@ -180,104 +147,8 @@ describe("target-free MCP lifecycle", () => { expect( text(await mcp.callTool({ name: "current_document", arguments: {} })), ).toContain("first.hop"); - const snapshotted = await mcp.callTool({ - name: "snapshot_evidence_bundle", - arguments: {}, - }); - const snapshotResult = z - .object({ result: z.object({ bundle_uri: z.string() }) }) - .parse(structured(snapshotted)).result; - const bundleResource = await mcp.readResource({ - uri: snapshotResult.bundle_uri, - }); - const bundle = evidenceBundleSchema.parse( - JSON.parse( - z.object({ text: z.string() }).parse(bundleResource.contents[0]).text, - ), - ); - expect(bundle.records).toHaveLength(1); - const evidencePath = join(directory, "evidence.json"); - expect( - structured( - await mcp.callTool({ - name: "export_evidence_bundle", - arguments: { path: evidencePath }, - }), - ).result, - ).toMatchObject({ path: evidencePath, records: 1 }); - expect( - structured( - await mcp.callTool({ - name: "import_evidence_bundle", - arguments: { path: evidencePath }, - }), - ).result, - ).toEqual({ imported: 0, total: 1 }); - const externalEvidencePath = join(directory, "external-evidence.json"); - await writeFile( - externalEvidencePath, - JSON.stringify( - createEvidenceBundle([ - createEvidence( - undefined, - { id: "fixture", name: "Fixture", version: "1" }, - { operation: "external_observation", parameters: {}, result: true }, - ), - ]), - ), - ); - let changesBeforeMutation = resourceListChanges; - expect( - structured( - await mcp.callTool({ - name: "import_evidence_bundle", - arguments: { path: externalEvidencePath }, - }), - ).result, - ).toEqual({ imported: 1, total: 2 }); - await new Promise((resolve) => setImmediate(resolve)); - expect(resourceListChanges).toBe(changesBeforeMutation); - changesBeforeMutation = resourceListChanges; - const recordedUnknown = z - .object({ - result: z.object({ - unknown_id: z.string(), - revision: z.number(), - }), - }) - .parse( - structured( - await mcp.callTool({ - name: "record_unknown", - arguments: { - approved: true, - question: "Does the alternate branch execute?", - severity: "medium", - domain: "control-flow", - required_authority: "controlled-replay", - required_confidence: "observed", - required_environment: null, - recommended_probes: [], - relationships: [], - }, - }), - ), - ).result; - expect(recordedUnknown.revision).toBe(1); - await new Promise((resolve) => setImmediate(resolve)); - expect(resourceListChanges).toBe(changesBeforeMutation); - const listedUnknowns = await mcp.callTool({ - name: "list_unknowns", - arguments: {}, - }); - expect(listedUnknowns.isError, text(listedUnknowns)).not.toBe(true); - expect( - z - .object({ - result: z.object({ items: z.array(z.unknown()) }), - }) - .parse(structured(listedUnknowns)).result.items, - ).toHaveLength(1); + const { recordedUnknown, changesBeforeMutation } = + await snapshotAndRecordUnknown(lifecycle, directory); const resolved = await mcp.callTool({ name: "update_unknown", arguments: { @@ -302,8 +173,8 @@ describe("target-free MCP lifecycle", () => { }); expect(resolved.isError).not.toBe(true); await new Promise((resolve) => setImmediate(resolve)); - expect(unknownResourceUpdates).toBe(1); - expect(resourceListChanges).toBe(changesBeforeMutation); + expect(lifecycle.unknownResourceUpdates()).toBe(1); + expect(lifecycle.resourceListChanges()).toBe(changesBeforeMutation); expect( structured( await mcp.callTool({ @@ -354,10 +225,14 @@ describe("target-free MCP lifecycle", () => { ).not.toBe(true); await mcp.callTool({ name: "close_binary", arguments: {} }); }, 10_000); +}); +describe("process residuals over MCP", () => { it("records approved process residuals in the unknown registry", async () => { if (!(await probeProcessCaptureCapability()).available) return; - const session = new BinarySession(() => client("fixture", [])); + const session = composeBinarySessionFromFactory(() => + client("fixture", []), + ); const server = createServer(session, session, { logger: silentLogger, processPolicy: { diff --git a/tests/boundary/mcp/sessionMcpHarness.ts b/tests/boundary/mcp/sessionMcpHarness.ts new file mode 100644 index 000000000..fe994e550 --- /dev/null +++ b/tests/boundary/mcp/sessionMcpHarness.ts @@ -0,0 +1,195 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import type { CallToolResult } from "@modelcontextprotocol/server"; +import { expect } from "vitest"; +import { z } from "zod"; + +import { composeBinarySessionFromProvider } from "../../../src/application/BinarySessionComposition.js"; +import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; +import { createEvidence } from "../../../src/domain/evidence.js"; +import { + createEvidenceBundle, + evidenceBundleSchema, +} from "../../../src/domain/evidenceBundle.js"; +import { ok } from "../../../src/domain/result.js"; +import { silentLogger } from "../../../src/logger.js"; +import { createServer } from "../../../src/server/createServer.js"; +import type { AnalysisProvider } from "../../../src/application/AnalysisProvider.js"; + +const SNAPSHOT_PROFILE = createAnalysisProfile( + { id: "fixture", name: "Fixture analysis provider", version: "1" }, + 1, + { fixture: true }, +); + +export interface SessionMcpHarness { + readonly mcp: Client; + readonly first: string; + readonly second: string; + readonly closed: string[]; + readonly resourceListChanges: () => number; + readonly unknownResourceUpdates: () => number; +} + +export const createSessionMcpHarness = async ( + root: string, + provider: (closed: string[]) => AnalysisProvider, + resources: Array<{ close(): Promise }>, +): Promise => { + const first = join(root, "first.hop"); + const second = join(root, "second.hop"); + await Promise.all([writeFile(first, "one"), writeFile(second, "two")]); + const closed: string[] = []; + const session = composeBinarySessionFromProvider(provider(closed), { + resolveAnalysisProfile: () => + Promise.resolve(ok({ profile: SNAPSHOT_PROFILE, compatibility: {} })), + }); + const filePolicy = { + roots: [root], + maxBytes: 1024 * 1024, + maxDepth: 68, + maxStringLength: 1024, + maxNodes: 10_000, + }; + const server = createServer(session, session, { + logger: silentLogger, + evidenceFilePolicy: filePolicy, + analysisSnapshotFilePolicy: filePolicy, + }); + const mcp = new Client({ name: "session-test", version: "1.0.0" }); + let resourceChanges = 0; + let unknownUpdates = 0; + mcp.setNotificationHandler("notifications/resources/list_changed", () => { + resourceChanges += 1; + }); + mcp.setNotificationHandler("notifications/resources/updated", (notice) => { + if (notice.params.uri.startsWith("rea://unknown/")) unknownUpdates += 1; + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(mcp, server); + await server.connect(serverTransport); + await mcp.connect(clientTransport); + return { + mcp, + first, + second, + closed, + resourceListChanges: () => resourceChanges, + unknownResourceUpdates: () => unknownUpdates, + }; +}; + +export const snapshotAndRecordUnknown = async ( + harness: SessionMcpHarness, + root: string, +): Promise<{ + recordedUnknown: { unknown_id: string; revision: number }; + changesBeforeMutation: number; +}> => { + const { mcp } = harness; + const snapshotted = await mcp.callTool({ + name: "snapshot_evidence_bundle", + arguments: {}, + }); + const snapshotResult = z + .object({ result: z.object({ bundle_uri: z.string() }) }) + .parse(structured(snapshotted)).result; + const bundleResource = await mcp.readResource({ + uri: snapshotResult.bundle_uri, + }); + const bundle = evidenceBundleSchema.parse( + JSON.parse( + z.object({ text: z.string() }).parse(bundleResource.contents[0]).text, + ), + ); + expect(bundle.records).toHaveLength(1); + const evidencePath = join(root, "evidence.json"); + expect( + structured( + await mcp.callTool({ + name: "export_evidence_bundle", + arguments: { path: evidencePath }, + }), + ).result, + ).toMatchObject({ path: evidencePath, records: 1 }); + expect( + structured( + await mcp.callTool({ + name: "import_evidence_bundle", + arguments: { path: evidencePath }, + }), + ).result, + ).toEqual({ imported: 0, total: 1 }); + const externalEvidencePath = join(root, "external-evidence.json"); + await writeFile( + externalEvidencePath, + JSON.stringify( + createEvidenceBundle([ + createEvidence( + undefined, + { id: "fixture", name: "Fixture", version: "1" }, + { operation: "external_observation", parameters: {}, result: true }, + ), + ]), + ), + ); + expect( + structured( + await mcp.callTool({ + name: "import_evidence_bundle", + arguments: { path: externalEvidencePath }, + }), + ).result, + ).toEqual({ imported: 1, total: 2 }); + await new Promise((resolve) => setImmediate(resolve)); + expect(harness.resourceListChanges()).toBe(0); + const changesBeforeMutation = harness.resourceListChanges(); + const recordedUnknown = z + .object({ + result: z.object({ unknown_id: z.string(), revision: z.number() }), + }) + .parse( + structured( + await mcp.callTool({ + name: "record_unknown", + arguments: { + approved: true, + question: "Does the alternate branch execute?", + severity: "medium", + domain: "control-flow", + required_authority: "controlled-replay", + required_confidence: "observed", + required_environment: null, + recommended_probes: [], + relationships: [], + }, + }), + ), + ).result; + expect(recordedUnknown.revision).toBe(1); + await new Promise((resolve) => setImmediate(resolve)); + expect(harness.resourceListChanges()).toBe(changesBeforeMutation); + const listedUnknowns = await mcp.callTool({ + name: "list_unknowns", + arguments: {}, + }); + expect(listedUnknowns.isError).not.toBe(true); + expect( + z + .object({ result: z.object({ items: z.array(z.unknown()) }) }) + .parse(structured(listedUnknowns)).result.items, + ).toHaveLength(1); + return { recordedUnknown, changesBeforeMutation }; +}; + +const structured = (result: CallToolResult): Record => { + if ( + typeof result.structuredContent !== "object" || + result.structuredContent === null + ) + throw new Error("missing structured result"); + return z.record(z.string(), z.unknown()).parse(result.structuredContent); +}; diff --git a/tests/boundary/mcp/toolRegistrationOptions.test.ts b/tests/boundary/mcp/toolRegistrationOptions.test.ts new file mode 100644 index 000000000..5426c32a2 --- /dev/null +++ b/tests/boundary/mcp/toolRegistrationOptions.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; + +import { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js"; +import { toolRegistrationOptions } from "../../../src/server/toolRegistrationOptions.js"; + +describe("tool registration options", () => { + it("reuses finalized advertised schemas", () => { + const contract = TOOL_CONTRACTS[0]; + expect(contract).toBeDefined(); + if (contract === undefined) return; + + const first = toolRegistrationOptions(contract); + const second = toolRegistrationOptions(contract); + expect(first.inputSchema["~standard"].jsonSchema.input()).toBe( + second.inputSchema["~standard"].jsonSchema.output(), + ); + expect(first.outputSchema["~standard"].jsonSchema.input()).toBe( + second.outputSchema["~standard"].jsonSchema.output(), + ); + }); + + it("gives fallback parameter guidance operational meaning", () => { + const contract = TOOL_CONTRACTS.find( + ({ name }) => name === "analyze_function", + ); + expect(contract).toBeDefined(); + if (contract === undefined) return; + + const advertisedSchema = + toolRegistrationOptions(contract).inputSchema[ + "~standard" + ].jsonSchema.input(); + const properties = Object.fromEntries( + Object.entries(advertisedSchema.properties ?? {}), + ); + expect(properties.include_assembly).toMatchObject({ + description: "Whether to include assembly in the result.", + }); + expect(properties.max_instructions).toMatchObject({ + description: "Maximum permitted instructions for this operation.", + }); + expect(properties.pseudocode_offset).toMatchObject({ + description: "Zero-based index of the first pseudocode to return.", + }); + }); + + it("advertises validated contract examples on the MCP input schema", () => { + for (const name of [ + "record_unknown", + "run_replay_machine", + "build_reconstruction_obligation_ledger", + ]) { + const contract = TOOL_CONTRACTS.find( + (candidate) => candidate.name === name, + ); + expect(contract).toBeDefined(); + if (contract === undefined) continue; + + const schema = + toolRegistrationOptions(contract).inputSchema[ + "~standard" + ].jsonSchema.input(); + expect(schema.examples).toEqual( + contract.examples.map(({ input }) => input), + ); + } + }); +}); diff --git a/tests/toolResult.test.ts b/tests/boundary/mcp/toolResult.test.ts similarity index 90% rename from tests/toolResult.test.ts rename to tests/boundary/mcp/toolResult.test.ts index 6eb10655a..f3d43d287 100644 --- a/tests/toolResult.test.ts +++ b/tests/boundary/mcp/toolResult.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; -import type { ToolContract } from "../src/contracts/toolContracts.js"; -import { ok } from "../src/domain/result.js"; -import { err } from "../src/domain/result.js"; -import { HopperProcessError } from "../src/domain/errors.js"; -import { toCallToolResult } from "../src/server/toolResult.js"; -import { createEvidence } from "../src/domain/evidence.js"; -import { evidenceResultOf } from "../src/contracts/toolOutputSchemas.js"; +import type { ToolContract } from "../../../src/contracts/toolContracts.js"; +import { ok } from "../../../src/domain/result.js"; +import { err } from "../../../src/domain/result.js"; +import { HopperProcessError } from "../../../src/domain/errors.js"; +import { toCallToolResult } from "../../../src/server/toolResult.js"; +import { createEvidence } from "../../../src/domain/evidence.js"; +import { evidenceResultOf } from "../../../src/contracts/toolOutputSchemas.js"; const contract: ToolContract = { name: "provider_neutral_fixture", diff --git a/tests/eventProcessTree.test.ts b/tests/boundary/process/eventProcessTree.test.ts similarity index 97% rename from tests/eventProcessTree.test.ts rename to tests/boundary/process/eventProcessTree.test.ts index 3bb36e8f2..3b48feb74 100644 --- a/tests/eventProcessTree.test.ts +++ b/tests/boundary/process/eventProcessTree.test.ts @@ -9,9 +9,9 @@ import { type ProcessEventType, type ProcessNode, type ProcessTreeReconstruction, -} from "../src/domain/eventProcessTree.js"; +} from "../../../src/domain/eventProcessTree.js"; -describe("event-backed process tree capture", () => { +describe("event-backed process tree reconstruction", () => { it("reconstructs a simple process tree", () => { const events: ProcessTreeEvent[] = [ { @@ -133,7 +133,9 @@ describe("event-backed process tree capture", () => { const result = reconstructProcessTree(events, 1); expect(result.short_lived_descendants).toBe(1); }); +}); +describe("event-backed process tree transitions", () => { it("detects re-exec", () => { const events: ProcessTreeEvent[] = [ { @@ -252,7 +254,9 @@ describe("event-backed process tree capture", () => { expect(result.events_unmatched).toBe(1); expect(result.processes).toHaveLength(0); }); +}); +describe("event-backed process tree queries", () => { it("gets descendants", () => { const events: ProcessTreeEvent[] = [ { @@ -330,7 +334,9 @@ describe("event-backed process tree capture", () => { expect(containsProcess(result.processes, 1)).toBe(true); expect(containsProcess(result.processes, 999)).toBe(false); }); +}); +describe("event-backed process tree depth and types", () => { it("computes max tree depth", () => { const events: ProcessTreeEvent[] = [ { diff --git a/tests/boundary/process/javascriptRuntimeReconciliationAmbiguity.test.ts b/tests/boundary/process/javascriptRuntimeReconciliationAmbiguity.test.ts new file mode 100644 index 000000000..aee3edb84 --- /dev/null +++ b/tests/boundary/process/javascriptRuntimeReconciliationAmbiguity.test.ts @@ -0,0 +1,354 @@ +import { execFile } from "node:child_process"; +import { rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +import { afterEach, expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +import { createElectronEvidence } from "../../../src/application/ElectronEvidence.js"; +import { analyzeJavaScriptApplication } from "../../../src/application/JavaScriptApplicationService.js"; +import { reconcileJavaScriptRuntime } from "../../../src/domain/javascriptRuntimeReconciliation.js"; +import { createWebTextArtifact } from "../../../src/domain/webContentArtifact.js"; +import { permissionAuthorityForRoot } from "../../fixtures/permissionAuthority.js"; + +const SOURCE = `const worker = new Worker("./worker.js");\nexport const observed = worker;\n`; +const execute = promisify(execFile); + +const temporary: string[] = []; + +afterEach(async () => { + await Promise.all( + temporary + .splice(0) + .map(async (path) => rm(path, { recursive: true, force: true })), + ); +}); + +it("keeps byte-identical cross-layer candidates explicitly ambiguous", async () => { + const application = await applicationFixture(); + const assets = await createTestTempDirectory("rea-runtime-assets-static-"); + const runtime = await createTestTempDirectory("rea-runtime-assets-live-"); + temporary.push(application, assets, runtime); + await Promise.all([ + writeFile(join(assets, "app.js"), SOURCE), + writeFile(join(runtime, "app.js"), SOURCE), + writeFile(join(runtime, "index.html"), ""), + ]); + const result = reconcileJavaScriptRuntime({ + static_layers: [ + { + role: "application", + analysis: await analyzeFixture(application), + }, + { role: "assets", analysis: await analyzeFixture(assets) }, + ], + runtime_observations: [ + electronRuntimeEvidence(runtime, SOURCE, { includeWorker: false }), + ], + }); + const script = result.reconciliations.find( + ({ entity_kind: kind }) => kind === "script", + ); + + expect(script).toMatchObject({ + status: "ambiguous", + reason: "ambiguous-static-candidates", + candidate_static_count: 2, + }); + expect(script?.candidate_static_nodes).toHaveLength(2); + expect( + new Set( + script?.candidate_static_nodes.map( + ({ static_layer_id: layerId }) => layerId, + ), + ).size, + ).toBe(2); +}); + +it("retains one target per capture and keeps load-state absence unknown when output is bounded", async () => { + const fixture = await applicationFixture(); + temporary.push(fixture); + const staticEvidence = await analyzeFixture(fixture); + const runtimeObservations = [ + electronRuntimeEvidence(fixture, SOURCE, { + includeWorker: false, + targetId: "target-one", + }), + electronRuntimeEvidence(fixture, SOURCE, { + includeWorker: false, + targetId: "target-two", + }), + ]; + + const result = reconcileJavaScriptRuntime({ + static_layers: [{ role: "application", analysis: staticEvidence }], + runtime_observations: runtimeObservations, + limits: { + max_runtime_entities: 2, + max_reconciliation_items: 2, + max_static_load_states: 20_000, + }, + }); + + expect(result.runtime_captures).toHaveLength(2); + expect( + new Set(result.runtime_captures.map(({ target_node_id: id }) => id)).size, + ).toBe(2); + expect(result.summary).toMatchObject({ + runtime_targets: 2, + runtime_frames: 0, + runtime_scripts: 0, + runtime_workers: 0, + static_not_observed: 0, + }); + expect(result.coverage).toMatchObject({ + status: "truncated", + truncated: true, + omitted_runtime_entities: 4, + }); + expect( + result.static_load_states.every(({ status }) => status === "unknown"), + ).toBe(true); +}); + +it("rejects runtime source bytes whose Evidence omits source-capture approval", async () => { + const fixture = await applicationFixture(); + temporary.push(fixture); + const staticEvidence = await analyzeFixture(fixture); + const contradictoryRuntime = electronRuntimeEvidence(fixture, SOURCE, { + includeWorker: false, + sourceCaptureApproved: false, + }); + + expect(() => + reconcileJavaScriptRuntime({ + static_layers: [{ role: "application", analysis: staticEvidence }], + runtime_observations: [contradictoryRuntime], + }), + ).toThrow(/source-capture approval/u); +}); + +it("keeps graph omission counts unknown when a runtime section is unavailable", async () => { + const fixture = await applicationFixture(); + temporary.push(fixture); + const result = reconcileJavaScriptRuntime({ + static_layers: [ + { role: "application", analysis: await analyzeFixture(fixture) }, + ], + runtime_observations: [ + electronRuntimeEvidence(fixture, SOURCE, { + includeWorker: false, + workersUnavailable: true, + }), + ], + }); + + expect(result.coverage).toMatchObject({ + status: "partial", + truncated: false, + }); + expect(result.graph.coverage).toMatchObject({ + status: "partial", + truncated: false, + omitted_count: null, + }); +}); + +it("runs the local verifier from operator-provided paths without emitting source", async () => { + const fixture = await applicationFixture(); + const evidenceRoot = await createTestTempDirectory("rea-runtime-evidence-"); + const evidencePath = join(evidenceRoot, "runtime-evidence.json"); + temporary.push(fixture, evidenceRoot); + await writeFile( + evidencePath, + JSON.stringify(electronRuntimeEvidence(fixture, SOURCE)), + ); + + const { stdout } = await execute( + process.execPath, + [ + "scripts/verify-local-javascript-runtime.mjs", + "--application", + fixture, + "--runtime-evidence", + evidencePath, + ], + { cwd: process.cwd(), maxBuffer: 16 * 1_024 * 1_024 }, + ); + const output: unknown = JSON.parse(stdout); + + expect(output).toMatchObject({ + verified: true, + summary: { runtime_scripts: 1 }, + }); + expect(stdout).not.toContain(SOURCE.trim()); +}, 10_000); + +const applicationFixture = async (): Promise => { + const root = await createTestTempDirectory("rea-runtime-reconciliation-"); + await Promise.all([ + writeFile( + join(root, "package.json"), + JSON.stringify({ + name: "rea-runtime-reconciliation-fixture", + version: "1.0.0", + renderer: "index.html", + }), + ), + writeFile(join(root, "index.html"), ""), + writeFile(join(root, "app.js"), SOURCE), + writeFile(join(root, "worker.js"), "self.onmessage = () => undefined;\n"), + ]); + return root; +}; + +const analyzeFixture = async (root: string) => { + const authority = await permissionAuthorityForRoot( + root, + ["investigation_input"], + ["investigation_input"], + ); + const result = await analyzeJavaScriptApplication(authority, { + input_path: root, + approved: true, + }); + if (!result.ok) throw result.error; + return result.value; +}; + +const electronRuntimeEvidence = ( + root: string, + source: string, + options: { + readonly scriptFile?: string; + readonly includeWorker?: boolean; + readonly targetId?: string; + readonly sourceCaptureApproved?: boolean; + readonly workersUnavailable?: boolean; + } = {}, +) => { + const scriptFile = options.scriptFile ?? "app.js"; + const includeWorker = options.includeWorker ?? true; + const targetId = options.targetId ?? "target-main"; + const sourceCaptureApproved = options.sourceCaptureApproved ?? true; + const input = { + cdp_endpoint: "http://127.0.0.1:9223", + allowed_file_roots: [root], + target_id: targetId, + approved: true as const, + observation_ms: 100, + include_script_sources: sourceCaptureApproved, + source_capture_approved: sourceCaptureApproved, + limits: { + max_frames: 200, + max_dom_nodes: 2_000, + max_scripts: 500, + max_resources: 2_000, + max_workers: 500, + max_script_source_bytes: 1_048_576, + max_total_script_source_bytes: 4_194_304, + }, + }; + return createElectronEvidence( + "inspect_electron_page", + input, + { + schema_version: 1, + browser: { + product: "Electron/fixture", + protocol_version: "1.3", + revision: "fixture", + user_agent: "Electron fixture", + js_version: "13", + }, + target: { + target_id: targetId, + type: "page", + title: "Fixture", + file_path: join(root, "index.html"), + attached: false, + }, + capture_window: { + started_at: "2026-07-15T00:00:00.000Z", + ended_at: "2026-07-15T00:00:00.100Z", + observation_ms: 100, + }, + completeness: options.workersUnavailable + ? { + ...completeCapture(), + status: "attach_limited" as const, + conditions: ["attach_limited" as const], + attach_limited_sections: ["workers" as const], + unavailable_sections: ["workers" as const], + } + : completeCapture(), + frames: [ + { + frame_id: "frame-main", + parent_frame_id: null, + file_path: join(root, "index.html"), + }, + ], + dom: { total_nodes: 0, nodes: [] }, + scripts: { + total: 1, + items: [ + { + script_key: `electron_script_${"1".repeat(64)}`, + frame_id: "frame-main", + file_path: join(root, scriptFile), + cdp_hash: "fixture", + length: Buffer.byteLength(source), + is_module: true, + language: "JavaScript", + source: { + included: true as const, + artifact: createWebTextArtifact(source, "text/javascript"), + }, + }, + ], + }, + resources: [], + workers: includeWorker + ? [ + { + target_id: "worker-main", + type: "worker", + file_path: join(root, "worker.js"), + attached: false, + opener_target_id: targetId, + parent_frame_id: "frame-main", + }, + ] + : [], + limitations: ["Synthetic passive capture fixture."], + }, + { + id: "rea-cdp-electron", + name: "REA Electron file-page CDP observation provider", + version: "1", + }, + ); +}; + +const completeCapture = () => ({ + status: "complete_within_window" as const, + conditions: ["complete_within_window" as const], + policy_filtered_sections: [], + attach_limited_sections: [], + truncated_sections: [], + unavailable_sections: [], + excluded: [], + dropped_events: { + scripts: 0, + network_requests: 0, + console_events: 0, + websocket_connections: 0, + websocket_frames: 0, + webmcp_tools: 0, + timeline_events: 0, + total: 0, + }, +}); diff --git a/tests/boundary/process/processCaptureComparison.test.ts b/tests/boundary/process/processCaptureComparison.test.ts new file mode 100644 index 000000000..0a83a6974 --- /dev/null +++ b/tests/boundary/process/processCaptureComparison.test.ts @@ -0,0 +1,250 @@ +import { expect, it } from "vitest"; + +import { + compareProcessCaptures, + digestProcessCommitment, + type ProcessCapture, +} from "../../../src/domain/processCapture.js"; + +const emptyCapture = (): ProcessCapture => { + const normalization = { + paths: true, + pids: true, + ports: true, + time_bucket_ms: 10, + patterns: [], + }; + const scenario = { executable_sha256: "0".repeat(64) }; + const comparisonContract = {}; + const shimPlan: readonly unknown[] = []; + const replayPlan = {}; + return { + schema_version: 4, + manifest: { + rea_version: "test", + provider_version: "3", + platform: process.platform, + architecture: process.arch, + pty_backend: "node-pty", + started_at: "2026-01-01T00:00:00.000Z", + completed_at: "2026-01-01T00:00:00.001Z", + scenario, + comparison_contract: comparisonContract, + shim_plan: shimPlan, + replay_plan: replayPlan, + full_scenario_sha256: digestProcessCommitment(scenario), + comparison_contract_sha256: digestProcessCommitment(comparisonContract), + executable_sha256: "0".repeat(64), + normalization_sha256: digestProcessCommitment(normalization), + shim_plan_sha256: digestProcessCommitment(shimPlan), + replay_plan_sha256: digestProcessCommitment(replayPlan), + }, + normalization, + frames: [], + rendered_frames: [], + interaction_events: [], + exit: { code: 0, signal: null, reason: "exited" }, + settlement: { + state: "quiesced", + elapsed_ms: 50, + cleanup_outcome: "not_required", + }, + process_samples: [], + filesystem_checkpoints: [ + { name: "before", at_ms: 0, files: [], effects: [], truncated: false }, + { + name: "after_settlement", + at_ms: 50, + files: [], + effects: [], + truncated: false, + }, + ], + shim_events: [], + protocol_events: [], + replay_transitions: [], + reactive_run: null, + event_journal: [], + files_before: [], + files_after: [], + filesystem_effects: [], + truncated: false, + limitations: [], + residual_unknowns: [], + cleanup: { + owned_process_group: "verified", + temporary_root: "removed", + }, + }; +}; + +it("classifies missing observations as unknown and one-sided evidence as added", () => { + const base = { + schema_version: 4 as const, + manifest: emptyCapture().manifest, + settlement: emptyCapture().settlement, + normalization: { + paths: true, + pids: true, + ports: true, + time_bucket_ms: 10, + patterns: [], + }, + frames: [], + rendered_frames: [], + interaction_events: [], + exit: { code: 0, signal: null, reason: "exited" as const }, + process_samples: [], + filesystem_checkpoints: emptyCapture().filesystem_checkpoints, + shim_events: [], + protocol_events: [], + replay_transitions: [], + reactive_run: null, + files_before: [], + files_after: [], + filesystem_effects: [], + truncated: false, + limitations: [], + residual_unknowns: [], + cleanup: { + owned_process_group: "verified" as const, + temporary_root: "removed" as const, + }, + }; + const added = compareProcessCaptures(base, { + ...base, + frames: [{ sequence: 0, at_ms: 0, data: "new" }], + rendered_frames: [ + { + sequence: 0, + at_ms: 0, + columns: 3, + rows: 1, + cursor_x: 3, + cursor_y: 0, + active_buffer: "normal", + lines: ["new"], + serialized_state: "new", + }, + ], + }); + expect(added.terminal).toBe("added"); + expect(added.status).toBe("changed"); + const unknown = compareProcessCaptures( + { ...base, residual_unknowns: [{ scope: "process", reason: "sampled" }] }, + base, + ); + expect(unknown.process).toBe("unknown"); + expect(unknown.shim).toBe("unchanged"); + expect(unknown.status).toBe("unknown"); +}); + +it("compares raw terminal chunks even when rendered states agree", () => { + const capture = { + schema_version: 4 as const, + manifest: emptyCapture().manifest, + settlement: emptyCapture().settlement, + normalization: { + paths: true, + pids: true, + ports: true, + time_bucket_ms: 10, + patterns: [], + }, + frames: [{ sequence: 0, at_ms: 0, data: "bar" }], + rendered_frames: [], + interaction_events: [], + exit: { code: 0, signal: null, reason: "exited" as const }, + process_samples: [], + filesystem_checkpoints: emptyCapture().filesystem_checkpoints, + shim_events: [], + protocol_events: [], + replay_transitions: [], + reactive_run: null, + files_before: [], + files_after: [], + filesystem_effects: [], + truncated: false, + limitations: [], + residual_unknowns: [], + cleanup: { + owned_process_group: "verified" as const, + temporary_root: "removed" as const, + }, + }; + + const comparison = compareProcessCaptures(capture, { + ...capture, + frames: [{ sequence: 0, at_ms: 0, data: "foo\rbar" }], + }); + expect(comparison.terminal).toBe("changed"); + expect(comparison.first_divergence).toMatchObject({ + status: "found", + dimension: "terminal", + }); +}); + +it("keeps filesystem evidence unknown when stable snapshots match", () => { + const capture = { + schema_version: 4 as const, + manifest: emptyCapture().manifest, + settlement: emptyCapture().settlement, + normalization: { + paths: true, + pids: true, + ports: true, + time_bucket_ms: 10, + patterns: [], + }, + frames: [], + rendered_frames: [], + interaction_events: [], + exit: { code: 0, signal: null, reason: "exited" as const }, + process_samples: [], + filesystem_checkpoints: emptyCapture().filesystem_checkpoints, + shim_events: [], + protocol_events: [], + replay_transitions: [], + reactive_run: null, + files_before: [], + files_after: [], + filesystem_effects: [], + truncated: false, + limitations: [], + residual_unknowns: [ + { scope: "filesystem" as const, reason: "watcher unavailable" }, + ], + cleanup: { + owned_process_group: "verified" as const, + temporary_root: "removed" as const, + }, + }; + + const comparison = compareProcessCaptures(capture, capture); + expect(comparison.filesystem).toBe("unknown"); + expect(comparison.status).toBe("unknown"); + + const complete = { ...capture, residual_unknowns: [] }; + const transient = compareProcessCaptures(complete, { + ...complete, + filesystem_checkpoints: [ + { name: "before", at_ms: 0, files: [], effects: [], truncated: false }, + { + name: "during_run", + at_ms: 10, + files: [], + effects: [], + truncated: false, + }, + { + name: "after_settlement", + at_ms: 50, + files: [], + effects: [], + truncated: false, + }, + ], + }); + expect(transient.filesystem).toBe("changed"); + expect(transient.status).toBe("changed"); +}); diff --git a/tests/boundary/process/processCaptureLifecycle.test.ts b/tests/boundary/process/processCaptureLifecycle.test.ts new file mode 100644 index 000000000..b7bc500b2 --- /dev/null +++ b/tests/boundary/process/processCaptureLifecycle.test.ts @@ -0,0 +1,238 @@ +import { execFile } from "node:child_process"; +import { rm, symlink } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { expect, it } from "vitest"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +import { + captureProcessScenario, + probeProcessCaptureCapability, + ProcessCaptureError, +} from "../../../src/application/ProcessHarness.js"; +import { + parseProcessScenario, + type ProcessExecutionPolicy, +} from "../../../src/domain/processCapture.js"; + +const processFixture = fileURLToPath( + new URL("../../fixtures/processFidelity.mjs", import.meta.url), +); +const execFileAsync = promisify(execFile); + +it("does not follow or disclose symlink targets outside declared roots", async () => { + const root = await createTestTempDirectory("rea-symlink-test-"); + await symlink("/etc/passwd", join(root, "escape")); + try { + const capability = await probeProcessCaptureCapability(); + if (!capability.available) return; + const result = await captureProcessScenario( + parseProcessScenario({ + approved: true, + executable: "/usr/bin/true", + working_directory: root, + filesystem_roots: [root], + }), + { + enabled: true, + executableRoots: ["/usr/bin"], + workingRoots: [root], + allowedEnvironment: [], + allowExternalNetwork: true, + }, + ); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + const escaped = result.value.files_after.find((file) => + file.path.endsWith(":escape"), + ); + expect(escaped?.symlink_target).toBe(""); + expect(result.value.truncated).toBe(true); + expect(JSON.stringify(result.value.files_after)).not.toContain(root); + expect(JSON.stringify(result.value.files_after)).not.toContain( + "/etc/passwd", + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +it("does not launch when policy denies capture", async () => { + const scenario = parseProcessScenario({ + approved: true, + executable: "/bin/sh", + working_directory: "/tmp", + }); + const result = await captureProcessScenario(scenario, { + enabled: false, + executableRoots: [], + workingRoots: [], + allowedEnvironment: [], + allowExternalNetwork: true, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected policy refusal"); + expect(result.error).toBeInstanceOf(ProcessCaptureError); +}); + +it("distinguishes timeout from cancellation and cleans both runs", async () => { + const capability = await probeProcessCaptureCapability(); + if (!capability.available) return; + const policy: ProcessExecutionPolicy = { + enabled: true, + executableRoots: [dirname(process.execPath)], + workingRoots: [dirname(processFixture)], + allowedEnvironment: [], + allowExternalNetwork: true, + }; + const timedOut = await captureProcessScenario( + parseProcessScenario({ + approved: true, + executable: process.execPath, + arguments: [processFixture, "hang"], + working_directory: dirname(processFixture), + timeout_ms: 50, + idle_timeout_ms: 5_000, + }), + policy, + ); + expect(timedOut.ok).toBe(true); + if (!timedOut.ok) throw timedOut.error; + expect(timedOut.value.exit.reason).toBe("timeout"); + expect(timedOut.value.cleanup).toEqual({ + owned_process_group: "verified", + temporary_root: "removed", + }); + + const controller = new AbortController(); + setTimeout(() => controller.abort(), 50); + const cancelled = await captureProcessScenario( + parseProcessScenario({ + approved: true, + executable: process.execPath, + arguments: [processFixture, "hang"], + working_directory: dirname(processFixture), + timeout_ms: 5_000, + idle_timeout_ms: 5_000, + }), + policy, + controller.signal, + ); + expect(cancelled.ok).toBe(false); + if (cancelled.ok) throw new Error("expected cancellation"); + expect(cancelled.error.message).toContain("cancelled"); +}); + +it("captures source-owned interactive, resize, Unicode, and signal behavior", async () => { + const capability = await probeProcessCaptureCapability(); + if (!capability.available) return; + const result = await captureProcessScenario( + parseProcessScenario({ + approved: true, + executable: process.execPath, + arguments: [processFixture, "interactive"], + working_directory: dirname(processFixture), + events: [ + { type: "input", at_ms: 100, data: "answer" }, + { type: "resize", at_ms: 300, columns: 100, rows: 40 }, + { type: "signal", at_ms: 700, signal: "SIGINT" }, + ], + normalization: { time_bucket_ms: 60_000 }, + timeout_ms: 2_000, + idle_timeout_ms: 2_000, + }), + { + enabled: true, + executableRoots: [dirname(process.execPath)], + workingRoots: [dirname(processFixture)], + allowedEnvironment: [], + allowExternalNetwork: true, + }, + ); + if (!result.ok) throw result.error; + expect(result.ok).toBe(true); + const output = result.value.frames.map(({ data }) => data).join(""); + expect(output).toContain("prompt>"); + expect(output).toContain("input:answer unicode:雪"); + expect(output).toContain("resize:100x40"); + expect(output).toContain("signal:SIGINT"); + expect(result.value.exit.code).toBe(0); +}); + +it("dispatches scheduled events before a silent PTY produces output", async () => { + const capability = await probeProcessCaptureCapability(); + if (!capability.available) return; + const result = await captureProcessScenario( + parseProcessScenario({ + approved: true, + executable: process.execPath, + arguments: [processFixture, "silent-interactive"], + working_directory: dirname(processFixture), + events: [ + { type: "resize", at_ms: 25, columns: 100, rows: 40 }, + { type: "input", at_ms: 50, data: "answer" }, + ], + timeout_ms: 2_000, + idle_timeout_ms: 2_000, + }), + { + enabled: true, + executableRoots: [ + join(dirname(process.execPath), "missing"), + dirname(process.execPath), + ], + workingRoots: [ + join(dirname(processFixture), "missing"), + dirname(processFixture), + ], + allowedEnvironment: [], + allowExternalNetwork: true, + }, + ); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + expect(result.value.frames.map(({ data }) => data).join("")).toContain( + "input:answer", + ); + expect(result.value.interaction_events).toMatchObject([ + { type: "resize", outcome: "dispatched" }, + { type: "input", outcome: "dispatched" }, + ]); +}); + +it("samples and cleans a source-owned child and grandchild process tree", async () => { + const capability = await probeProcessCaptureCapability(); + if (!capability.available) return; + const result = await captureProcessScenario( + parseProcessScenario({ + approved: true, + executable: process.execPath, + arguments: [processFixture, "tree"], + working_directory: dirname(processFixture), + timeout_ms: 2_000, + idle_timeout_ms: 2_000, + }), + { + enabled: true, + executableRoots: [dirname(process.execPath)], + workingRoots: [dirname(processFixture)], + allowedEnvironment: [], + allowExternalNetwork: true, + }, + ); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + const commands = result.value.process_samples.map(({ command }) => command); + expect(commands.some((command) => command.includes("tree-child"))).toBe(true); + expect(commands.some((command) => command.includes("forks.js"))).toBe(false); + expect(commands.some((command) => command.includes("tree-grandchild"))).toBe( + true, + ); + expect(JSON.stringify(result.value.process_samples)).not.toContain( + dirname(processFixture), + ); + const { stdout } = await execFileAsync("ps", ["-axo", "command="]); + expect(stdout).not.toContain(`${processFixture} tree-child`); + expect(stdout).not.toContain(`${processFixture} tree-grandchild`); +}, 20_000); diff --git a/tests/boundary/process/processCaptureMultisource.test.ts b/tests/boundary/process/processCaptureMultisource.test.ts new file mode 100644 index 000000000..057738a34 --- /dev/null +++ b/tests/boundary/process/processCaptureMultisource.test.ts @@ -0,0 +1,269 @@ +import { rm } from "node:fs/promises"; +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, it } from "vitest"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +import { + captureProcessScenario, + probeProcessCaptureCapability, +} from "../../../src/application/ProcessHarness.js"; +import { + parseProcessScenario, + validateProcessCapture, + type ProcessCapture, +} from "../../../src/domain/processCapture.js"; + +type EventSource = "process" | "filesystem" | "http" | "websocket" | "shim"; + +function eventTrigger( + source: EventSource, + exact: Record, + ignoreFields: readonly string[], +) { + return { + kind: "event" as const, + source, + exact, + ignore_fields: ignoreFields, + since: { kind: "scenario_start" as const }, + consume: true, + cardinality: { min: 1, max: 1 }, + }; +} + +function collectionTriggers() { + const terminalTrigger = (literal: string) => ({ + kind: "terminal_text" as const, + view: "decoded" as const, + encoding: "utf8" as const, + literal, + case_sensitive: true, + control_sequences: "include" as const, + occurrence: 1, + since: { kind: "scenario_start" as const }, + consume: false, + }); + return [ + { + kind: "any" as const, + triggers: [ + terminalTrigger("Collecting"), + terminalTrigger("unreachable-alternative"), + ], + }, + eventTrigger( + "process", + { + pid: 3, + parent_pid: 1, + command: "rea-reactive-worker", + process_group_id: 1, + session_id: 1, + }, + ["at_ms"], + ), + eventTrigger( + "filesystem", + { + name: "ready_snapshot", + files: [], + effects: [], + truncated: false, + }, + ["at_ms"], + ), + eventTrigger( + "shim", + { + command: "codex", + route_index: 0, + arguments: ["probe"], + working_directory: "", + outcome: "matched", + }, + ["sequence", "at_ms"], + ), + { + kind: "sequence" as const, + triggers: [ + eventTrigger( + "http", + { + protocol: "http", + direction: "request", + method: "GET", + path: "/reactive", + data: "", + outcome: "matched", + }, + ["sequence", "at_ms"], + ), + eventTrigger( + "websocket", + { + protocol: "websocket", + direction: "received", + method: null, + path: "/ws", + data: "reactive-client", + outcome: "matched", + }, + ["sequence", "at_ms"], + ), + ], + }, + ]; +} + +function createMultiSourceScenario(root: string, script: string) { + return parseProcessScenario({ + approved: true, + executable: process.execPath, + arguments: [script, "codex"], + working_directory: root, + replay: { + http: [{ method: "GET", path: "/reactive", status: 200, body: "ok" }], + websocket_messages: ["reactive-server"], + }, + command_shims: [ + { + name: "codex", + routes: [ + { + arguments: ["probe"], + outputs: [], + termination: { type: "exit", code: 0 }, + }, + ], + }, + ], + reactive: { + version: 1, + initial_state: "ready", + deadline_ms: 8_000, + states: [ + { + id: "ready", + max_visits: 1, + deadline_ms: 3_000, + on: [ + { + id: "checkpoint_ready", + priority: 0, + max_uses: 1, + when: { + kind: "terminal_text", + view: "decoded", + encoding: "utf8", + literal: "Ready", + case_sensitive: true, + control_sequences: "include", + occurrence: 1, + since: { kind: "scenario_start" }, + consume: true, + }, + actions: [{ type: "checkpoint", name: "ready_snapshot" }], + target: { kind: "goto", state: "collecting" }, + }, + ], + }, + { + id: "collecting", + max_visits: 1, + deadline_ms: 5_000, + on: [ + { + id: "finish_all_sources", + priority: 0, + max_uses: 1, + when: { kind: "all", triggers: collectionTriggers() }, + actions: [], + target: { kind: "finish", outcome: "passed" }, + }, + ], + }, + ], + }, + }); +} + +// This is the single multi-source acceptance matrix for the process boundary. +it("runs the committed multi-source reactive fixture deterministically", async () => { + const root = await createTestTempDirectory("rea-reactive-e2e-"); + const script = fileURLToPath( + new URL("../../fixtures/processReactiveScenario.mjs", import.meta.url), + ); + const run = () => + captureProcessScenario(createMultiSourceScenario(root, script), { + enabled: true, + executableRoots: [dirname(process.execPath)], + workingRoots: [root], + allowedEnvironment: [], + allowExternalNetwork: true, + }); + try { + const capability = await probeProcessCaptureCapability(); + if (!capability.available) return; + const first = await run(); + const second = await run(); + if (!first.ok) throw first.error; + if (!second.ok) throw second.error; + expect(first.ok).toBe(true); + expect(second.ok).toBe(true); + const trace = (capture: ProcessCapture) => + capture.reactive_run?.transitions.map((transition) => ({ + transition_id: transition.transition_id, + from: transition.state_before, + to: transition.state_after, + outcome: transition.outcome, + trigger_collections: transition.trigger_event_ids.map( + (id) => id.split(".")[1], + ), + })); + expect( + first.value.reactive_run?.outcome, + JSON.stringify({ + run: first.value.reactive_run, + processes: first.value.process_samples, + checkpoints: first.value.filesystem_checkpoints, + shims: first.value.shim_events, + protocols: first.value.protocol_events, + }), + ).toBe("passed"); + expect(trace(first.value)).toEqual(trace(second.value)); + expect(trace(first.value)).toEqual([ + expect.objectContaining({ transition_id: "checkpoint_ready" }), + expect.objectContaining({ + transition_id: "finish_all_sources", + trigger_collections: expect.arrayContaining([ + "frames", + "process_samples", + "filesystem_checkpoints", + "shim_events", + "protocol_events", + ]), + }), + ]); + for (const outcome of ["action_rejected", "target_lost"] as const) { + const failedActionCapture = { + ...first.value, + reactive_run: { + status: "finished" as const, + outcome, + active_state: "ready", + transitions: [], + controls: [], + }, + }; + expect( + validateProcessCapture(failedActionCapture).filter(({ path }) => + path.startsWith("reactive_run"), + ), + ).toEqual([]); + } + expect(first.value.truncated).toBe(false); + } finally { + await rm(root, { recursive: true, force: true }); + } +}, 30_000); diff --git a/tests/boundary/process/processCaptureObservation.test.ts b/tests/boundary/process/processCaptureObservation.test.ts new file mode 100644 index 000000000..a794b5830 --- /dev/null +++ b/tests/boundary/process/processCaptureObservation.test.ts @@ -0,0 +1,407 @@ +import { expect, it } from "vitest"; + +import { snapshotRoots } from "../../../src/application/FilesystemSnapshot.js"; +import { + buildCaptureResult, + prepareProcessCapture, + type ProcessPreparationHost, +} from "../../../src/application/ProcessCaptureLifecycle.js"; +import { ProcessCheckpoints } from "../../../src/application/ProcessCheckpoints.js"; +import { normalizeProcessSamples } from "../../../src/application/ProcessNormalization.js"; +import { + isInitializedPtyRoot, + readLinuxChildren, +} from "../../../src/application/ProcessSampling.js"; +import { TerminalRenderer } from "../../../src/application/TerminalRenderer.js"; +import { + authorizeProcessScenario, + compareProcessCaptures, + digestProcessCommitment, + parseProcessCapture, + parseProcessScenario, + type ProcessCapture, +} from "../../../src/domain/processCapture.js"; + +const base = { + approved: true as const, + executable: "/bin/sh", + working_directory: "/tmp", +}; + +const emptyCapture = (): ProcessCapture => { + const normalization = { + paths: true, + pids: true, + ports: true, + time_bucket_ms: 10, + patterns: [], + }; + const scenario = { executable_sha256: "0".repeat(64) }; + const comparisonContract = {}; + const shimPlan: readonly unknown[] = []; + const replayPlan = {}; + return { + schema_version: 4, + manifest: { + rea_version: "test", + provider_version: "3", + platform: process.platform, + architecture: process.arch, + pty_backend: "node-pty", + started_at: "2026-01-01T00:00:00.000Z", + completed_at: "2026-01-01T00:00:00.001Z", + scenario, + comparison_contract: comparisonContract, + shim_plan: shimPlan, + replay_plan: replayPlan, + full_scenario_sha256: digestProcessCommitment(scenario), + comparison_contract_sha256: digestProcessCommitment(comparisonContract), + executable_sha256: "0".repeat(64), + normalization_sha256: digestProcessCommitment(normalization), + shim_plan_sha256: digestProcessCommitment(shimPlan), + replay_plan_sha256: digestProcessCommitment(replayPlan), + }, + normalization, + frames: [], + rendered_frames: [], + interaction_events: [], + exit: { code: 0, signal: null, reason: "exited" }, + settlement: { + state: "quiesced", + elapsed_ms: 50, + cleanup_outcome: "not_required", + }, + process_samples: [], + filesystem_checkpoints: [ + { name: "before", at_ms: 0, files: [], effects: [], truncated: false }, + { + name: "after_settlement", + at_ms: 50, + files: [], + effects: [], + truncated: false, + }, + ], + shim_events: [], + protocol_events: [], + replay_transitions: [], + reactive_run: null, + event_journal: [], + files_before: [], + files_after: [], + filesystem_effects: [], + truncated: false, + limitations: [], + residual_unknowns: [], + cleanup: { + owned_process_group: "verified", + temporary_root: "removed", + }, + }; +}; + +it("returns detached terminal and filesystem checkpoint observations", async () => { + const observed: string[] = []; + const renderer = new TerminalRenderer({ + columns: 40, + rows: 12, + scrollback: 100, + maxFrames: 10, + maxBytes: 10_000, + normalize: (value) => value, + recordEvent: (collection, index) => + observed.push(`${collection}:${String(index)}`), + }); + renderer.write("A", 20); + renderer.resize(40, 12, 10); + const frames = await renderer.frames(); + expect(frames.map(({ at_ms }) => at_ms)).toEqual([20, 10]); + expect(observed).toEqual(["rendered_frames:0", "rendered_frames:1"]); + if (frames[0] !== undefined) Reflect.set(frames[0], "cursor_x", 999); + expect((await renderer.frames())[0]?.cursor_x).not.toBe(999); + await renderer.dispose(); + + const scenario = parseProcessScenario(base); + const checkpoints = new ProcessCheckpoints( + scenario, + Date.now(), + { files: [], truncated: false }, + { signal: undefined }, + ); + const first = await checkpoints.finish({ files: [], truncated: false }); + if (first[0] !== undefined) Reflect.set(first[0], "name", "tampered"); + const second = await checkpoints.finish({ files: [], truncated: false }); + expect(second[0]?.name).toBe("before"); + await checkpoints.dispose(); +}); + +it("preserves rendered observation order instead of timestamp sorting", () => { + const capture = emptyCapture(); + const renderedFrames: ProcessCapture["rendered_frames"] = [ + { + sequence: 0, + at_ms: 20, + columns: 1, + rows: 1, + cursor_x: 0, + cursor_y: 0, + active_buffer: "normal", + lines: ["first"], + serialized_state: "first", + }, + { + sequence: 1, + at_ms: 10, + columns: 1, + rows: 1, + cursor_x: 0, + cursor_y: 0, + active_buffer: "normal", + lines: ["second"], + serialized_state: "second", + }, + ]; + const result = buildCaptureResult({ + frames: [], + reactiveRun: null, + exit: { exitCode: 0, reason: "exited" }, + samples: [], + replay: { + httpUrl: "http://127.0.0.1", + websocketUrl: "ws://127.0.0.1/ws", + events: [], + transitions: [], + truncated: false, + close: () => Promise.resolve(), + }, + before: { files: [], truncated: false }, + after: { files: [], truncated: false }, + truncated: false, + scenario: parseProcessScenario(base), + rootPid: 1, + samplingPartial: false, + renderedFrames, + interactions: [], + checkpoints: capture.filesystem_checkpoints, + shimEvents: [], + settlement: capture.settlement, + manifest: capture.manifest, + eventJournal: [], + }); + + expect(result.rendered_frames).toEqual(renderedFrames); +}); + +it("cleans the temporary root when capture home creation fails", async () => { + const cleaned: string[] = []; + const host: ProcessPreparationHost = { + createTemporaryRoot: () => Promise.resolve("/tmp/rea-process-fixture"), + createHome: () => Promise.reject(new Error("mkdir failed")), + cleanup: (path) => { + cleaned.push(path); + return Promise.resolve(); + }, + }; + + await expect( + prepareProcessCapture( + parseProcessScenario(base), + { + enabled: true, + executableRoots: ["/bin"], + workingRoots: ["/tmp"], + allowedEnvironment: [], + allowExternalNetwork: true, + }, + undefined, + host, + ), + ).rejects.toThrow("mkdir failed"); + expect(cleaned).toEqual(["/tmp/rea-process-fixture"]); +}); + +it("normalizes every sampled process identifier in command text", () => { + const samples = normalizeProcessSamples( + [ + { + at_ms: 0, + pid: 101, + parent_pid: 0, + process_group_id: 101, + session_id: 101, + command: "root 101", + }, + { + at_ms: 10, + pid: 202, + parent_pid: 101, + process_group_id: 101, + session_id: 101, + command: "child 202 peer=101 unrelated 1202", + }, + ], + parseProcessScenario(base), + 101, + ); + + expect(samples[1]?.command).toBe("child peer= unrelated 1202"); +}); + +it("collects and deduplicates children from every Linux thread", async () => { + const signal = new AbortController().signal; + expect( + await readLinuxChildren(100, signal, { + taskIds: () => Promise.resolve([100, 101, 102]), + children: (_pid, taskId) => + Promise.resolve( + taskId === 100 ? "201 202" : taskId === 101 ? "202 203" : "", + ), + }), + ).toEqual([201, 202, 203]); +}); + +it("admits PTY samples only after stable session and token setup", () => { + const initialized = { + pid: 100, + parent_pid: 10, + process_group_id: 100, + session_id: 100, + startTime: "200", + }; + expect( + isInitializedPtyRoot({ + rootPid: 100, + expectedRunId: "run-token", + before: { ...initialized, process_group_id: 10, session_id: 10 }, + observedRunId: undefined, + after: initialized, + }), + ).toBe(false); + expect( + isInitializedPtyRoot({ + rootPid: 100, + expectedRunId: "run-token", + before: initialized, + observedRunId: "run-token", + after: { ...initialized, startTime: "201" }, + }), + ).toBe(false); + expect( + isInitializedPtyRoot({ + rootPid: 100, + expectedRunId: "run-token", + before: initialized, + observedRunId: "run-token", + after: initialized, + }), + ).toBe(true); + expect( + isInitializedPtyRoot({ + rootPid: 100, + expectedRunId: "run-token", + before: { ...initialized, session_id: null }, + observedRunId: "run-token", + after: { ...initialized, session_id: null }, + }), + ).toBe(true); +}); + +it("keeps interaction and shim residual uncertainty in separate scopes", () => { + const baseCapture = emptyCapture(); + const interaction = parseProcessCapture({ + ...baseCapture, + residual_unknowns: [ + { scope: "interaction", reason: "Interaction capture was partial." }, + ], + }); + const shim = parseProcessCapture({ + ...baseCapture, + residual_unknowns: [{ scope: "shim", reason: "Shim capture was partial." }], + }); + + expect(compareProcessCaptures(interaction, baseCapture)).toMatchObject({ + status: "unknown", + terminal: "unchanged", + interaction: "unknown", + shim: "unchanged", + }); + expect(compareProcessCaptures(shim, baseCapture)).toMatchObject({ + status: "unknown", + terminal: "unchanged", + interaction: "unchanged", + shim: "unknown", + }); +}); + +it("cancels filesystem snapshots before traversing declared roots", async () => { + const controller = new AbortController(); + controller.abort(); + await expect( + snapshotRoots( + parseProcessScenario({ + ...base, + filesystem_roots: ["/tmp"], + }), + controller.signal, + ), + ).rejects.toMatchObject({ name: "AbortError" }); +}); + +it("parses bounded scenarios and rejects unordered events", () => { + expect(parseProcessScenario(base).timeout_ms).toBe(30_000); + expect(() => + parseProcessScenario({ + ...base, + events: [ + { type: "input", at_ms: 2, data: "a" }, + { type: "input", at_ms: 1, data: "b" }, + ], + }), + ).toThrow(/ordered/); + expect(() => + parseProcessScenario({ + ...base, + environment: { HOME: "/unsafe" }, + }), + ).toThrow(/reserved/); + expect(() => + parseProcessScenario({ + ...base, + events: [{ type: "input", at_ms: 31_000, data: "late" }], + }), + ).toThrow(/after the scenario timeout/); +}); + +it("requires explicit operator approval for host network access", () => { + expect( + authorizeProcessScenario(parseProcessScenario(base), { + enabled: true, + executableRoots: ["/bin"], + workingRoots: ["/tmp"], + allowedEnvironment: [], + allowExternalNetwork: false, + }), + ).toEqual({ + allowed: false, + reason: "host network access is not approved by operator policy", + }); +}); + +it("refuses paths and environment outside operator policy", () => { + const scenario = parseProcessScenario({ + ...base, + environment: { TOKEN: "secret" }, + }); + expect( + authorizeProcessScenario(scenario, { + enabled: true, + executableRoots: ["/bin"], + workingRoots: ["/tmp"], + allowedEnvironment: [], + allowExternalNetwork: true, + }), + ).toEqual({ + allowed: false, + reason: "scenario requests an environment variable not allowed by policy", + }); +}); diff --git a/tests/boundary/process/processCaptureReactive.test.ts b/tests/boundary/process/processCaptureReactive.test.ts new file mode 100644 index 000000000..ad136caa1 --- /dev/null +++ b/tests/boundary/process/processCaptureReactive.test.ts @@ -0,0 +1,424 @@ +import { rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { expect, it } from "vitest"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +import { + captureProcessScenario, + probeProcessCaptureCapability, +} from "../../../src/application/ProcessHarness.js"; +import { + parseProcessScenario, + validateProcessCapture, + type ProcessCapture, +} from "../../../src/domain/processCapture.js"; + +function createInteractiveScenario(root: string, script: string) { + return parseProcessScenario({ + approved: true, + executable: process.execPath, + arguments: [script], + working_directory: root, + reactive: { + version: 1, + initial_state: "waiting", + deadline_ms: 5_000, + states: [ + { + id: "waiting", + max_visits: 1, + deadline_ms: 2_000, + on: [ + { + id: "answer", + priority: 0, + max_uses: 1, + when: { + kind: "terminal_text", + view: "decoded", + encoding: "utf8", + literal: "Ready", + case_sensitive: true, + control_sequences: "include", + occurrence: 1, + since: { kind: "scenario_start" }, + consume: true, + }, + actions: [ + { type: "send_input", data: "accepted\n", sensitive: true }, + { type: "close_stdin" }, + ], + target: { kind: "goto", state: "finishing" }, + }, + ], + }, + { + id: "finishing", + max_visits: 1, + deadline_ms: 2_000, + on: [ + { + id: "complete", + priority: 0, + max_uses: 1, + when: { + kind: "terminal_text", + view: "decoded", + encoding: "utf8", + literal: "Done", + case_sensitive: true, + control_sequences: "include", + occurrence: 1, + since: { kind: "state_entry" }, + consume: true, + }, + actions: [], + target: { kind: "finish", outcome: "passed" }, + }, + ], + }, + ], + }, + }); +} + +function assertCompletedCapture(capture: ProcessCapture): void { + expect(capture.reactive_run).toMatchObject({ + status: "finished", + outcome: "passed", + active_state: "finishing", + }); + expect( + capture.reactive_run?.transitions.map(({ transition_id }) => transition_id), + ).toEqual(["answer", "complete"]); + expect(capture.interaction_events).toContainEqual( + expect.objectContaining({ + type: "input", + data: "", + outcome: "dispatched", + }), + ); + expect(capture.interaction_events).toContainEqual( + expect.objectContaining({ + type: "stdin_close", + data: "", + outcome: "dispatched", + }), + ); + expect(capture.frames.map(({ data }) => data).join("")).toContain("Done"); + expect(capture.manifest.comparison_contract).toHaveProperty("reactive"); + expect(JSON.stringify(capture.manifest.comparison_contract)).not.toContain( + "accepted", + ); + expect(JSON.stringify(capture.manifest.scenario)).not.toContain("accepted"); +} + +function assertControlAndTransitionValidation(capture: ProcessCapture): void { + const reactiveRun = capture.reactive_run; + if (reactiveRun === null) throw new Error("reactive result missing"); + const lastOrder = capture.event_journal + ?.filter(({ collection }) => + [ + "frames", + "interaction_events", + "filesystem_checkpoints", + "shim_events", + ].includes(collection), + ) + .at(-1)?.capture_order; + if (lastOrder === undefined) + throw new Error("reactive observation order missing"); + expect( + validateProcessCapture({ + ...capture, + reactive_run: { + ...reactiveRun, + controls: [ + ...reactiveRun.controls, + { + sequence: reactiveRun.controls.length, + kind: "target_lost", + after_capture_order: lastOrder, + }, + ], + }, + }), + ).toContainEqual( + expect.objectContaining({ + path: `reactive_run.controls[${String(reactiveRun.controls.length)}]`, + message: "reactive control cannot follow a finished run", + }), + ); + expect( + validateProcessCapture({ + ...capture, + reactive_run: { + ...reactiveRun, + transitions: reactiveRun.transitions.map((transition, index) => + index === 0 + ? { ...transition, transition_id: "fabricated" } + : transition, + ), + }, + }), + ).toContainEqual( + expect.objectContaining({ + path: "reactive_run.transitions[0]", + message: + "reactive transition is not declared by its recorded source state", + }), + ); + expect( + validateProcessCapture({ + ...capture, + reactive_run: { + status: "finished", + outcome: "passed", + active_state: "waiting", + transitions: [], + controls: [], + }, + }), + ).toContainEqual( + expect.objectContaining({ + path: "reactive_run.outcome", + message: "reactive outcome differs from deterministic journal replay", + }), + ); + const suffix = reactiveRun.transitions + .slice(1) + .map((transition, sequence) => ({ + ...transition, + sequence, + })); + expect( + validateProcessCapture({ + ...capture, + reactive_run: { ...reactiveRun, transitions: suffix }, + }), + ).toContainEqual( + expect.objectContaining({ + path: "reactive_run.transitions[0].state_before", + message: + "reactive transition journal must start at the declared initial state", + }), + ); +} + +function assertReplayValidation(capture: ProcessCapture): void { + const reactiveRun = capture.reactive_run; + if (reactiveRun === null) throw new Error("reactive result missing"); + expect( + validateProcessCapture({ + ...capture, + reactive_run: { + ...reactiveRun, + transitions: reactiveRun.transitions.map((transition, index) => + index === 0 + ? { ...transition, trigger_event_ids: [], action_event_ids: [] } + : transition, + ), + }, + }), + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: "reactive_run.transitions", + message: expect.stringContaining( + "reactive transition journal is not reproduced", + ), + }), + expect.objectContaining({ + path: "reactive_run.transitions[0].action_event_ids", + message: expect.stringContaining( + "recorded action observations do not match proposed effects", + ), + }), + ]), + ); + expect( + validateProcessCapture({ + ...capture, + reactive_run: { ...reactiveRun, status: "running", outcome: null }, + }), + ).toContainEqual( + expect.objectContaining({ + message: "completed capture requires a finished reactive outcome", + }), + ); + expect( + validateProcessCapture({ + ...capture, + reactive_run: { + ...reactiveRun, + transitions: [ + reactiveRun.transitions[0]!, + { ...reactiveRun.transitions[0]!, sequence: 1 }, + ], + }, + }), + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: "reactive transition states must be contiguous", + }), + expect.objectContaining({ + message: + "reactive transition differs from deterministic journal replay", + }), + ]), + ); +} + +it("drives a process from terminal observations and retains the reactive transition journal", async () => { + const root = await createTestTempDirectory("rea-reactive-harness-test-"); + const script = join(root, "reactive.mjs"); + await writeFile( + script, + [ + 'import { createInterface } from "node:readline";', + "const input = createInterface({ input: process.stdin, terminal: false });", + 'process.stdout.write("Ready\\n");', + 'input.once("line", () => { process.stdout.write("Done\\n"); input.close(); });', + ].join("\n"), + ); + const scenario = createInteractiveScenario(root, script); + try { + const capability = await probeProcessCaptureCapability(); + if (!capability.available) return; + const result = await captureProcessScenario(scenario, { + enabled: true, + executableRoots: [dirname(process.execPath)], + workingRoots: [root], + allowedEnvironment: [], + allowExternalNetwork: true, + }); + if (!result.ok) throw result.error; + assertCompletedCapture(result.value); + assertControlAndTransitionValidation(result.value); + assertReplayValidation(result.value); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +it("records target loss before post-exit settlement can win the deadline race", async () => { + const root = await createTestTempDirectory("rea-reactive-target-loss-test-"); + const script = join(root, "exit.mjs"); + await writeFile(script, 'process.stdout.write("exiting\\n");\n'); + const scenario = parseProcessScenario({ + approved: true, + executable: process.execPath, + arguments: [script], + working_directory: root, + settle_ms: 500, + reactive: { + version: 1, + initial_state: "waiting", + deadline_ms: 5_000, + states: [ + { + id: "waiting", + max_visits: 1, + // Leave process startup scheduling headroom; this test targets + // post-exit settlement ordering, not a 300ms startup deadline. + deadline_ms: 2_000, + on: [ + { + id: "unreachable", + priority: 0, + max_uses: 1, + when: { + kind: "terminal_text", + view: "decoded", + encoding: "utf8", + literal: "never-produced", + case_sensitive: true, + control_sequences: "include", + occurrence: 1, + since: { kind: "scenario_start" }, + consume: true, + }, + actions: [], + target: { kind: "finish", outcome: "passed" }, + }, + ], + }, + ], + }, + }); + try { + const capability = await probeProcessCaptureCapability(); + if (!capability.available) return; + const result = await captureProcessScenario(scenario, { + enabled: true, + executableRoots: [dirname(process.execPath)], + workingRoots: [root], + allowedEnvironment: [], + allowExternalNetwork: true, + }); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + expect(result.value.reactive_run).toMatchObject({ + status: "finished", + outcome: "target_lost", + active_state: "waiting", + transitions: [], + controls: [ + expect.objectContaining({ + kind: "target_lost", + }), + ], + }); + const frame = result.value.frames.at(-1); + const journal = result.value.event_journal; + const reactiveRun = result.value.reactive_run; + if (frame === undefined || journal === undefined || reactiveRun === null) + throw new Error("ordered reactive evidence missing"); + const laterCaptureOrder = journal.length; + const withLaterMatch = { + ...result.value, + frames: [ + ...result.value.frames, + { + ...frame, + sequence: result.value.frames.length, + at_ms: frame.at_ms + 1, + data: "never-produced", + }, + ], + event_journal: [ + ...journal, + { + capture_order: laterCaptureOrder, + collection: "frames" as const, + index: result.value.frames.length, + }, + ], + }; + expect( + validateProcessCapture(withLaterMatch).filter(({ path }) => + path.startsWith("reactive_run"), + ), + ).toEqual([]); + expect( + validateProcessCapture({ + ...withLaterMatch, + reactive_run: { + ...reactiveRun, + controls: reactiveRun.controls.map((control) => ({ + ...control, + after_capture_order: laterCaptureOrder, + })), + }, + }), + ).toContainEqual( + expect.objectContaining({ + path: "reactive_run.outcome", + message: "reactive outcome differs from deterministic journal replay", + }), + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}, 10_000); diff --git a/tests/boundary/process/processCaptureReplayMachine.test.ts b/tests/boundary/process/processCaptureReplayMachine.test.ts new file mode 100644 index 000000000..93554f55e --- /dev/null +++ b/tests/boundary/process/processCaptureReplayMachine.test.ts @@ -0,0 +1,230 @@ +import { readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { expect, it } from "vitest"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +import { + captureProcessScenario, + probeProcessCaptureCapability, +} from "../../../src/application/ProcessHarness.js"; +import { + digestProcessCommitment, + parseProcessScenario, + type ProcessExecutionPolicy, +} from "../../../src/domain/processCapture.js"; + +it("runs a login and reconnect replay machine inside process capture", async () => { + const root = await createTestTempDirectory("rea-replay-machine-test-"); + const script = join(root, "client.mjs"); + await writeFile( + script, + [ + 'const token = "machine-secret";', + 'await fetch(`${process.env.REA_REPLAY_HTTP_URL}/login`, { method: "POST", body: JSON.stringify({ token }) });', + "const api = await fetch(`${process.env.REA_REPLAY_HTTP_URL}/api`, { headers: { authorization: token } });", + 'if (await api.text() !== "connect") throw new Error("bad API response");', + "const connect = (acknowledge) => new Promise((resolve, reject) => {", + " const socket = new WebSocket(process.env.REA_REPLAY_WEBSOCKET_URL);", + " socket.addEventListener('error', reject);", + " socket.addEventListener('message', (event) => {", + " if (acknowledge) socket.send('ack');", + " else { console.log(`reply:${event.data}`); socket.close(); resolve(); }", + " });", + " if (acknowledge) socket.addEventListener('close', () => resolve());", + "});", + "await connect(true);", + "await connect(false);", + ].join("\n"), + ); + const scenario = parseProcessScenario({ + approved: true, + executable: process.execPath, + arguments: [script], + working_directory: root, + replay: { + machine: { + initial_state: "login", + states: [ + { name: "login" }, + { name: "api" }, + { name: "socket" }, + { name: "ack" }, + { name: "reconnect" }, + { name: "complete", terminal: true }, + ], + transitions: [ + { + id: "login", + from: "login", + to: "api", + trigger: { protocol: "http", method: "POST", path: "/login" }, + captures: [ + { + variable: "token", + value: { source: "request_json", path: ["token"] }, + sensitive: true, + }, + ], + actions: [{ type: "http_response", status: 204, body: "" }], + max_uses: 1, + }, + { + id: "api", + from: "api", + to: "socket", + trigger: { protocol: "http", method: "GET", path: "/api" }, + guards: [ + { + variable: "token", + value: { source: "request_header", name: "authorization" }, + }, + ], + actions: [{ type: "http_response", status: 200, body: "connect" }], + max_uses: 1, + }, + { + id: "initial_socket", + from: "socket", + to: "ack", + trigger: { protocol: "websocket_connect", path: "/ws" }, + actions: [{ type: "websocket_send", data: "welcome" }], + max_uses: 1, + }, + { + id: "ack", + from: "ack", + to: "reconnect", + trigger: { + protocol: "websocket_message", + path: "/ws", + body: "ack", + }, + actions: [{ type: "disconnect" }], + max_uses: 1, + }, + { + id: "reconnect", + from: "reconnect", + to: "complete", + trigger: { protocol: "websocket_connect", path: "/ws" }, + actions: [{ type: "websocket_send", data: "done" }], + max_uses: 1, + }, + ], + max_transitions: 5, + }, + }, + }); + try { + const capability = await probeProcessCaptureCapability(); + if (!capability.available) return; + const capture = await captureProcessScenario(scenario, { + enabled: true, + executableRoots: [dirname(process.execPath)], + workingRoots: [root], + allowedEnvironment: [], + allowExternalNetwork: true, + }); + expect(capture.ok).toBe(true); + if (!capture.ok) throw capture.error; + expect( + capture.value.replay_transitions.map( + ({ transition_id }) => transition_id, + ), + ).toEqual(["login", "api", "initial_socket", "ack", "reconnect"]); + expect(capture.value.frames.map(({ data }) => data).join("")).toContain( + "reply:done", + ); + expect(JSON.stringify(capture.value)).not.toContain("machine-secret"); + expect(capture.value.manifest.replay_plan_sha256).toBe( + digestProcessCommitment(scenario.replay), + ); + expect( + capture.value.event_journal?.filter( + ({ collection }) => collection === "replay_transitions", + ), + ).toHaveLength(capture.value.replay_transitions.length); + for (const [index, entry] of (capture.value.event_journal ?? []).entries()) + if (entry.collection === "replay_transitions") + expect(capture.value.event_journal?.[index - 1]?.collection).toBe( + "protocol_events", + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}, 20_000); + +it("captures PTY, filesystem, descendants, HTTP replay, and redacts environment", async () => { + const root = await createTestTempDirectory("rea-harness-test-"); + const script = join(root, "fixture.mjs"); + await writeFile( + script, + [ + 'import { writeFile } from "node:fs/promises";', + 'import { spawn } from "node:child_process";', + 'await writeFile(new URL("result.txt", `file://${process.cwd()}/`), "created");', + "const response = await fetch(`${process.env.REA_REPLAY_HTTP_URL}/probe`);", + "console.log(`reply:${await response.text()}`);", + "console.log(`sensitive:${process.env.SECRET}`);", + 'const child = spawn(process.execPath, ["-e", "setTimeout(()=>{}, 150)"], { stdio: "ignore" });', + "await new Promise((resolve) => setTimeout(resolve, 80));", + "child.kill();", + ].join("\n"), + ); + const policy: ProcessExecutionPolicy = { + enabled: true, + executableRoots: [dirname(process.execPath)], + workingRoots: [root], + allowedEnvironment: ["SECRET"], + allowExternalNetwork: true, + }; + const scenario = parseProcessScenario({ + approved: true, + executable: process.execPath, + arguments: [script], + working_directory: root, + filesystem_roots: [root], + environment: { SECRET: "do-not-record" }, + secret_aliases: ["SECRET"], + replay: { + http: [{ method: "GET", path: "/probe", status: 200, body: "ok" }], + }, + }); + try { + const capability = await probeProcessCaptureCapability(); + if (!capability.available) { + expect(capability.reason).toMatch(/native PTY/); + return; + } + const capture = await captureProcessScenario(scenario, policy); + expect(capture.ok).toBe(true); + if (!capture.ok) throw capture.error; + expect(capture.value.frames.map((frame) => frame.data).join("")).toContain( + "reply:ok", + ); + expect(capture.value.frames.map((frame) => frame.data).join("")).toContain( + "sensitive:", + ); + expect( + capture.value.files_after.some((file) => + file.path.endsWith("result.txt"), + ), + ).toBe(true); + expect( + capture.value.filesystem_effects.some( + (effect) => + effect.path.endsWith(":result.txt") && effect.status === "created", + ), + ).toBe(true); + expect( + capture.value.protocol_events.some( + (event) => event.protocol === "http" && event.path === "/probe", + ), + ).toBe(true); + expect(JSON.stringify(capture.value)).not.toContain("do-not-record"); + expect(await readFile(join(root, "result.txt"), "utf8")).toBe("created"); + expect(JSON.stringify(capture.value.files_after)).not.toContain(root); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/boundary/process/processCaptureReplayTransport.test.ts b/tests/boundary/process/processCaptureReplayTransport.test.ts new file mode 100644 index 000000000..199a08f12 --- /dev/null +++ b/tests/boundary/process/processCaptureReplayTransport.test.ts @@ -0,0 +1,328 @@ +import { expect, it } from "vitest"; +import WebSocket from "ws"; + +import { startLoopbackReplay } from "../../../src/application/LoopbackReplay.js"; +import { parseProcessScenario } from "../../../src/domain/processCapture.js"; + +it("serves bounded HTTP and WebSocket replay on loopback", async () => { + const scenario = parseProcessScenario({ + approved: true, + executable: "/bin/sh", + working_directory: "/tmp", + replay: { + http: [{ method: "GET", path: "/ready", status: 201, body: "ready" }], + websocket_messages: ["welcome"], + }, + }); + const replay = await startLoopbackReplay(scenario); + try { + const response = await fetch(`${replay.httpUrl}/ready`); + expect(response.status).toBe(201); + expect(await response.text()).toBe("ready"); + const websocketMessage = await new Promise( + (resolveMessage, rejectMessage) => { + const socket = new WebSocket(replay.websocketUrl); + socket.once("message", (value) => { + resolveMessage(value.toString()); + socket.close(); + }); + socket.once("error", rejectMessage); + }, + ); + expect(websocketMessage).toBe("welcome"); + expect(replay.events.map((event) => event.protocol)).toContain("websocket"); + } finally { + await replay.close(); + } +}); + +it("matches bounded HTTP scripts without persisting request secrets", async () => { + const scenario = parseProcessScenario({ + approved: true, + executable: "/bin/sh", + working_directory: "/tmp", + replay: { + http: [ + { + method: "POST", + path: "/callback", + request_headers: { authorization: "Bearer fixture-secret" }, + request_body: "credential-body", + status: 302, + response_headers: { location: "/done" }, + body: "redirecting", + max_calls: 1, + }, + { + method: "GET", + path: "/disconnect", + status: 200, + body: "", + disconnect: true, + }, + ], + }, + }); + const replay = await startLoopbackReplay(scenario); + try { + const request = () => + fetch(`${replay.httpUrl}/callback`, { + method: "POST", + headers: { authorization: "Bearer fixture-secret" }, + body: "credential-body", + redirect: "manual", + }); + const matched = await request(); + expect(matched.status).toBe(302); + expect(matched.headers.get("location")).toBe("/done"); + expect((await request()).status).toBe(409); + expect((await fetch(`${replay.httpUrl}/missing`)).status).toBe(404); + await expect(fetch(`${replay.httpUrl}/disconnect`)).rejects.toThrow(); + expect(replay.events.map(({ outcome }) => outcome)).toEqual( + expect.arrayContaining([ + "matched", + "script_exhausted", + "unmatched", + "disconnected", + ]), + ); + expect(JSON.stringify(replay.events)).not.toContain("fixture-secret"); + expect(JSON.stringify(replay.events)).not.toContain("credential-body"); + } finally { + await replay.close(); + } +}); + +it("consumes ordered WebSocket reconnect scripts and reports exhaustion", async () => { + const scenario = parseProcessScenario({ + approved: true, + executable: "/bin/sh", + working_directory: "/tmp", + replay: { + websocket_connections: [ + { + messages: [{ data: "first", delay_ms: 1 }], + disconnect_after: true, + }, + { + messages: [{ data: "second" }], + disconnect_after: true, + }, + ], + }, + }); + const replay = await startLoopbackReplay(scenario); + const receive = () => + new Promise((resolveMessage, rejectMessage) => { + const socket = new WebSocket(replay.websocketUrl); + socket.once("message", (value) => resolveMessage(value.toString())); + socket.once("error", rejectMessage); + }); + try { + await expect(receive()).resolves.toBe("first"); + await expect(receive()).resolves.toBe("second"); + await new Promise((resolveClose, rejectClose) => { + const socket = new WebSocket(replay.websocketUrl); + socket.once("close", () => resolveClose()); + socket.once("error", rejectClose); + }); + expect(replay.events.at(-1)?.outcome).toBe("script_exhausted"); + } finally { + await replay.close(); + } +}); + +it("serializes WebSocket transition actions across rapid messages", async () => { + const scenario = parseProcessScenario({ + approved: true, + executable: "/bin/sh", + working_directory: "/tmp", + replay: { + machine: { + initial_state: "connecting", + states: [ + { name: "connecting" }, + { name: "first" }, + { name: "second" }, + { name: "complete", terminal: true }, + ], + transitions: [ + { + id: "connect", + from: "connecting", + to: "first", + trigger: { protocol: "websocket_connect", path: "/ws" }, + actions: [{ type: "websocket_send", data: "ready" }], + max_uses: 1, + }, + { + id: "first", + from: "first", + to: "second", + trigger: { + protocol: "websocket_message", + path: "/ws", + body: "first", + }, + actions: [ + { type: "delay", duration_ms: 30 }, + { type: "websocket_send", data: "first-done" }, + ], + max_uses: 1, + }, + { + id: "second", + from: "second", + to: "complete", + trigger: { + protocol: "websocket_message", + path: "/ws", + body: "second", + }, + actions: [{ type: "websocket_send", data: "second-done" }], + max_uses: 1, + }, + ], + max_transitions: 3, + }, + }, + }); + const replay = await startLoopbackReplay(scenario); + try { + const messages = await new Promise((resolve, reject) => { + const received: string[] = []; + const socket = new WebSocket(replay.websocketUrl); + socket.on("message", (value) => { + received.push(value.toString()); + if (received.length === 1) { + socket.send("first"); + socket.send("second"); + } + if (received.length === 3) { + socket.close(); + resolve(received); + } + }); + socket.once("error", reject); + }); + expect(messages).toEqual(["ready", "first-done", "second-done"]); + } finally { + await replay.close(); + } +}); + +it("drains queued machine work before exposing finalized snapshots", async () => { + const scenario = parseProcessScenario({ + approved: true, + executable: "/bin/sh", + working_directory: "/tmp", + replay: { + machine: { + initial_state: "connecting", + states: [ + { name: "connecting" }, + { name: "first" }, + { name: "second" }, + { name: "complete", terminal: true }, + ], + transitions: [ + { + id: "connect", + from: "connecting", + to: "first", + trigger: { protocol: "websocket_connect", path: "/ws" }, + actions: [{ type: "websocket_send", data: "ready" }], + max_uses: 1, + }, + { + id: "first", + from: "first", + to: "second", + trigger: { + protocol: "websocket_message", + path: "/ws", + body: "first", + }, + actions: [ + { type: "delay", duration_ms: 30 }, + { type: "disconnect" }, + ], + max_uses: 1, + }, + { + id: "second", + from: "second", + to: "complete", + trigger: { + protocol: "websocket_message", + path: "/ws", + body: "second", + }, + actions: [{ type: "websocket_send", data: "done" }], + max_uses: 1, + }, + ], + max_transitions: 3, + }, + }, + }); + const replay = await startLoopbackReplay(scenario); + await new Promise((resolve, reject) => { + const socket = new WebSocket(replay.websocketUrl); + socket.once("message", () => { + socket.send("first"); + socket.send("second"); + setTimeout(resolve, 5); + }); + socket.once("error", reject); + }); + + await replay.close(); + const finalized = replay.transitions; + expect(finalized.map(({ transition_id }) => transition_id)).toEqual([ + "connect", + "first", + "second", + ]); + await new Promise((resolve) => setTimeout(resolve, 40)); + expect(replay.transitions).toEqual(finalized); +}); + +it("enforces replay duration before normalizing recorded timestamps", async () => { + const scenario = parseProcessScenario({ + approved: true, + executable: "/bin/sh", + working_directory: "/tmp", + normalization: { time_bucket_ms: 60_000 }, + replay: { + machine: { + initial_state: "waiting", + states: [{ name: "waiting" }, { name: "complete", terminal: true }], + transitions: [ + { + id: "late", + from: "waiting", + to: "complete", + trigger: { protocol: "http", method: "GET", path: "/late" }, + actions: [{ type: "http_response", status: 200, body: "late" }], + max_uses: 1, + }, + ], + max_transitions: 1, + limits: { duration_ms: 5 }, + }, + }, + }); + const replay = await startLoopbackReplay(scenario); + try { + await new Promise((resolve) => setTimeout(resolve, 20)); + const response = await fetch(`${replay.httpUrl}/late`); + expect(response.status).toBe(409); + expect(replay.events.at(-1)).toMatchObject({ + at_ms: 0, + outcome: "limit_exhausted", + }); + } finally { + await replay.close(); + } +}); diff --git a/tests/boundary/process/processCaptureTerminal.test.ts b/tests/boundary/process/processCaptureTerminal.test.ts new file mode 100644 index 000000000..9a73ecd69 --- /dev/null +++ b/tests/boundary/process/processCaptureTerminal.test.ts @@ -0,0 +1,190 @@ +import { rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { expect, it } from "vitest"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +import { + captureProcessScenario, + probeProcessCaptureCapability, +} from "../../../src/application/ProcessHarness.js"; +import { parseProcessScenario } from "../../../src/domain/processCapture.js"; + +const buildTerminalScenario = (root: string, script: string) => + parseProcessScenario({ + approved: true, + executable: process.execPath, + arguments: [script], + working_directory: root, + checkpoints: [ + { + name: "probe_seen", + trigger: { type: "terminal_literal", value: "probe:codex 1.2.3" }, + }, + ], + command_shims: [ + { + name: "codex", + routes: [ + { + arguments: ["--version"], + outputs: [{ at_ms: 0, stream: "stdout", data: "codex 1.2.3\n" }], + termination: { type: "exit", code: 0 }, + }, + ], + }, + { + name: "node", + routes: [ + { + arguments: ["--version"], + outputs: [{ at_ms: 0, stream: "stdout", data: "node 9.8.7\n" }], + termination: { type: "exit", code: 0 }, + }, + ], + }, + ], + reactive: { + version: 1, + initial_state: "waiting_for_shim", + deadline_ms: 5_000, + states: [ + { + id: "waiting_for_shim", + max_visits: 1, + deadline_ms: 2_000, + on: [ + { + id: "record_shim", + priority: 0, + max_uses: 1, + when: { + kind: "event", + source: "shim", + exact: { + command: "codex", + route_index: 0, + arguments: ["--version"], + working_directory: "", + outcome: "matched", + }, + ignore_fields: ["sequence", "at_ms"], + since: { kind: "scenario_start" }, + consume: true, + cardinality: { min: 1, max: 1 }, + }, + actions: [{ type: "checkpoint", name: "reactive_shim_seen" }], + target: { + kind: "goto", + state: "waiting_for_checkpoint", + }, + }, + ], + }, + { + id: "waiting_for_checkpoint", + max_visits: 1, + deadline_ms: 2_000, + on: [ + { + id: "finish_checkpoint", + priority: 0, + max_uses: 1, + when: { + kind: "event", + source: "filesystem", + exact: { + name: "reactive_shim_seen", + files: [], + effects: [], + truncated: false, + }, + ignore_fields: ["at_ms"], + since: { kind: "scenario_start" }, + consume: true, + cardinality: { min: 1, max: 1 }, + }, + actions: [], + target: { kind: "finish", outcome: "passed" }, + }, + ], + }, + ], + }, + }); + +// The assertions share one captured process and therefore one cleanup scope. +it("renders terminal state, records shim invocations, and captures literal checkpoints", async () => { + const root = await createTestTempDirectory("rea-v3-test-"); + const script = join(root, "scenario.mjs"); + await writeFile( + script, + [ + 'import { spawnSync } from "node:child_process";', + 'const result = spawnSync("codex", ["--version"], { encoding: "utf8" });', + 'const node = spawnSync("node", ["--version"], { encoding: "utf8" });', + "process.stdout.write(`probe:${result.stdout}`);", + "process.stdout.write(`runtime:${node.stdout}`);", + ].join("\n"), + ); + try { + const capability = await probeProcessCaptureCapability(); + if (!capability.available) return; + const result = await captureProcessScenario( + buildTerminalScenario(root, script), + { + enabled: true, + executableRoots: [dirname(process.execPath)], + workingRoots: [root], + allowedEnvironment: [], + allowExternalNetwork: true, + }, + ); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + expect(result.value.schema_version).toBe(4); + expect(result.value.rendered_frames.at(-1)?.lines.join("\n")).toContain( + "probe:codex 1.2.3", + ); + expect(result.value.shim_events).toEqual([ + expect.objectContaining({ + command: "codex", + arguments: ["--version"], + outcome: "matched", + }), + expect.objectContaining({ + command: "node", + arguments: ["--version"], + outcome: "matched", + }), + ]); + expect(result.value.filesystem_checkpoints.map(({ name }) => name)).toEqual( + expect.arrayContaining([ + "before", + "reactive_shim_seen", + "probe_seen", + "after_settlement", + ]), + ); + expect(result.value.reactive_run).toMatchObject({ + status: "finished", + outcome: "passed", + transitions: [ + expect.objectContaining({ + transition_id: "record_shim", + trigger_event_ids: ["obs.shim_events.0"], + action_event_ids: [ + expect.stringMatching(/^obs\.filesystem_checkpoints\./u), + ], + }), + expect.objectContaining({ + transition_id: "finish_checkpoint", + trigger_event_ids: [ + expect.stringMatching(/^obs\.filesystem_checkpoints\./u), + ], + }), + ], + }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}, 20_000); diff --git a/tests/boundary/process/processCaptureTraceProjection.test.ts b/tests/boundary/process/processCaptureTraceProjection.test.ts new file mode 100644 index 000000000..bf695b390 --- /dev/null +++ b/tests/boundary/process/processCaptureTraceProjection.test.ts @@ -0,0 +1,197 @@ +import { expect, it } from "vitest"; + +import { + compareProcessCaptures, + digestProcessCommitment, + type ProcessCapture, +} from "../../../src/domain/processCapture.js"; + +const emptyCapture = (): ProcessCapture => { + const normalization = { + paths: true, + pids: true, + ports: true, + time_bucket_ms: 10, + patterns: [], + }; + const scenario = { executable_sha256: "0".repeat(64) }; + const comparisonContract = {}; + const shimPlan: readonly unknown[] = []; + const replayPlan = {}; + return { + schema_version: 4, + manifest: { + rea_version: "test", + provider_version: "3", + platform: process.platform, + architecture: process.arch, + pty_backend: "node-pty", + started_at: "2026-01-01T00:00:00.000Z", + completed_at: "2026-01-01T00:00:00.001Z", + scenario, + comparison_contract: comparisonContract, + shim_plan: shimPlan, + replay_plan: replayPlan, + full_scenario_sha256: digestProcessCommitment(scenario), + comparison_contract_sha256: digestProcessCommitment(comparisonContract), + executable_sha256: "0".repeat(64), + normalization_sha256: digestProcessCommitment(normalization), + shim_plan_sha256: digestProcessCommitment(shimPlan), + replay_plan_sha256: digestProcessCommitment(replayPlan), + }, + normalization, + frames: [], + rendered_frames: [], + interaction_events: [], + exit: { code: 0, signal: null, reason: "exited" }, + settlement: { + state: "quiesced", + elapsed_ms: 50, + cleanup_outcome: "not_required", + }, + process_samples: [], + filesystem_checkpoints: [ + { name: "before", at_ms: 0, files: [], effects: [], truncated: false }, + { + name: "after_settlement", + at_ms: 50, + files: [], + effects: [], + truncated: false, + }, + ], + shim_events: [], + protocol_events: [], + replay_transitions: [], + reactive_run: null, + event_journal: [], + files_before: [], + files_after: [], + filesystem_effects: [], + truncated: false, + limitations: [], + residual_unknowns: [], + cleanup: { + owned_process_group: "verified", + temporary_root: "removed", + }, + }; +}; + +it("detects changes in normalized process sample metadata", () => { + const capture = { + schema_version: 4 as const, + manifest: emptyCapture().manifest, + settlement: emptyCapture().settlement, + normalization: { + paths: true, + pids: true, + ports: true, + time_bucket_ms: 10, + patterns: [], + }, + frames: [], + rendered_frames: [], + interaction_events: [], + exit: { code: 0, signal: null, reason: "exited" as const }, + process_samples: [ + { + at_ms: 10, + pid: 1, + parent_pid: 0, + process_group_id: 1, + session_id: 1, + command: "worker", + }, + ], + filesystem_checkpoints: emptyCapture().filesystem_checkpoints, + shim_events: [], + protocol_events: [], + replay_transitions: [], + reactive_run: null, + files_before: [], + files_after: [], + filesystem_effects: [], + truncated: false, + limitations: [], + residual_unknowns: [], + cleanup: { + owned_process_group: "verified" as const, + temporary_root: "removed" as const, + }, + }; + const changed = { + ...capture, + process_samples: [ + { + at_ms: 20, + pid: 1, + parent_pid: 2, + process_group_id: 1, + session_id: 1, + command: "worker", + }, + ], + }; + + const comparison = compareProcessCaptures(capture, changed); + expect(comparison.process).toBe("changed"); + expect(comparison.status).toBe("changed"); +}); + +it("compares replay state transitions as protocol evidence", () => { + const replayPlan = { + machine: { + initial_state: "anonymous", + states: [ + { name: "anonymous" }, + { name: "authenticated", terminal: true }, + ], + transitions: [ + { + id: "login", + from: "anonymous", + to: "authenticated", + trigger: { protocol: "http", method: "POST", path: "/login" }, + captures: [ + { + variable: "token", + value: { source: "request_json", path: ["token"] }, + sensitive: true, + }, + ], + actions: [{ type: "http_response", status: 204, body: "" }], + max_uses: 1, + }, + ], + max_transitions: 1, + }, + }; + const empty = emptyCapture(); + const baseline = { + ...empty, + manifest: { + ...empty.manifest, + replay_plan: replayPlan, + replay_plan_sha256: digestProcessCommitment(replayPlan), + }, + }; + const transitioned = { + ...baseline, + replay_transitions: [ + { + sequence: 0, + at_ms: 0, + transition_id: "login", + state_before: "anonymous", + state_after: "authenticated", + sensitive_aliases: ["token"], + }, + ], + }; + + expect(compareProcessCaptures(baseline, transitioned)).toMatchObject({ + status: "changed", + protocol: "added", + }); +}); diff --git a/tests/boundary/process/processCaptureValidation.test.ts b/tests/boundary/process/processCaptureValidation.test.ts new file mode 100644 index 000000000..853dfefac --- /dev/null +++ b/tests/boundary/process/processCaptureValidation.test.ts @@ -0,0 +1,328 @@ +import { expect, it } from "vitest"; + +import { createProcessCaptureJournal } from "../../../src/application/ProcessCaptureJournal.js"; +import { + compareProcessCaptures, + digestProcessCommitment, + LEGACY_PROCESS_CAPTURE_MESSAGE, + parseProcessCapture, + processCaptureSchema, + validateProcessCapture, + type ProcessCapture, + type ProcessCaptureEventJournalEntry, +} from "../../../src/domain/processCapture.js"; + +const emptyCapture = (): ProcessCapture => { + const normalization = { + paths: true, + pids: true, + ports: true, + time_bucket_ms: 10, + patterns: [], + }; + const scenario = { executable_sha256: "0".repeat(64) }; + const comparisonContract = {}; + const shimPlan: readonly unknown[] = []; + const replayPlan = {}; + return { + schema_version: 4, + manifest: { + rea_version: "test", + provider_version: "3", + platform: process.platform, + architecture: process.arch, + pty_backend: "node-pty", + started_at: "2026-01-01T00:00:00.000Z", + completed_at: "2026-01-01T00:00:00.001Z", + scenario, + comparison_contract: comparisonContract, + shim_plan: shimPlan, + replay_plan: replayPlan, + full_scenario_sha256: digestProcessCommitment(scenario), + comparison_contract_sha256: digestProcessCommitment(comparisonContract), + executable_sha256: "0".repeat(64), + normalization_sha256: digestProcessCommitment(normalization), + shim_plan_sha256: digestProcessCommitment(shimPlan), + replay_plan_sha256: digestProcessCommitment(replayPlan), + }, + normalization, + frames: [], + rendered_frames: [], + interaction_events: [], + exit: { code: 0, signal: null, reason: "exited" }, + settlement: { + state: "quiesced", + elapsed_ms: 50, + cleanup_outcome: "not_required", + }, + process_samples: [], + filesystem_checkpoints: [ + { name: "before", at_ms: 0, files: [], effects: [], truncated: false }, + { + name: "after_settlement", + at_ms: 50, + files: [], + effects: [], + truncated: false, + }, + ], + shim_events: [], + protocol_events: [], + replay_transitions: [], + reactive_run: null, + event_journal: [], + files_before: [], + files_after: [], + filesystem_effects: [], + truncated: false, + limitations: [], + residual_unknowns: [], + cleanup: { + owned_process_group: "verified", + temporary_root: "removed", + }, + }; +}; + +it("never considers truncated captures equivalent", () => { + const capture = { + schema_version: 4 as const, + manifest: emptyCapture().manifest, + settlement: emptyCapture().settlement, + normalization: { + paths: true, + pids: true, + ports: true, + time_bucket_ms: 10, + patterns: [], + }, + frames: [], + rendered_frames: [], + interaction_events: [], + exit: { code: 0, signal: null, reason: "exited" as const }, + process_samples: [], + filesystem_checkpoints: emptyCapture().filesystem_checkpoints, + shim_events: [], + protocol_events: [], + replay_transitions: [], + reactive_run: null, + files_before: [], + files_after: [], + filesystem_effects: [], + truncated: true, + limitations: [], + residual_unknowns: [], + cleanup: { + owned_process_group: "verified" as const, + temporary_root: "removed" as const, + }, + }; + expect(compareProcessCaptures(capture, capture).status).toBe("truncated"); +}); + +it("rejects altered v4 commitments and accepts canonical key reordering", () => { + const capture = emptyCapture(); + expect(parseProcessCapture(capture)).toEqual(capture); + expect(digestProcessCommitment({ second: 2, first: 1 })).toBe( + digestProcessCommitment({ first: 1, second: 2 }), + ); + expect(() => + parseProcessCapture({ + ...capture, + manifest: { + ...capture.manifest, + normalization_sha256: "f".repeat(64), + }, + }), + ).toThrow("normalization_sha256"); + expect(() => + parseProcessCapture({ + ...capture, + manifest: { + ...capture.manifest, + executable_sha256: "f".repeat(64), + }, + }), + ).toThrow("executable_sha256"); +}); + +it("accepts old captures without a journal and validates complete journals", () => { + const journal = createProcessCaptureJournal(); + const observed: ProcessCaptureEventJournalEntry[] = []; + const unsubscribe = journal.subscribe((entry) => observed.push(entry)); + journal.recordEvent("lifecycle", 0); + const settlementEntry = journal.record("lifecycle", 1); + unsubscribe(); + journal.recordEvent("frames", 0); + expect(journal.entries).toEqual([ + { capture_order: 0, collection: "lifecycle", index: 0 }, + { capture_order: 1, collection: "lifecycle", index: 1 }, + { capture_order: 2, collection: "frames", index: 0 }, + ]); + expect(observed).toEqual(journal.entries.slice(0, 2)); + expect(settlementEntry).toEqual(journal.entries[1]); + + const nestedJournal = createProcessCaptureJournal(); + const notifications: string[] = []; + nestedJournal.subscribe((entry) => { + notifications.push(`a:${String(entry.capture_order)}`); + if (entry.capture_order === 0) nestedJournal.record("frames", 1); + }); + nestedJournal.subscribe((entry) => + notifications.push(`b:${String(entry.capture_order)}`), + ); + nestedJournal.record("frames", 0); + expect(notifications).toEqual(["a:0", "b:0", "a:1", "b:1"]); + + const capture = emptyCapture(); + const { event_journal: _eventJournal, ...oldCapture } = capture; + expect(parseProcessCapture(oldCapture).event_journal).toEqual([]); + + const eventJournal = [ + { capture_order: 0, collection: "filesystem_checkpoints", index: 0 }, + { capture_order: 1, collection: "lifecycle", index: 0 }, + { capture_order: 2, collection: "lifecycle", index: 1 }, + { capture_order: 3, collection: "filesystem_checkpoints", index: 1 }, + ] as const; + expect( + validateProcessCapture({ ...capture, event_journal: eventJournal }), + ).toEqual([]); + + for (const [candidate, message] of [ + [ + eventJournal.map((entry, index) => + index === 1 ? { ...entry, capture_order: 2 } : entry, + ), + "contiguous", + ], + [ + eventJournal.map((entry, index) => + index === 3 + ? { ...entry, collection: "lifecycle" as const, index: 1 } + : entry, + ), + "unique", + ], + [eventJournal.slice(0, 3), "every captured observation"], + [ + eventJournal.map((entry, index) => + index === 3 ? { ...entry, index: 2 } : entry, + ), + "outside", + ], + ] as const) { + expect( + validateProcessCapture({ ...capture, event_journal: candidate }), + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining(message), + }), + ]), + ); + } +}); + +it.each([ + ["machine limit", 1, 5, 10], + ["transition use limit", 5, 1, 10], + ["state visit limit", 5, 5, 1], +])( + "rejects replay journals that exceed the declared %s", + (_name, maxTransitions, maxUses, maxVisits) => { + const replayPlan = { + machine: { + initial_state: "active", + states: [ + { name: "active", max_visits: maxVisits }, + { name: "complete", terminal: true }, + ], + transitions: [ + { + id: "again", + from: "active", + to: "active", + trigger: { + protocol: "websocket_message", + path: "/ws", + body: "again", + }, + actions: [{ type: "websocket_send", data: "again" }], + max_uses: maxUses, + }, + { + id: "finish", + from: "active", + to: "complete", + trigger: { + protocol: "websocket_message", + path: "/ws", + body: "finish", + }, + actions: [{ type: "websocket_send", data: "done" }], + max_uses: 1, + }, + ], + max_transitions: maxTransitions, + }, + }; + const capture = emptyCapture(); + const candidate: ProcessCapture = { + ...capture, + manifest: { + ...capture.manifest, + replay_plan: replayPlan, + replay_plan_sha256: digestProcessCommitment(replayPlan), + }, + replay_transitions: [0, 1].map((sequence) => ({ + sequence, + at_ms: sequence, + transition_id: "again", + state_before: "active", + state_after: "active", + sensitive_aliases: [], + })), + }; + + expect(validateProcessCapture(candidate)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining("limit"), + }), + ]), + ); + }, +); + +it("tells agents and users to recapture unsupported v3 evidence", () => { + const legacy = { ...emptyCapture(), schema_version: 3 }; + expect(() => parseProcessCapture(legacy)).toThrow( + LEGACY_PROCESS_CAPTURE_MESSAGE, + ); + const parsed = processCaptureSchema.safeParse(legacy); + expect(parsed.success).toBe(false); + if (parsed.success) throw new Error("expected legacy capture rejection"); + expect(parsed.error.issues[0]?.message).toBe(LEGACY_PROCESS_CAPTURE_MESSAGE); +}); + +it("requires compatible contracts and enforces capture age through a clock seam", () => { + const capture = emptyCapture(); + expect(() => + compareProcessCaptures(capture, { + ...capture, + manifest: { + ...capture.manifest, + comparison_contract: { changed: true }, + comparison_contract_sha256: digestProcessCommitment({ + changed: true, + }), + }, + }), + ).toThrow("incompatible comparison contracts"); + expect(() => + compareProcessCaptures(capture, capture, { + maxCaptureAgeMs: 1, + now: () => Date.parse("2026-01-01T00:00:01.000Z"), + }), + ).toThrow("max_capture_age_ms"); +}); diff --git a/tests/processObservation.test.ts b/tests/boundary/process/processObservation.test.ts similarity index 95% rename from tests/processObservation.test.ts rename to tests/boundary/process/processObservation.test.ts index 3522be5cc..781bb1745 100644 --- a/tests/processObservation.test.ts +++ b/tests/boundary/process/processObservation.test.ts @@ -1,14 +1,14 @@ import { describe, expect, it } from "vitest"; -import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../src/contracts/processCaptureExample.js"; +import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../../../src/contracts/processCaptureExample.js"; import { processCaptureSchema, type ProcessCapture, -} from "../src/domain/processCapture.js"; +} from "../../../src/domain/processCapture.js"; import { createProcessObservation, projectProcessObservation, -} from "../src/domain/processObservation.js"; +} from "../../../src/domain/processObservation.js"; const emptyCapture = processCaptureSchema.parse(EMPTY_PROCESS_CAPTURE_EXAMPLE); diff --git a/tests/boundary/process/processOwnership.test.ts b/tests/boundary/process/processOwnership.test.ts new file mode 100644 index 000000000..49a7e0cd6 --- /dev/null +++ b/tests/boundary/process/processOwnership.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it, vi } from "vitest"; +import { + cleanupOwnedProcessGroup, + parseProcessEnvironment, + selectCapturedProcessGroupIds, + type ProcessOwnershipHost, +} from "../../../src/process/ProcessOwnership.js"; +import { matchesOwnedProcessCommand } from "../../../src/process/ProcessCommandIdentity.js"; +const ownership = { + runId: "run-token", + leaderPid: 100, + processGroupId: 100, +}; +const host = ( + environments: Readonly>>>, +): { + readonly adapter: ProcessOwnershipHost; + readonly signalGroup: ReturnType; +} => { + const signalGroup = vi.fn(); + return { + adapter: { + listProcesses: () => + Promise.resolve( + Object.keys(environments).map((pid) => ({ + pid: Number(pid), + parentPid: Number(pid) === 100 ? 1 : 100, + processGroupId: 100, + state: "S", + command: "fixture", + })), + ), + environment: (pid) => Promise.resolve(environments[pid] ?? {}), + signalGroup, + }, + signalGroup, + }; +}; +describe("owned process-group cleanup discovery", () => { + it("excludes sampled groups whose leader was outside the captured tree", () => { + expect( + selectCapturedProcessGroupIds(100, [ + { pid: 100, process_group_id: 42 }, + { pid: 100, process_group_id: 100 }, + { pid: 101, process_group_id: 42 }, + { pid: 102, process_group_id: 102 }, + { pid: 103, process_group_id: 102 }, + ]), + ).toEqual([100, 102]); + }); + it("drops nameless Linux environment entries", () => { + expect( + parseProcessEnvironment("=ignored\0REA_PROCESS_RUN_ID=owned\0EMPTY=\0"), + ).toEqual({ REA_PROCESS_RUN_ID: "owned", EMPTY: "" }); + }); + it("matches macOS executable names after process-table normalization", () => { + expect( + matchesOwnedProcessCommand( + "node /tmp/fake-launcher.mjs", + "/opt/node/bin/node", + "darwin", + ), + ).toBe(true); + expect( + matchesOwnedProcessCommand("(node)", "/opt/node/bin/node", "darwin"), + ).toBe(true); + expect( + matchesOwnedProcessCommand( + "node /tmp/fake-launcher.mjs", + "/opt/python/bin/python3", + "darwin", + ), + ).toBe(false); + expect( + matchesOwnedProcessCommand( + "node /tmp/fake-launcher.mjs", + "/opt/node/bin/node", + "linux", + ), + ).toBe(false); + expect( + matchesOwnedProcessCommand("(node)", "/opt/node/bin/node", "linux"), + ).toBe(false); + }); + it("signals only a group whose every member carries the run token", async () => { + const { adapter, signalGroup } = host({ + 100: { REA_PROCESS_RUN_ID: "run-token" }, + 101: { REA_PROCESS_RUN_ID: "run-token" }, + }); + expect(await cleanupOwnedProcessGroup(ownership, adapter)).toEqual({ + cleaned: true, + signaled: true, + }); + expect(signalGroup).toHaveBeenCalledWith(100, "SIGKILL"); + }); + it("validates and signals rooted descendant groups once, root first", async () => { + const processes = [ + { pid: 100, parentPid: 1, processGroupId: 100 }, + { pid: 110, parentPid: 100, processGroupId: 100 }, + { pid: 101, parentPid: 100, processGroupId: 101 }, + { pid: 102, parentPid: 101, processGroupId: 102 }, + { pid: 103, parentPid: 102, processGroupId: 102 }, + ].map((process) => ({ + ...process, + state: "S", + command: "fixture", + })); + const signalGroup = vi.fn(); + const adapter: ProcessOwnershipHost = { + listProcesses: () => Promise.resolve(processes), + environment: () => Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }), + signalGroup, + }; + await expect(cleanupOwnedProcessGroup(ownership, adapter)).resolves.toEqual( + { cleaned: true, signaled: true }, + ); + expect(signalGroup.mock.calls).toEqual([ + [100, "SIGKILL"], + [101, "SIGKILL"], + [102, "SIGKILL"], + ]); + }); + it("leaves an unrelated Hopper process group outside the rooted tree untouched", async () => { + const processes = [ + { pid: 100, parentPid: 1, processGroupId: 100, command: "rea-hopper" }, + { pid: 101, parentPid: 100, processGroupId: 101, command: "owned-child" }, + { + pid: 900, + parentPid: 1, + processGroupId: 900, + command: + "/Applications/Hopper Disassembler.app/Contents/MacOS/Hopper Disassembler", + }, + ].map((process) => ({ ...process, state: "S" })); + const environment = vi.fn((pid: number) => + Promise.resolve({ + REA_PROCESS_RUN_ID: pid === 900 ? "unrelated-run" : "run-token", + }), + ); + const signalGroup = vi.fn(); + const adapter: ProcessOwnershipHost = { + listProcesses: () => Promise.resolve(processes), + environment, + signalGroup, + }; + await expect(cleanupOwnedProcessGroup(ownership, adapter)).resolves.toEqual( + { cleaned: true, signaled: true }, + ); + expect(signalGroup.mock.calls).toEqual([ + [100, "SIGKILL"], + [101, "SIGKILL"], + ]); + expect(environment.mock.calls.some(([pid]) => pid === 900)).toBe(false); + }); +}); diff --git a/tests/boundary/process/processOwnershipPart2.test.ts b/tests/boundary/process/processOwnershipPart2.test.ts new file mode 100644 index 000000000..bc5b49284 --- /dev/null +++ b/tests/boundary/process/processOwnershipPart2.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it, vi } from "vitest"; +import { + cleanupOwnedProcessGroup, + observeOwnedProcessLineage, + type ProcessOwnershipHost, +} from "../../../src/process/ProcessOwnership.js"; +const ownership = { + runId: "run-token", + leaderPid: 100, + processGroupId: 100, +}; +const host = ( + environments: Readonly>>>, +): { + readonly adapter: ProcessOwnershipHost; + readonly signalGroup: ReturnType; +} => { + const signalGroup = vi.fn(); + return { + adapter: { + listProcesses: () => + Promise.resolve( + Object.keys(environments).map((pid) => ({ + pid: Number(pid), + parentPid: Number(pid) === 100 ? 1 : 100, + processGroupId: 100, + state: "S", + command: "fixture", + })), + ), + environment: (pid) => Promise.resolve(environments[pid] ?? {}), + signalGroup, + }, + signalGroup, + }; +}; +describe("owned process-group cleanup discovery", () => { + it("fails before signaling when any descendant-group member is unowned", async () => { + const signalGroup = vi.fn(); + const adapter: ProcessOwnershipHost = { + listProcesses: () => + Promise.resolve([ + { + pid: 100, + parentPid: 1, + processGroupId: 100, + state: "S", + command: "fixture", + }, + { + pid: 101, + parentPid: 100, + processGroupId: 101, + state: "S", + command: "child-session", + }, + { + pid: 104, + parentPid: 101, + processGroupId: 101, + state: "S", + command: "child-helper", + }, + ]), + environment: (pid) => + Promise.resolve({ + REA_PROCESS_RUN_ID: pid === 104 ? "other-run" : "run-token", + }), + signalGroup, + }; + await expect(cleanupOwnedProcessGroup(ownership, adapter)).resolves.toEqual( + { + cleaned: false, + reason: "process tree contains an unowned or PID-reused process", + failures: [{ pid: 104, reason: "run-token-mismatch" }], + }, + ); + expect(signalGroup).not.toHaveBeenCalled(); + }); + it("fails before signaling when a descendant group leader is not rooted", async () => { + const signalGroup = vi.fn(); + const adapter: ProcessOwnershipHost = { + listProcesses: () => + Promise.resolve([ + { + pid: 100, + parentPid: 1, + processGroupId: 100, + state: "S", + command: "fixture", + }, + { + pid: 105, + parentPid: 100, + processGroupId: 101, + state: "S", + command: "descendant", + }, + { + pid: 101, + parentPid: 999, + processGroupId: 101, + state: "S", + command: "unrelated-group-leader", + }, + ]), + environment: () => Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }), + signalGroup, + }; + await expect(cleanupOwnedProcessGroup(ownership, adapter)).resolves.toEqual( + { + cleaned: false, + reason: + "descendant process-group leader identity could not be verified", + }, + ); + expect(signalGroup).not.toHaveBeenCalled(); + }); + it("records sorted launcher and descendant lineage after token checks", async () => { + const { adapter } = host({ + 102: { REA_PROCESS_RUN_ID: "run-token" }, + 100: { REA_PROCESS_RUN_ID: "run-token" }, + 101: { REA_PROCESS_RUN_ID: "run-token" }, + }); + await expect( + observeOwnedProcessLineage(ownership, adapter), + ).resolves.toEqual({ + status: "verified", + observedAt: expect.any(String), + lineage: { + schemaVersion: 1, + runId: "run-token", + launcherPid: 100, + launcherParentPid: 1, + processGroupId: 100, + descendants: [ + { pid: 101, parentPid: 100, processGroupId: 100 }, + { pid: 102, parentPid: 100, processGroupId: 100 }, + ], + }, + }); + }); +}); diff --git a/tests/boundary/process/processOwnershipPart3.test.ts b/tests/boundary/process/processOwnershipPart3.test.ts new file mode 100644 index 000000000..77dfa8f75 --- /dev/null +++ b/tests/boundary/process/processOwnershipPart3.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from "vitest"; +import { + observeOwnedProcessLineage, + type ProcessOwnershipHost, +} from "../../../src/process/ProcessOwnership.js"; +const ownership = { + runId: "run-token", + leaderPid: 100, + processGroupId: 100, +}; +describe("owned process-group cleanup discovery", () => { + it("walks the PPID tree across process-group boundaries", async () => { + const environment = vi.fn((pid: number) => + Promise.resolve({ + REA_PROCESS_RUN_ID: pid === 103 ? "unrelated-run" : "run-token", + }), + ); + const adapter: ProcessOwnershipHost = { + listProcesses: () => + Promise.resolve([ + { + pid: 103, + parentPid: 999, + processGroupId: 100, + state: "S", + command: "unrelated-same-group", + }, + { + pid: 102, + parentPid: 101, + processGroupId: 102, + state: "S", + command: "grandchild-session", + }, + { + pid: 100, + parentPid: 1, + processGroupId: 100, + state: "S", + command: "fixture", + }, + { + pid: 101, + parentPid: 100, + processGroupId: 101, + state: "S", + command: "child-group", + }, + ]), + environment, + signalGroup: vi.fn(), + }; + await expect( + observeOwnedProcessLineage(ownership, adapter), + ).resolves.toEqual({ + status: "verified", + observedAt: expect.any(String), + lineage: { + schemaVersion: 1, + runId: "run-token", + launcherPid: 100, + launcherParentPid: 1, + processGroupId: 100, + descendants: [ + { pid: 101, parentPid: 100, processGroupId: 101 }, + { pid: 102, parentPid: 101, processGroupId: 102 }, + ], + }, + }); + expect(environment.mock.calls.map(([pid]) => pid)).toEqual([100, 101, 102]); + }); +}); diff --git a/tests/boundary/process/processOwnershipValidation.test.ts b/tests/boundary/process/processOwnershipValidation.test.ts new file mode 100644 index 000000000..4c7519f18 --- /dev/null +++ b/tests/boundary/process/processOwnershipValidation.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it, vi } from "vitest"; +import { + cleanupOwnedProcessGroup, + observeOwnedProcessLineage, + type ProcessOwnershipHost, +} from "../../../src/process/ProcessOwnership.js"; +const ownership = { + runId: "run-token", + leaderPid: 100, + processGroupId: 100, +}; +const host = ( + environments: Readonly>>>, +): { + readonly adapter: ProcessOwnershipHost; + readonly signalGroup: ReturnType; +} => { + const signalGroup = vi.fn(); + return { + adapter: { + listProcesses: () => + Promise.resolve( + Object.keys(environments).map((pid) => ({ + pid: Number(pid), + parentPid: Number(pid) === 100 ? 1 : 100, + processGroupId: 100, + state: "S", + command: "fixture", + })), + ), + environment: (pid) => Promise.resolve(environments[pid] ?? {}), + signalGroup, + }, + signalGroup, + }; +}; +describe("owned process-group cleanup validation", () => { + it("fails closed when a descendant in another process group lacks the token", async () => { + const adapter: ProcessOwnershipHost = { + listProcesses: () => + Promise.resolve([ + { + pid: 100, + parentPid: 1, + processGroupId: 100, + state: "S", + command: "fixture", + }, + { + pid: 101, + parentPid: 100, + processGroupId: 101, + state: "S", + command: "child-session", + }, + ]), + environment: (pid) => + Promise.resolve({ + REA_PROCESS_RUN_ID: pid === 100 ? "run-token" : "other-run", + }), + signalGroup: vi.fn(), + }; + await expect( + observeOwnedProcessLineage(ownership, adapter), + ).resolves.toEqual({ + status: "unavailable", + observedAt: expect.any(String), + runId: "run-token", + launcherPid: 100, + processGroupId: 100, + reason: "process lineage contains an unowned or PID-reused process", + }); + }); + it("does not publish lineage when any member fails ownership checks", async () => { + const { adapter } = host({ + 100: { REA_PROCESS_RUN_ID: "run-token" }, + 101: { REA_PROCESS_RUN_ID: "other-run" }, + }); + await expect( + observeOwnedProcessLineage(ownership, adapter), + ).resolves.toEqual({ + status: "unavailable", + observedAt: expect.any(String), + runId: "run-token", + launcherPid: 100, + processGroupId: 100, + reason: "process lineage contains an unowned or PID-reused process", + }); + }); + it("fails closed for stale metadata or an unrelated concurrent process", async () => { + const { adapter, signalGroup } = host({ + 100: { REA_PROCESS_RUN_ID: "run-token" }, + 101: { REA_PROCESS_RUN_ID: "different-run" }, + }); + expect(await cleanupOwnedProcessGroup(ownership, adapter)).toEqual({ + cleaned: false, + reason: "process tree contains an unowned or PID-reused process", + failures: [{ pid: 101, reason: "run-token-mismatch" }], + }); + expect(signalGroup).not.toHaveBeenCalled(); + }); + it("checks every member and aggregates ownership read failures", async () => { + const environment = vi.fn((pid: number) => { + if (pid === 100) + return Promise.reject(new Error("transient procfs read")); + return Promise.resolve( + pid === 101 + ? { REA_PROCESS_RUN_ID: "different-run" } + : { REA_PROCESS_RUN_ID: "run-token" }, + ); + }); + const signalGroup = vi.fn(); + const adapter: ProcessOwnershipHost = { + listProcesses: () => + Promise.resolve( + [100, 101, 102].map((pid) => ({ + pid, + parentPid: pid === 100 ? 1 : 100, + processGroupId: 100, + state: "S", + command: "fixture", + })), + ), + environment, + signalGroup, + }; + expect(await cleanupOwnedProcessGroup(ownership, adapter)).toEqual({ + cleaned: false, + reason: "process tree contains an unowned or PID-reused process", + failures: [ + { pid: 100, reason: "environment-unreadable" }, + { pid: 101, reason: "run-token-mismatch" }, + ], + }); + expect(environment.mock.calls.map(([pid]) => pid)).toEqual([100, 101, 102]); + expect(signalGroup).not.toHaveBeenCalled(); + }); + it("accepts a member that exits during ownership revalidation", async () => { + const liveLauncher = { + pid: 100, + parentPid: 1, + processGroupId: 100, + state: "S", + command: "fixture", + }; + const listProcesses = vi + .fn() + .mockResolvedValueOnce([liveLauncher]) + .mockResolvedValue([]); + const signalGroup = vi.fn(); + const adapter: ProcessOwnershipHost = { + listProcesses, + environment: () => + Promise.reject(new Error("process exited before environment read")), + signalGroup, + }; + await expect(cleanupOwnedProcessGroup(ownership, adapter)).resolves.toEqual( + { + cleaned: true, + signaled: false, + }, + ); + expect(signalGroup).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/boundary/process/processOwnershipValidationPart2.test.ts b/tests/boundary/process/processOwnershipValidationPart2.test.ts new file mode 100644 index 000000000..f1e89a00e --- /dev/null +++ b/tests/boundary/process/processOwnershipValidationPart2.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it, vi } from "vitest"; +import { + cleanupOwnedProcessGroup, + observeOwnedProcessGroup, + type ProcessOwnershipHost, +} from "../../../src/process/ProcessOwnership.js"; +const ownership = { + runId: "run-token", + leaderPid: 100, + processGroupId: 100, +}; +const host = ( + environments: Readonly>>>, +): { + readonly adapter: ProcessOwnershipHost; + readonly signalGroup: ReturnType; +} => { + const signalGroup = vi.fn(); + return { + adapter: { + listProcesses: () => + Promise.resolve( + Object.keys(environments).map((pid) => ({ + pid: Number(pid), + parentPid: Number(pid) === 100 ? 1 : 100, + processGroupId: 100, + state: "S", + command: "fixture", + })), + ), + environment: (pid) => Promise.resolve(environments[pid] ?? {}), + signalGroup, + }, + signalGroup, + }; +}; +describe("owned process-group cleanup validation", () => { + it("ignores exited zombie members during live ownership checks", async () => { + const environment = vi.fn((pid: number) => + Promise.resolve(pid === 101 ? {} : { REA_PROCESS_RUN_ID: "run-token" }), + ); + const signalGroup = vi.fn(); + const adapter: ProcessOwnershipHost = { + listProcesses: () => + Promise.resolve([ + { + pid: 100, + parentPid: 1, + processGroupId: 100, + state: "S", + command: "fixture", + }, + { + pid: 101, + parentPid: 100, + processGroupId: 100, + state: "Z", + command: "[node] ", + }, + ]), + environment, + signalGroup, + }; + expect(await cleanupOwnedProcessGroup(ownership, adapter)).toEqual({ + cleaned: true, + signaled: true, + }); + expect(environment.mock.calls).toEqual([[100], [100]]); + expect(signalGroup).toHaveBeenCalledWith(100, "SIGKILL"); + }); + it("observes a zombie-only group as settled", async () => { + const environment = vi.fn(() => Promise.resolve({})); + const adapter: ProcessOwnershipHost = { + listProcesses: () => + Promise.resolve([ + { + pid: 101, + parentPid: 1, + processGroupId: 100, + state: "Z+", + command: "[node] ", + }, + ]), + environment, + signalGroup: vi.fn(), + }; + expect(await observeOwnedProcessGroup(ownership, adapter)).toEqual({ + state: "empty", + }); + expect(environment).not.toHaveBeenCalled(); + }); + it("is idempotent when the owned group has already exited", async () => { + const { adapter } = host({}); + expect(await cleanupOwnedProcessGroup(ownership, adapter)).toEqual({ + cleaned: true, + signaled: false, + }); + }); + it("fails closed when the launcher command identity changes", async () => { + const { adapter, signalGroup } = host({ + 100: { REA_PROCESS_RUN_ID: "run-token" }, + }); + expect( + await cleanupOwnedProcessGroup( + { + ...ownership, + expectedCommand: "/owned/hopper", + expectedParentPid: 1, + }, + adapter, + ), + ).toEqual({ + cleaned: false, + reason: + "owned launcher command identity did not match (observed=fixture; expected=/owned/hopper)", + }); + expect(signalGroup).not.toHaveBeenCalled(); + }); + it("fails closed when the launcher parent identity changes", async () => { + const { adapter, signalGroup } = host({ + 100: { REA_PROCESS_RUN_ID: "run-token" }, + }); + expect( + await cleanupOwnedProcessGroup( + { ...ownership, expectedCommand: "fixture", expectedParentPid: 999 }, + adapter, + ), + ).toEqual({ + cleaned: false, + reason: "owned launcher parent identity did not match", + }); + expect(signalGroup).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/boundary/process/processOwnershipValidationPart3.test.ts b/tests/boundary/process/processOwnershipValidationPart3.test.ts new file mode 100644 index 000000000..11d72954f --- /dev/null +++ b/tests/boundary/process/processOwnershipValidationPart3.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { + cleanupWindowsProcessTree, + type WindowsProcessTreeHost, +} from "../../../src/process/ProcessOwnership.js"; +describe("Windows P0 process-tree cleanup", () => { + it("reports whether taskkill signaled or found an exited tree", async () => { + const terminated: WindowsProcessTreeHost = { + terminateTree: () => Promise.resolve("terminated"), + }; + const missing: WindowsProcessTreeHost = { + terminateTree: () => Promise.resolve("missing"), + }; + await expect(cleanupWindowsProcessTree(42, terminated)).resolves.toEqual({ + cleaned: true, + signaled: true, + }); + await expect(cleanupWindowsProcessTree(42, missing)).resolves.toEqual({ + cleaned: true, + signaled: false, + }); + }); + it("keeps invalid identity and termination failures explicit", async () => { + const failing: WindowsProcessTreeHost = { + terminateTree: () => Promise.reject(new Error("taskkill failed")), + }; + await expect(cleanupWindowsProcessTree(0, failing)).resolves.toEqual({ + cleaned: false, + reason: "Windows process-tree PID is invalid", + }); + await expect(cleanupWindowsProcessTree(42, failing)).resolves.toEqual({ + cleaned: false, + reason: + "Windows P0 process-tree termination failed; Job Object ownership is unavailable", + }); + }); +}); diff --git a/tests/providerProcess.test.ts b/tests/boundary/process/providerProcess.test.ts similarity index 94% rename from tests/providerProcess.test.ts rename to tests/boundary/process/providerProcess.test.ts index d52b126a9..676ee511b 100644 --- a/tests/providerProcess.test.ts +++ b/tests/boundary/process/providerProcess.test.ts @@ -5,27 +5,27 @@ import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { PendingOperations } from "../src/process/PendingOperations.js"; -import { PrivateRuntimeRoot } from "../src/process/PrivateRuntimeRoot.js"; -import { ProviderStartupDeadline } from "../src/process/ProviderDeadline.js"; +import { PendingOperations } from "../../../src/process/PendingOperations.js"; +import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js"; +import { ProviderStartupDeadline } from "../../../src/process/ProviderDeadline.js"; import { cleanupOwnedProcessGroup, observeOwnedProcessLineage, -} from "../src/process/ProcessOwnership.js"; +} from "../../../src/process/ProcessOwnership.js"; import { ProviderProcessSupervisor, spawnOwnedProviderProcess, type ProviderProcessDiagnostic, -} from "../src/process/ProviderProcess.js"; +} from "../../../src/process/ProviderProcess.js"; import { spawnProviderProcessFixture, stopProviderProcessFixture, waitForDetachedProviderChild, waitForProviderProcessReady, -} from "./fixtures/providerProcess.js"; +} from "../../fixtures/providerProcess.js"; const processFixturePath = fileURLToPath( - new URL("./fixtures/providerProcess.mjs", import.meta.url), + new URL("../../fixtures/providerProcess.mjs", import.meta.url), ); const waitForPidExit = async ( @@ -50,7 +50,7 @@ afterEach(() => { vi.useRealTimers(); }); -describe("provider process lifecycle primitives", () => { +describe("provider process runtime and wait primitives", () => { it("allocates a private runtime root and removes it idempotently", async () => { const runtime = await PrivateRuntimeRoot.create({ parent: tmpdir(), @@ -145,7 +145,9 @@ describe("provider process lifecycle primitives", () => { expect(operations.size).toBe(0); expect(vi.getTimerCount()).toBe(0); }); +}); +describe("provider process output and cleanup primitives", () => { it("captures exact byte totals while bounding retained stdout and stderr", async () => { const child = spawnProviderProcessFixture("burst", 4_096); const diagnostics: ProviderProcessDiagnostic[] = []; @@ -254,7 +256,9 @@ describe("provider process lifecycle primitives", () => { await stopProviderProcessFixture(child); } }); +}); +describe("provider process spawning primitives", () => { it("spawns an owned process group with exact identity coordinates", async () => { const runId = "provider-process-run"; const spawned = await spawnOwnedProviderProcess({ diff --git a/tests/runtimeExecutableDiagnostics.test.ts b/tests/boundary/process/runtimeExecutableDiagnostics.test.ts similarity index 96% rename from tests/runtimeExecutableDiagnostics.test.ts rename to tests/boundary/process/runtimeExecutableDiagnostics.test.ts index f78af225e..629c82b1d 100644 --- a/tests/runtimeExecutableDiagnostics.test.ts +++ b/tests/boundary/process/runtimeExecutableDiagnostics.test.ts @@ -3,9 +3,9 @@ import { delimiter, join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { inspectRuntimeExecutables } from "../src/application/RuntimeExecutableDiagnostics.js"; +import { inspectRuntimeExecutables } from "../../../src/application/RuntimeExecutableDiagnostics.js"; const roots: string[] = []; diff --git a/tests/ghidraBridgeTruthfulness.test.ts b/tests/boundary/providers/ghidra/ghidraBridgeTruthfulness.test.ts similarity index 93% rename from tests/ghidraBridgeTruthfulness.test.ts rename to tests/boundary/providers/ghidra/ghidraBridgeTruthfulness.test.ts index d435b3422..d3b474f2a 100644 --- a/tests/ghidraBridgeTruthfulness.test.ts +++ b/tests/boundary/providers/ghidra/ghidraBridgeTruthfulness.test.ts @@ -10,13 +10,19 @@ beforeAll(async () => { [bridgeSource, queueSource] = await Promise.all([ readFile( fileURLToPath( - new URL("../bridge/ghidra/ReaGhidraBridge.java", import.meta.url), + new URL( + "../../../../bridge/ghidra/ReaGhidraBridge.java", + import.meta.url, + ), ), "utf8", ), readFile( fileURLToPath( - new URL("../src/ghidra/GhidraRequestQueue.ts", import.meta.url), + new URL( + "../../../../src/ghidra/GhidraRequestQueue.ts", + import.meta.url, + ), ), "utf8", ), diff --git a/tests/ghidraClient.test.ts b/tests/boundary/providers/ghidra/ghidraClient.test.ts similarity index 92% rename from tests/ghidraClient.test.ts rename to tests/boundary/providers/ghidra/ghidraClient.test.ts index 7d7d67b73..15defe06c 100644 --- a/tests/ghidraClient.test.ts +++ b/tests/boundary/providers/ghidra/ghidraClient.test.ts @@ -8,20 +8,20 @@ import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; -import { ok } from "../src/domain/result.js"; +import { ok } from "../../../../src/domain/result.js"; import { GhidraClient, type GhidraDiagnostic, -} from "../src/ghidra/GhidraClient.js"; +} from "../../../../src/ghidra/GhidraClient.js"; import type { GhidraLaunchSession, GhidraLauncher, -} from "../src/ghidra/GhidraLauncher.js"; -import type { GhidraTransportKind } from "../src/ghidra/GhidraTransport.js"; -import { GHIDRA_SESSION_CAPABILITIES } from "../src/ghidra/GhidraSessionValues.js"; +} from "../../../../src/ghidra/GhidraLauncher.js"; +import type { GhidraTransportKind } from "../../../../src/ghidra/GhidraTransport.js"; +import { GHIDRA_SESSION_CAPABILITIES } from "../../../../src/ghidra/GhidraSessionValues.js"; const fixturePath = fileURLToPath( - new URL("./fixtures/fakeGhidra.mjs", import.meta.url), + new URL("../../../fixtures/fakeGhidra.mjs", import.meta.url), ); const PROFILE_DIGEST = "a".repeat(64); const PROVIDER_VERSION = "12.1.2"; @@ -224,7 +224,9 @@ describe("GhidraClient", () => { expect(result.ok).toBe(false); if (!result.ok) expect(result.error.kind).toBe(expectedKind); }); +}); +describe("GhidraClient startup lifecycle", () => { it("applies one startup deadline and removes the private runtime", async () => { const launcher = new FixtureLauncher("silent"); const client = clientFor(launcher, { startupTimeoutMs: 30 }); @@ -238,7 +240,8 @@ describe("GhidraClient", () => { await expect(access(launcher.runtimeRoots[0] ?? "")).rejects.toMatchObject({ code: "ENOENT", }); - expect(exited(launcher.processes[0])).toBe(true); + await client.close(); + await expect(waitForExit(launcher.processes[0])).resolves.toBe(true); }); it("cancels startup promptly and leaves no project or process", async () => { @@ -254,9 +257,14 @@ describe("GhidraClient", () => { await expect(access(launcher.runtimeRoots[0] ?? "")).rejects.toMatchObject({ code: "ENOENT", }); - expect(exited(launcher.processes[0])).toBe(true); + await client.close(); + const process_ = launcher.processes[0]; + if (process_ !== undefined) + await expect(waitForExit(process_)).resolves.toBe(true); }); +}); +describe("GhidraClient established requests", () => { it("times out an established ping without corrupting shutdown", async () => { const client = clientFor(new FixtureLauncher("hang_after_start")); await expect(client.start()).resolves.toMatchObject({ ok: true }); @@ -364,7 +372,9 @@ describe("GhidraClient", () => { controller.abort(); await Promise.all(requests.slice(0, 32)); }); +}); +describe("GhidraClient cleanup and diagnostics", () => { it("makes double close idempotent and detaches process listeners", async () => { const launcher = new FixtureLauncher(); const client = clientFor(launcher); @@ -434,5 +444,14 @@ const exited = (process_: ChildProcess | undefined): boolean => process_ !== undefined && (process_.exitCode !== null || process_.signalCode !== null); +const waitForExit = async ( + process_: ChildProcess | undefined, +): Promise => { + for (let attempt = 0; attempt < 1_000 && !exited(process_); attempt += 1) { + await wait(10); + } + return exited(process_); +}; + const wait = (milliseconds: number): Promise => new Promise((resolve) => setTimeout(resolve, milliseconds)); diff --git a/tests/ghidraDoctor.test.ts b/tests/boundary/providers/ghidra/ghidraDoctor.test.ts similarity index 93% rename from tests/ghidraDoctor.test.ts rename to tests/boundary/providers/ghidra/ghidraDoctor.test.ts index ea7d3e825..8d51d96db 100644 --- a/tests/ghidraDoctor.test.ts +++ b/tests/boundary/providers/ghidra/ghidraDoctor.test.ts @@ -1,14 +1,17 @@ import { describe, expect, it } from "vitest"; -import { runDoctor, type DoctorHost } from "../src/application/Doctor.js"; +import { + runDoctor, + type DoctorHost, +} from "../../../../src/application/Doctor.js"; import { inspectGhidraInstallation, type GhidraInstallationHost, type GhidraInstallationInspection, -} from "../src/ghidra/GhidraInstallation.js"; -import { projectGhidraDoctorInspection } from "../src/ghidra/GhidraDoctor.js"; -import { CATALOG_IDENTITY } from "../src/catalogIdentity.js"; -import { PRODUCT_IDENTITY } from "../src/identity.js"; +} from "../../../../src/ghidra/GhidraInstallation.js"; +import { projectGhidraDoctorInspection } from "../../../../src/ghidra/GhidraDoctor.js"; +import { CATALOG_IDENTITY } from "../../../../src/catalogIdentity.js"; +import { PRODUCT_IDENTITY } from "../../../../src/identity.js"; const INSTALL = "/opt/ghidra_12.1.2_PUBLIC"; const installationHost = ( diff --git a/tests/ghidraFunctionValues.test.ts b/tests/boundary/providers/ghidra/ghidraFunctionValues.test.ts similarity index 97% rename from tests/ghidraFunctionValues.test.ts rename to tests/boundary/providers/ghidra/ghidraFunctionValues.test.ts index 6533d2329..a296be220 100644 --- a/tests/ghidraFunctionValues.test.ts +++ b/tests/boundary/providers/ghidra/ghidraFunctionValues.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it } from "vitest"; -import type { JsonValue } from "../src/domain/jsonValue.js"; +import type { JsonValue } from "../../../../src/domain/jsonValue.js"; import { GHIDRA_FUNCTION_OPERATIONS, isGhidraFunctionOperation, parseGhidraFunctionInput, parseGhidraFunctionResult, type GhidraFunctionOperation, -} from "../src/ghidra/GhidraFunctionValues.js"; +} from "../../../../src/ghidra/GhidraFunctionValues.js"; import { ghidraBounded, ghidraFunctionClassification, @@ -15,7 +15,7 @@ import { ghidraFunctionIdentity, ghidraNativeApiBoundary, ghidraReferenceEdge, -} from "./fixtures/ghidraFunction.js"; +} from "../../../fixtures/ghidraFunction.js"; describe("Ghidra function-analysis boundary values", () => { it("defaults bounded inputs and rejects undeclared or implicit addresses", () => { @@ -86,7 +86,9 @@ describe("Ghidra function-analysis boundary values", () => { expect(isGhidraFunctionOperation("list_procedures")).toBe(false); expect(isGhidraFunctionOperation("set_comment")).toBe(false); }); +}); +describe("Ghidra function-analysis result values", () => { it("parses provider-classified function facts and reference kinds", () => { expect( parseGhidraFunctionResult("read_function_instructions", { diff --git a/tests/ghidraInstallation.test.ts b/tests/boundary/providers/ghidra/ghidraInstallation.test.ts similarity index 98% rename from tests/ghidraInstallation.test.ts rename to tests/boundary/providers/ghidra/ghidraInstallation.test.ts index 4fee8b34b..63362e68f 100644 --- a/tests/ghidraInstallation.test.ts +++ b/tests/boundary/providers/ghidra/ghidraInstallation.test.ts @@ -7,7 +7,7 @@ import { SUPPORTED_GHIDRA_VERSION, type GhidraInstallationHost, type GhidraJavaObservation, -} from "../src/ghidra/GhidraInstallation.js"; +} from "../../../../src/ghidra/GhidraInstallation.js"; const INSTALL = "/opt/ghidra"; const PROPERTIES = `${INSTALL}/Ghidra/application.properties`; @@ -113,7 +113,9 @@ describe("Ghidra installation inspection", () => { rejectionCode: null, }); }); +}); +describe("Ghidra installation rejection diagnostics", () => { it.each([ { name: "missing configuration", diff --git a/tests/ghidraInventoryValues.test.ts b/tests/boundary/providers/ghidra/ghidraInventoryValues.test.ts similarity index 97% rename from tests/ghidraInventoryValues.test.ts rename to tests/boundary/providers/ghidra/ghidraInventoryValues.test.ts index 771d9efb6..2a4d843e6 100644 --- a/tests/ghidraInventoryValues.test.ts +++ b/tests/boundary/providers/ghidra/ghidraInventoryValues.test.ts @@ -6,8 +6,8 @@ import { parseGhidraInventoryInput, parseGhidraInventoryResult, type GhidraInventoryOperation, -} from "../src/ghidra/GhidraInventoryValues.js"; -import type { JsonValue } from "../src/domain/jsonValue.js"; +} from "../../../../src/ghidra/GhidraInventoryValues.js"; +import type { JsonValue } from "../../../../src/domain/jsonValue.js"; describe("Ghidra inventory boundary values", () => { it("defaults bounded provider inputs without accepting undeclared fields", () => { diff --git a/tests/ghidraLauncher.test.ts b/tests/boundary/providers/ghidra/ghidraLauncher.test.ts similarity index 96% rename from tests/ghidraLauncher.test.ts rename to tests/boundary/providers/ghidra/ghidraLauncher.test.ts index a747e6d92..63d9d93a5 100644 --- a/tests/ghidraLauncher.test.ts +++ b/tests/boundary/providers/ghidra/ghidraLauncher.test.ts @@ -4,19 +4,19 @@ import { fileURLToPath } from "node:url"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; import { ghidraHeadlessArguments, ghidraHeadlessCommand, GhidraHeadlessLauncher, -} from "../src/ghidra/GhidraLauncher.js"; +} from "../../../../src/ghidra/GhidraLauncher.js"; const fixturePath = fileURLToPath( new URL( process.platform === "win32" - ? "./fixtures/captureGhidraLaunch.cmd" - : "./fixtures/captureGhidraLaunch.mjs", + ? "../../../fixtures/captureGhidraLaunch.cmd" + : "../../../fixtures/captureGhidraLaunch.mjs", import.meta.url, ), ); diff --git a/tests/ghidraProvider.test.ts b/tests/boundary/providers/ghidra/ghidraProvider.test.ts similarity index 81% rename from tests/ghidraProvider.test.ts rename to tests/boundary/providers/ghidra/ghidraProvider.test.ts index 9118271d9..18a791d13 100644 --- a/tests/ghidraProvider.test.ts +++ b/tests/boundary/providers/ghidra/ghidraProvider.test.ts @@ -1,18 +1,18 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; -import { parseConfig } from "../src/config.js"; -import type { BinaryTarget } from "../src/domain/binaryTarget.js"; +import { parseConfig } from "../../../../src/config.js"; +import type { BinaryTarget } from "../../../../src/domain/binaryTarget.js"; import { GHIDRA_PROVIDER_IDENTITY, GHIDRA_PROVIDER_TOOL_CONTRACTS, GhidraProvider, type GhidraProviderClientFactory, -} from "../src/ghidra/GhidraProvider.js"; -import type { GhidraInstallationHost } from "../src/ghidra/GhidraInstallation.js"; -import { GHIDRA_SESSION_CAPABILITIES } from "../src/ghidra/GhidraSessionValues.js"; -import { err, ok } from "../src/domain/result.js"; -import { GhidraSessionError } from "../src/ghidra/GhidraSessionError.js"; -import { silentLogger } from "../src/logger.js"; +} from "../../../../src/ghidra/GhidraProvider.js"; +import type { GhidraInstallationHost } from "../../../../src/ghidra/GhidraInstallation.js"; +import { GHIDRA_SESSION_CAPABILITIES } from "../../../../src/ghidra/GhidraSessionValues.js"; +import { err, ok } from "../../../../src/domain/result.js"; +import { GhidraSessionError } from "../../../../src/ghidra/GhidraSessionError.js"; +import { silentLogger } from "../../../../src/logger.js"; const INSTALL = "/opt/ghidra_12.1.2_PUBLIC"; const installationHost = (): GhidraInstallationHost => ({ @@ -40,8 +40,20 @@ const provider = ( describe("Ghidra provider", () => { it("discovers the exact installation once without launching Ghidra", () => { - const host = installationHost(); - const probe = vi.spyOn(host, "probeJava"); + let probeCount = 0; + const host: GhidraInstallationHost = { + ...installationHost(), + probeJava: () => { + probeCount += 1; + return { + version: "21.0.11", + major: 21, + home: "/usr/lib/jvm/jdk-21", + bits: 64, + jdk: true, + }; + }, + }; const ghidra = provider(host); expect(ghidra.identity()).toEqual(GHIDRA_PROVIDER_IDENTITY); @@ -91,7 +103,7 @@ describe("Ghidra provider", () => { }, }); expect(ghidra.inspectAvailability()).toMatchObject({ status: "available" }); - expect(probe).toHaveBeenCalledOnce(); + expect(probeCount).toBe(1); }); it("separates target kind, format, and concrete architecture", () => { @@ -127,7 +139,9 @@ describe("Ghidra provider", () => { code: "architecture_unsupported", }); }); +}); +describe("Ghidra platform support", () => { it("admits only native x86-64 PE applications on Windows P0", () => { const ghidra = provider({ ...installationHost(), platform: "win32" }); const nativeApplication: BinaryTarget = { @@ -186,35 +200,56 @@ describe("Ghidra provider", () => { code: "architecture_unsupported", }); }); +}); +describe("Ghidra client projection", () => { it("commits exact provider, isolation, and resource semantics", async () => { - const callTool = vi.fn().mockResolvedValue( - ok({ - items: [ - { - address: "0x1000", - value: "fixture_main", - value_truncated: false, - procedure: { - external: false, - thunk: false, - thunk_target: null, + const toolCalls: Array<{ + readonly operation: string; + readonly input: unknown; + readonly options: unknown; + }> = []; + let startCount = 0; + const factoryOptions: unknown[] = []; + const callTool: ReturnType["callTool"] = ( + operation, + input, + options, + ) => { + toolCalls.push({ operation, input, options }); + return Promise.resolve( + ok({ + items: [ + { + address: "0x1000", + value: "fixture_main", + value_truncated: false, + procedure: { + external: false, + thunk: false, + thunk_target: null, + }, }, - }, - ], - offset: 0, - limit: 100, - total: 1, - next_offset: null, - has_more: false, - }), - ); - const start = vi.fn().mockResolvedValue(ok(sessionInfo())); - const clientFactory = vi.fn(() => ({ - start, - callTool, - close: () => Promise.resolve(), - })); + ], + offset: 0, + limit: 100, + total: 1, + next_offset: null, + has_more: false, + }), + ); + }; + const clientFactory: GhidraProviderClientFactory = (options) => { + factoryOptions.push(options); + return { + start: () => { + startCount += 1; + return Promise.resolve(ok(sessionInfo())); + }, + callTool, + close: () => Promise.resolve(), + }; + }; const ghidra = provider(installationHost(), clientFactory); const resolved = await ghidra.resolveAnalysisProfile( executableTarget("elf", "x86_64"), @@ -243,16 +278,18 @@ describe("Ghidra provider", () => { runId: "11111111-1111-4111-8111-111111111111", }) .execute("list_procedures", {}); - expect(clientFactory).toHaveBeenCalledWith( + expect(factoryOptions).toEqual([ expect.objectContaining({ runId: "11111111-1111-4111-8111-111111111111", }), - ); - expect(callTool).toHaveBeenCalledWith( - "list_procedures", - { document: null, offset: 0, limit: 100 }, - {}, - ); + ]); + expect(toolCalls).toEqual([ + { + operation: "list_procedures", + input: { document: null, offset: 0, limit: 100 }, + options: {}, + }, + ]); expect(result).toMatchObject({ ok: true, value: { @@ -271,9 +308,11 @@ describe("Ghidra provider", () => { }, }, }); - expect(start).not.toHaveBeenCalled(); + expect(startCount).toBe(0); }); +}); +describe("Ghidra result projection", () => { it("rejects malformed inventory output before Evidence creation", async () => { const ghidra = provider(installationHost(), () => ({ start: () => Promise.resolve(ok(sessionInfo())), diff --git a/tests/ghidraTargetSnapshot.test.ts b/tests/boundary/providers/ghidra/ghidraTargetSnapshot.test.ts similarity index 90% rename from tests/ghidraTargetSnapshot.test.ts rename to tests/boundary/providers/ghidra/ghidraTargetSnapshot.test.ts index 4867c286e..afaa6e559 100644 --- a/tests/ghidraTargetSnapshot.test.ts +++ b/tests/boundary/providers/ghidra/ghidraTargetSnapshot.test.ts @@ -4,9 +4,9 @@ import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; -import { createGhidraTargetSnapshot } from "../src/ghidra/GhidraTargetSnapshot.js"; +import { createGhidraTargetSnapshot } from "../../../../src/ghidra/GhidraTargetSnapshot.js"; const roots: string[] = []; diff --git a/tests/ghidraTransport.test.ts b/tests/boundary/providers/ghidra/ghidraTransport.test.ts similarity index 93% rename from tests/ghidraTransport.test.ts rename to tests/boundary/providers/ghidra/ghidraTransport.test.ts index 776993b72..3b04363b1 100644 --- a/tests/ghidraTransport.test.ts +++ b/tests/boundary/providers/ghidra/ghidraTransport.test.ts @@ -3,9 +3,9 @@ import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; -import { observeGhidraEndpoint } from "../src/ghidra/GhidraTransport.js"; +import { observeGhidraEndpoint } from "../../../../src/ghidra/GhidraTransport.js"; const roots: string[] = []; diff --git a/tests/bridgeLauncher.test.ts b/tests/boundary/providers/hopper/bridgeLauncher.test.ts similarity index 95% rename from tests/bridgeLauncher.test.ts rename to tests/boundary/providers/hopper/bridgeLauncher.test.ts index 591259f11..2108c2e92 100644 --- a/tests/bridgeLauncher.test.ts +++ b/tests/boundary/providers/hopper/bridgeLauncher.test.ts @@ -6,17 +6,17 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; import { type HopperApplicationLauncherOptions, linuxDemoLaunch, usesLinuxDemo, -} from "../src/hopper/BridgeLauncher.js"; +} from "../../../../src/hopper/BridgeLauncher.js"; const execFileAsync = promisify(execFile); const demoHelperPath = fileURLToPath( - new URL("../scripts/hopper-demo-x11.py", import.meta.url), + new URL("../../../../scripts/hopper-demo-x11.py", import.meta.url), ); const options = (launcherPath: string): HopperApplicationLauncherOptions => ({ diff --git a/tests/boundary/providers/hopper/bridgeRegex.literalBounds.test.ts b/tests/boundary/providers/hopper/bridgeRegex.literalBounds.test.ts new file mode 100644 index 000000000..e7ec2b0b1 --- /dev/null +++ b/tests/boundary/providers/hopper/bridgeRegex.literalBounds.test.ts @@ -0,0 +1,278 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { z } from "zod"; + +const bridgePath = fileURLToPath( + new URL("../../../../bridge/hopper_bridge.py", import.meta.url), +); +const probePath = fileURLToPath( + new URL("../../../fixtures/bridgeRegexProbe.py", import.meta.url), +); + +const errorResultSchema = z.object({ + action: z.enum(["match", "search"]), + ok: z.literal(false), + type: z.string(), + diagnostic_type: z.string(), + message: z.string(), +}); +const matchResultSchema = z.object({ + action: z.literal("match"), + ok: z.literal(true), + backtracking_paths: z.number().int().positive(), + matched: z.boolean(), +}); +const searchResultSchema = z.object({ + action: z.literal("search"), + ok: z.literal(true), + result: z.object({ + items: z.array( + z.object({ + address: z.string(), + value: z.string(), + value_truncated: z.boolean(), + }), + ), + offset: z.number().int().nonnegative(), + limit: z.number().int().positive(), + total: z.number().int().nonnegative(), + next_offset: z.number().int().nonnegative().nullable(), + has_more: z.boolean(), + }), +}); +const probeResultSchema = z.union([ + errorResultSchema, + matchResultSchema, + searchResultSchema, +]); +type ProbeResult = z.infer; + +type ProbeInput = + | { + readonly action: "match"; + readonly pattern: string; + readonly value: string; + readonly case_sensitive?: boolean; + } + | { + readonly action: "search"; + readonly items: readonly (readonly [string, string])[]; + readonly params: Readonly>; + }; + +interface PendingProbe { + readonly resolve: (result: ProbeResult) => void; + readonly reject: (error: Error) => void; + timeout: NodeJS.Timeout | undefined; +} + +const PROBE_TIMEOUT_MS = 3_000; +const PROBE_BUFFER_LIMIT = 1_024 * 1_024; +const pendingProbes: PendingProbe[] = []; +let probeProcess: ChildProcessWithoutNullStreams | undefined; +let probeStdout = ""; +let probeStderr = ""; +let probeFailure: Error | undefined; +let stoppingProbe = false; + +const failureWithStderr = (message: string): Error => { + const stderr = probeStderr.trim(); + return new Error(stderr.length === 0 ? message : `${message}: ${stderr}`); +}; + +const rejectPendingProbes = (error: Error): void => { + for (const pending of pendingProbes.splice(0)) { + if (pending.timeout !== undefined) clearTimeout(pending.timeout); + pending.reject(error); + } +}; + +const failProbeProcess = (error: Error): void => { + probeFailure ??= error; + rejectPendingProbes(probeFailure); + if ( + !stoppingProbe && + probeProcess?.exitCode === null && + probeProcess.signalCode === null + ) + probeProcess.kill("SIGKILL"); +}; + +const acceptProbeLine = (line: string): void => { + const pending = pendingProbes.shift(); + if (pending === undefined) { + failProbeProcess( + failureWithStderr("Python probe returned an extra result"), + ); + return; + } + if (pending.timeout !== undefined) clearTimeout(pending.timeout); + try { + const decoded: unknown = JSON.parse(line); + pending.resolve(probeResultSchema.parse(decoded)); + } catch (cause: unknown) { + const error = + cause instanceof Error + ? cause + : failureWithStderr("Python probe returned an invalid result"); + pending.reject(error); + failProbeProcess(error); + } +}; + +const acceptProbeOutput = (chunk: string): void => { + probeStdout += chunk; + if (Buffer.byteLength(probeStdout) > PROBE_BUFFER_LIMIT) { + failProbeProcess(failureWithStderr("Python probe output exceeded 1 MiB")); + return; + } + let newline = probeStdout.indexOf("\n"); + while (newline >= 0) { + const line = probeStdout.slice(0, newline).replace(/\r$/u, ""); + probeStdout = probeStdout.slice(newline + 1); + acceptProbeLine(line); + newline = probeStdout.indexOf("\n"); + } +}; + +const startProbeProcess = async (): Promise => { + stoppingProbe = false; + probeFailure = undefined; + probeStdout = ""; + probeStderr = ""; + const child = spawn("python3", [probePath, bridgePath], { + stdio: ["pipe", "pipe", "pipe"], + }); + probeProcess = child; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", acceptProbeOutput); + child.stderr.on("data", (chunk: string) => { + probeStderr += chunk; + if (Buffer.byteLength(probeStderr) > PROBE_BUFFER_LIMIT) + failProbeProcess(new Error("Python probe stderr exceeded 1 MiB")); + }); + child.stdin.on("error", (error) => { + if (!stoppingProbe) failProbeProcess(error); + }); + child.on("error", (error) => { + if (!stoppingProbe) failProbeProcess(error); + }); + child.on("exit", (code, signal) => { + if (!stoppingProbe) + failProbeProcess( + failureWithStderr( + `Python probe exited before responding (code ${String(code)}, signal ${String(signal)})`, + ), + ); + }); + await new Promise((resolve, reject) => { + const onSpawn = (): void => { + child.off("error", onError); + resolve(); + }; + const onError = (error: Error): void => { + child.off("spawn", onSpawn); + reject(error); + }; + child.once("spawn", onSpawn); + child.once("error", onError); + }); +}; + +const hasExited = (child: ChildProcessWithoutNullStreams): boolean => + child.exitCode !== null || child.signalCode !== null; + +const waitForExit = ( + child: ChildProcessWithoutNullStreams, + timeoutMs: number, +): Promise => { + if (hasExited(child)) return Promise.resolve(true); + return new Promise((resolve) => { + const onExit = (): void => { + clearTimeout(timeout); + resolve(true); + }; + const timeout = setTimeout(() => { + child.off("exit", onExit); + resolve(false); + }, timeoutMs); + child.once("exit", onExit); + }); +}; + +const stopProbeProcess = async (): Promise => { + const child = probeProcess; + if (child === undefined) return; + stoppingProbe = true; + rejectPendingProbes(new Error("Python probe stopped before responding")); + if (!hasExited(child)) child.stdin.end(); + if (!(await waitForExit(child, 500))) child.kill("SIGTERM"); + if (!(await waitForExit(child, 500))) child.kill("SIGKILL"); + if (!(await waitForExit(child, 500))) + throw new Error("Python probe did not stop after SIGKILL"); + probeProcess = undefined; +}; + +const probe = (input: ProbeInput): Promise => { + const child = probeProcess; + if (probeFailure !== undefined) return Promise.reject(probeFailure); + if (child === undefined || hasExited(child)) + return Promise.reject(new Error("Python probe is not running")); + return new Promise((resolve, reject) => { + const pending: PendingProbe = { resolve, reject, timeout: undefined }; + pending.timeout = setTimeout(() => { + failProbeProcess( + new Error( + `Python probe request timed out after ${String(PROBE_TIMEOUT_MS)} ms`, + ), + ); + }, PROBE_TIMEOUT_MS); + pendingProbes.push(pending); + child.stdin.write(`${JSON.stringify(input)}\n`, (error) => { + if (error !== null && error !== undefined) failProbeProcess(error); + }); + }); +}; + +beforeAll(startProbeProcess); +beforeEach(async () => { + if ( + probeFailure === undefined && + probeProcess !== undefined && + !hasExited(probeProcess) + ) + return; + await stopProbeProcess(); + await startProbeProcess(); +}); +afterAll(stopProbeProcess); + +describe("Hopper bridge literal bounds", () => { + it("keeps literal matching exhaustive while bounding returned text", async () => { + const result = await probe({ + action: "search", + items: [["0x1000", `${"x".repeat(4_096)}needle`]], + params: { + pattern: "needle", + mode: "literal", + case_sensitive: true, + offset: 0, + limit: 1, + }, + }); + expect(result).toMatchObject({ + action: "search", + ok: true, + result: { + total: 1, + has_more: false, + items: [{ address: "0x1000", value_truncated: true }], + }, + }); + if (result.action === "search" && result.ok) + expect(result.result.items[0]?.value).toHaveLength(4_096); + }); +}); diff --git a/tests/bridgeRegex.test.ts b/tests/boundary/providers/hopper/bridgeRegex.matching.test.ts similarity index 72% rename from tests/bridgeRegex.test.ts rename to tests/boundary/providers/hopper/bridgeRegex.matching.test.ts index b882a6d59..57e734e9a 100644 --- a/tests/bridgeRegex.test.ts +++ b/tests/boundary/providers/hopper/bridgeRegex.matching.test.ts @@ -5,10 +5,10 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { z } from "zod"; const bridgePath = fileURLToPath( - new URL("../bridge/hopper_bridge.py", import.meta.url), + new URL("../../../../bridge/hopper_bridge.py", import.meta.url), ); const probePath = fileURLToPath( - new URL("./fixtures/bridgeRegexProbe.py", import.meta.url), + new URL("../../../fixtures/bridgeRegexProbe.py", import.meta.url), ); const errorResultSchema = z.object({ @@ -258,7 +258,7 @@ const invalidRequest = (action: "match" | "search", message: string) => ({ message, }); -describe("Hopper bridge regex bounds", () => { +describe("Hopper bridge regex matching", () => { it("accepts useful patterns whose static backtracking budget is bounded", async () => { await expect( probe({ @@ -387,164 +387,4 @@ describe("Hopper bridge regex bounds", () => { matched: false, }); }); - - it("rejects ambiguous bounded repetition before Python regex execution", async () => { - await expect( - probe({ - action: "match", - pattern: "(a|aa){1,35}b", - value: "a".repeat(35), - case_sensitive: true, - }), - ).resolves.toEqual( - invalidRequest( - "match", - "Regex exceeds the 10000-path backtracking budget", - ), - ); - }); - - it("rejects excessive combinations across adjacent optional atoms", async () => { - await expect( - probe({ - action: "match", - pattern: `${"a?".repeat(14)}b`, - value: "a".repeat(14), - case_sensitive: true, - }), - ).resolves.toEqual( - invalidRequest( - "match", - "Regex exceeds the 10000-path backtracking budget", - ), - ); - }); - - it("bounds cumulative work before evaluating a costly candidate", async () => { - await expect( - probe({ - action: "match", - pattern: "(?:a|aa){1,12}b", - value: "a".repeat(4_096), - case_sensitive: true, - }), - ).resolves.toEqual( - invalidRequest( - "match", - "Regex search exceeds the 1000000-unit work budget", - ), - ); - }); - - it("applies the regex work budget across the full inventory", async () => { - await expect( - probe({ - action: "search", - items: [ - ["0x1000", "a".repeat(60)], - ["0x2000", "a".repeat(60)], - ], - params: { - pattern: "(?:a|aa){1,8}b", - mode: "regex", - case_sensitive: true, - offset: 0, - limit: 1, - }, - }), - ).resolves.toEqual( - invalidRequest( - "search", - "Regex search exceeds the 1000000-unit work budget", - ), - ); - }); - - it("rejects oversized regex candidates instead of matching a prefix", async () => { - await expect( - probe({ - action: "match", - pattern: "needle", - value: `${"x".repeat(4_096)}needle`, - }), - ).resolves.toEqual( - invalidRequest( - "match", - "Regex candidate exceeds the 4096-character safety limit", - ), - ); - }); - - it("accepts a regex candidate at the exact character limit", async () => { - await expect( - probe({ - action: "match", - pattern: "z$", - value: `${"a".repeat(4_095)}z`, - case_sensitive: true, - }), - ).resolves.toEqual({ - action: "match", - ok: true, - backtracking_paths: 1, - matched: true, - }); - }); - - it.each([ - [256, true], - [257, false], - ])("enforces the %i-character pattern boundary", async (length, accepted) => { - const pattern = "a".repeat(length); - const result = await probe({ - action: "search", - items: [["0x1000", pattern]], - params: { - pattern, - mode: "regex", - case_sensitive: true, - offset: 0, - limit: 1, - }, - }); - if (accepted) { - expect(result).toMatchObject({ - action: "search", - ok: true, - result: { total: 1 }, - }); - return; - } - expect(result).toEqual( - invalidRequest( - "search", - "pattern must contain between 1 and 256 characters", - ), - ); - }); - - it("keeps literal matching exhaustive while bounding returned text", async () => { - const result = await probe({ - action: "search", - items: [["0x1000", `${"x".repeat(4_096)}needle`]], - params: { - pattern: "needle", - mode: "literal", - case_sensitive: true, - offset: 0, - limit: 1, - }, - }); - expect(result).toMatchObject({ - action: "search", - ok: true, - result: { - total: 1, - has_more: false, - items: [{ address: "0x1000", value_truncated: true }], - }, - }); - if (result.action === "search" && result.ok) - expect(result.result.items[0]?.value).toHaveLength(4_096); - }); }); diff --git a/tests/boundary/providers/hopper/bridgeRegex.workLimits.test.ts b/tests/boundary/providers/hopper/bridgeRegex.workLimits.test.ts new file mode 100644 index 000000000..349c00f6b --- /dev/null +++ b/tests/boundary/providers/hopper/bridgeRegex.workLimits.test.ts @@ -0,0 +1,396 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { z } from "zod"; + +const bridgePath = fileURLToPath( + new URL("../../../../bridge/hopper_bridge.py", import.meta.url), +); +const probePath = fileURLToPath( + new URL("../../../fixtures/bridgeRegexProbe.py", import.meta.url), +); + +const errorResultSchema = z.object({ + action: z.enum(["match", "search"]), + ok: z.literal(false), + type: z.string(), + diagnostic_type: z.string(), + message: z.string(), +}); +const matchResultSchema = z.object({ + action: z.literal("match"), + ok: z.literal(true), + backtracking_paths: z.number().int().positive(), + matched: z.boolean(), +}); +const searchResultSchema = z.object({ + action: z.literal("search"), + ok: z.literal(true), + result: z.object({ + items: z.array( + z.object({ + address: z.string(), + value: z.string(), + value_truncated: z.boolean(), + }), + ), + offset: z.number().int().nonnegative(), + limit: z.number().int().positive(), + total: z.number().int().nonnegative(), + next_offset: z.number().int().nonnegative().nullable(), + has_more: z.boolean(), + }), +}); +const probeResultSchema = z.union([ + errorResultSchema, + matchResultSchema, + searchResultSchema, +]); +type ProbeResult = z.infer; + +type ProbeInput = + | { + readonly action: "match"; + readonly pattern: string; + readonly value: string; + readonly case_sensitive?: boolean; + } + | { + readonly action: "search"; + readonly items: readonly (readonly [string, string])[]; + readonly params: Readonly>; + }; + +interface PendingProbe { + readonly resolve: (result: ProbeResult) => void; + readonly reject: (error: Error) => void; + timeout: NodeJS.Timeout | undefined; +} + +const PROBE_TIMEOUT_MS = 3_000; +const PROBE_BUFFER_LIMIT = 1_024 * 1_024; +const pendingProbes: PendingProbe[] = []; +let probeProcess: ChildProcessWithoutNullStreams | undefined; +let probeStdout = ""; +let probeStderr = ""; +let probeFailure: Error | undefined; +let stoppingProbe = false; + +const failureWithStderr = (message: string): Error => { + const stderr = probeStderr.trim(); + return new Error(stderr.length === 0 ? message : `${message}: ${stderr}`); +}; + +const rejectPendingProbes = (error: Error): void => { + for (const pending of pendingProbes.splice(0)) { + if (pending.timeout !== undefined) clearTimeout(pending.timeout); + pending.reject(error); + } +}; + +const failProbeProcess = (error: Error): void => { + probeFailure ??= error; + rejectPendingProbes(probeFailure); + if ( + !stoppingProbe && + probeProcess?.exitCode === null && + probeProcess.signalCode === null + ) + probeProcess.kill("SIGKILL"); +}; + +const acceptProbeLine = (line: string): void => { + const pending = pendingProbes.shift(); + if (pending === undefined) { + failProbeProcess( + failureWithStderr("Python probe returned an extra result"), + ); + return; + } + if (pending.timeout !== undefined) clearTimeout(pending.timeout); + try { + const decoded: unknown = JSON.parse(line); + pending.resolve(probeResultSchema.parse(decoded)); + } catch (cause: unknown) { + const error = + cause instanceof Error + ? cause + : failureWithStderr("Python probe returned an invalid result"); + pending.reject(error); + failProbeProcess(error); + } +}; + +const acceptProbeOutput = (chunk: string): void => { + probeStdout += chunk; + if (Buffer.byteLength(probeStdout) > PROBE_BUFFER_LIMIT) { + failProbeProcess(failureWithStderr("Python probe output exceeded 1 MiB")); + return; + } + let newline = probeStdout.indexOf("\n"); + while (newline >= 0) { + const line = probeStdout.slice(0, newline).replace(/\r$/u, ""); + probeStdout = probeStdout.slice(newline + 1); + acceptProbeLine(line); + newline = probeStdout.indexOf("\n"); + } +}; + +const startProbeProcess = async (): Promise => { + stoppingProbe = false; + probeFailure = undefined; + probeStdout = ""; + probeStderr = ""; + const child = spawn("python3", [probePath, bridgePath], { + stdio: ["pipe", "pipe", "pipe"], + }); + probeProcess = child; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", acceptProbeOutput); + child.stderr.on("data", (chunk: string) => { + probeStderr += chunk; + if (Buffer.byteLength(probeStderr) > PROBE_BUFFER_LIMIT) + failProbeProcess(new Error("Python probe stderr exceeded 1 MiB")); + }); + child.stdin.on("error", (error) => { + if (!stoppingProbe) failProbeProcess(error); + }); + child.on("error", (error) => { + if (!stoppingProbe) failProbeProcess(error); + }); + child.on("exit", (code, signal) => { + if (!stoppingProbe) + failProbeProcess( + failureWithStderr( + `Python probe exited before responding (code ${String(code)}, signal ${String(signal)})`, + ), + ); + }); + await new Promise((resolve, reject) => { + const onSpawn = (): void => { + child.off("error", onError); + resolve(); + }; + const onError = (error: Error): void => { + child.off("spawn", onSpawn); + reject(error); + }; + child.once("spawn", onSpawn); + child.once("error", onError); + }); +}; + +const hasExited = (child: ChildProcessWithoutNullStreams): boolean => + child.exitCode !== null || child.signalCode !== null; + +const waitForExit = ( + child: ChildProcessWithoutNullStreams, + timeoutMs: number, +): Promise => { + if (hasExited(child)) return Promise.resolve(true); + return new Promise((resolve) => { + const onExit = (): void => { + clearTimeout(timeout); + resolve(true); + }; + const timeout = setTimeout(() => { + child.off("exit", onExit); + resolve(false); + }, timeoutMs); + child.once("exit", onExit); + }); +}; + +const stopProbeProcess = async (): Promise => { + const child = probeProcess; + if (child === undefined) return; + stoppingProbe = true; + rejectPendingProbes(new Error("Python probe stopped before responding")); + if (!hasExited(child)) child.stdin.end(); + if (!(await waitForExit(child, 500))) child.kill("SIGTERM"); + if (!(await waitForExit(child, 500))) child.kill("SIGKILL"); + if (!(await waitForExit(child, 500))) + throw new Error("Python probe did not stop after SIGKILL"); + probeProcess = undefined; +}; + +const probe = (input: ProbeInput): Promise => { + const child = probeProcess; + if (probeFailure !== undefined) return Promise.reject(probeFailure); + if (child === undefined || hasExited(child)) + return Promise.reject(new Error("Python probe is not running")); + return new Promise((resolve, reject) => { + const pending: PendingProbe = { resolve, reject, timeout: undefined }; + pending.timeout = setTimeout(() => { + failProbeProcess( + new Error( + `Python probe request timed out after ${String(PROBE_TIMEOUT_MS)} ms`, + ), + ); + }, PROBE_TIMEOUT_MS); + pendingProbes.push(pending); + child.stdin.write(`${JSON.stringify(input)}\n`, (error) => { + if (error !== null && error !== undefined) failProbeProcess(error); + }); + }); +}; + +beforeAll(startProbeProcess); +beforeEach(async () => { + if ( + probeFailure === undefined && + probeProcess !== undefined && + !hasExited(probeProcess) + ) + return; + await stopProbeProcess(); + await startProbeProcess(); +}); +afterAll(stopProbeProcess); + +const invalidRequest = (action: "match" | "search", message: string) => ({ + action, + ok: false, + type: "ValueError", + diagnostic_type: "invalid_request", + message, +}); + +describe("Hopper bridge regex work limits", () => { + it("rejects ambiguous bounded repetition before Python regex execution", async () => { + await expect( + probe({ + action: "match", + pattern: "(a|aa){1,35}b", + value: "a".repeat(35), + case_sensitive: true, + }), + ).resolves.toEqual( + invalidRequest( + "match", + "Regex exceeds the 10000-path backtracking budget", + ), + ); + }); + + it("rejects excessive combinations across adjacent optional atoms", async () => { + await expect( + probe({ + action: "match", + pattern: `${"a?".repeat(14)}b`, + value: "a".repeat(14), + case_sensitive: true, + }), + ).resolves.toEqual( + invalidRequest( + "match", + "Regex exceeds the 10000-path backtracking budget", + ), + ); + }); + + it("bounds cumulative work before evaluating a costly candidate", async () => { + await expect( + probe({ + action: "match", + pattern: "(?:a|aa){1,12}b", + value: "a".repeat(4_096), + case_sensitive: true, + }), + ).resolves.toEqual( + invalidRequest( + "match", + "Regex search exceeds the 1000000-unit work budget", + ), + ); + }); + + it("applies the regex work budget across the full inventory", async () => { + await expect( + probe({ + action: "search", + items: [ + ["0x1000", "a".repeat(60)], + ["0x2000", "a".repeat(60)], + ], + params: { + pattern: "(?:a|aa){1,8}b", + mode: "regex", + case_sensitive: true, + offset: 0, + limit: 1, + }, + }), + ).resolves.toEqual( + invalidRequest( + "search", + "Regex search exceeds the 1000000-unit work budget", + ), + ); + }); + + it("rejects oversized regex candidates instead of matching a prefix", async () => { + await expect( + probe({ + action: "match", + pattern: "needle", + value: `${"x".repeat(4_096)}needle`, + }), + ).resolves.toEqual( + invalidRequest( + "match", + "Regex candidate exceeds the 4096-character safety limit", + ), + ); + }); + + it("accepts a regex candidate at the exact character limit", async () => { + await expect( + probe({ + action: "match", + pattern: "z$", + value: `${"a".repeat(4_095)}z`, + case_sensitive: true, + }), + ).resolves.toEqual({ + action: "match", + ok: true, + backtracking_paths: 1, + matched: true, + }); + }); + + it.each([ + [256, true], + [257, false], + ])("enforces the %i-character pattern boundary", async (length, accepted) => { + const pattern = "a".repeat(length); + const result = await probe({ + action: "search", + items: [["0x1000", pattern]], + params: { + pattern, + mode: "regex", + case_sensitive: true, + offset: 0, + limit: 1, + }, + }); + if (accepted) { + expect(result).toMatchObject({ + action: "search", + ok: true, + result: { total: 1 }, + }); + return; + } + expect(result).toEqual( + invalidRequest( + "search", + "pattern must contain between 1 and 256 characters", + ), + ); + }); +}); diff --git a/tests/bridgeTruthfulness.test.ts b/tests/boundary/providers/hopper/bridgeTruthfulness.test.ts similarity index 99% rename from tests/bridgeTruthfulness.test.ts rename to tests/boundary/providers/hopper/bridgeTruthfulness.test.ts index 57377d607..1029a6c74 100644 --- a/tests/bridgeTruthfulness.test.ts +++ b/tests/boundary/providers/hopper/bridgeTruthfulness.test.ts @@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises"; import { describe, expect, it } from "vitest"; const bridgeSource = await readFile( - new URL("../bridge/hopper_bridge.py", import.meta.url), + new URL("../../../../bridge/hopper_bridge.py", import.meta.url), "utf8", ); diff --git a/tests/hopperAnalysisProfile.test.ts b/tests/boundary/providers/hopper/hopperAnalysisProfile.test.ts similarity index 93% rename from tests/hopperAnalysisProfile.test.ts rename to tests/boundary/providers/hopper/hopperAnalysisProfile.test.ts index 44f1d7774..48c8c456c 100644 --- a/tests/hopperAnalysisProfile.test.ts +++ b/tests/boundary/providers/hopper/hopperAnalysisProfile.test.ts @@ -3,14 +3,14 @@ import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; -import type { BinaryTarget } from "../src/domain/binaryTarget.js"; +import type { BinaryTarget } from "../../../../src/domain/binaryTarget.js"; import { hopperLoaderArgsForTarget, resolveHopperAnalysisProfile, -} from "../src/hopper/HopperAnalysisProfile.js"; -import { HOPPER_PROVIDER_IDENTITY } from "../src/hopper/HopperProvider.js"; +} from "../../../../src/hopper/HopperAnalysisProfile.js"; +import { HOPPER_PROVIDER_IDENTITY } from "../../../../src/hopper/HopperProvider.js"; let directory: string | undefined; diff --git a/tests/hopperBridgeFacade.test.ts b/tests/boundary/providers/hopper/hopperBridgeFacade.test.ts similarity index 94% rename from tests/hopperBridgeFacade.test.ts rename to tests/boundary/providers/hopper/hopperBridgeFacade.test.ts index ce556494e..c3f3e95ef 100644 --- a/tests/hopperBridgeFacade.test.ts +++ b/tests/boundary/providers/hopper/hopperBridgeFacade.test.ts @@ -5,9 +5,12 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; const execute = promisify(execFile); -const bridgePath = new URL("../bridge/hopper_bridge.py", import.meta.url); +const bridgePath = new URL( + "../../../../bridge/hopper_bridge.py", + import.meta.url, +); const probePath = new URL( - "./fixtures/hopperBridgeFacadeProbe.py", + "../../../fixtures/hopperBridgeFacadeProbe.py", import.meta.url, ); diff --git a/tests/boundary/providers/hopper/hopperClient.cleanup.test.ts b/tests/boundary/providers/hopper/hopperClient.cleanup.test.ts new file mode 100644 index 000000000..5039d1edb --- /dev/null +++ b/tests/boundary/providers/hopper/hopperClient.cleanup.test.ts @@ -0,0 +1,260 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { getEventListeners } from "node:events"; +import { access } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { projectAnalysisError } from "../../../../src/domain/errors.js"; +import { ok } from "../../../../src/domain/result.js"; +import type { + BridgeLauncher, + BridgeSession, +} from "../../../../src/hopper/BridgeLauncher.js"; +import { HopperClient } from "../../../../src/hopper/HopperClient.js"; +import { cleanupOwnedProcessGroup } from "../../../../src/process/ProcessOwnership.js"; +import { spawnOwnedProviderProcess } from "../../../../src/process/ProviderProcess.js"; + +const fixturePath = fileURLToPath( + new URL("../../../fixtures/fakeHopper.mjs", import.meta.url), +); + +class FixtureLauncher implements BridgeLauncher { + socketPaths: string[] = []; + directories: string[] = []; + runIds: string[] = []; + processes: ChildProcess[] = []; + + constructor(readonly tokenOverride?: string) {} + + launch(session: BridgeSession) { + this.socketPaths.push(session.socketPath); + this.directories.push(session.directory); + this.runIds.push(session.runId); + const child = spawn( + process.execPath, + [ + fixturePath, + session.socketPath, + this.tokenOverride ?? session.token, + session.runId, + ], + { + stdio: ["ignore", "ignore", "pipe"], + }, + ); + this.processes.push(child); + return Promise.resolve( + ok({ + process: child, + ownsProcessLifetime: true, + }), + ); + } +} + +class OwnedFixtureLauncher implements BridgeLauncher { + ownership: + | { + readonly runId: string; + readonly leaderPid: number; + readonly processGroupId: number; + } + | undefined; + + async launch(session: BridgeSession) { + const started = await spawnOwnedProviderProcess({ + command: process.execPath, + arguments: [ + fixturePath, + session.socketPath, + session.token, + session.runId, + "cleanup-required", + ], + runId: session.runId, + expectedCommand: null, + }); + this.ownership = started.ownership; + return ok({ + process: started.process, + ownsProcessLifetime: true, + ownership: started.ownership, + shutdownByCleanup: true, + cleanup: () => cleanupOwnedProcessGroup(started.ownership), + }); + } +} + +class UnconfirmedFixtureLauncher implements BridgeLauncher { + launch(session: BridgeSession) { + return Promise.resolve( + ok({ + process: spawn( + process.execPath, + [ + fixturePath, + session.socketPath, + session.token, + session.runId, + "unconfirmed", + ], + { stdio: ["ignore", "ignore", "pipe"] }, + ), + ownsProcessLifetime: false, + }), + ); + } +} + +const clients: HopperClient[] = []; +const startClient = async () => { + const client = new HopperClient({ + launcher: new FixtureLauncher(), + requestTimeoutMs: 100, + startupTimeoutMs: 1_000, + }); + clients.push(client); + await expect(client.start()).resolves.toEqual({ + ok: true, + value: { name: "REA Hopper bridge", version: "1.0.0" }, + }); + return client; +}; + +afterEach(async () => { + await Promise.all(clients.splice(0).map((client) => client.close())); +}); + +describe("HopperClient cleanup", () => { + it("ignores more than 1,024 late responses without corrupting the session", async () => { + const client = await startClient(); + const timedOut = await Promise.all( + Array.from({ length: 1_025 }, (_, index) => + client.callTool("echo", { index, delay: 150 }, { timeoutMs: 5 }), + ), + ); + expect(timedOut.every((result) => !result.ok)).toBe(true); + await new Promise((resolve) => setTimeout(resolve, 200)); + await expect(client.callTool("echo", { value: "alive" })).resolves.toEqual({ + ok: true, + value: { value: "alive" }, + }); + }); + + it("makes concurrent close callers await the same shutdown", async () => { + const client = await startClient(); + let firstSettled = false; + const first = client.close().then(() => { + firstSettled = true; + }); + await client.close(); + expect(firstSettled).toBe(true); + await first; + }); + + it("reports unconfirmed unowned document cleanup as a typed close failure", async () => { + const client = new HopperClient({ + launcher: new UnconfirmedFixtureLauncher(), + startupTimeoutMs: 1_000, + }); + clients.push(client); + expect((await client.start()).ok).toBe(true); + + const closed = await client.closeWithOutcome(); + + expect(closed).toMatchObject({ + ok: false, + error: { + _tag: "ProviderAdapterError", + cleanupIncomplete: true, + cleanupResources: ["hopper-document"], + }, + }); + if (!closed.ok) + expect(projectAnalysisError(closed.error)).toMatchObject({ + code: "cleanup_incomplete", + details: { + provider_id: "hopper", + operation: "close_binary", + cleanup: "incomplete", + resources: ["hopper-document"], + }, + }); + }); + + it("kills only its owned Hopper group and emits sanitized shutdown coordinates", async () => { + const unrelated = spawn( + process.execPath, + ["-e", "process.title = 'Hopper'; setInterval(() => undefined, 1000)"], + { detached: true, stdio: "ignore" }, + ); + await new Promise((resolve, reject) => { + unrelated.once("spawn", resolve); + unrelated.once("error", reject); + }); + const launcher = new OwnedFixtureLauncher(); + const diagnostics: unknown[] = []; + const runId = "22222222-2222-4222-8222-222222222222"; + const client = new HopperClient({ + launcher, + runId, + startupTimeoutMs: 1_000, + onDiagnostic: (event) => diagnostics.push(event), + }); + clients.push(client); + + try { + await expect(client.start()).resolves.toMatchObject({ ok: true }); + await client.close(); + + const unrelatedPid = unrelated.pid; + if (unrelatedPid === undefined) + throw new Error("Unrelated Hopper fixture has no PID"); + expect(() => process.kill(unrelatedPid, 0)).not.toThrow(); + expect(diagnostics).toContainEqual({ + type: "owned-shutdown", + status: "verified-cleanup", + launcher_pid: launcher.ownership?.leaderPid, + process_group_id: launcher.ownership?.processGroupId, + cleanup_signaled: true, + reason: null, + }); + expect(JSON.stringify(diagnostics)).not.toContain(runId); + } finally { + await stopUnrelatedFixture(unrelated); + } + }); + + it("makes sequential double-close leave no runtime or process listeners", async () => { + const launcher = new FixtureLauncher(); + const client = new HopperClient({ launcher, startupTimeoutMs: 1_000 }); + clients.push(client); + await expect(client.start()).resolves.toMatchObject({ ok: true }); + + await client.close(); + await client.close(); + + const directory = launcher.directories[0] ?? ""; + const child = launcher.processes[0]; + await expect(access(directory)).rejects.toMatchObject({ code: "ENOENT" }); + expect(child).toBeDefined(); + if (child === undefined) + throw new Error("Fixture process was not captured"); + expect(child.exitCode !== null || child.signalCode !== null).toBe(true); + expect(getEventListeners(child, "exit")).toHaveLength(0); + expect(getEventListeners(child, "close")).toHaveLength(0); + expect(getEventListeners(child, "error")).toHaveLength(0); + expect(child.stderr).not.toBeNull(); + if (child.stderr === null) throw new Error("Fixture stderr is unavailable"); + expect(getEventListeners(child.stderr, "data")).toHaveLength(0); + }); +}); +const stopUnrelatedFixture = async (child: ChildProcess): Promise => { + if (child.exitCode !== null || child.signalCode !== null) return; + const exited = new Promise((resolve) => { + child.once("exit", () => resolve()); + }); + child.kill("SIGKILL"); + await exited; +}; diff --git a/tests/boundary/providers/hopper/hopperClient.progress.test.ts b/tests/boundary/providers/hopper/hopperClient.progress.test.ts new file mode 100644 index 000000000..b3cc682d5 --- /dev/null +++ b/tests/boundary/providers/hopper/hopperClient.progress.test.ts @@ -0,0 +1,198 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { ok } from "../../../../src/domain/result.js"; +import type { + BridgeLauncher, + BridgeSession, +} from "../../../../src/hopper/BridgeLauncher.js"; +import { HopperClient } from "../../../../src/hopper/HopperClient.js"; + +const fixturePath = fileURLToPath( + new URL("../../../fixtures/fakeHopper.mjs", import.meta.url), +); + +class FixtureLauncher implements BridgeLauncher { + socketPaths: string[] = []; + directories: string[] = []; + runIds: string[] = []; + processes: ChildProcess[] = []; + + constructor(readonly tokenOverride?: string) {} + + launch(session: BridgeSession) { + this.socketPaths.push(session.socketPath); + this.directories.push(session.directory); + this.runIds.push(session.runId); + const child = spawn( + process.execPath, + [ + fixturePath, + session.socketPath, + this.tokenOverride ?? session.token, + session.runId, + ], + { + stdio: ["ignore", "ignore", "pipe"], + }, + ); + this.processes.push(child); + return Promise.resolve( + ok({ + process: child, + ownsProcessLifetime: true, + }), + ); + } +} + +const clients: HopperClient[] = []; +const startClient = async () => { + const client = new HopperClient({ + launcher: new FixtureLauncher(), + requestTimeoutMs: 100, + startupTimeoutMs: 1_000, + }); + clients.push(client); + await expect(client.start()).resolves.toEqual({ + ok: true, + value: { name: "REA Hopper bridge", version: "1.0.0" }, + }); + return client; +}; + +afterEach(async () => { + await Promise.all(clients.splice(0).map((client) => client.close())); +}); + +describe("HopperClient progress", () => { + it("forwards correlated Python progress before the terminal response", async () => { + const client = await startClient(); + const updates: Array<{ + readonly phase: string; + readonly completed: number; + readonly terminal?: boolean; + }> = []; + const result = await client.callTool( + "echo", + { value: "progress" }, + { + progress: { + report: (update) => { + updates.push(update); + return Promise.resolve(); + }, + }, + }, + ); + + expect(result).toEqual({ ok: true, value: { value: "progress" } }); + expect(updates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + phase: "hopper_bridge", + completed: 0, + }), + expect.objectContaining({ + phase: "hopper_bridge", + completed: 1, + terminal: true, + }), + ]), + ); + }); + + it("isolates synchronous progress observer failures from bridge responses", async () => { + const client = await startClient(); + await expect( + client.callTool( + "echo", + { value: "alive" }, + { + progress: { + report: () => { + throw new Error("observer failed"); + }, + }, + }, + ), + ).resolves.toEqual({ ok: true, value: { value: "alive" } }); + }); + + it("projects a missing bridge API as typed capability unavailability", async () => { + const client = await startClient(); + const result = await client.callTool("capability_unavailable"); + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.error).toMatchObject({ + _tag: "AnalysisCapabilityUnavailableError", + providerId: "hopper", + operation: "capability_unavailable", + reason: "fixture API is unavailable", + }); + }); + + it("cancels and ignores late responses without corrupting the session", async () => { + const client = await startClient(); + const controller = new AbortController(); + const pending = client.callTool("hang", {}, { signal: controller.signal }); + controller.abort(); + const cancelled = await pending; + expect(cancelled.ok).toBe(false); + if (!cancelled.ok) + expect(cancelled.error._tag).toBe("HopperCancelledError"); + + const timedOut = await client.callTool( + "echo", + { delay: 30 }, + { timeoutMs: 5 }, + ); + expect(timedOut.ok).toBe(false); + await new Promise((resolve) => setTimeout(resolve, 40)); + await expect(client.callTool("echo", { value: "alive" })).resolves.toEqual({ + ok: true, + value: { value: "alive" }, + }); + }); + + it("retains timed-out bridge activity until the late reply releases the queue", async () => { + const client = await startClient(); + const progress: string[] = []; + const result = await client.callTool( + "echo", + { delay: 60 }, + { + timeoutMs: 5, + progress: { + report: (update) => { + progress.push(update.message); + return Promise.resolve(); + }, + }, + }, + ); + + expect(result).toMatchObject({ + ok: false, + error: { _tag: "HopperTimeoutError" }, + }); + expect(client.requestActivity()).toMatchObject({ + operation: "echo", + callerState: "timed_out", + timeoutMs: 5, + }); + expect(progress).toEqual( + expect.arrayContaining([ + expect.stringContaining("started on Hopper's serial bridge"), + ]), + ); + await new Promise((resolve) => setTimeout(resolve, 70)); + expect(client.requestActivity()).toBeNull(); + await expect(client.callTool("echo", { alive: true })).resolves.toEqual({ + ok: true, + value: { alive: true }, + }); + }); +}); diff --git a/tests/boundary/providers/hopper/hopperClient.protocol.test.ts b/tests/boundary/providers/hopper/hopperClient.protocol.test.ts new file mode 100644 index 000000000..0e03f8431 --- /dev/null +++ b/tests/boundary/providers/hopper/hopperClient.protocol.test.ts @@ -0,0 +1,183 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { OFFICIAL_TOOL_CONTRACTS } from "../../../../src/contracts/toolContracts.js"; +import { ok } from "../../../../src/domain/result.js"; +import type { + BridgeLauncher, + BridgeSession, +} from "../../../../src/hopper/BridgeLauncher.js"; +import { HopperClient } from "../../../../src/hopper/HopperClient.js"; +import type { HopperDiagnostic } from "../../../../src/hopper/HopperDiagnostics.js"; +import { providerCleanupFailure } from "../../../../src/hopper/HopperDiagnostics.js"; + +const fixturePath = fileURLToPath( + new URL("../../../fixtures/fakeHopper.mjs", import.meta.url), +); + +class FixtureLauncher implements BridgeLauncher { + socketPaths: string[] = []; + directories: string[] = []; + runIds: string[] = []; + processes: ChildProcess[] = []; + + constructor(readonly tokenOverride?: string) {} + + launch(session: BridgeSession) { + this.socketPaths.push(session.socketPath); + this.directories.push(session.directory); + this.runIds.push(session.runId); + const child = spawn( + process.execPath, + [ + fixturePath, + session.socketPath, + this.tokenOverride ?? session.token, + session.runId, + ], + { + stdio: ["ignore", "ignore", "pipe"], + }, + ); + this.processes.push(child); + return Promise.resolve( + ok({ + process: child, + ownsProcessLifetime: true, + }), + ); + } +} + +const clients: HopperClient[] = []; +const startClient = async () => { + const client = new HopperClient({ + launcher: new FixtureLauncher(), + requestTimeoutMs: 1_000, + startupTimeoutMs: 1_000, + }); + clients.push(client); + await expect(client.start()).resolves.toEqual({ + ok: true, + value: { name: "REA Hopper bridge", version: "1.0.0" }, + }); + return client; +}; + +afterEach(async () => { + await Promise.all(clients.splice(0).map((client) => client.close())); +}); + +describe("HopperClient protocol", () => { + it("sanitizes unexpected owned-cleanup exceptions", () => { + expect( + providerCleanupFailure( + new Error("secret-run-token 22222222-2222-4222-8222-222222222222"), + ), + ).toEqual({ + cleaned: false, + reason: "owned provider cleanup failed", + }); + }); + + it("uses the caller-assigned provider run identity", async () => { + const launcher = new FixtureLauncher(); + const client = new HopperClient({ + launcher, + runId: "11111111-1111-4111-8111-111111111111", + startupTimeoutMs: 1_000, + }); + clients.push(client); + + expect((await client.start()).ok).toBe(true); + expect(launcher.runIds).toEqual(["11111111-1111-4111-8111-111111111111"]); + }); + + it("keeps the native socket path below macOS sockaddr_un limits", async () => { + const launcher = new FixtureLauncher(); + const client = new HopperClient({ launcher, startupTimeoutMs: 1_000 }); + clients.push(client); + const started = await client.start(); + expect(started.ok).toBe(true); + expect(Buffer.byteLength(launcher.socketPaths[0] ?? "")).toBeLessThan(104); + }); + + it("serializes concurrent calls until the active Hopper reply arrives", async () => { + const client = await startClient(); + const slow = client.callTool("echo", { label: "slow", delay: 30 }); + const fast = client.callTool("echo", { label: "fast", delay: 1 }); + let fastSettled = false; + void fast.then(() => { + fastSettled = true; + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(fastSettled).toBe(false); + await expect(Promise.all([slow, fast])).resolves.toEqual([ + { ok: true, value: { label: "slow", delay: 30 } }, + { ok: true, value: { label: "fast", delay: 1 } }, + ]); + }); + + it("routes every established operation through the authenticated bridge", async () => { + const client = await startClient(); + const results = await Promise.all( + OFFICIAL_TOOL_CONTRACTS.map(({ name }) => client.callTool(name, {})), + ); + expect(results).toHaveLength(36); + expect(results.every((result) => result.ok)).toBe(true); + }); + + it("rejects a bridge session with the wrong capability token", async () => { + const client = new HopperClient({ + launcher: new FixtureLauncher("wrong-token"), + startupTimeoutMs: 1_000, + }); + clients.push(client); + const result = await client.start(); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error._tag).toBe("HopperRemoteError"); + }); + + it.each([ + ["malformed", "HopperProtocolError"], + ["wrong_id", "HopperProtocolError"], + ["wrong_event_id", "HopperProtocolError"], + ["malformed_event", "HopperProtocolError"], + ["remote_error", "HopperRemoteError"], + ["hang", "HopperTimeoutError"], + ["exit", "HopperProcessError"], + ])("projects %s as %s", async (method, expectedTag) => { + const client = await startClient(); + const result = await client.callTool(method); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error._tag).toBe(expectedTag); + }); + + it("preserves a sanitized bridge exception diagnostic", async () => { + const diagnostics: HopperDiagnostic[] = []; + const client = new HopperClient({ + launcher: new FixtureLauncher(), + requestTimeoutMs: 100, + startupTimeoutMs: 1_000, + onDiagnostic: (event) => diagnostics.push(event), + }); + clients.push(client); + await expect(client.start()).resolves.toMatchObject({ ok: true }); + const result = await client.callTool("remote_error"); + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.error).toMatchObject({ + _tag: "HopperRemoteError", + diagnosticType: "bridge_exception", + }); + expect(diagnostics).toContainEqual({ + type: "bridge-diagnostic", + request_id: 2, + code: -32001, + category: "bridge_exception", + message: "safe fake failure", + }); + }); +}); diff --git a/tests/boundary/providers/hopper/hopperClient.restart.test.ts b/tests/boundary/providers/hopper/hopperClient.restart.test.ts new file mode 100644 index 000000000..177fb32e9 --- /dev/null +++ b/tests/boundary/providers/hopper/hopperClient.restart.test.ts @@ -0,0 +1,111 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { ok } from "../../../../src/domain/result.js"; +import type { + BridgeLauncher, + BridgeSession, +} from "../../../../src/hopper/BridgeLauncher.js"; +import { HopperClient } from "../../../../src/hopper/HopperClient.js"; + +const fixturePath = fileURLToPath( + new URL("../../../fixtures/fakeHopper.mjs", import.meta.url), +); + +class FixtureLauncher implements BridgeLauncher { + socketPaths: string[] = []; + directories: string[] = []; + runIds: string[] = []; + processes: ChildProcess[] = []; + + constructor(readonly tokenOverride?: string) {} + + launch(session: BridgeSession) { + this.socketPaths.push(session.socketPath); + this.directories.push(session.directory); + this.runIds.push(session.runId); + const child = spawn( + process.execPath, + [ + fixturePath, + session.socketPath, + this.tokenOverride ?? session.token, + session.runId, + ], + { + stdio: ["ignore", "ignore", "pipe"], + }, + ); + this.processes.push(child); + return Promise.resolve( + ok({ + process: child, + ownsProcessLifetime: true, + }), + ); + } +} + +const clients: HopperClient[] = []; +const startClient = async () => { + const client = new HopperClient({ + launcher: new FixtureLauncher(), + requestTimeoutMs: 100, + startupTimeoutMs: 1_000, + }); + clients.push(client); + await expect(client.start()).resolves.toEqual({ + ok: true, + value: { name: "REA Hopper bridge", version: "1.0.0" }, + }); + return client; +}; + +afterEach(async () => { + await Promise.all(clients.splice(0).map((client) => client.close())); +}); + +describe("HopperClient restart", () => { + it("does not finish startup after an immediate close", async () => { + const client = new HopperClient({ + launcher: new FixtureLauncher(), + startupTimeoutMs: 1_000, + }); + clients.push(client); + + const starting = client.start(); + await client.close(); + + const result = await starting; + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error._tag).toBe("HopperCancelledError"); + }); + + it("starts a fresh session after close completes", async () => { + const client = await startClient(); + await client.close(); + + await expect(client.start()).resolves.toEqual({ + ok: true, + value: { name: "REA Hopper bridge", version: "1.0.0" }, + }); + }); + + it("serializes a restart requested while close is in progress", async () => { + const client = await startClient(); + const closing = client.close(); + const restarting = client.start(); + + await closing; + await expect(restarting).resolves.toEqual({ + ok: true, + value: { name: "REA Hopper bridge", version: "1.0.0" }, + }); + await expect(client.callTool("echo", { value: "fresh" })).resolves.toEqual({ + ok: true, + value: { value: "fresh" }, + }); + }); +}); diff --git a/tests/boundary/providers/hopper/hopperClient.startupFailures.test.ts b/tests/boundary/providers/hopper/hopperClient.startupFailures.test.ts new file mode 100644 index 000000000..3ec839aea --- /dev/null +++ b/tests/boundary/providers/hopper/hopperClient.startupFailures.test.ts @@ -0,0 +1,281 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { access } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { projectAnalysisError } from "../../../../src/domain/errors.js"; +import type { HopperStartupDiagnostic } from "../../../../src/domain/hopperStartupFailure.js"; +import { ok } from "../../../../src/domain/result.js"; +import type { + BridgeLauncher, + BridgeSession, +} from "../../../../src/hopper/BridgeLauncher.js"; +import { HopperClient } from "../../../../src/hopper/HopperClient.js"; +import { LINUX_PRIVATE_DISPLAY_DIAGNOSTIC_PREFIX } from "../../../../src/hopper/LinuxPrivateDisplayDiagnostic.js"; + +const fixturePath = fileURLToPath( + new URL("../../../fixtures/fakeHopper.mjs", import.meta.url), +); + +class FixtureLauncher implements BridgeLauncher { + socketPaths: string[] = []; + directories: string[] = []; + runIds: string[] = []; + processes: ChildProcess[] = []; + + constructor(readonly tokenOverride?: string) {} + + launch(session: BridgeSession) { + this.socketPaths.push(session.socketPath); + this.directories.push(session.directory); + this.runIds.push(session.runId); + const child = spawn( + process.execPath, + [ + fixturePath, + session.socketPath, + this.tokenOverride ?? session.token, + session.runId, + ], + { + stdio: ["ignore", "ignore", "pipe"], + }, + ); + this.processes.push(child); + return Promise.resolve( + ok({ + process: child, + ownsProcessLifetime: true, + }), + ); + } +} + +class SilentLauncher implements BridgeLauncher { + launch() { + return Promise.resolve( + ok({ + process: spawn( + process.execPath, + ["-e", "setInterval(() => {}, 1000)"], + { + stdio: ["ignore", "ignore", "pipe"], + }, + ), + ownsProcessLifetime: true, + }), + ); + } +} + +class CancelThenFixtureLauncher implements BridgeLauncher { + #launches = 0; + + launch(session: BridgeSession) { + this.#launches += 1; + return this.#launches === 1 + ? new SilentLauncher().launch() + : new FixtureLauncher().launch(session); + } +} + +class ExitingLauncher implements BridgeLauncher { + constructor(readonly code: number) {} + + launch() { + return Promise.resolve( + ok({ + process: spawn(process.execPath, ["-e", `process.exit(${this.code})`], { + stdio: ["ignore", "ignore", "pipe"], + }), + ownsProcessLifetime: true, + }), + ); + } +} + +class DiagnosticExitingLauncher implements BridgeLauncher { + launch() { + const diagnostic: HopperStartupDiagnostic = { + schema_version: 1, + component: "hopper_private_display", + operation: "launch", + status: "error", + failure_code: "x11_socket_directory_unusable", + reason: "socket_directory_read_only", + socket_directory: "/tmp/.X11-unix", + socket_directory_mode: "0777", + mount_read_only: true, + effective_socket_directory_mode: "0777", + effective_mount_read_only: true, + wsl: true, + strategy: "direct", + fallback_reason: null, + xvfb_stderr_bytes: 100, + xvfb_stderr_truncated: false, + }; + const line = `${LINUX_PRIVATE_DISPLAY_DIAGNOSTIC_PREFIX}${JSON.stringify(diagnostic)}\n`; + return Promise.resolve( + ok({ + process: spawn( + process.execPath, + [ + "-e", + `process.stderr.write(${JSON.stringify(line)}, () => process.exit(80))`, + ], + { stdio: ["ignore", "ignore", "pipe"] }, + ), + ownsProcessLifetime: true, + }), + ); + } +} + +class LateSilentLauncher implements BridgeLauncher { + readonly directories: string[] = []; + readonly processes: ChildProcess[] = []; + + async launch(session: BridgeSession) { + this.directories.push(session.directory); + const launched = await new SilentLauncher().launch(); + if (launched.ok) this.processes.push(launched.value.process); + await new Promise((resolve) => setTimeout(resolve, 50)); + return launched; + } +} + +const clients: HopperClient[] = []; + +afterEach(async () => { + await Promise.all(clients.splice(0).map((client) => client.close())); +}); + +describe("HopperClient startup failures", () => { + it("cancels bridge startup without waiting for the startup timeout", async () => { + const client = new HopperClient({ + launcher: new CancelThenFixtureLauncher(), + // Keep the retry proof independent from scheduler pressure when the + // complete boundary project is starting several child-process fixtures. + startupTimeoutMs: 30_000, + }); + clients.push(client); + const controller = new AbortController(); + const startedAt = Date.now(); + const pending = client.callTool("echo", {}, { signal: controller.signal }); + setTimeout(() => { + controller.abort(); + }, 10); + const result = await pending; + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error._tag).toBe("HopperCancelledError"); + expect(Date.now() - startedAt).toBeLessThan(500); + await expect(client.start()).resolves.toEqual({ + ok: true, + value: { name: "REA Hopper bridge", version: "1.0.0" }, + }); + await expect( + client.callTool("echo", { value: "retried" }), + ).resolves.toEqual({ + ok: true, + value: { value: "retried" }, + }); + }); + + it("applies one deadline to launcher, socket, and health startup phases", async () => { + const launcher = new LateSilentLauncher(); + const client = new HopperClient({ launcher, startupTimeoutMs: 500 }); + clients.push(client); + + const result = await client.start(); + + expect(result).toMatchObject({ + ok: false, + error: { _tag: "HopperTimeoutError", timeoutMs: 500 }, + }); + expect(launcher.directories).toHaveLength(1); + await expect(access(launcher.directories[0] ?? "")).rejects.toMatchObject({ + code: "ENOENT", + }); + const process = launcher.processes[0]; + expect( + process !== undefined && + (process.exitCode !== null || process.signalCode !== null), + ).toBe(true); + }); + + it.each([70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80])( + "reports Linux adapter exit %i during bridge startup", + async (exitCode) => { + const client = new HopperClient({ + launcher: new ExitingLauncher(exitCode), + startupTimeoutMs: 10_000, + }); + clients.push(client); + const result = await client.start(); + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.error).toMatchObject({ + _tag: "HopperProcessError", + exitCode, + }); + }, + ); + + it("projects actionable CI remediation for Hopper UI, license, and lifecycle failures", async () => { + const client = new HopperClient({ + launcher: new ExitingLauncher(75), + startupTimeoutMs: 10_000, + }); + clients.push(client); + const result = await client.start(); + expect(result.ok).toBe(false); + if (result.ok) return; + + const projected = projectAnalysisError(result.error); + expect(projected.message).toContain( + "rea doctor --provider hopper --detail full --json", + ); + expect(projected.message).toContain("UI or license prompt"); + expect(projected.message).toContain("close stale Hopper sessions"); + expect(projected.details).toMatchObject({ + failure_code: "hopper_exited_during_startup", + exit_code: 75, + }); + }); + + it("preserves the bounded private-display diagnostic from an adapter exit", async () => { + const client = new HopperClient({ + launcher: new DiagnosticExitingLauncher(), + startupTimeoutMs: 10_000, + }); + clients.push(client); + const result = await client.start(); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "HopperProcessError", + exitCode: 80, + failureCode: "x11_socket_directory_unusable", + diagnostic: { + socket_directory: "/tmp/.X11-unix", + socket_directory_mode: "0777", + mount_read_only: true, + wsl: true, + strategy: "direct", + }, + }, + }); + }); + + it("allows a short-lived launcher to hand off bridge startup", async () => { + const client = new HopperClient({ + launcher: new ExitingLauncher(0), + startupTimeoutMs: 100, + }); + clients.push(client); + const result = await client.start(); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error._tag).toBe("HopperTimeoutError"); + }); +}); diff --git a/tests/hopperValues.test.ts b/tests/boundary/providers/hopper/hopperValues.test.ts similarity index 98% rename from tests/hopperValues.test.ts rename to tests/boundary/providers/hopper/hopperValues.test.ts index 4c319d7fd..8947c326b 100644 --- a/tests/hopperValues.test.ts +++ b/tests/boundary/providers/hopper/hopperValues.test.ts @@ -8,7 +8,7 @@ import { parseProcedures, parseRelatedAddresses, parseSegments, -} from "../src/domain/hopperValues.js"; +} from "../../../../src/domain/hopperValues.js"; describe("Hopper boundary values", () => { it("parses Hopper's address-keyed name and string maps", () => { diff --git a/tests/linuxHopper.test.ts b/tests/boundary/providers/hopper/linuxHopper.test.ts similarity index 99% rename from tests/linuxHopper.test.ts rename to tests/boundary/providers/hopper/linuxHopper.test.ts index c9fc6716b..65a92016f 100644 --- a/tests/linuxHopper.test.ts +++ b/tests/boundary/providers/hopper/linuxHopper.test.ts @@ -12,7 +12,7 @@ import { type LinuxHopperInstallHost, type LinuxHopperLauncherStatus, type LinuxPackageFamily, -} from "../src/application/LinuxHopper.js"; +} from "../../../../src/application/LinuxHopper.js"; class RecordingLinuxHost implements LinuxHopperInstallHost { distributionValue: LinuxDistribution | undefined = { diff --git a/tests/linuxPrivateDisplay.test.ts b/tests/boundary/providers/hopper/linuxPrivateDisplay.test.ts similarity index 94% rename from tests/linuxPrivateDisplay.test.ts rename to tests/boundary/providers/hopper/linuxPrivateDisplay.test.ts index dba47a318..43a57b1a3 100644 --- a/tests/linuxPrivateDisplay.test.ts +++ b/tests/boundary/providers/hopper/linuxPrivateDisplay.test.ts @@ -3,24 +3,24 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import type { HopperStartupDiagnostic } from "../src/domain/hopperStartupFailure.js"; +import type { HopperStartupDiagnostic } from "../../../../src/domain/hopperStartupFailure.js"; import { runLinuxPrivateDisplayProbe, selectLinuxPrivateDisplayStrategy, type LinuxPrivateDisplayProbeProcessResult, type LinuxPrivateDisplayProbeRunner, type LinuxPrivateDisplayRunnableStrategy, -} from "../src/hopper/LinuxPrivateDisplayProbe.js"; +} from "../../../../src/hopper/LinuxPrivateDisplayProbe.js"; import { LINUX_PRIVATE_DISPLAY_DIAGNOSTIC_PREFIX, parseLinuxPrivateDisplayDiagnostic, -} from "../src/hopper/LinuxPrivateDisplayDiagnostic.js"; +} from "../../../../src/hopper/LinuxPrivateDisplayDiagnostic.js"; const helperPath = fileURLToPath( - new URL("../scripts/hopper-demo-x11.py", import.meta.url), + new URL("../../../../scripts/hopper-demo-x11.py", import.meta.url), ); const hangingHelperPath = fileURLToPath( - new URL("./fixtures/x11ProbeHang.py", import.meta.url), + new URL("../../../fixtures/x11ProbeHang.py", import.meta.url), ); const diagnostic = ( diff --git a/tests/macHopper.test.ts b/tests/boundary/providers/hopper/macHopper.test.ts similarity index 98% rename from tests/macHopper.test.ts rename to tests/boundary/providers/hopper/macHopper.test.ts index e03e64f13..921e5de4d 100644 --- a/tests/macHopper.test.ts +++ b/tests/boundary/providers/hopper/macHopper.test.ts @@ -6,7 +6,7 @@ import { installMacHopper, macHopperInstallDisclosure, type MacHopperInstallHost, -} from "../src/application/MacHopper.js"; +} from "../../../../src/application/MacHopper.js"; class FakeMacHopperHost implements MacHopperInstallHost { readonly archive = new TextEncoder().encode("hopper-dmg"); diff --git a/tests/managedApplicationGraph.test.ts b/tests/boundary/providers/managed/managedApplicationGraph.test.ts similarity index 89% rename from tests/managedApplicationGraph.test.ts rename to tests/boundary/providers/managed/managedApplicationGraph.test.ts index 309f78d3a..bd5942f18 100644 --- a/tests/managedApplicationGraph.test.ts +++ b/tests/boundary/providers/managed/managedApplicationGraph.test.ts @@ -2,17 +2,20 @@ import { createHash } from "node:crypto"; import { describe, expect, it } from "vitest"; -import { projectManagedApplicationGraphEvidence } from "../src/application/ManagedApplicationGraphService.js"; -import { traceApplicationFeatureEvidence } from "../src/application/JavaScriptApplicationWorkflowService.js"; -import { MANAGED_STATIC_PROVIDER } from "../src/application/InvestigationProviders.js"; -import type { BinaryTarget } from "../src/domain/binaryTarget.js"; -import { createEvidence, parseEvidence } from "../src/domain/evidence.js"; -import { parseJavaScriptApplicationGraph } from "../src/domain/javascriptApplicationGraph.js"; -import { managedApplicationGraphResultSchema } from "../src/domain/managedApplicationGraph.js"; -import { inspectManagedArtifactBytes } from "../src/dotnet/ManagedArtifactInspector.js"; -import { inspectManagedMembersBytes } from "../src/dotnet/ManagedMemberInspector.js"; -import { inspectManagedNativeBoundariesBytes } from "../src/dotnet/ManagedNativeBoundaryInspector.js"; -import { buildManagedPeFixture } from "./fixtures/managedPe.js"; +import { projectManagedApplicationGraphEvidence } from "../../../../src/application/ManagedApplicationGraphService.js"; +import { traceApplicationFeatureEvidence } from "../../../../src/application/JavaScriptApplicationWorkflowService.js"; +import { MANAGED_STATIC_PROVIDER } from "../../../../src/application/InvestigationProviders.js"; +import type { BinaryTarget } from "../../../../src/domain/binaryTarget.js"; +import { + createEvidence, + parseEvidence, +} from "../../../../src/domain/evidence.js"; +import { parseJavaScriptApplicationGraph } from "../../../../src/domain/javascriptApplicationGraph.js"; +import { managedApplicationGraphResultSchema } from "../../../../src/domain/managedApplicationGraph.js"; +import { inspectManagedArtifactBytes } from "../../../../src/dotnet/ManagedArtifactInspector.js"; +import { inspectManagedMembersBytes } from "../../../../src/dotnet/ManagedMemberInspector.js"; +import { inspectManagedNativeBoundariesBytes } from "../../../../src/dotnet/ManagedNativeBoundaryInspector.js"; +import { buildManagedPeFixture } from "../../../fixtures/managedPe.js"; const artifactLimits = { referenceOffset: 0, @@ -205,7 +208,9 @@ describe("managed application graph projection", () => { summary: { matched_seeds: 1 }, }); }); +}); +describe("managed application graph coverage", () => { it("preserves source-page omissions in graph and per-fact coverage", () => { const bytes = buildManagedPeFixture(); const binary = target(bytes); diff --git a/tests/boundary/providers/managed/managedArtifactProvider.cilDecoding.test.ts b/tests/boundary/providers/managed/managedArtifactProvider.cilDecoding.test.ts new file mode 100644 index 000000000..bb859c4c1 --- /dev/null +++ b/tests/boundary/providers/managed/managedArtifactProvider.cilDecoding.test.ts @@ -0,0 +1,138 @@ +import { createHash } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import type { BinaryTarget } from "../../../../src/domain/binaryTarget.js"; +import { inspectManagedMembersBytes } from "../../../../src/dotnet/ManagedMemberInspector.js"; +import { buildManagedPeFixture } from "../../../fixtures/managedPe.js"; + +const memberLimits = { + typeOffset: 0, + typeLimit: 100, + methodOffset: 0, + methodLimit: 100, + fieldOffset: 0, + fieldLimit: 100, + memberRefOffset: 0, + memberRefLimit: 100, + edgeOffset: 0, + edgeLimit: 100, + instructionAnchorLimit: 100, + maxMetadataBytes: 1024 * 1024, + maxTableRows: 1_000, + maxHeapItemBytes: 1024 * 1024, + maxMethodBodyBytes: 1024 * 1024, + maxMethodInstructions: 1_000, +}; + +describe("managed CIL decoding", () => { + it("reads fat method header size from the full flags-and-size word", () => { + const il = Buffer.from([ + 0x21, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x26, 0x22, 0x00, + 0x00, 0x80, 0x3f, 0x26, 0x23, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf0, + 0x3f, 0x26, 0xfe, 0x06, 0x01, 0x00, 0x00, 0x06, 0x26, 0x2a, + ]); + const header = Buffer.alloc(12); + header.writeUInt16LE(0x3013, 0); + header.writeUInt16LE(8, 2); + header.writeUInt32LE(il.length, 4); + const bytes = buildManagedPeFixture({ + ilBody: Buffer.concat([header, il]), + }); + const result = inspectManagedMembersBytes( + bytes, + target(bytes), + memberLimits, + ); + + expect(result.methods.items[0]?.body).toMatchObject({ + status: "present", + header_format: "fat", + il_size: il.length, + il_sha256: createHash("sha256").update(il).digest("hex"), + opcode_counts: { + "ldc.i8": 1, + pop: 4, + "ldc.r4": 1, + "ldc.r8": 1, + ldftn: 1, + ret: 1, + }, + issue: null, + }); + }); + + it("does not normalize reserved CIL opcodes as operand-free instructions", () => { + const bytes = buildManagedPeFixture({ + ilBody: Buffer.from([0x0a, 0x24, 0x2a]), + }); + const result = inspectManagedMembersBytes( + bytes, + target(bytes), + memberLimits, + ); + + expect(result.methods.items[0]?.body).toMatchObject({ + status: "malformed", + il_size: 2, + normalized_il_sha256: null, + decoded_instruction_count: 0, + issue: "Unsupported CIL opcode 0x24 at IL offset 0", + }); + }); + + it("keeps the documented decoded-CIL v1 golden vector stable", () => { + const il = Buffer.from([0x00, 0x2a]); + const tinyBytes = buildManagedPeFixture({ + ilBody: Buffer.from([0x0a, ...il]), + }); + const fatHeader = Buffer.alloc(12); + fatHeader.writeUInt16LE(0x3013, 0); + fatHeader.writeUInt16LE(32, 2); + fatHeader.writeUInt32LE(il.length, 4); + fatHeader.writeUInt32LE(0x1100_0001, 8); + const fatBytes = buildManagedPeFixture({ + ilBody: Buffer.concat([fatHeader, il]), + }); + const tiny = inspectManagedMembersBytes( + tinyBytes, + target(tinyBytes), + memberLimits, + ); + const fat = inspectManagedMembersBytes( + fatBytes, + target(fatBytes), + memberLimits, + ); + + expect(tiny.methods.items[0]?.body).toMatchObject({ + status: "present", + header_format: "tiny", + max_stack: 8, + init_locals: false, + local_var_sig_token: null, + il_size: 2, + il_sha256: createHash("sha256").update(il).digest("hex"), + normalized_il_sha256: + "5e5fad7741cb44bca3a4f045546b7449990da343f612f5f34c0ca30e9eee0636", + opcode_counts: { nop: 1, ret: 1 }, + exception_regions: [], + }); + expect(fat.methods.items[0]?.body).toMatchObject({ + header_format: "fat", + max_stack: 32, + init_locals: true, + local_var_sig_token: "0x11000001", + il_sha256: tiny.methods.items[0]?.body.il_sha256, + normalized_il_sha256: tiny.methods.items[0]?.body.normalized_il_sha256, + }); + }); +}); +const target = (bytes: Buffer): BinaryTarget => ({ + path: "/fixture.exe", + sha256: createHash("sha256").update(bytes).digest("hex"), + kind: "executable", + format: "pe", + architecture: "x86", + availableArchitectures: ["x86"], +}); diff --git a/tests/boundary/providers/managed/managedArtifactProvider.cilLimits.test.ts b/tests/boundary/providers/managed/managedArtifactProvider.cilLimits.test.ts new file mode 100644 index 000000000..ca83e9d5c --- /dev/null +++ b/tests/boundary/providers/managed/managedArtifactProvider.cilLimits.test.ts @@ -0,0 +1,73 @@ +import { createHash } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import type { BinaryTarget } from "../../../../src/domain/binaryTarget.js"; +import { inspectManagedMembersBytes } from "../../../../src/dotnet/ManagedMemberInspector.js"; +import { buildManagedPeFixture } from "../../../fixtures/managedPe.js"; + +const memberLimits = { + typeOffset: 0, + typeLimit: 100, + methodOffset: 0, + methodLimit: 100, + fieldOffset: 0, + fieldLimit: 100, + memberRefOffset: 0, + memberRefLimit: 100, + edgeOffset: 0, + edgeLimit: 100, + instructionAnchorLimit: 100, + maxMetadataBytes: 1024 * 1024, + maxTableRows: 1_000, + maxHeapItemBytes: 1024 * 1024, + maxMethodBodyBytes: 1024 * 1024, + maxMethodInstructions: 1_000, +}; + +describe("managed CIL limits", () => { + it("marks instruction-limited CIL as partial without assigning normalized identity", () => { + const bytes = buildManagedPeFixture(); + const result = inspectManagedMembersBytes(bytes, target(bytes), { + ...memberLimits, + maxMethodInstructions: 1, + }); + const il = bytes.subarray(0x0a01, 0x0a0d); + + expect(result.metadata.status).toBe("complete"); + expect(result.methods.items[0]?.body).toMatchObject({ + status: "partial", + il_size: 12, + il_sha256: createHash("sha256").update(il).digest("hex"), + normalized_il_sha256: null, + instruction_count: 1, + decoded_instruction_count: 1, + truncated_instructions: 1, + issue: + "Instruction decode reached max_method_instructions 1 before the end of the method body", + }); + expect(result.coverage).toEqual({ + state: "partial", + issues: [ + { + code: "limit-exceeded", + scope: "method.0x06000001.body.instructions", + offset: 0x0a00, + detail: + "Instruction decode reached max_method_instructions 1 before the end of the method body", + }, + ], + }); + expect(result.limitations.join(" ")).toContain( + "has no normalized CIL identity", + ); + }); +}); +const target = (bytes: Buffer): BinaryTarget => ({ + path: "/fixture.exe", + sha256: createHash("sha256").update(bytes).digest("hex"), + kind: "executable", + format: "pe", + architecture: "x86", + availableArchitectures: ["x86"], +}); diff --git a/tests/boundary/providers/managed/managedArtifactProvider.cliEvidence.test.ts b/tests/boundary/providers/managed/managedArtifactProvider.cliEvidence.test.ts new file mode 100644 index 000000000..8efb327b2 --- /dev/null +++ b/tests/boundary/providers/managed/managedArtifactProvider.cliEvidence.test.ts @@ -0,0 +1,59 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; + +import { runProviderAnalysis } from "../../../../src/application/DirectAnalysis.js"; +import { parseEvidence } from "../../../../src/domain/evidence.js"; +import { buildManagedPeFixture } from "../../../fixtures/managedPe.js"; + +describe("managed CLI evidence", () => { + it("has the same evidence shape through direct CLI analysis plumbing", async () => { + const directory = await createTestTempDirectory("rea-managed-cli-"); + const path = join(directory, "fixture.exe"); + await writeFile(path, buildManagedPeFixture()); + + const evidence = parseEvidence( + await runProviderAnalysis(path, "inspect_managed_artifact", { + reference_limit: 1, + }), + ); + expect(evidence).toMatchObject({ + operation: "inspect_managed_artifact", + provider: { id: "rea-dotnet-static" }, + subject: { local_path: path, format: "pe" }, + normalized_result: { + classification: { status: "managed", runtime_family: "modern-dotnet" }, + references: { limit: 1 }, + }, + }); + + const memberEvidence = parseEvidence( + await runProviderAnalysis(path, "inspect_managed_members", {}), + ); + expect(memberEvidence).toMatchObject({ + operation: "inspect_managed_members", + provider: { id: "rea-dotnet-static" }, + subject: { local_path: path, format: "pe" }, + normalized_result: { + identity_scope: { token_identity: "build-local" }, + methods: { total: 1 }, + }, + }); + + const boundaryEvidence = parseEvidence( + await runProviderAnalysis(path, "inspect_managed_native_boundaries", {}), + ); + expect(boundaryEvidence).toMatchObject({ + operation: "inspect_managed_native_boundaries", + provider: { id: "rea-dotnet-static" }, + subject: { local_path: path, format: "pe" }, + normalized_result: { + identity_scope: { token_identity: "build-local" }, + pinvoke_imports: { total: 0 }, + }, + }); + }); +}); diff --git a/tests/boundary/providers/managed/managedArtifactProvider.failures.test.ts b/tests/boundary/providers/managed/managedArtifactProvider.failures.test.ts new file mode 100644 index 000000000..450d16203 --- /dev/null +++ b/tests/boundary/providers/managed/managedArtifactProvider.failures.test.ts @@ -0,0 +1,176 @@ +import { createHash } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; + +import type { AnalysisExecution } from "../../../../src/application/AnalysisProvider.js"; +import type { BinaryTarget } from "../../../../src/domain/binaryTarget.js"; +import { parseBinaryTarget } from "../../../../src/domain/binaryTarget.js"; +import { + managedArtifactInspectionSchema, + managedMemberInspectionSchema, + managedNativeBoundaryInspectionSchema, +} from "../../../../src/domain/managedArtifact.js"; +import { inspectManagedArtifactBytes } from "../../../../src/dotnet/ManagedArtifactInspector.js"; +import { ManagedStaticProvider } from "../../../../src/dotnet/ManagedStaticProvider.js"; +import { + buildManagedPeFixture, + buildNativePeFixture, +} from "../../../fixtures/managedPe.js"; + +const limits = { + referenceOffset: 0, + referenceLimit: 100, + resourceOffset: 0, + resourceLimit: 100, + attributeOffset: 0, + attributeLimit: 100, + maxMetadataBytes: 1024 * 1024, + maxTableRows: 1_000, + maxHeapItemBytes: 1024 * 1024, +}; + +describe("managed artifact failures", () => { + it("reports native PE, malformed metadata, and bounded metadata failures as typed results", () => { + const native = inspectManagedArtifactBytes( + buildNativePeFixture(), + target(buildNativePeFixture()), + limits, + ); + expect(native.classification.status).toBe("not-managed"); + expect(native.metadata.status).toBe("absent"); + expect(native.coverage.state).toBe("unavailable"); + + const malformedBytes = buildManagedPeFixture({ + corruptMetadataSignature: true, + }); + const malformed = inspectManagedArtifactBytes( + malformedBytes, + target(malformedBytes), + limits, + ); + expect(malformed.classification.status).toBe("malformed"); + expect(malformed.coverage.issues).toEqual([ + expect.objectContaining({ code: "invalid-metadata-root" }), + ]); + + const unsupportedTableBytes = buildManagedPeFixture({ + metadataValidMaskExtra: 1n << 50n, + }); + const unsupportedTable = inspectManagedArtifactBytes( + unsupportedTableBytes, + target(unsupportedTableBytes), + limits, + ); + expect(unsupportedTable.coverage.issues).toEqual([ + expect.objectContaining({ code: "invalid-tables" }), + ]); + + const limited = inspectManagedArtifactBytes( + buildManagedPeFixture(), + target(buildManagedPeFixture()), + { ...limits, maxMetadataBytes: 256 }, + ); + expect(limited.metadata.status).toBe("partial"); + expect(limited.coverage.issues).toEqual([ + expect.objectContaining({ code: "limit-exceeded" }), + ]); + }); + + it("executes as a read-only auxiliary provider with digest, cancellation, and format boundaries", async () => { + const directory = await createTestTempDirectory("rea-managed-provider-"); + const bytes = buildManagedPeFixture(); + const path = join(directory, "fixture.exe"); + await writeFile(path, bytes); + const parsed = await parseBinaryTarget(path); + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + + const client = new ManagedStaticProvider().createClient(parsed.value); + const observed = await client.execute("inspect_managed_artifact", {}); + expect(observed.ok).toBe(true); + if (!observed.ok) return; + expect(asManagedResult(observed.value)).toMatchObject({ + classification: { status: "managed", runtime_family: "modern-dotnet" }, + artifact: { path, sha256: parsed.value.sha256, format: "pe" }, + }); + expect(observed.value.provider.id).toBe("rea-dotnet-static"); + expect(observed.value.subject).toMatchObject({ path, format: "pe" }); + + const members = await client.execute("inspect_managed_members", {}); + expect(members.ok).toBe(true); + if (!members.ok) return; + expect(asManagedMemberResult(members.value)).toMatchObject({ + artifact: { path, sha256: parsed.value.sha256, format: "pe" }, + methods: { total: 1 }, + call_edges: { total: 1 }, + field_accesses: { total: 1 }, + }); + + const boundaries = await client.execute( + "inspect_managed_native_boundaries", + {}, + ); + expect(boundaries.ok).toBe(true); + if (!boundaries.ok) return; + expect(asManagedNativeBoundaryResult(boundaries.value)).toMatchObject({ + artifact: { path, sha256: parsed.value.sha256, format: "pe" }, + module_refs: { total: 0 }, + pinvoke_imports: { total: 0 }, + native_implementations: { total: 0 }, + }); + + const cancelled = new AbortController(); + cancelled.abort(); + const cancelledResult = await client.execute( + "inspect_managed_artifact", + {}, + { signal: cancelled.signal }, + ); + expect(cancelledResult).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + + const changed = Buffer.from(bytes); + changed[0x300] = 0; + await writeFile(path, changed); + const staleDigest = await client.execute("inspect_managed_artifact", {}); + expect(staleDigest).toMatchObject({ + ok: false, + error: { _tag: "EvidenceIntegrityError" }, + }); + + await client.close(); + const nativeClient = new ManagedStaticProvider().createClient({ + ...parsed.value, + format: "elf", + }); + await expect( + nativeClient.execute("inspect_managed_artifact", {}), + ).resolves.toMatchObject({ + ok: false, + error: { _tag: "AnalysisCapabilityUnavailableError" }, + }); + }); +}); +const target = (bytes: Buffer): BinaryTarget => ({ + path: "/fixture.exe", + sha256: createHash("sha256").update(bytes).digest("hex"), + kind: "executable", + format: "pe", + architecture: "x86", + availableArchitectures: ["x86"], +}); + +const asManagedResult = (execution: AnalysisExecution) => + managedArtifactInspectionSchema.parse(execution.result); + +const asManagedMemberResult = (execution: AnalysisExecution) => + managedMemberInspectionSchema.parse(execution.result); + +const asManagedNativeBoundaryResult = (execution: AnalysisExecution) => + managedNativeBoundaryInspectionSchema.parse(execution.result); diff --git a/tests/boundary/providers/managed/managedArtifactProvider.inventory.test.ts b/tests/boundary/providers/managed/managedArtifactProvider.inventory.test.ts new file mode 100644 index 000000000..3522601c7 --- /dev/null +++ b/tests/boundary/providers/managed/managedArtifactProvider.inventory.test.ts @@ -0,0 +1,171 @@ +import { createHash } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import type { BinaryTarget } from "../../../../src/domain/binaryTarget.js"; +import { inspectManagedArtifactBytes } from "../../../../src/dotnet/ManagedArtifactInspector.js"; +import { inspectManagedMembersBytes } from "../../../../src/dotnet/ManagedMemberInspector.js"; +import { inspectManagedNativeBoundariesBytes } from "../../../../src/dotnet/ManagedNativeBoundaryInspector.js"; +import { buildManagedPeFixture } from "../../../fixtures/managedPe.js"; + +const limits = { + referenceOffset: 0, + referenceLimit: 100, + resourceOffset: 0, + resourceLimit: 100, + attributeOffset: 0, + attributeLimit: 100, + maxMetadataBytes: 1024 * 1024, + maxTableRows: 1_000, + maxHeapItemBytes: 1024 * 1024, +}; + +const memberLimits = { + typeOffset: 0, + typeLimit: 100, + methodOffset: 0, + methodLimit: 100, + fieldOffset: 0, + fieldLimit: 100, + memberRefOffset: 0, + memberRefLimit: 100, + edgeOffset: 0, + edgeLimit: 100, + instructionAnchorLimit: 100, + maxMetadataBytes: 1024 * 1024, + maxTableRows: 1_000, + maxHeapItemBytes: 1024 * 1024, + maxMethodBodyBytes: 1024 * 1024, + maxMethodInstructions: 1_000, +}; + +const nativeBoundaryLimits = { + moduleRefOffset: 0, + moduleRefLimit: 100, + importOffset: 0, + importLimit: 100, + implementationOffset: 0, + implementationLimit: 100, + maxMetadataBytes: 1024 * 1024, + maxTableRows: 1_000, + maxHeapItemBytes: 1024 * 1024, +}; + +describe("managed artifact inventory", () => { + it("inventories module, assembly, framework, references, and resources without loading CLR code", () => { + const resource = Buffer.from("source-owned resource"); + const bytes = buildManagedPeFixture({ resourceData: resource }); + const result = inspectManagedArtifactBytes(bytes, target(bytes), limits); + + expect(result.classification).toMatchObject({ + status: "managed", + runtime_family: "modern-dotnet", + implementation: "cil", + managed_architecture: "anycpu", + }); + expect(result.pe.cli).toMatchObject({ + flag_names: ["il-only"], + entry_point: { kind: "metadata-token", value: "0x06000001" }, + }); + expect(result.module).toMatchObject({ + name: "Fixture.dll", + mvid: "00112233-4455-6677-8899-aabbccddeeff", + token: "0x00000001", + }); + expect(result.assembly).toMatchObject({ + name: "Fixture.Managed", + version: "1.2.3.4", + token: "0x20000001", + }); + expect(result.target_frameworks).toEqual([".NETCoreApp,Version=v8.0"]); + expect(result.references).toMatchObject({ + total: 1, + returned: 1, + complete: true, + items: [expect.objectContaining({ name: "System.Runtime" })], + }); + expect(result.resources.items).toEqual([ + expect.objectContaining({ + name: "Fixture.resources", + embedded: true, + visibility: "private", + data_length: resource.length, + data_sha256: createHash("sha256").update(resource).digest("hex"), + }), + ]); + expect(result.coverage).toMatchObject({ state: "complete", issues: [] }); + }); + + it("keeps classification evidence complete when caller pages visible references", () => { + const bytes = buildManagedPeFixture({ + references: ["System.Runtime", "UnityEngine.CoreModule"], + }); + const result = inspectManagedArtifactBytes(bytes, target(bytes), { + ...limits, + referenceLimit: 1, + }); + + expect(result.classification.runtime_family).toBe("unity-mono"); + expect(result.references).toMatchObject({ + total: 2, + returned: 1, + complete: false, + }); + expect(result.coverage.state).toBe("partial"); + expect(result.coverage.issues).toEqual([]); + }); + + it("accepts CLI metadata GUIDs without RFC UUID version or variant bits", () => { + const bytes = buildManagedPeFixture({ + mvid: Buffer.from("3aebc60edc4a544b1f458b4ed40b33b1", "hex"), + }); + const result = inspectManagedArtifactBytes(bytes, target(bytes), limits); + const members = inspectManagedMembersBytes( + bytes, + target(bytes), + memberLimits, + ); + const boundaries = inspectManagedNativeBoundariesBytes( + bytes, + target(bytes), + nativeBoundaryLimits, + ); + + expect(result.module?.mvid).toBe("0ec6eb3a-4adc-4b54-1f45-8b4ed40b33b1"); + expect(result.classification.status).toBe("managed"); + expect(members.identity_scope.requires_mvid).toBe(result.module?.mvid); + expect(boundaries.identity_scope.requires_mvid).toBe(result.module?.mvid); + }); + + it("decodes ECMA-335 pointer and byref element types without swapping them", () => { + const bytes = buildManagedPeFixture({ + fieldSignature: Buffer.from([0x06, 0x1d, 0x0f, 0x0f, 0x08]), + methodSignature: Buffer.from([ + 0x00, 0x02, 0x10, 0x08, 0x0f, 0x08, 0x10, 0x0e, + ]), + }); + const result = inspectManagedMembersBytes( + bytes, + target(bytes), + memberLimits, + ); + + expect(result.fields.items[0]?.signature).toMatchObject({ + parse_status: "decoded", + field_type: "i4**[]", + }); + expect(result.methods.items[0]?.signature).toMatchObject({ + parse_status: "decoded", + return_type: "i4&", + parameter_types: ["i4*", "string&"], + }); + }); +}); +const target = (bytes: Buffer): BinaryTarget => ({ + path: "/fixture.exe", + sha256: createHash("sha256").update(bytes).digest("hex"), + kind: "executable", + format: "pe", + architecture: "x86", + availableArchitectures: ["x86"], +}); diff --git a/tests/boundary/providers/managed/managedArtifactProvider.members.test.ts b/tests/boundary/providers/managed/managedArtifactProvider.members.test.ts new file mode 100644 index 000000000..ac81483f5 --- /dev/null +++ b/tests/boundary/providers/managed/managedArtifactProvider.members.test.ts @@ -0,0 +1,140 @@ +import { createHash } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import type { BinaryTarget } from "../../../../src/domain/binaryTarget.js"; +import { inspectManagedMembersBytes } from "../../../../src/dotnet/ManagedMemberInspector.js"; +import { buildManagedPeFixture } from "../../../fixtures/managedPe.js"; + +const memberLimits = { + typeOffset: 0, + typeLimit: 100, + methodOffset: 0, + methodLimit: 100, + fieldOffset: 0, + fieldLimit: 100, + memberRefOffset: 0, + memberRefLimit: 100, + edgeOffset: 0, + edgeLimit: 100, + instructionAnchorLimit: 100, + maxMetadataBytes: 1024 * 1024, + maxTableRows: 1_000, + maxHeapItemBytes: 1024 * 1024, + maxMethodBodyBytes: 1024 * 1024, + maxMethodInstructions: 1_000, +}; + +describe("managed member inspection", () => { + it("inspects metadata members, signatures, CIL hashes, call edges, and field anchors", () => { + const bytes = buildManagedPeFixture(); + const result = inspectManagedMembersBytes( + bytes, + target(bytes), + memberLimits, + ); + + expect(result.identity_scope).toEqual({ + token_identity: "build-local", + requires_artifact_sha256: target(bytes).sha256, + requires_mvid: "00112233-4455-6677-8899-aabbccddeeff", + }); + expect(result.types.items).toEqual([ + expect.objectContaining({ + token: "0x02000001", + full_name: "Fixture.Program", + field_list: { first_row: 1, last_row: 1, count: 1 }, + method_list: { first_row: 1, last_row: 1, count: 1 }, + }), + ]); + expect(result.fields.items).toEqual([ + expect.objectContaining({ + token: "0x04000001", + declaring_type: "Fixture.Program", + name: "counter", + signature: expect.objectContaining({ + kind: "field", + parse_status: "decoded", + field_type: "i4", + }), + }), + ]); + expect(result.methods.items).toEqual([ + expect.objectContaining({ + token: "0x06000001", + declaring_type: "Fixture.Program", + name: "Main", + rva: 0x2800, + signature: expect.objectContaining({ + kind: "method", + parse_status: "decoded", + return_type: "void", + parameter_types: [], + }), + body: expect.objectContaining({ + status: "present", + header_format: "tiny", + file_offset: 0x0a00, + il_size: 12, + instruction_count: 4, + decoded_instruction_count: 4, + truncated_instructions: 0, + opcode_counts: { "ldarg.0": 1, ldfld: 1, call: 1, ret: 1 }, + anchors: [ + { + il_offset: 1, + opcode: "ldfld", + operand_kind: "field", + operand: "0x04000001", + }, + { + il_offset: 6, + opcode: "call", + operand_kind: "method", + operand: "0x0a000001", + }, + ], + }), + }), + ]); + expect(result.member_refs.items).toEqual([ + expect.objectContaining({ + token: "0x0a000001", + name: ".ctor", + signature: expect.objectContaining({ + kind: "method", + parse_status: "decoded", + parameter_types: ["string"], + }), + }), + ]); + expect(result.call_edges.items).toEqual([ + { + caller_token: "0x06000001", + caller: "Fixture.Program.Main", + opcode: "call", + target_token: "0x0a000001", + target_kind: "member-ref", + target_name: ".ctor", + }, + ]); + expect(result.field_accesses.items).toEqual([ + { + method_token: "0x06000001", + method: "Fixture.Program.Main", + opcode: "ldfld", + field_token: "0x04000001", + field_name: "counter", + }, + ]); + expect(result.coverage).toMatchObject({ state: "complete", issues: [] }); + }); +}); +const target = (bytes: Buffer): BinaryTarget => ({ + path: "/fixture.exe", + sha256: createHash("sha256").update(bytes).digest("hex"), + kind: "executable", + format: "pe", + architecture: "x86", + availableArchitectures: ["x86"], +}); diff --git a/tests/boundary/providers/managed/managedArtifactProvider.nativeBoundaries.test.ts b/tests/boundary/providers/managed/managedArtifactProvider.nativeBoundaries.test.ts new file mode 100644 index 000000000..0353b29ac --- /dev/null +++ b/tests/boundary/providers/managed/managedArtifactProvider.nativeBoundaries.test.ts @@ -0,0 +1,154 @@ +import { createHash } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import type { BinaryTarget } from "../../../../src/domain/binaryTarget.js"; +import { inspectManagedMembersBytes } from "../../../../src/dotnet/ManagedMemberInspector.js"; +import { inspectManagedNativeBoundariesBytes } from "../../../../src/dotnet/ManagedNativeBoundaryInspector.js"; +import { + buildManagedPeFixture, + buildNativePeFixture, +} from "../../../fixtures/managedPe.js"; + +const memberLimits = { + typeOffset: 0, + typeLimit: 100, + methodOffset: 0, + methodLimit: 100, + fieldOffset: 0, + fieldLimit: 100, + memberRefOffset: 0, + memberRefLimit: 100, + edgeOffset: 0, + edgeLimit: 100, + instructionAnchorLimit: 100, + maxMetadataBytes: 1024 * 1024, + maxTableRows: 1_000, + maxHeapItemBytes: 1024 * 1024, + maxMethodBodyBytes: 1024 * 1024, + maxMethodInstructions: 1_000, +}; + +const nativeBoundaryLimits = { + moduleRefOffset: 0, + moduleRefLimit: 100, + importOffset: 0, + importLimit: 100, + implementationOffset: 0, + implementationLimit: 100, + maxMetadataBytes: 1024 * 1024, + maxTableRows: 1_000, + maxHeapItemBytes: 1024 * 1024, +}; + +describe("managed native boundaries", () => { + it("inspects managed/native PInvoke declarations without verifying native exports", () => { + const bytes = buildManagedPeFixture({ + pinvoke: { + moduleName: "user32.dll", + importName: "MessageBoxW", + mappingFlags: 0x0345, + }, + readyToRun: true, + }); + const result = inspectManagedNativeBoundariesBytes( + bytes, + target(bytes), + nativeBoundaryLimits, + ); + + expect(result.cli_native).toMatchObject({ + il_only: true, + ready_to_run_signature: true, + managed_native_header_rva: 0x2700, + managed_native_header_size: 4, + }); + expect(result.module_refs.items).toEqual([ + { + token: "0x1a000001", + row_offset: expect.any(Number), + name: "user32.dll", + }, + ]); + expect(result.pinvoke_imports.items).toEqual([ + expect.objectContaining({ + token: "0x1c000001", + member_token: "0x06000001", + member_kind: "method", + member_name: "Main", + import_name: "MessageBoxW", + import_scope_token: "0x1a000001", + import_scope_name: "user32.dll", + no_mangle: true, + char_set: "unicode", + call_convention: "stdcall", + supports_last_error: true, + verification: "managed-declaration-only", + }), + ]); + expect(result.native_implementations.items).toEqual([ + expect.objectContaining({ + token: "0x06000001", + name: "Main", + pinvoke_declared: true, + boundary_kind: "pinvoke", + body_interpretation: "native-or-runtime", + }), + ]); + expect(result.summary).toMatchObject({ + module_ref_count: 1, + pinvoke_import_count: 1, + native_implementation_count: 1, + ready_to_run: true, + mixed_mode_or_native_header: true, + }); + expect(result.limitations).toContain( + "P/Invoke rows prove managed import declarations only; this inspection does not verify that a native library, export, thunk, or provider-qualified function exists.", + ); + }); + + it("keeps member evidence bounded and typed for pagination and unavailable metadata", () => { + const bytes = buildManagedPeFixture(); + const paged = inspectManagedMembersBytes(bytes, target(bytes), { + ...memberLimits, + methodLimit: 0 + 1, + instructionAnchorLimit: 1, + }); + expect(paged.methods).toMatchObject({ + total: 1, + returned: 1, + complete: true, + }); + expect(paged.methods.items[0]?.body.anchors).toHaveLength(1); + + const nativeBytes = buildNativePeFixture(); + const native = inspectManagedMembersBytes( + nativeBytes, + target(nativeBytes), + memberLimits, + ); + expect(native.metadata.status).toBe("absent"); + expect(native.coverage.state).toBe("unavailable"); + + const malformedBytes = buildManagedPeFixture({ + corruptMetadataSignature: true, + }); + const malformed = inspectManagedMembersBytes( + malformedBytes, + target(malformedBytes), + memberLimits, + ); + expect(malformed.metadata.status).toBe("malformed"); + expect(malformed.coverage.issues).toEqual([ + expect.objectContaining({ code: "invalid-metadata-root" }), + ]); + }); +}); +const target = (bytes: Buffer): BinaryTarget => ({ + path: "/fixture.exe", + sha256: createHash("sha256").update(bytes).digest("hex"), + kind: "executable", + format: "pe", + architecture: "x86", + availableArchitectures: ["x86"], +}); diff --git a/tests/managedMemberComparison.test.ts b/tests/boundary/providers/managed/managedMemberComparison.test.ts similarity index 92% rename from tests/managedMemberComparison.test.ts rename to tests/boundary/providers/managed/managedMemberComparison.test.ts index 1b1f55c2c..d9dd904e2 100644 --- a/tests/managedMemberComparison.test.ts +++ b/tests/boundary/providers/managed/managedMemberComparison.test.ts @@ -4,18 +4,18 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; -import { compareManagedMemberPaths } from "../src/application/ManagedMemberComparisonService.js"; -import { MANAGED_STATIC_PROVIDER } from "../src/application/InvestigationProviders.js"; +import { compareManagedMemberPaths } from "../../../../src/application/ManagedMemberComparisonService.js"; +import { MANAGED_STATIC_PROVIDER } from "../../../../src/application/InvestigationProviders.js"; import { compareManagedMembers, managedMemberComparisonResultSchema, -} from "../src/domain/managedMemberComparison.js"; -import { createEvidence } from "../src/domain/evidence.js"; -import type { BinaryTarget } from "../src/domain/binaryTarget.js"; -import { inspectManagedMembersBytes } from "../src/dotnet/ManagedMemberInspector.js"; -import { buildManagedPeFixture } from "./fixtures/managedPe.js"; +} from "../../../../src/domain/managedMemberComparison.js"; +import { createEvidence } from "../../../../src/domain/evidence.js"; +import type { BinaryTarget } from "../../../../src/domain/binaryTarget.js"; +import { inspectManagedMembersBytes } from "../../../../src/dotnet/ManagedMemberInspector.js"; +import { buildManagedPeFixture } from "../../../fixtures/managedPe.js"; const memberLimits = { typeOffset: 0, @@ -138,7 +138,9 @@ describe("managed member comparison", () => { right_status: "partial", }); }); +}); +describe("managed member comparison uncertainty", () => { it("keeps unmatched members unknown when the opposite page is incomplete", () => { const left = inspect(buildManagedPeFixture(), "/tmp/left-paged.dll"); const right = inspect(buildManagedPeFixture(), "/tmp/right-paged.dll"); @@ -255,7 +257,9 @@ describe("managed member comparison", () => { }), ]); }); +}); +describe("managed member comparison path workflow", () => { it("compares two local paths and returns derived Evidence", async () => { const directory = await createTestTempDirectory("rea-managed-compare-"); const leftPath = join(directory, "left.dll"); diff --git a/tests/managedReconstruction.test.ts b/tests/boundary/providers/managed/managedReconstruction.test.ts similarity index 91% rename from tests/managedReconstruction.test.ts rename to tests/boundary/providers/managed/managedReconstruction.test.ts index 463964346..63cc3e29f 100644 --- a/tests/managedReconstruction.test.ts +++ b/tests/boundary/providers/managed/managedReconstruction.test.ts @@ -2,14 +2,14 @@ import { createHash } from "node:crypto"; import { describe, expect, it } from "vitest"; -import { importManagedReconstructionEvidence } from "../src/application/ManagedReconstructionService.js"; -import { MANAGED_RECONSTRUCTION_IMPORT_EXAMPLE } from "../src/contracts/managedWorkflowExamples.js"; +import { importManagedReconstructionEvidence } from "../../../../src/application/ManagedReconstructionService.js"; +import { MANAGED_RECONSTRUCTION_IMPORT_EXAMPLE } from "../../../../src/contracts/managedWorkflowExamples.js"; import { importManagedReconstruction, managedReconstructionImportInputSchema, -} from "../src/domain/managedReconstruction.js"; -import { managedMemberInspectionSchema } from "../src/domain/managedArtifact.js"; -import { createEvidence } from "../src/domain/evidence.js"; +} from "../../../../src/domain/managedReconstruction.js"; +import { managedMemberInspectionSchema } from "../../../../src/domain/managedArtifact.js"; +import { createEvidence } from "../../../../src/domain/evidence.js"; const exampleInput = () => managedReconstructionImportInputSchema.parse( diff --git a/tests/peInspection.test.ts b/tests/boundary/providers/managed/peInspection.test.ts similarity index 99% rename from tests/peInspection.test.ts rename to tests/boundary/providers/managed/peInspection.test.ts index ca4612c80..694d0a9ad 100644 --- a/tests/peInspection.test.ts +++ b/tests/boundary/providers/managed/peInspection.test.ts @@ -17,7 +17,7 @@ import { type PeResource, type PeDebugEntry, type PeTlsCallback, -} from "../src/domain/peInspection.js"; +} from "../../../../src/domain/peInspection.js"; const validManifest: PeManifest = { dos_magic: 0x5a4d, diff --git a/tests/boundary/providers/native/artifactProvider.archiveInventory.test.ts b/tests/boundary/providers/native/artifactProvider.archiveInventory.test.ts new file mode 100644 index 000000000..b883fd892 --- /dev/null +++ b/tests/boundary/providers/native/artifactProvider.archiveInventory.test.ts @@ -0,0 +1,161 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { createPackage, createPackageWithOptions } from "@electron/asar"; +import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; +import { describe, expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; + +import { runProviderAnalysis } from "../../../../src/application/DirectAnalysis.js"; +import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js"; +import { + artifactInspectionInputSchema, + artifactInventoryInputSchema, +} from "../../../../src/contracts/artifactToolContracts.js"; +import { artifactInventoryResultSchema } from "../../../../src/domain/artifactGraph.js"; +import { artifactInspectionResultSchema } from "../../../../src/domain/artifactInspection.js"; +import type { BinaryTarget } from "../../../../src/domain/binaryTarget.js"; +import { parseBinaryTarget } from "../../../../src/domain/binaryTarget.js"; +import { parseEvidence } from "../../../../src/domain/evidence.js"; + +describe("artifact archive inventory", () => { + it("streams ZIP and official ASAR inventories within shared limits", async () => { + const root = await createTestTempDirectory("rea-containers-"); + const zipPath = join(root, "fixture.apk"); + const zipWriter = new ZipWriter(new Uint8ArrayWriter()); + await zipWriter.add("assets/index.js", new TextReader("export default 1;")); + await zipWriter.add("lib/arm64-v8a/addon.so", new TextReader("native")); + await writeFile(zipPath, await zipWriter.close()); + const parsed = await parseBinaryTarget(zipPath); + expect(parsed.ok && parsed.value.format).toBe("apk"); + if (!parsed.ok) return; + const zipResult = await inventory(parsed.value); + expect(zipResult.occurrences.total).toBe(3); + expect(zipResult.nodes.items.map(({ kind }) => kind)).toEqual( + expect.arrayContaining(["javascript", "dynamic-library"]), + ); + const cliEvidence = parseEvidence( + await runProviderAnalysis(zipPath, "inventory_artifact", {}), + ); + expect(cliEvidence).toMatchObject({ + operation: "inventory_artifact", + provider: { id: "rea-artifact-graph" }, + subject: { format: "apk" }, + }); + const inspectionEvidence = parseEvidence( + await runProviderAnalysis(zipPath, "inspect_artifact", { + max_observations: 2, + max_relationships: 1, + }), + ); + const inspection = artifactInspectionResultSchema.parse( + inspectionEvidence.normalized_result, + ); + expect(inspection).toMatchObject({ + substeps: [ + { + operation: "inventory_artifact", + status: "completed", + evidence_id: expect.stringMatching(/^ev_[a-f0-9]{64}$/u), + }, + ], + coverage: { status: "truncated", substeps_completed: 1 }, + }); + expect(inspectionEvidence.evidence_links).toEqual( + inspection.evidence_links, + ); + const cancellation = new AbortController(); + cancellation.abort(); + await expect( + runProviderAnalysis( + zipPath, + "inventory_artifact", + {}, + undefined, + cancellation.signal, + ), + ).resolves.toMatchObject({ code: "cancelled" }); + await expect( + runProviderAnalysis( + zipPath, + "inspect_artifact", + {}, + undefined, + cancellation.signal, + ), + ).resolves.toMatchObject({ code: "cancelled" }); + + const source = join(root, "asar-source"); + await mkdir(source); + await writeFile(join(source, "main.js"), "console.log('ok');\n"); + const asarPath = join(root, "app.asar"); + await createPackage(source, asarPath); + const asarResult = await inventory(target(asarPath, "asar")); + expect(asarResult.occurrences.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ logical_path: "main.js" }), + ]), + ); + + const unpackedPath = join(root, "unpacked.asar"); + await createPackageWithOptions(source, unpackedPath, { unpack: "*.js" }); + const unpackedResult = await inventory(target(unpackedPath, "asar")); + expect(unpackedResult.occurrences.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + logical_path: "main.js", + hash_status: "verified", + }), + ]), + ); + await writeFile( + join(`${unpackedPath}.unpacked`, "main.js"), + "changed();\n", + ); + const corrupted = await new ArtifactProvider() + .createClient(target(unpackedPath, "asar")) + .execute("inventory_artifact", artifactInventoryInputSchema.parse({})); + expect(corrupted).toMatchObject({ + ok: false, + error: { + _tag: "ArtifactOperationError", + reason: "integrity", + artifactDetails: { + logicalPath: "main.js", + unpacked: true, + }, + }, + }); + const failedInspection = await new ArtifactProvider() + .createClient(target(unpackedPath, "asar")) + .execute("inspect_artifact", artifactInspectionInputSchema.parse({})); + expect(failedInspection).toMatchObject({ + ok: false, + error: { _tag: "ArtifactOperationError", reason: "integrity" }, + }); + }); +}); +const inventory = async (targetValue: BinaryTarget) => { + const result = await new ArtifactProvider().createClient(targetValue).execute( + "inventory_artifact", + artifactInventoryInputSchema.parse({ + node_limit: 500, + occurrence_limit: 500, + edge_limit: 500, + }), + ); + if (!result.ok) throw result.error; + return artifactInventoryResultSchema.parse(result.value.result); +}; + +const target = ( + path: string, + format: BinaryTarget["format"] | "directory", +): BinaryTarget => ({ + path, + sourcePath: path, + sha256: "0".repeat(64), + kind: format === "directory" ? "executable" : "archive", + format: format === "directory" ? "mach-o" : format, +}); diff --git a/tests/boundary/providers/native/artifactProvider.archiveSafety.test.ts b/tests/boundary/providers/native/artifactProvider.archiveSafety.test.ts new file mode 100644 index 000000000..7970868cd --- /dev/null +++ b/tests/boundary/providers/native/artifactProvider.archiveSafety.test.ts @@ -0,0 +1,138 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; +import { describe, expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; + +import { runProviderAnalysis } from "../../../../src/application/DirectAnalysis.js"; +import { + ArtifactPathRegistry, + normalizeArtifactPath, +} from "../../../../src/artifacts/ArtifactPaths.js"; +import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js"; +import { + artifactExtractionInputSchema, + artifactInventoryInputSchema, +} from "../../../../src/contracts/artifactToolContracts.js"; +import { artifactInventoryResultSchema } from "../../../../src/domain/artifactGraph.js"; +import type { BinaryTarget } from "../../../../src/domain/binaryTarget.js"; +import { parseBinaryTarget } from "../../../../src/domain/binaryTarget.js"; +import { parseEvidence } from "../../../../src/domain/evidence.js"; + +describe("artifact archive safety", () => { + it.each([ + ["fixture.msix", "msix"], + ["fixture.appxbundle", "appx"], + ] as const)( + "reuses the hardened ZIP reader for %s package inventory", + async (name, format) => { + const root = await createTestTempDirectory("rea-windows-package-"); + const path = join(root, name); + const writer = new ZipWriter(new Uint8ArrayWriter()); + await writer.add( + "Assets/main.js", + new TextReader("export default 'windows';"), + ); + await writer.add( + "VFS/ProgramFilesX64/App/addon.node", + new TextReader("native"), + ); + await writeFile(path, await writer.close()); + + const parsed = await parseBinaryTarget(path); + expect(parsed.ok && parsed.value).toMatchObject({ + kind: "archive", + format, + }); + if (!parsed.ok) return; + const result = await inventory(parsed.value); + expect(result.manifest.root_format).toBe(format); + expect(result.nodes.items.map(({ kind }) => kind)).toEqual( + expect.arrayContaining(["javascript", "native-addon"]), + ); + const selected = result.occurrences.items.find( + ({ logical_path: logicalPath }) => logicalPath === "Assets/main.js", + ); + expect(selected).toBeDefined(); + if (selected === undefined) return; + const output = join(root, "output"); + const extracted = await new ArtifactProvider() + .createClient(parsed.value) + .execute( + "extract_artifact", + artifactExtractionInputSchema.parse({ + approved: true, + output_root: output, + occurrence_ids: [selected.occurrence_id], + }), + ); + expect(extracted.ok).toBe(true); + expect(await readFile(join(output, "Assets", "main.js"), "utf8")).toBe( + "export default 'windows';", + ); + expect( + parseEvidence( + await runProviderAnalysis(path, "inventory_artifact", { + node_limit: 10, + occurrence_limit: 10, + edge_limit: 10, + }), + ), + ).toMatchObject({ + operation: "inventory_artifact", + subject: { format }, + }); + }, + ); + + it("rejects unsafe, colliding, and over-ratio entries", async () => { + expect(() => normalizeArtifactPath("../escape", limits())).toThrow( + /normalized/u, + ); + const registry = new ArtifactPathRegistry(); + registry.add("A.js", "file"); + expect(() => registry.add("a.js", "file")).toThrow(/collision/u); + + const root = await createTestTempDirectory("rea-bomb-"); + const zipPath = join(root, "bomb.zip"); + const writer = new ZipWriter(new Uint8ArrayWriter()); + await writer.add("zeros", new TextReader("0".repeat(100_000))); + await writeFile(zipPath, await writer.close()); + const client = new ArtifactProvider().createClient(target(zipPath, "zip")); + const result = await client.execute( + "inventory_artifact", + artifactInventoryInputSchema.parse({ max_compression_ratio: 2 }), + ); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "ArtifactOperationError", reason: "limit" }, + }); + }); +}); +const inventory = async (targetValue: BinaryTarget) => { + const result = await new ArtifactProvider().createClient(targetValue).execute( + "inventory_artifact", + artifactInventoryInputSchema.parse({ + node_limit: 500, + occurrence_limit: 500, + edge_limit: 500, + }), + ); + if (!result.ok) throw result.error; + return artifactInventoryResultSchema.parse(result.value.result); +}; + +const target = ( + path: string, + format: BinaryTarget["format"] | "directory", +): BinaryTarget => ({ + path, + sourcePath: path, + sha256: "0".repeat(64), + kind: format === "directory" ? "executable" : "archive", + format: format === "directory" ? "mach-o" : format, +}); + +const limits = () => ({ maxDepth: 20, maxPathBytes: 4_096 }); diff --git a/tests/boundary/providers/native/artifactProvider.directoryInventory.test.ts b/tests/boundary/providers/native/artifactProvider.directoryInventory.test.ts new file mode 100644 index 000000000..be7c94ef5 --- /dev/null +++ b/tests/boundary/providers/native/artifactProvider.directoryInventory.test.ts @@ -0,0 +1,89 @@ +import { mkdir, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; + +import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js"; +import { artifactInventoryInputSchema } from "../../../../src/contracts/artifactToolContracts.js"; +import { artifactInventoryResultSchema } from "../../../../src/domain/artifactGraph.js"; +import type { BinaryTarget } from "../../../../src/domain/binaryTarget.js"; + +describe("artifact directory inventory", () => { + it("inventories app trees deterministically without following symlinks", async () => { + const root = await createTestTempDirectory("rea-artifacts-"); + const app = join(root, "Fixture.app"); + await mkdir(join(app, "Contents", "Resources"), { recursive: true }); + await mkdir(join(app, "Contents", "MacOS"), { recursive: true }); + await mkdir(join(app, "Contents", "Frameworks", "Fixture.framework"), { + recursive: true, + }); + const machO = Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 0x0c, 0, 0, 1]); + await writeFile(join(app, "Contents", "MacOS", "Fixture"), machO, { + mode: 0o755, + }); + await writeFile( + join(app, "Contents", "Frameworks", "Fixture.framework", "Fixture"), + machO, + ); + await writeFile(join(app, "Contents", "Resources", "main.js"), "main();\n"); + await writeFile(join(app, "Contents", "Resources", "main.js.MAP"), "{}\n"); + await symlink("/etc/passwd", join(app, "Contents", "Resources", "outside")); + const client = new ArtifactProvider().createClient( + target(app, "directory"), + ); + const input = artifactInventoryInputSchema.parse({ + node_limit: 500, + occurrence_limit: 500, + edge_limit: 500, + }); + const first = await client.execute("inventory_artifact", input); + const second = await client.execute("inventory_artifact", input); + expect(first.ok).toBe(true); + expect(second.ok).toBe(true); + if (!first.ok || !second.ok) return; + const left = artifactInventoryResultSchema.parse(first.value.result); + const right = artifactInventoryResultSchema.parse(second.value.result); + expect(left.manifest).toEqual(right.manifest); + expect(left.nodes.items.map(({ kind }) => kind)).toEqual( + expect.arrayContaining([ + "executable", + "framework", + "javascript", + "source-map", + ]), + ); + expect( + left.occurrences.items.find(({ logical_path: path }) => + path.endsWith("outside"), + ), + ).toMatchObject({ artifact_id: null, entry_kind: "symlink" }); + const script = left.occurrences.items.find(({ logical_path: path }) => + path.endsWith("main.js"), + ); + const sourceMap = left.occurrences.items.find(({ logical_path: path }) => + path.endsWith("main.js.MAP"), + ); + expect(left.edges.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + relation: "maps-source", + parent_artifact_id: script?.artifact_id, + child_artifact_id: sourceMap?.artifact_id, + }), + ]), + ); + }); +}); + +const target = ( + path: string, + format: BinaryTarget["format"] | "directory", +): BinaryTarget => ({ + path, + sourcePath: path, + sha256: "0".repeat(64), + kind: format === "directory" ? "executable" : "archive", + format: format === "directory" ? "mach-o" : format, +}); diff --git a/tests/boundary/providers/native/artifactProvider.extraction.test.ts b/tests/boundary/providers/native/artifactProvider.extraction.test.ts new file mode 100644 index 000000000..0bea824c0 --- /dev/null +++ b/tests/boundary/providers/native/artifactProvider.extraction.test.ts @@ -0,0 +1,140 @@ +import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; + +import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js"; +import { + artifactExtractionInputSchema, + artifactInventoryInputSchema, +} from "../../../../src/contracts/artifactToolContracts.js"; +import { + artifactExtractionResultSchema, + artifactInventoryResultSchema, +} from "../../../../src/domain/artifactGraph.js"; +import type { BinaryTarget } from "../../../../src/domain/binaryTarget.js"; + +describe("artifact extraction", () => { + it("extracts only selected occurrences through an exclusively owned output tree", async () => { + const root = await createTestTempDirectory("rea-extract-"); + const source = join(root, "source"); + await mkdir(join(source, "assets"), { recursive: true }); + await writeFile(join(source, "assets", "selected.js"), "selected();\n"); + await writeFile(join(source, "assets", "ignored.js"), "ignored();\n"); + const targetValue = target(source, "directory"); + const graph = await inventory(targetValue); + const selected = graph.occurrences.items.find( + ({ logical_path: path }) => path === "assets/selected.js", + ); + expect(selected).toBeDefined(); + if (selected === undefined) return; + const cancelledOutput = join(root, "cancelled-output"); + const controller = new AbortController(); + controller.abort(); + const cancelled = await new ArtifactProvider() + .createClient(targetValue) + .execute( + "extract_artifact", + artifactExtractionInputSchema.parse({ + approved: true, + output_root: cancelledOutput, + occurrence_ids: [selected.occurrence_id], + }), + { signal: controller.signal }, + ); + expect(cancelled).toMatchObject({ + ok: false, + error: { _tag: "ArtifactOperationError", reason: "cancelled" }, + }); + await expect(access(cancelledOutput)).rejects.toThrow(); + const output = join(root, "output"); + const result = await new ArtifactProvider() + .createClient(targetValue) + .execute( + "extract_artifact", + artifactExtractionInputSchema.parse({ + approved: true, + output_root: output, + occurrence_ids: [selected.occurrence_id], + }), + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + const firstExtraction = artifactExtractionResultSchema.parse( + result.value.result, + ); + expect(firstExtraction).toMatchObject({ + output_root: "$OUTPUT_ROOT", + containment_verified: true, + artifacts: { total: 1 }, + extraction_manifest: { output_root_alias: "$OUTPUT_ROOT" }, + }); + expect(await readFile(join(output, "assets", "selected.js"), "utf8")).toBe( + "selected();\n", + ); + await expect( + access(join(output, "assets", "ignored.js")), + ).rejects.toThrow(); + + const relocatedOutput = join(root, "relocated-output"); + const relocated = await new ArtifactProvider() + .createClient(targetValue) + .execute( + "extract_artifact", + artifactExtractionInputSchema.parse({ + approved: true, + output_root: relocatedOutput, + occurrence_ids: [selected.occurrence_id], + }), + ); + expect(relocated.ok).toBe(true); + if (!relocated.ok) return; + expect( + artifactExtractionResultSchema.parse(relocated.value.result) + .extraction_manifest, + ).toEqual(firstExtraction.extraction_manifest); + + const second = await new ArtifactProvider() + .createClient(targetValue) + .execute( + "extract_artifact", + artifactExtractionInputSchema.parse({ + approved: true, + output_root: output, + occurrence_ids: [selected.occurrence_id], + }), + ); + expect(second).toMatchObject({ + ok: false, + error: { _tag: "ArtifactOperationError", reason: "path" }, + }); + expect(await readdir(root)).not.toEqual( + expect.arrayContaining([expect.stringMatching(/^\.output\.rea-/u)]), + ); + }); +}); +const inventory = async (targetValue: BinaryTarget) => { + const result = await new ArtifactProvider().createClient(targetValue).execute( + "inventory_artifact", + artifactInventoryInputSchema.parse({ + node_limit: 500, + occurrence_limit: 500, + edge_limit: 500, + }), + ); + if (!result.ok) throw result.error; + return artifactInventoryResultSchema.parse(result.value.result); +}; + +const target = ( + path: string, + format: BinaryTarget["format"] | "directory", +): BinaryTarget => ({ + path, + sourcePath: path, + sha256: "0".repeat(64), + kind: format === "directory" ? "executable" : "archive", + format: format === "directory" ? "mach-o" : format, +}); diff --git a/tests/boundary/providers/native/artifactProvider.paginationAndSlices.test.ts b/tests/boundary/providers/native/artifactProvider.paginationAndSlices.test.ts new file mode 100644 index 000000000..273953d87 --- /dev/null +++ b/tests/boundary/providers/native/artifactProvider.paginationAndSlices.test.ts @@ -0,0 +1,116 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; + +import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js"; +import { MachOSliceArtifactReader } from "../../../../src/artifacts/MachOSliceArtifactReader.js"; +import { artifactInventoryInputSchema } from "../../../../src/contracts/artifactToolContracts.js"; +import { artifactInventoryResultSchema } from "../../../../src/domain/artifactGraph.js"; +import type { BinaryTarget } from "../../../../src/domain/binaryTarget.js"; +import { ok } from "../../../../src/domain/result.js"; +import type { NativeCommandRunner } from "../../../../src/native/CommandRunner.js"; + +describe("artifact pagination and slices", () => { + it("paginates wide graphs without changing manifest identity", async () => { + const root = await createTestTempDirectory("rea-wide-"); + await Promise.all( + Array.from({ length: 520 }, async (_, index) => + writeFile( + join(root, `file-${String(index).padStart(4, "0")}.txt`), + `${index}`, + ), + ), + ); + const client = new ArtifactProvider().createClient( + target(root, "directory"), + ); + const [first, secondPage] = await Promise.all([ + client.execute( + "inventory_artifact", + artifactInventoryInputSchema.parse({ occurrence_limit: 500 }), + ), + client.execute( + "inventory_artifact", + artifactInventoryInputSchema.parse({ + occurrence_offset: 500, + occurrence_limit: 500, + }), + ), + ]); + expect(first.ok && secondPage.ok).toBe(true); + if (!first.ok || !secondPage.ok) return; + const left = artifactInventoryResultSchema.parse(first.value.result); + const right = artifactInventoryResultSchema.parse(secondPage.value.result); + expect(left.manifest).toEqual(right.manifest); + expect(left.occurrences).toMatchObject({ total: 521, next_offset: 500 }); + expect(right.occurrences).toMatchObject({ + offset: 500, + next_offset: null, + }); + }, 15_000); + + it("uses bounded native lipo metadata for universal slice ranges", async () => { + const root = await createTestTempDirectory("rea-slices-"); + const binary = join(root, "fat"); + await writeFile(binary, Buffer.from("0123456789abcdef")); + const runner: NativeCommandRunner = { + run: () => + Promise.resolve( + ok({ + tool: "lipo", + executable: "/usr/bin/lipo", + executableSha256: "1".repeat(64), + toolVersion: null, + versionReason: "fixture", + arguments: ["-detailed_info", binary], + stdout: + "architecture x86_64\n cputype 16777223\n cpusubtype 3\n offset 0\n size 8\n align 2^2\narchitecture arm64\n cputype 16777228\n cpusubtype 0\n offset 8\n size 8\n align 2^2\n", + stderr: "", + stdoutBytes: 1, + stderrBytes: 0, + stdoutTruncated: false, + stderrTruncated: false, + exitCode: 0, + signal: null, + }), + ), + }; + const reader = new MachOSliceArtifactReader(binary, runner); + const entries = []; + for await (const entry of reader.entries()) entries.push(entry); + expect(entries).toHaveLength(2); + const secondEntry = entries[1]; + expect(secondEntry).toBeDefined(); + if (secondEntry === undefined) return; + expect(secondEntry).toMatchObject({ + path: "slices/arm64", + byteOffset: 8, + declaredSize: 8, + }); + const chunks: Buffer[] = []; + const stream = await reader.open(secondEntry); + for await (const chunk of stream) chunks.push(Buffer.from(chunk)); + expect(Buffer.concat(chunks).toString()).toBe("89abcdef"); + expect(reader.provenance()).toEqual([ + expect.objectContaining({ tool: "lipo", effects: ["read"] }), + ]); + const provenance = reader.provenance(); + if (provenance[0] !== undefined) + Reflect.set(provenance[0], "tool", "forged"); + expect(reader.provenance()[0]?.tool).toBe("lipo"); + }); +}); + +const target = ( + path: string, + format: BinaryTarget["format"] | "directory", +): BinaryTarget => ({ + path, + sourcePath: path, + sha256: "0".repeat(64), + kind: format === "directory" ? "executable" : "archive", + format: format === "directory" ? "mach-o" : format, +}); diff --git a/tests/bytecodeProvider.test.ts b/tests/boundary/providers/native/bytecodeProvider.test.ts similarity index 98% rename from tests/bytecodeProvider.test.ts rename to tests/boundary/providers/native/bytecodeProvider.test.ts index a44a6dcff..4e1c2d23f 100644 --- a/tests/bytecodeProvider.test.ts +++ b/tests/boundary/providers/native/bytecodeProvider.test.ts @@ -8,7 +8,7 @@ import { detectBytecodeFamily, type BytecodeArtifact, type BytecodeSymbol, -} from "../src/domain/bytecodeProvider.js"; +} from "../../../../src/domain/bytecodeProvider.js"; describe("bytecode provider", () => { it("detects JVM family from extensions", () => { diff --git a/tests/nativeApiInspection.test.ts b/tests/boundary/providers/native/nativeApiInspection.test.ts similarity index 88% rename from tests/nativeApiInspection.test.ts rename to tests/boundary/providers/native/nativeApiInspection.test.ts index 5eb1f7624..08d9791a6 100644 --- a/tests/nativeApiInspection.test.ts +++ b/tests/boundary/providers/native/nativeApiInspection.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; -import { projectNativeApiInspection } from "../src/application/NativeApiInspection.js"; -import { functionDossierSchema } from "../src/domain/hopperValues.js"; -import { ghidraFunctionDossier } from "./fixtures/ghidraFunction.js"; +import { projectNativeApiInspection } from "../../../../src/application/NativeApiInspection.js"; +import { functionDossierSchema } from "../../../../src/domain/hopperValues.js"; +import { ghidraFunctionDossier } from "../../../fixtures/ghidraFunction.js"; describe("native API inspection", () => { it("projects structured boundary evidence through inspectable substeps", () => { diff --git a/tests/nativeDmgArtifactReader.test.ts b/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts similarity index 97% rename from tests/nativeDmgArtifactReader.test.ts rename to tests/boundary/providers/native/nativeDmgArtifactReader.test.ts index 73cc1e50f..08e722ae8 100644 --- a/tests/nativeDmgArtifactReader.test.ts +++ b/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts @@ -7,7 +7,7 @@ import { describe, expect, it } from "vitest"; import { NativeDmgArtifactReader, type NativeDmgHost, -} from "../src/artifacts/NativeDmgArtifactReader.js"; +} from "../../../../src/artifacts/NativeDmgArtifactReader.js"; describe("native DMG artifact reader", () => { it("uses plist attachment metadata and detaches returned devices", async () => { diff --git a/tests/nativeProvider.test.ts b/tests/boundary/providers/native/nativeProvider.test.ts similarity index 94% rename from tests/nativeProvider.test.ts rename to tests/boundary/providers/native/nativeProvider.test.ts index 03736a989..ad11f68b3 100644 --- a/tests/nativeProvider.test.ts +++ b/tests/boundary/providers/native/nativeProvider.test.ts @@ -3,18 +3,18 @@ import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; -import { NativeMacOSProvider } from "../src/native/NativeMacOSProvider.js"; +import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.js"; import { NativeCommandFailure, XcrunCommandRunner, type NativeCommandCapture, type NativeCommandRunner, -} from "../src/native/CommandRunner.js"; -import type { BinaryTarget } from "../src/domain/binaryTarget.js"; -import { err, ok } from "../src/domain/result.js"; -import { parseLipoArchitectures } from "../src/native/parsers/lipo.js"; +} from "../../../../src/native/CommandRunner.js"; +import type { BinaryTarget } from "../../../../src/domain/binaryTarget.js"; +import { err, ok } from "../../../../src/domain/result.js"; +import { parseLipoArchitectures } from "../../../../src/native/parsers/lipo.js"; let directory: string | undefined; afterEach(async () => { @@ -23,7 +23,7 @@ afterEach(async () => { directory = undefined; }); -describe("native macOS provider", () => { +describe("native macOS provider discovery and inspection", () => { it("retries a failed native tool resolution and caches only success", async () => { let resolutions = 0; const runner = new XcrunCommandRunner((tool) => { @@ -172,7 +172,9 @@ describe("native macOS provider", () => { ], }); }); +}); +describe("native macOS provider failures and parsing", () => { it("classifies unavailable, malformed, timeout, and cancellation", async () => { const unavailable = new NativeMacOSProvider( new FailingRunner("unavailable"), @@ -343,4 +345,7 @@ const machoTarget = (path: string, sourcePath?: string): BinaryTarget => ({ }); const fixture = (name: string): Promise => - readFile(new URL(`./fixtures/native-macos/${name}`, import.meta.url), "utf8"); + readFile( + new URL(`../../../fixtures/native-macos/${name}`, import.meta.url), + "utf8", + ); diff --git a/tests/objcSwiftMetadata.test.ts b/tests/boundary/providers/native/objcSwiftMetadata.test.ts similarity index 98% rename from tests/objcSwiftMetadata.test.ts rename to tests/boundary/providers/native/objcSwiftMetadata.test.ts index a8f876faf..4d68bb5f6 100644 --- a/tests/objcSwiftMetadata.test.ts +++ b/tests/boundary/providers/native/objcSwiftMetadata.test.ts @@ -16,9 +16,8 @@ import { type ObjcProtocol, type ObjcPropertyAttribute, type SwiftDecl, - type SwiftAccessLevel, type DbSaveResult, -} from "../src/domain/objcSwiftMetadata.js"; +} from "../../../../src/domain/objcSwiftMetadata.js"; describe("ObjC/Swift metadata", () => { it("identifies getter selectors", () => { diff --git a/tests/javascriptReplay.test.ts b/tests/boundary/providers/replay/javascriptReplay.test.ts similarity index 94% rename from tests/javascriptReplay.test.ts rename to tests/boundary/providers/replay/javascriptReplay.test.ts index 0bdf45df6..eb288448b 100644 --- a/tests/javascriptReplay.test.ts +++ b/tests/boundary/providers/replay/javascriptReplay.test.ts @@ -3,20 +3,20 @@ import { join, resolve } from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; -import { runControlledReplay } from "../src/application/JavaScriptReplayService.js"; +import { runControlledReplay } from "../../../../src/application/JavaScriptReplayService.js"; import type { JavaScriptReplayHost, JavaScriptReplayPolicy, JavaScriptReplayRunner, -} from "../src/application/JavaScriptReplayPlanning.js"; -import { PermissionAuthority } from "../src/application/PermissionAuthority.js"; -import { createPermissionPolicy } from "../src/domain/permissionPolicy.js"; +} from "../../../../src/application/JavaScriptReplayPlanning.js"; +import { PermissionAuthority } from "../../../../src/application/PermissionAuthority.js"; +import { createPermissionPolicy } from "../../../../src/domain/permissionPolicy.js"; import { controlledReplayInputSchema, controlledReplayOutputSchema, -} from "../src/domain/javascriptReplay.js"; +} from "../../../../src/domain/javascriptReplay.js"; const root = resolve("tests/fixtures/replay"); const policy: JavaScriptReplayPolicy = { @@ -152,7 +152,7 @@ const completedRunner = (): JavaScriptReplayRunner => ({ }), }); -describe("controlled JavaScript replay", () => { +describe("controlled JavaScript replay planning", () => { it("rejects relative paths and more than 128 combined cases", () => { expect( controlledReplayInputSchema.safeParse({ @@ -225,7 +225,9 @@ describe("controlled JavaScript replay", () => { expect(!result.ok && result.error._tag).toBe("ReplayPlanStaleError"); expect(execute).not.toHaveBeenCalled(); }); +}); +describe("controlled JavaScript replay evidence and authorization", () => { it("creates controlled-replay Evidence after an approved exact plan", async () => { const dependencies = { policy, @@ -305,7 +307,9 @@ describe("controlled JavaScript replay", () => { right.ok && right.value, ); }); +}); +describe("controlled JavaScript replay cancellation and export", () => { it("projects an observed cancelled termination through the shared error algebra", async () => { const execute = vi.fn( async (prepared) => ({ diff --git a/tests/javascriptReplayWorker.test.ts b/tests/boundary/providers/replay/javascriptReplayWorker.test.ts similarity index 100% rename from tests/javascriptReplayWorker.test.ts rename to tests/boundary/providers/replay/javascriptReplayWorker.test.ts diff --git a/tests/replayMachineRun.test.ts b/tests/boundary/providers/replay/replayMachineRun.test.ts similarity index 98% rename from tests/replayMachineRun.test.ts rename to tests/boundary/providers/replay/replayMachineRun.test.ts index 048c47f63..1bfc33957 100644 --- a/tests/replayMachineRun.test.ts +++ b/tests/boundary/providers/replay/replayMachineRun.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { replayMachineRunInputSchema, runReplayMachine, -} from "../src/domain/replayMachineRun.js"; +} from "../../../../src/domain/replayMachineRun.js"; const loginRun = () => replayMachineRunInputSchema.parse({ @@ -104,7 +104,7 @@ const httpEvent = (path: string, atMs = 0) => ({ body: "", }); -describe("direct replay machine runner", () => { +describe("direct replay machine execution", () => { it("returns actions, aliases, terminal state, and exact limit use", () => { const result = runReplayMachine(loginRun()); @@ -222,7 +222,9 @@ describe("direct replay machine runner", () => { runReplayMachine(input).decisions.map(({ outcome }) => outcome), ).toEqual(["matched", "transition_exhausted"]); }); +}); +describe("direct replay machine protocol and limits", () => { it("classifies reconnects that arrive in the wrong state", () => { const input = parseMachineRun( { @@ -332,7 +334,9 @@ describe("direct replay machine runner", () => { bytes: 0, }); }); +}); +describe("direct replay machine output bounds", () => { it("records large repeated actions once instead of amplifying each match", () => { const largeBody = "x".repeat(1_000_000); const input = parseMachineRun( diff --git a/tests/boundary/providers/replay/replayMachineRuntime.test.ts b/tests/boundary/providers/replay/replayMachineRuntime.test.ts new file mode 100644 index 000000000..87a59349d --- /dev/null +++ b/tests/boundary/providers/replay/replayMachineRuntime.test.ts @@ -0,0 +1,333 @@ +import { expect, it } from "vitest"; + +import { replayMachineSchema } from "../../../../src/domain/replayMachine.js"; +import { ReplayMachineRuntime } from "../../../../src/domain/replayMachineRuntime.js"; + +const machine = replayMachineSchema.parse({ + initial_state: "login", + states: [ + { name: "login" }, + { name: "api" }, + { name: "complete", terminal: true }, + ], + transitions: [ + { + id: "capture_token", + from: "login", + to: "api", + priority: 10, + trigger: { protocol: "http", method: "POST", path: "/login" }, + captures: [ + { + variable: "session_value", + value: { source: "request_json", path: ["session_value"] }, + sensitive: true, + }, + ], + actions: [{ type: "http_response", status: 204, headers: {}, body: "" }], + max_uses: 1, + }, + { + id: "authorize_api", + from: "api", + to: "complete", + priority: 10, + trigger: { protocol: "http", method: "GET", path: "/api" }, + guards: [ + { + variable: "session_value", + value: { source: "request_header", name: "authorization" }, + }, + ], + actions: [ + { type: "http_response", status: 200, headers: {}, body: "done" }, + ], + max_uses: 1, + }, + ], + max_transitions: 2, +}); + +const event = ( + method: string, + path: string, + body = "", + headers: Readonly> = {}, +) => ({ + protocol: "http" as const, + connection: "not_applicable" as const, + at_ms: 0, + method, + path, + body, + headers, +}); + +it("captures secret values in memory and journals aliases only", () => { + const runtime = new ReplayMachineRuntime(machine); + expect( + runtime.dispatch( + event("POST", "/login", '{"session_value":"test-auth-token"}'), + ), + ).toMatchObject({ + outcome: "matched", + transition: { + transition_id: "capture_token", + sensitive_aliases: ["session_value"], + }, + }); + const expectedRedaction = ["Bearer <", "secret", ":session_value>"].join(""); + expect(runtime.redact("Bearer test-auth-token")).toBe(expectedRedaction); + expect( + runtime.dispatch( + event("GET", "/api", "", { Authorization: "test-auth-token" }), + ), + ).toMatchObject({ outcome: "matched" }); + expect(JSON.stringify(runtime.timeline)).not.toContain("test-auth-token"); + expect(runtime.state).toBe("complete"); +}); + +it("classifies wrong-state, guard, capture, and exhaustion failures", () => { + expect( + new ReplayMachineRuntime(machine).dispatch(event("GET", "/api")), + ).toMatchObject({ outcome: "invalid_state" }); + expect( + new ReplayMachineRuntime(machine).dispatch(event("POST", "/login", "{}")), + ).toMatchObject({ outcome: "invalid_capture" }); + const runtime = new ReplayMachineRuntime(machine); + runtime.dispatch( + event("POST", "/login", '{"session_value":"test-auth-token"}'), + ); + expect( + runtime.dispatch( + event("GET", "/api", "", { + authorization: "test-token-placeholder", + }), + ), + ).toMatchObject({ + outcome: "guard_failed", + }); + const bounded = new ReplayMachineRuntime( + replayMachineSchema.parse({ + initial_state: "active", + states: [{ name: "active" }, { name: "done", terminal: true }], + transitions: [ + { + id: "once", + from: "active", + to: "active", + trigger: { protocol: "http", method: "GET", path: "/once" }, + actions: [ + { type: "http_response", status: 200, headers: {}, body: "ok" }, + ], + max_uses: 1, + }, + { + id: "finish", + from: "active", + to: "done", + trigger: { protocol: "http", method: "GET", path: "/done" }, + actions: [ + { type: "http_response", status: 200, headers: {}, body: "ok" }, + ], + max_uses: 1, + }, + ], + max_transitions: 2, + }), + ); + expect(bounded.dispatch(event("GET", "/once"))).toMatchObject({ + outcome: "matched", + }); + expect(bounded.dispatch(event("GET", "/once"))).toMatchObject({ + outcome: "transition_exhausted", + }); +}); + +it("matches bounded headers and bodies and distinguishes wrong-state reconnects", () => { + const runtime = new ReplayMachineRuntime( + replayMachineSchema.parse({ + initial_state: "connected", + states: [{ name: "connected" }, { name: "done", terminal: true }], + transitions: [ + { + id: "finish", + from: "connected", + to: "done", + trigger: { + protocol: "http", + method: "POST", + path: "/finish", + headers: { "content-type": "application/json" }, + body: '{"done":true}', + }, + actions: [ + { type: "http_response", status: 204, headers: {}, body: "" }, + ], + max_uses: 1, + }, + { + id: "initial_socket", + from: "connected", + to: "connected", + trigger: { protocol: "websocket_connect", path: "/ws" }, + actions: [{ type: "disconnect" }], + max_uses: 1, + }, + ], + max_transitions: 2, + }), + ); + expect(runtime.dispatch(event("POST", "/finish", "wrong"))).toMatchObject({ + outcome: "unmatched", + }); + expect( + runtime.dispatch({ + protocol: "websocket_connect", + connection: "initial", + at_ms: 0, + method: null, + path: "/ws", + headers: {}, + body: "", + }), + ).toMatchObject({ outcome: "matched" }); + expect( + runtime.dispatch({ + protocol: "websocket_connect", + connection: "reconnect", + at_ms: 0, + method: null, + path: "/ws", + headers: {}, + body: "", + }), + ).toMatchObject({ outcome: "transition_exhausted" }); + expect( + runtime.dispatch( + event("POST", "/finish", '{"done":true}', { + "content-type": "application/json", + }), + ), + ).toMatchObject({ outcome: "matched" }); + expect( + runtime.dispatch({ + protocol: "websocket_connect", + connection: "reconnect", + at_ms: 0, + method: null, + path: "/ws", + headers: {}, + body: "", + }), + ).toMatchObject({ outcome: "unexpected_reconnect" }); +}); + +it("enforces state, connection, message, byte, and duration limits", () => { + const limitedMachine = replayMachineSchema.parse({ + initial_state: "active", + states: [ + { name: "active", max_visits: 1 }, + { name: "done", terminal: true }, + ], + transitions: [ + { + id: "loop", + from: "active", + to: "active", + trigger: { protocol: "websocket_message", path: "/ws" }, + actions: [{ type: "websocket_send", data: "ack" }], + max_uses: 2, + }, + { + id: "finish", + from: "active", + to: "done", + trigger: { protocol: "http", method: "GET", path: "/done" }, + actions: [ + { type: "http_response", status: 200, headers: {}, body: "ok" }, + ], + max_uses: 1, + }, + ], + max_transitions: 3, + limits: { connections: 1, messages: 1, bytes: 100, duration_ms: 10 }, + }); + const stateLimited = new ReplayMachineRuntime(limitedMachine); + expect( + stateLimited.dispatch({ + protocol: "websocket_message", + connection: "not_applicable", + at_ms: 0, + method: null, + path: "/ws", + headers: {}, + body: "one", + }), + ).toMatchObject({ outcome: "limit_exhausted" }); + const messageLimited = new ReplayMachineRuntime( + replayMachineSchema.parse({ + ...limitedMachine, + states: limitedMachine.states.map((state) => + state.name === "active" ? { ...state, max_visits: 3 } : state, + ), + }), + ); + const message = { + protocol: "websocket_message" as const, + connection: "not_applicable" as const, + at_ms: 0, + method: null, + path: "/ws", + headers: {}, + body: "one", + }; + expect(messageLimited.dispatch(message)).toMatchObject({ + outcome: "matched", + }); + expect(messageLimited.dispatch(message)).toMatchObject({ + outcome: "limit_exhausted", + }); + const durationLimited = new ReplayMachineRuntime(limitedMachine); + expect( + durationLimited.dispatch({ ...event("GET", "/done"), at_ms: 11 }), + ).toMatchObject({ outcome: "limit_exhausted" }); + expect( + new ReplayMachineRuntime(limitedMachine).dispatch({ + ...event("GET", "/done"), + at_ms: Number.NaN, + }), + ).toMatchObject({ outcome: "limit_exhausted" }); + const monotonic = new ReplayMachineRuntime(limitedMachine); + expect( + monotonic.dispatch({ ...event("GET", "/missing"), at_ms: 5 }), + ).toMatchObject({ outcome: "unmatched" }); + expect( + monotonic.dispatch({ ...event("GET", "/missing"), at_ms: 4 }), + ).toMatchObject({ outcome: "limit_exhausted" }); + const byteLimited = new ReplayMachineRuntime( + replayMachineSchema.parse({ + ...limitedMachine, + limits: { ...limitedMachine.limits, bytes: 4 }, + }), + ); + expect(byteLimited.dispatch(event("GET", "/missing", "12345"))).toMatchObject( + { outcome: "limit_exhausted" }, + ); + const connectionLimited = new ReplayMachineRuntime(limitedMachine); + const connect = { + protocol: "websocket_connect" as const, + connection: "initial" as const, + at_ms: 0, + method: null, + path: "/unknown", + headers: {}, + body: "", + }; + expect(connectionLimited.dispatch(connect)).toMatchObject({ + outcome: "unmatched", + }); + expect( + connectionLimited.dispatch({ ...connect, connection: "reconnect" }), + ).toMatchObject({ outcome: "limit_exhausted" }); +}); diff --git a/tests/boundary/providers/replay/replayMachineRuntimeCaptures.test.ts b/tests/boundary/providers/replay/replayMachineRuntimeCaptures.test.ts new file mode 100644 index 000000000..5e5e87978 --- /dev/null +++ b/tests/boundary/providers/replay/replayMachineRuntimeCaptures.test.ts @@ -0,0 +1,308 @@ +import { expect, it } from "vitest"; + +import { replayMachineSchema } from "../../../../src/domain/replayMachine.js"; +import { ReplayMachineRuntime } from "../../../../src/domain/replayMachineRuntime.js"; + +const machine = replayMachineSchema.parse({ + initial_state: "login", + states: [ + { name: "login" }, + { name: "api" }, + { name: "complete", terminal: true }, + ], + transitions: [ + { + id: "capture_token", + from: "login", + to: "api", + priority: 10, + trigger: { protocol: "http", method: "POST", path: "/login" }, + captures: [ + { + variable: "session_value", + value: { source: "request_json", path: ["session_value"] }, + sensitive: true, + }, + ], + actions: [{ type: "http_response", status: 204, headers: {}, body: "" }], + max_uses: 1, + }, + { + id: "authorize_api", + from: "api", + to: "complete", + priority: 10, + trigger: { protocol: "http", method: "GET", path: "/api" }, + guards: [ + { + variable: "session_value", + value: { source: "request_header", name: "authorization" }, + }, + ], + actions: [ + { type: "http_response", status: 200, headers: {}, body: "done" }, + ], + max_uses: 1, + }, + ], + max_transitions: 2, +}); + +const event = ( + method: string, + path: string, + body = "", + headers: Readonly> = {}, +) => ({ + protocol: "http" as const, + connection: "not_applicable" as const, + at_ms: 0, + method, + path, + body, + headers, +}); + +it("compares captured JSON values canonically", () => { + const objectMachine = replayMachineSchema.parse({ + initial_state: "capture", + states: [ + { name: "capture" }, + { name: "guard" }, + { name: "done", terminal: true }, + ], + transitions: [ + { + id: "capture_profile", + from: "capture", + to: "guard", + trigger: { protocol: "http", method: "POST", path: "/capture" }, + captures: [ + { + variable: "profile", + value: { source: "request_json", path: ["profile"] }, + }, + ], + actions: [ + { type: "http_response", status: 204, headers: {}, body: "" }, + ], + max_uses: 1, + }, + { + id: "guard_profile", + from: "guard", + to: "done", + trigger: { protocol: "http", method: "POST", path: "/guard" }, + guards: [ + { + variable: "profile", + value: { source: "request_json", path: ["profile"] }, + }, + ], + actions: [ + { type: "http_response", status: 204, headers: {}, body: "" }, + ], + max_uses: 1, + }, + ], + max_transitions: 2, + }); + const runtime = new ReplayMachineRuntime(objectMachine); + + expect( + runtime.dispatch( + event("POST", "/capture", '{"profile":{"name":"A","id":1}}'), + ), + ).toMatchObject({ outcome: "matched" }); + expect( + runtime.dispatch( + event("POST", "/guard", '{"profile":{"id":1,"name":"A"}}'), + ), + ).toMatchObject({ outcome: "matched" }); +}); + +it("does not traverse inherited properties in JSON capture paths", () => { + const inheritedPathMachine = replayMachineSchema.parse({ + ...machine, + transitions: machine.transitions.map((transition, index) => + index === 0 + ? { + ...transition, + captures: [ + { + variable: "session_value", + value: { + source: "request_json" as const, + path: ["__proto__", "toString"], + }, + sensitive: true, + }, + ], + } + : transition, + ), + }); + + expect( + new ReplayMachineRuntime(inheritedPathMachine).dispatch( + event("POST", "/login", "{}"), + ), + ).toMatchObject({ outcome: "invalid_capture" }); +}); + +it("rejects non-string sensitive captures and retains rotated values", () => { + expect( + new ReplayMachineRuntime(machine).dispatch( + event("POST", "/login", '{"session_value":1234}'), + ), + ).toMatchObject({ outcome: "invalid_capture" }); + + const rotatingMachine = replayMachineSchema.parse({ + initial_state: "active", + states: [ + { name: "active", max_visits: 3 }, + { name: "done", terminal: true }, + ], + transitions: [ + { + id: "rotate", + from: "active", + to: "active", + trigger: { protocol: "http", method: "POST", path: "/rotate" }, + captures: [ + { + variable: "session_value", + value: { source: "request_json", path: ["value"] }, + sensitive: true, + }, + ], + actions: [ + { type: "http_response", status: 204, headers: {}, body: "" }, + ], + max_uses: 2, + }, + { + id: "finish", + from: "active", + to: "done", + trigger: { protocol: "http", method: "GET", path: "/done" }, + actions: [ + { type: "http_response", status: 204, headers: {}, body: "" }, + ], + max_uses: 1, + }, + ], + max_transitions: 3, + }); + const rotating = new ReplayMachineRuntime(rotatingMachine); + rotating.dispatch(event("POST", "/rotate", '{"value":"alpha"}')); + rotating.dispatch(event("POST", "/rotate", '{"value":"beta"}')); + const alias = ["<", "secret", ":session_value>"].join(""); + + expect(rotating.redact("alpha beta")).toBe(`${alias} ${alias}`); +}); + +it("falls back to a lower-priority transition after a preferred one is exhausted", () => { + const fallbackMachine = replayMachineSchema.parse({ + initial_state: "active", + states: [ + { name: "active", max_visits: 2 }, + { name: "done", terminal: true }, + ], + transitions: [ + { + id: "preferred", + from: "active", + to: "active", + priority: 10, + trigger: { protocol: "http", method: "GET", path: "/next" }, + actions: [ + { type: "http_response", status: 200, headers: {}, body: "again" }, + ], + max_uses: 1, + }, + { + id: "fallback", + from: "active", + to: "done", + priority: 20, + trigger: { protocol: "http", method: "GET", path: "/next" }, + actions: [ + { type: "http_response", status: 200, headers: {}, body: "done" }, + ], + max_uses: 1, + }, + ], + max_transitions: 2, + }); + const runtime = new ReplayMachineRuntime(fallbackMachine); + + expect(runtime.dispatch(event("GET", "/next"))).toMatchObject({ + outcome: "matched", + transition: { transition_id: "preferred" }, + }); + expect(runtime.dispatch(event("GET", "/next"))).toMatchObject({ + outcome: "matched", + transition: { transition_id: "fallback" }, + }); +}); + +it("rejects non-finite parsed JSON captures and guard values", () => { + const jsonMachine = replayMachineSchema.parse({ + initial_state: "capture", + states: [ + { name: "capture" }, + { name: "guard" }, + { name: "done", terminal: true }, + ], + transitions: [ + { + id: "capture_value", + from: "capture", + to: "guard", + trigger: { protocol: "http", method: "POST", path: "/capture" }, + captures: [ + { + variable: "value", + value: { source: "request_json", path: ["value"] }, + }, + ], + actions: [ + { type: "http_response", status: 204, headers: {}, body: "" }, + ], + max_uses: 1, + }, + { + id: "guard_value", + from: "guard", + to: "done", + trigger: { protocol: "http", method: "POST", path: "/guard" }, + guards: [ + { + variable: "value", + value: { source: "request_json", path: ["value"] }, + }, + ], + actions: [ + { type: "http_response", status: 204, headers: {}, body: "" }, + ], + max_uses: 1, + }, + ], + max_transitions: 2, + }); + + expect( + new ReplayMachineRuntime(jsonMachine).dispatch( + event("POST", "/capture", '{"value":1e400}'), + ), + ).toMatchObject({ outcome: "invalid_capture" }); + + const guarded = new ReplayMachineRuntime(jsonMachine); + expect( + guarded.dispatch(event("POST", "/capture", '{"value":null}')), + ).toMatchObject({ outcome: "matched" }); + expect( + guarded.dispatch(event("POST", "/guard", '{"value":1e400}')), + ).toMatchObject({ outcome: "guard_failed" }); +}); diff --git a/tests/replayWorkerProtocol.test.ts b/tests/boundary/providers/replay/replayWorkerProtocol.test.ts similarity index 95% rename from tests/replayWorkerProtocol.test.ts rename to tests/boundary/providers/replay/replayWorkerProtocol.test.ts index 9d21fd3e9..b2b73edbc 100644 --- a/tests/replayWorkerProtocol.test.ts +++ b/tests/boundary/providers/replay/replayWorkerProtocol.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { parseReplayWorkerResponse } from "../src/replay/ReplayWorkerProtocol.js"; +import { parseReplayWorkerResponse } from "../../../../src/replay/ReplayWorkerProtocol.js"; const digest = "1".repeat(64); const cases = [{ case_id: "case-1", sha256: digest }]; diff --git a/tests/browserMcp.test.ts b/tests/browserMcp.test.ts deleted file mode 100644 index 8edabb9aa..000000000 --- a/tests/browserMcp.test.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; -import { afterEach, describe, expect, it } from "vitest"; - -import { BinarySession } from "../src/application/BinarySession.js"; -import { loadConfiguredPermissionAuthority } from "../src/application/PermissionConfiguration.js"; -import { CdpBrowserProvider } from "../src/browser/CdpBrowserProvider.js"; -import { parseConfig } from "../src/config.js"; -import { JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE } from "../src/contracts/javascriptRuntimeReconciliationExample.js"; -import { TOOL_CONTRACTS } from "../src/contracts/toolContracts.js"; -import { createServer } from "../src/server/createServer.js"; -import { observed } from "./fixtures/analysisExecution.js"; -import { - startFakeCdpBrowser, - type FakeCdpBrowser, -} from "./fixtures/fakeCdpBrowser.js"; - -const resources: Array<{ close(): Promise }> = []; -const browsers: FakeCdpBrowser[] = []; -const INTEGRATION_TEST_TIMEOUT_MS = 20_000; - -afterEach(async () => { - await Promise.all( - resources.splice(0).map(async (resource) => resource.close()), - ); - await Promise.all(browsers.splice(0).map(async (browser) => browser.close())); -}); - -describe("browser MCP tools", () => { - it( - "exposes CLI-equivalent Evidence v2 and session resources", - async () => { - const browser = await startFakeCdpBrowser({ - sessionTimeline: "same_origin", - webMcpTools: true, - sensitiveShapes: true, - }); - browsers.push(browser); - const connected = await connectBrowser(browser); - - const tools = await connected.client.listTools(); - expect(tools.tools.length).toBeLessThan(TOOL_CONTRACTS.length); - expect(tools.tools.map(({ name }) => name)).toEqual( - expect.arrayContaining([ - "list_browser_targets", - "inspect_web_page", - "analyze_web_bundle", - "observe_web_session", - "discover_webmcp_tools", - "compare_web_captures", - "capture_web_screenshot", - "compare_web_screenshots", - ]), - ); - const status = await connected.client.callTool({ - name: "binary_session", - arguments: { detail: "full" }, - }); - expect(status.structuredContent).toMatchObject({ - result: { - tool_availability: expect.arrayContaining([ - expect.objectContaining({ - name: "inspect_web_page", - available: true, - reason: "available", - }), - ]), - }, - }); - const listed = await connected.client.callTool({ - name: "list_browser_targets", - arguments: { - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - approved: true, - }, - }); - expect(listed.isError).not.toBe(true); - expect(listed.structuredContent).toMatchObject({ - result: { - targets: { items: [{ target_id: "allowed-page" }] }, - }, - }); - const inspected = await connected.client.callTool({ - name: "inspect_web_page", - arguments: { - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - approved: true, - observation_ms: 0, - include_console_text: true, - console_text_approved: true, - include_json_body_shapes: true, - json_body_schema_approved: true, - include_websocket_shapes: true, - websocket_shape_approved: true, - include_script_sources: true, - }, - }); - expect(inspected.isError).not.toBe(true); - expect(inspected.structuredContent).toMatchObject({ - result: { - target: { target_id: "allowed-page" }, - console: { prior_activity_available: false }, - network: { - prior_activity_available: false, - requests: [ - expect.objectContaining({ - body_shapes: expect.objectContaining({ status: "included" }), - }), - ], - }, - }, - }); - const reconciled = await connected.client.callTool({ - name: "reconcile_javascript_runtime", - arguments: { - static_layers: - JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE.static_layers, - runtime_observations: [ - evidenceFor(connected.session, inspected.structuredContent), - ], - }, - }); - expect(reconciled.isError).not.toBe(true); - expect(reconciled.structuredContent).toMatchObject({ - result: { summary: { runtime_scripts: 1 } }, - }); - expect(JSON.stringify(reconciled.structuredContent)).not.toContain( - "source-secret", - ); - const analyzed = await connected.client.callTool({ - name: "analyze_web_bundle", - arguments: { - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - approved: true, - source_capture_approved: true, - observation_ms: 0, - }, - }); - expect(analyzed.isError).not.toBe(true); - expect(analyzed.structuredContent).toMatchObject({ - result: { - capture: { scripts_analyzed: 1 }, - observations: { source_maps: { status: "not_requested" } }, - }, - }); - const observedSession = await connected.client.callTool({ - name: "observe_web_session", - arguments: { - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - approved: true, - observation_ms: 5, - }, - }); - expect(observedSession.isError).not.toBe(true); - expect(observedSession.structuredContent).toMatchObject({ - result: { - window: { end_reason: "window_elapsed" }, - timeline: expect.arrayContaining([ - expect.objectContaining({ type: "same_origin_reload" }), - ]), - }, - }); - const webMcp = await connected.client.callTool({ - name: "discover_webmcp_tools", - arguments: { - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - approved: true, - observation_ms: 0, - }, - }); - expect(webMcp.isError).not.toBe(true); - expect(webMcp.structuredContent).toMatchObject({ - result: { - tools: { - items: [expect.objectContaining({ name: "search_orders" })], - }, - }, - }); - const capture = normalizedResultOf(inspected.structuredContent); - const compared = await connected.client.callTool({ - name: "compare_web_captures", - arguments: { - before: { inspection: capture }, - after: { inspection: capture }, - }, - }); - expect(compared.isError).not.toBe(true); - expect(compared.structuredContent).toMatchObject({ - result: { overall_status: "unknown" }, - }); - const screenshot = await connected.client.callTool({ - name: "capture_web_screenshot", - arguments: { - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - approved: true, - screenshot_approved: true, - }, - }); - expect(screenshot.isError).not.toBe(true); - const screenshotArtifact = artifactOf(screenshot.structuredContent); - const visual = await connected.client.callTool({ - name: "compare_web_screenshots", - arguments: { - before: screenshotArtifact, - after: screenshotArtifact, - }, - }); - expect(visual.isError).not.toBe(true); - expect(visual.structuredContent).toMatchObject({ - result: { status: "identical", changed_pixels: 0 }, - }); - const evidenceId = evidenceIdOf(inspected.structuredContent); - const evidence = await connected.client.readResource({ - uri: `rea://evidence/${evidenceId}`, - }); - expect(evidence.contents[0]).toEqual( - expect.objectContaining({ text: expect.stringContaining(evidenceId) }), - ); - }, - INTEGRATION_TEST_TIMEOUT_MS, - ); - - it("denies origins outside the administrator ceiling before CDP attach", async () => { - const browser = await startFakeCdpBrowser(); - browsers.push(browser); - const connected = await connectBrowser(browser); - const denied = await connected.client.callTool({ - name: "inspect_web_page", - arguments: { - cdp_endpoint: browser.endpoint, - allowed_origins: ["https://unapproved.example.test"], - target_id: "allowed-page", - approved: true, - observation_ms: 0, - }, - }); - expect(denied.isError).toBe(true); - expect(denied.structuredContent).toMatchObject({ - error: { - code: "permission_required", - details: { - missing: { origins: ["https://unapproved.example.test"] }, - }, - }, - }); - expect(browser.commands).toHaveLength(0); - }); -}); - -const connectBrowser = async (browser: FakeCdpBrowser) => { - const config = parseConfig({ - REA_BROWSER_OBSERVE_ENABLED: "true", - REA_BROWSER_CDP_ENDPOINTS_JSON: JSON.stringify([browser.endpoint]), - REA_BROWSER_ALLOWED_ORIGINS_JSON: JSON.stringify([browser.allowedOrigin]), - }); - if (!config.ok) throw config.error; - const authority = await loadConfiguredPermissionAuthority(config.value); - if (!authority.ok) throw authority.error; - const session = new BinarySession(() => ({ - execute: () => Promise.resolve(observed(null)), - close: () => Promise.resolve(), - })); - const server = createServer(session, session, { - browserObservation: new CdpBrowserProvider(), - permissionAuthority: authority.value, - availabilityPolicy: () => ({ - processCaptureEnabled: false, - evidenceFileRoots: 0, - investigationInputRoots: 0, - browserObservationEnabled: true, - }), - }); - const client = new Client({ name: "browser-mcp-test", version: "1" }); - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - resources.push(client, server, session); - await server.connect(serverTransport); - await client.connect(clientTransport); - return { client, session }; -}; - -const evidenceIdOf = (value: unknown): string => { - if ( - typeof value !== "object" || - value === null || - !("evidence_id" in value) || - typeof value.evidence_id !== "string" - ) - throw new TypeError("Missing browser evidence ID"); - return value.evidence_id; -}; - -const normalizedResultOf = (value: unknown): unknown => { - if (typeof value !== "object" || value === null || !("result" in value)) - throw new TypeError("Missing normalized browser result"); - return value.result; -}; - -const evidenceFor = (session: BinarySession, value: unknown) => { - const evidence = session.evidenceById(evidenceIdOf(value)); - if (evidence === undefined) throw new TypeError("Missing session Evidence"); - return evidence; -}; - -const artifactOf = (value: unknown): unknown => { - const normalized = normalizedResultOf(value); - if ( - typeof normalized !== "object" || - normalized === null || - !("artifact" in normalized) - ) - throw new TypeError("Missing screenshot artifact"); - return normalized.artifact; -}; diff --git a/tests/cdpBrowserProvider.test.ts b/tests/cdpBrowserProvider.test.ts deleted file mode 100644 index f4c548485..000000000 --- a/tests/cdpBrowserProvider.test.ts +++ /dev/null @@ -1,1496 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; - -import { CdpBrowserProvider } from "../src/browser/CdpBrowserProvider.js"; -import { - inspectWebPageInputSchema, - listBrowserTargetsInputSchema, - webPageInspectionSchema, -} from "../src/domain/browserObservation.js"; -import { analyzeWebBundleInputSchema } from "../src/domain/webBundleAnalysis.js"; -import { observeWebSessionInputSchema } from "../src/domain/browserSession.js"; -import { discoverWebMcpToolsInputSchema } from "../src/domain/webMcpDiscovery.js"; -import { captureWebScreenshotInputSchema } from "../src/domain/webScreenshot.js"; -import { - startFakeCdpBrowser, - type FakeCdpBrowser, -} from "./fixtures/fakeCdpBrowser.js"; - -describe("CdpBrowserProvider", () => { - const browsers: FakeCdpBrowser[] = []; - - afterEach(async () => { - await Promise.all( - browsers.splice(0).map(async (browser) => browser.close()), - ); - }); - - it("lists only exact-origin pages and sanitizes URLs", async () => { - const browser = await startFakeCdpBrowser(); - browsers.push(browser); - const provider = new CdpBrowserProvider(); - const result = await provider.listTargets( - listBrowserTargetsInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - approved: true, - }), - ); - - if (!result.ok) throw result.error; - expect(result.value.targets.items).toEqual([ - expect.objectContaining({ - target_id: "allowed-page", - origin: browser.allowedOrigin, - url: `${browser.allowedOrigin}/app?token=%5BREDACTED%5D`, - }), - ]); - expect(result.value.excluded).toEqual({ - disallowed_origin: 1, - unsupported_url: 1, - non_page: 1, - }); - expect(JSON.stringify(result.value)).not.toContain("forbidden"); - expect(JSON.stringify(result.value)).not.toContain("page-secret"); - }); - - it.each([ - ["absolute", true, ""], - ["host/path", "host-path", ""], - ["root-relative", "root-relative", ""], - ["prefixed host/path", "prefixed", "Loading "], - ] as const)( - "sanitizes %s transitional target titles", - async (_label, urlShapedAllowedTitle, prefix) => { - const browser = await startFakeCdpBrowser({ urlShapedAllowedTitle }); - browsers.push(browser); - const provider = new CdpBrowserProvider(); - const input = { - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - approved: true as const, - target_id: "allowed-page", - }; - const expected = `${prefix}${browser.allowedOrigin}/app?startup=%5BREDACTED%5D`; - const listed = await provider.listTargets( - listBrowserTargetsInputSchema.parse(input), - ); - if (!listed.ok) throw listed.error; - expect(listed.value.targets.items[0]?.title).toBe(expected); - - const inspected = await provider.inspectPage( - inspectWebPageInputSchema.parse({ ...input, observation_ms: 0 }), - ); - if (!inspected.ok) throw inspected.error; - expect(inspected.value.target.title).toBe(expected); - expect(JSON.stringify({ listed, inspected })).not.toContain( - "title-secret", - ); - }, - ); - - it("uses page-scoped discovery sockets as direct target transports", async () => { - const browser = await startFakeCdpBrowser({ - pageScopedVersionWebSocket: true, - omitTargetWebSocket: true, - additionalPageWithWebSocket: true, - additionalPageWithoutWebSocket: true, - }); - browsers.push(browser); - const provider = new CdpBrowserProvider(); - const listed = await provider.listTargets( - listBrowserTargetsInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - approved: true, - }), - ); - if (!listed.ok) throw listed.error; - expect( - listed.value.targets.items.map(({ target_id }) => target_id), - ).toEqual(["allowed-page", "allowed-page-with-socket"]); - expect(listed.value.limitations).toContain( - "1 otherwise allowed page target(s) lacked a validated direct CDP WebSocket and were excluded.", - ); - - const inspected = await provider.inspectPage( - inspectWebPageInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - approved: true, - observation_ms: 0, - }), - ); - if (!inspected.ok) throw inspected.error; - expect(inspected.value.target.target_id).toBe("allowed-page"); - expect(inspected.value.network.requests).toHaveLength(1); - - const second = await provider.inspectPage( - inspectWebPageInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page-with-socket", - approved: true, - observation_ms: 0, - }), - ); - if (!second.ok) throw second.error; - expect(second.value.target.target_id).toBe("allowed-page-with-socket"); - const methods = browser.commands.map(({ method }) => method); - expect(methods).not.toContain("Target.attachToTarget"); - expect(methods).not.toContain("Target.detachFromTarget"); - expect( - browser.commands.every(({ sessionId }) => sessionId === undefined), - ).toBe(true); - }); - - it("projects direct-session cancellation onto the requested operation", async () => { - const browser = await startFakeCdpBrowser({ - pageScopedVersionWebSocket: true, - omitTargetWebSocket: true, - }); - browsers.push(browser); - const controller = new AbortController(); - const result = await new CdpBrowserProvider().observeSession( - observeWebSessionInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - approved: true, - observation_ms: 1_000, - }), - { - signal: controller.signal, - progress: { - report(update) { - if (update.completed === 1) controller.abort(); - return Promise.resolve(); - }, - }, - }, - ); - - expect(result).toMatchObject({ - ok: false, - error: { - _tag: "AnalysisCancelledError", - operation: "observe_web_session", - }, - }); - const methods = browser.commands.map(({ method }) => method); - expect(methods).not.toContain("Target.attachToTarget"); - expect(methods).not.toContain("Target.detachFromTarget"); - expect(methods).not.toContain("Target.closeTarget"); - expect(methods).not.toContain("Browser.close"); - }); - - it("projects capture-window cancellation onto bundle analysis", async () => { - const browser = await startFakeCdpBrowser({ - pageScopedVersionWebSocket: true, - omitTargetWebSocket: true, - }); - browsers.push(browser); - const controller = new AbortController(); - const result = await new CdpBrowserProvider().analyzeBundle( - analyzeWebBundleInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - approved: true, - source_capture_approved: true, - observation_ms: 1_000, - }), - { - signal: controller.signal, - progress: { - report(update) { - if (update.completed === 2) controller.abort(); - return Promise.resolve(); - }, - }, - }, - ); - - expect(result).toMatchObject({ - ok: false, - error: { - _tag: "AnalysisCancelledError", - operation: "analyze_web_bundle", - }, - }); - }); - - it("rejects empty browser attachment session identifiers", async () => { - const browser = await startFakeCdpBrowser({ invalidAttachedSession: true }); - browsers.push(browser); - const result = await new CdpBrowserProvider().inspectPage( - inspectWebPageInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - approved: true, - observation_ms: 0, - }), - ); - expect(result).toMatchObject({ - ok: false, - error: { _tag: "BrowserObservationError", reason: "protocol_error" }, - }); - expect(browser.commands.map(({ method }) => method)).toEqual([ - "Target.attachToTarget", - ]); - }); - - it("captures bounded passive evidence without retaining sensitive values", async () => { - const browser = await startFakeCdpBrowser({ - binaryWebSocketEvent: true, - foreignSessionEvents: true, - unrelatedWorker: true, - }); - browsers.push(browser); - const provider = new CdpBrowserProvider(); - const result = await provider.inspectPage( - inspectWebPageInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - approved: true, - observation_ms: 0, - include_storage_keys: true, - include_storage_fingerprints: true, - }), - ); - - if (!result.ok) throw result.error; - expect(() => webPageInspectionSchema.parse(result.value)).not.toThrow(); - expect(result.value.frames).toHaveLength(1); - expect(result.value.dom.nodes).toHaveLength(2); - expect(result.value.dom.nodes[1]?.attribute_names).toEqual([ - "token", - "href", - "rel", - ]); - expect(result.value.accessibility).toMatchObject({ - text_capture: { - status: "not_approved", - retained_bytes: 0, - }, - nodes: [expect.objectContaining({ name: null, description: null })], - }); - expect(result.value.scripts.items).toHaveLength(1); - expect(result.value.scripts.items[0]?.script_key).toMatch( - /^scr_[a-f0-9]{64}$/u, - ); - expect(result.value.scripts.items[0]?.frame_id).toBe("frame-main"); - expect(result.value.scripts.items[0]?.source_map_url).toBe( - `${browser.allowedOrigin}/app.js.map?token=%5BREDACTED%5D`, - ); - expect(result.value.scripts.items[0]?.resource_reconciliation).toEqual({ - status: "exact", - resource_key: result.value.resources[0]?.resource_key, - }); - expect(result.value.scripts.items[0]?.source).toEqual({ - included: false, - reason: "source capture was not approved", - }); - expect(result.value.resources).toHaveLength(1); - expect(result.value.network.requests).toHaveLength(1); - expect(result.value.network.requests[0]?.initiator).toEqual({ - type: "script", - url: `${browser.allowedOrigin}/app.js?caller=%5BREDACTED%5D`, - line: 3, - column: 5, - }); - expect(result.value.network.websocket_events).toEqual([ - { - request_id: "websocket-1", - direction: "sent", - opcode: 1, - payload_bytes: Buffer.byteLength("websocket-secret"), - payload_shape: null, - }, - { - request_id: "websocket-1", - direction: "received", - opcode: 2, - payload_bytes: 3, - payload_shape: null, - }, - ]); - expect(result.value.console.events[0]?.argument_types).toEqual(["string"]); - expect(result.value.workers).toHaveLength(1); - expect(result.value.workers[0]).toMatchObject({ - opener_target_id: "allowed-page", - parent_frame_id: null, - }); - expect(result.value.metadata).toMatchObject({ - headers_allowlisted: true, - responses: [ - { - content_length: 321, - content_encoding: "br", - csp: { nonce_count: 1, hash_count: 1 }, - policies: { - coop: "same-origin", - coep: "require-corp", - permissions_policy_features: ["camera", "geolocation"], - }, - }, - ], - dom_urls: [ - { - attribute: "href", - url: `${browser.allowedOrigin}/agent?token=%5BREDACTED%5D`, - destination_scope: "approved", - }, - ], - agent_hints: expect.arrayContaining([ - expect.objectContaining({ - mechanism: "link_rel", - declaration: "mcp service-desc", - }), - expect.objectContaining({ - mechanism: "dom_link_rel", - declaration: "mcp", - }), - expect.objectContaining({ - mechanism: "response_header", - declaration: "x-model-context", - }), - ]), - }); - expect(result.value.storage).toEqual( - expect.objectContaining({ - local_storage_keys: ["public-key"], - session_storage_keys: ["public-key"], - indexed_db_names: ["app-db"], - cache_names: ["assets-v1"], - content_fingerprints: expect.arrayContaining([ - expect.objectContaining({ scope: "cookie", complete: true }), - expect.objectContaining({ scope: "local_storage", complete: true }), - expect.objectContaining({ scope: "session_storage", complete: true }), - expect.objectContaining({ - scope: "indexed_db_schema", - complete: true, - }), - expect.objectContaining({ - scope: "indexed_db_record", - complete: true, - }), - expect.objectContaining({ scope: "cache_entry", complete: true }), - ]), - fingerprint_algorithm: "sha256", - fingerprints_complete: true, - values_redacted: true, - }), - ); - const serialized = JSON.stringify(result.value); - for (const secret of [ - "page-secret", - "frame-secret", - "dom-secret", - "forbidden", - "resource-secret", - "script-secret", - "inline-secret", - "map-secret", - "network-secret", - "request-secret", - "request-body-secret", - "response-secret", - "response-body-secret", - "websocket-secret", - "websocket-url-secret", - "console-secret", - "unknown-origin-console-secret", - "unknown-console-value-secret", - "storage-secret", - "cookie-secret", - "indexed-db-secret", - "cache-body-secret", - "secret-id", - "dom-url-secret", - "link-secret", - "csp-secret", - "hash-secret", - "header-secret", - ]) - expect(serialized).not.toContain(secret); - const methods = browser.commands.map((command) => command.method); - expect(methods).toContain("Target.detachFromTarget"); - expect(methods).not.toContain("Browser.close"); - expect(methods).not.toContain("Target.closeTarget"); - expect(methods).not.toContain("Runtime.evaluate"); - expect(methods).not.toContain("Network.getResponseBody"); - }); - - it("captures only approved redacted console text and value-free payload shapes", async () => { - const browser = await startFakeCdpBrowser({ - sensitiveShapes: true, - binaryWebSocketEvent: true, - }); - browsers.push(browser); - const result = await new CdpBrowserProvider().inspectPage( - inspectWebPageInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - approved: true, - observation_ms: 0, - include_console_text: true, - console_text_approved: true, - include_json_body_shapes: true, - json_body_schema_approved: true, - include_websocket_shapes: true, - websocket_shape_approved: true, - }), - ); - - if (!result.ok) throw result.error; - expect(result.value.console.events[0]?.text_capture).toEqual({ - status: "included", - values: [ - { argument_index: 0, type: "string", text: "authorization=[REDACTED]" }, - { argument_index: 1, type: "number", text: "42" }, - ], - retained_bytes: 26, - truncated_values: 0, - }); - expect(result.value.network.requests[0]?.body_shapes).toMatchObject({ - status: "included", - request: { - root_type: "object", - properties: expect.arrayContaining([ - expect.objectContaining({ path: "/token", types: ["string"] }), - expect.objectContaining({ - path: "/filters/active", - types: ["boolean"], - }), - ]), - }, - response: { - root_type: "object", - properties: expect.arrayContaining([ - expect.objectContaining({ - path: "/result/token", - types: ["string"], - }), - expect.objectContaining({ path: "/items/*/id", types: ["number"] }), - ]), - }, - }); - expect(result.value.network.websocket_events).toEqual([ - expect.objectContaining({ - opcode: 1, - payload_shape: expect.objectContaining({ - format: "json", - json_shape: expect.objectContaining({ - properties: expect.arrayContaining([ - expect.objectContaining({ path: "/token", types: ["string"] }), - ]), - }), - }), - }), - expect.objectContaining({ - opcode: 2, - payload_shape: { - format: "binary", - json_shape: null, - truncated: false, - }, - }), - ]); - const serialized = JSON.stringify(result.value); - for (const secret of [ - "request-body-secret", - "response-body-secret", - "websocket-secret", - "console-secret", - "object-secret", - ]) - expect(serialized).not.toContain(secret); - const methods = browser.commands.map(({ method }) => method); - expect(methods).toContain("Network.getResponseBody"); - expect(methods).not.toContain("Runtime.getProperties"); - expect(methods).not.toContain("Runtime.callFunctionOn"); - }); - - it("reports independent sensitive-capture truncation at aggregate byte limits", async () => { - const browser = await startFakeCdpBrowser({ sensitiveShapes: true }); - browsers.push(browser); - const result = await new CdpBrowserProvider().inspectPage( - inspectWebPageInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - approved: true, - observation_ms: 0, - include_console_text: true, - console_text_approved: true, - include_json_body_shapes: true, - json_body_schema_approved: true, - include_websocket_shapes: true, - websocket_shape_approved: true, - limits: { - max_console_text_field_bytes: 5, - max_total_console_text_bytes: 5, - max_json_body_bytes: 10, - max_total_json_body_bytes: 10, - max_websocket_shape_bytes: 5, - max_total_websocket_shape_bytes: 5, - }, - }), - ); - - if (!result.ok) throw result.error; - expect(result.value.console.events[0]?.text_capture).toEqual({ - status: "truncated", - values: [{ argument_index: 0, type: "string", text: "autho" }], - retained_bytes: 5, - truncated_values: 2, - }); - expect(result.value.network.requests[0]?.body_shapes.status).toBe( - "truncated", - ); - expect(result.value.network.websocket_events[0]?.payload_shape).toEqual({ - format: "text", - json_shape: null, - truncated: true, - }); - expect(result.value.completeness.truncated_sections).toEqual( - expect.arrayContaining([ - "console_text", - "json_body_shapes", - "websocket_shapes", - ]), - ); - }); - - it("fails closed on malformed approved response and binary payload encodings", async () => { - const browser = await startFakeCdpBrowser({ - invalidResponseBodyBase64: true, - binaryWebSocketEvent: true, - invalidBinaryWebSocketEvent: true, - }); - browsers.push(browser); - const result = await new CdpBrowserProvider().inspectPage( - inspectWebPageInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - approved: true, - observation_ms: 0, - include_json_body_shapes: true, - json_body_schema_approved: true, - include_websocket_shapes: true, - websocket_shape_approved: true, - }), - ); - - if (!result.ok) throw result.error; - expect(result.value.network.requests[0]?.body_shapes).toMatchObject({ - status: "partial", - request: expect.any(Object), - response: null, - }); - expect(result.value.network.websocket_events[1]).toMatchObject({ - opcode: 2, - payload_bytes: 0, - payload_shape: { format: "binary", json_shape: null }, - }); - expect(result.value.completeness.unavailable_sections).toEqual( - expect.arrayContaining(["json_body_shapes", "websocket_frames"]), - ); - }); - - it("captures bounded accessibility text only after independent approval", async () => { - const browser = await startFakeCdpBrowser(); - browsers.push(browser); - const result = await new CdpBrowserProvider().inspectPage( - inspectWebPageInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - approved: true, - observation_ms: 0, - include_accessibility_text: true, - limits: { - max_ax_text_field_bytes: 6, - max_total_ax_text_bytes: 6, - }, - }), - ); - - if (!result.ok) throw result.error; - expect(result.value.accessibility).toMatchObject({ - text_capture: { - status: "truncated", - retained_bytes: 6, - truncated_fields: 2, - }, - nodes: [expect.objectContaining({ name: "Submit" })], - }); - expect(result.value.completeness.truncated_sections).toContain( - "accessibility", - ); - }); - - it("drops network evidence when a request redirects outside the approved origin", async () => { - const browser = await startFakeCdpBrowser({ - redirectToDisallowedOrigin: true, - }); - browsers.push(browser); - const result = await new CdpBrowserProvider().inspectPage( - inspectWebPageInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - approved: true, - observation_ms: 0, - }), - ); - - if (!result.ok) throw result.error; - expect(result.value.network.requests).toEqual([]); - }); - - it("includes allowed script source only after explicit approval", async () => { - const browser = await startFakeCdpBrowser(); - browsers.push(browser); - const provider = new CdpBrowserProvider(); - const result = await provider.inspectPage( - inspectWebPageInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - approved: true, - observation_ms: 0, - include_script_sources: true, - }), - ); - - if (!result.ok) throw result.error; - expect(result.value.scripts.items[0]?.source).toEqual( - expect.objectContaining({ - included: true, - artifact: expect.objectContaining({ - text: "export const observed = 'source-secret';", - bytes: 40, - uri: expect.stringMatching(/^rea:\/\/web-content\/sha256\//u), - }), - }), - ); - expect(browser.commands.map((command) => command.method)).toContain( - "Debugger.getScriptSource", - ); - }); - - it("fetches and validates source maps only after separate approval", async () => { - const browser = await startFakeCdpBrowser({ - sourceMapBody: JSON.stringify({ - version: 3, - names: [], - sources: ["src/main.ts"], - sourcesContent: ["export const original = true;"], - mappings: "AAAA", - }), - }); - browsers.push(browser); - const result = await new CdpBrowserProvider().analyzeBundle( - analyzeWebBundleInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - approved: true, - source_capture_approved: true, - observation_ms: 0, - fetch_source_maps: true, - source_map_fetch_approved: true, - }), - ); - - if (!result.ok) throw result.error; - expect(result.value.observations.source_maps).toMatchObject({ - status: "included", - items: [ - { - status: "included", - original_sources: [ - expect.objectContaining({ - source: `${browser.allowedOrigin}/src/main.ts`, - }), - ], - mappings: [expect.any(Object)], - }, - ], - }); - const sourceMapRequest = browser.httpRequests.find(({ url }) => - url.startsWith("/app.js.map"), - ); - expect(sourceMapRequest).toEqual({ - url: "/app.js.map?token=map-secret", - authorization: undefined, - cookie: undefined, - referer: undefined, - }); - }); - - it("observes user-driven reload, SPA, redirect, failure, and lifecycle events", async () => { - const browser = await startFakeCdpBrowser({ - sessionTimeline: "same_origin", - }); - browsers.push(browser); - const result = await new CdpBrowserProvider().observeSession( - observeWebSessionInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - approved: true, - observation_ms: 20, - }), - ); - - if (!result.ok) throw result.error; - expect(result.value.window.end_reason).toBe("window_elapsed"); - expect(result.value.timeline.map(({ type }) => type)).toEqual([ - "navigation_requested", - "same_origin_reload", - "same_document_navigation", - "redirect", - "load_failed", - "lifecycle", - ]); - expect(result.value.timeline).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: "same_document_navigation", - url: `${browser.allowedOrigin}/reloaded?token=%5BREDACTED%5D`, - }), - expect.objectContaining({ - type: "load_failed", - detail: "net::ERR_CONNECTION_REFUSED", - }), - ]), - ); - expect(result.value.completeness.status).toBe("attach_limited"); - expect(JSON.stringify(result.value)).not.toContain("session-secret"); - expect(JSON.stringify(result.value)).not.toContain("redirect-secret"); - expect(browser.commands.map(({ method }) => method)).toContain( - "Target.detachFromTarget", - ); - }); - - it("ends a browser session when its flat target session detaches", async () => { - const browser = await startFakeCdpBrowser({ - sessionTimeline: "target_detached", - }); - browsers.push(browser); - const result = await new CdpBrowserProvider().observeSession( - observeWebSessionInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - approved: true, - observation_ms: 1_000, - }), - ); - - if (!result.ok) throw result.error; - expect(result.value.window.end_reason).toBe("target_terminated"); - expect(result.value.timeline.at(-1)).toMatchObject({ - type: "target_terminated", - detail: "target_terminated", - }); - }); - - it("ends a direct page session when its transport disconnects", async () => { - const browser = await startFakeCdpBrowser({ - pageScopedVersionWebSocket: true, - omitTargetWebSocket: true, - closeAfterMethod: "Page.setLifecycleEventsEnabled", - }); - browsers.push(browser); - const result = await new CdpBrowserProvider().observeSession( - observeWebSessionInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - approved: true, - observation_ms: 1_000, - }), - ); - - if (!result.ok) throw result.error; - expect(result.value.window.end_reason).toBe("target_terminated"); - expect(result.value.timeline.at(-1)).toMatchObject({ - type: "target_terminated", - detail: "target_terminated", - }); - const methods = browser.commands.map(({ method }) => method); - expect(methods).not.toContain("Target.attachToTarget"); - expect(methods).not.toContain("Target.detachFromTarget"); - expect(methods).not.toContain("Target.closeTarget"); - }); - - it("discovers untrusted WebMCP declarations without registering or invoking them", async () => { - const browser = await startFakeCdpBrowser({ webMcpTools: true }); - browsers.push(browser); - const result = await new CdpBrowserProvider().discoverWebMcpTools( - discoverWebMcpToolsInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - approved: true, - observation_ms: 0, - }), - ); - - if (!result.ok) throw result.error; - expect(result.value).toMatchObject({ - status: "available", - tools: { - total: 1, - items: [ - { - name: "search_orders", - description: "Search orders; authorization=[REDACTED]", - declaration_kind: "declarative", - owner_origin: browser.allowedOrigin, - annotations: { - read_only: true, - untrusted_content: true, - autosubmit: false, - }, - trust: "page-declared-untrusted", - registration_source: { - url: `${browser.allowedOrigin}/app.js?token=%5BREDACTED%5D`, - line: 12, - column: 4, - }, - }, - ], - }, - }); - const serialized = JSON.stringify(result.value); - for (const secret of [ - "tool-secret", - "schema-secret", - "tool-source-secret", - "private-tool-secret", - ]) - expect(serialized).not.toContain(secret); - const methods = browser.commands.map(({ method }) => method); - expect(methods).toContain("WebMCP.enable"); - expect(methods).not.toContain("WebMCP.invokeTool"); - expect(methods).not.toContain("Runtime.evaluate"); - }); - - it("reports WebMCP unavailable when the experimental domain is absent", async () => { - const browser = await startFakeCdpBrowser({ - unsupportedMethods: ["WebMCP.enable"], - }); - browsers.push(browser); - const result = await new CdpBrowserProvider().discoverWebMcpTools( - discoverWebMcpToolsInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - approved: true, - observation_ms: 0, - }), - ); - - if (!result.ok) throw result.error; - expect(result.value.status).toBe("unavailable"); - expect(result.value.tools.items).toEqual([]); - expect(result.value.completeness.unavailable_sections).toContain( - "webmcp_tools", - ); - }); - - it("rejects WebMCP evidence when the page leaves its approved origin", async () => { - const browser = await startFakeCdpBrowser({ - webMcpTools: true, - frameUrlAfterFirstRead: "https://private.example.test/tools", - }); - browsers.push(browser); - const result = await new CdpBrowserProvider().discoverWebMcpTools( - discoverWebMcpToolsInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - approved: true, - observation_ms: 0, - }), - ); - - expect(result).toMatchObject({ - ok: false, - error: { - _tag: "BrowserObservationError", - operation: "discover_webmcp_tools", - reason: "target_not_allowed", - }, - }); - expect(browser.commands.map(({ method }) => method)).not.toContain( - "WebMCP.invokeTool", - ); - }); - - it("bounds WebMCP registration replay and reports dropped declarations", async () => { - const browser = await startFakeCdpBrowser({ - webMcpTools: true, - extraCollections: true, - }); - browsers.push(browser); - const result = await new CdpBrowserProvider().discoverWebMcpTools( - discoverWebMcpToolsInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - approved: true, - observation_ms: 0, - max_tools: 1, - }), - ); - - if (!result.ok) throw result.error; - expect(result.value.tools).toMatchObject({ - total: 2, - items: [expect.any(Object)], - }); - expect(result.value.completeness).toMatchObject({ - status: "truncated", - dropped_events: { webmcp_tools: 1 }, - }); - }); - - it("removes WebMCP declarations after their child frame leaves scope", async () => { - const browser = await startFakeCdpBrowser({ - webMcpTools: true, - extraCollections: true, - webMcpChildLeavesScope: true, - }); - browsers.push(browser); - const result = await new CdpBrowserProvider().discoverWebMcpTools( - discoverWebMcpToolsInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - approved: true, - observation_ms: 0, - }), - ); - - if (!result.ok) throw result.error; - const names = result.value.tools.items.map(({ name }) => name); - expect(names).toEqual( - expect.arrayContaining(["search_orders", "update_order"]), - ); - expect(names).not.toEqual( - expect.arrayContaining(["child_tool", "escaped_child_tool"]), - ); - expect(result.value.completeness.policy_filtered_sections).toContain( - "webmcp_tools", - ); - expect(JSON.stringify(result.value)).not.toContain( - "cross-origin-child-secret", - ); - }); - - it("captures an explicitly approved content-addressed viewport screenshot", async () => { - const browser = await startFakeCdpBrowser(); - browsers.push(browser); - const result = await new CdpBrowserProvider().captureScreenshot( - captureWebScreenshotInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - approved: true, - screenshot_approved: true, - }), - ); - - if (!result.ok) throw result.error; - expect(result.value).toMatchObject({ - viewport: { width: 1, height: 1 }, - artifact: { - uri: expect.stringMatching(/^rea:\/\/web-screenshot\/sha256\//u), - bytes: 70, - media_type: "image/png", - }, - }); - expect(browser.commands.map(({ method }) => method)).toContain( - "Page.captureScreenshot", - ); - }); - - it("discards screenshot pixels when the main frame navigates during capture", async () => { - const browser = await startFakeCdpBrowser({ - navigateDuringScreenshotUrl: "https://private.example.test/screenshot", - }); - browsers.push(browser); - const result = await new CdpBrowserProvider().captureScreenshot( - captureWebScreenshotInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - approved: true, - screenshot_approved: true, - }), - ); - - expect(result).toMatchObject({ - ok: false, - error: { - _tag: "BrowserObservationError", - operation: "capture_web_screenshot", - reason: "target_not_allowed", - }, - }); - }); - - it("ends immediately when a redirect leaves policy without exposing its URL", async () => { - const browser = await startFakeCdpBrowser({ - sessionTimeline: "outside_policy", - }); - browsers.push(browser); - const result = await new CdpBrowserProvider().observeSession( - observeWebSessionInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - approved: true, - observation_ms: 1_000, - }), - ); - - if (!result.ok) throw result.error; - expect(result.value.window.end_reason).toBe("target_left_scope"); - expect(result.value.timeline.at(-1)).toMatchObject({ - type: "redirect", - url: null, - destination_scope: "outside_policy", - }); - expect(result.value.completeness).toMatchObject({ - status: "policy_filtered", - policy_filtered_sections: ["timeline"], - excluded: [ - { - section: "timeline", - reason: "out_of_target_scope", - count: 1, - }, - ], - }); - expect(JSON.stringify(result.value)).not.toContain("private.example.test"); - }); - - it("fails closed when the final session frame leaves policy without an event", async () => { - const browser = await startFakeCdpBrowser({ - frameUrlAfterFirstRead: "https://private.example.test/final", - }); - browsers.push(browser); - const result = await new CdpBrowserProvider().observeSession( - observeWebSessionInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - approved: true, - observation_ms: 1, - }), - ); - - if (!result.ok) throw result.error; - expect(result.value.window.end_reason).toBe("target_left_scope"); - expect(result.value.target.final_url).toBeNull(); - expect(result.value.completeness).toMatchObject({ - status: "policy_filtered", - policy_filtered_sections: ["timeline"], - }); - expect(JSON.stringify(result.value)).not.toContain("private.example.test"); - }); - - it("rejects missing and disallowed page targets before attaching", async () => { - const browser = await startFakeCdpBrowser(); - browsers.push(browser); - const provider = new CdpBrowserProvider(); - for (const targetId of ["missing", "disallowed-page"]) { - const result = await provider.inspectPage( - inspectWebPageInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: targetId, - approved: true, - observation_ms: 0, - }), - ); - expect(result.ok).toBe(false); - if (!result.ok) - expect(result.error).toMatchObject({ - _tag: "BrowserObservationError", - reason: - targetId === "missing" ? "target_not_found" : "target_not_allowed", - }); - } - expect(browser.commands).toHaveLength(0); - }); - - it("returns typed errors for malformed discovery and disconnects", async () => { - const malformed = await startFakeCdpBrowser({ malformedDiscovery: true }); - browsers.push(malformed); - const malformedResult = await new CdpBrowserProvider().listTargets( - listBrowserTargetsInputSchema.parse({ - cdp_endpoint: malformed.endpoint, - allowed_origins: [malformed.allowedOrigin], - approved: true, - }), - ); - expect(malformedResult).toMatchObject({ - ok: false, - error: { - _tag: "BrowserObservationError", - reason: "invalid_endpoint_response", - }, - }); - - const disconnecting = await startFakeCdpBrowser({ - closeOnMethod: "Page.getFrameTree", - }); - browsers.push(disconnecting); - const disconnectedResult = await new CdpBrowserProvider().inspectPage( - inspectWebPageInputSchema.parse({ - cdp_endpoint: disconnecting.endpoint, - allowed_origins: [disconnecting.allowedOrigin], - target_id: "allowed-page", - approved: true, - observation_ms: 0, - }), - ); - expect(disconnectedResult).toMatchObject({ - ok: false, - error: { _tag: "BrowserObservationError", reason: "disconnected" }, - }); - }); - - it("reports configured truncation without over-reading DOM or script source", async () => { - const browser = await startFakeCdpBrowser(); - browsers.push(browser); - const result = await new CdpBrowserProvider().inspectPage( - inspectWebPageInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - approved: true, - observation_ms: 0, - include_script_sources: true, - limits: { - max_frames: 1, - max_dom_nodes: 1, - max_ax_nodes: 1, - max_scripts: 1, - max_resources: 1, - max_workers: 1, - max_storage_keys: 1, - max_script_source_bytes: 10, - max_total_script_source_bytes: 10, - max_network_events: 1, - max_console_events: 1, - max_websocket_events: 1, - }, - }), - ); - if (!result.ok) throw result.error; - expect(result.value.completeness).toMatchObject({ - status: "truncated", - truncated_sections: ["dom", "script_sources"], - }); - expect(result.value.dom).toMatchObject({ total_nodes: 2 }); - expect(result.value.dom.nodes).toHaveLength(1); - expect(result.value.scripts.items[0]?.source).toMatchObject({ - included: false, - reason: "declared script length exceeds per-script limit", - }); - expect(browser.commands.map(({ method }) => method)).not.toContain( - "Debugger.getScriptSource", - ); - }); - - it("bounds frames, resources, workers, accessibility, and storage inventories", async () => { - const browser = await startFakeCdpBrowser({ extraCollections: true }); - browsers.push(browser); - const result = await new CdpBrowserProvider().inspectPage( - inspectWebPageInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - approved: true, - observation_ms: 0, - include_storage_keys: true, - limits: { - max_frames: 1, - max_dom_nodes: 2, - max_ax_nodes: 1, - max_scripts: 1, - max_resources: 1, - max_workers: 1, - max_storage_keys: 1, - max_script_source_bytes: 10, - max_total_script_source_bytes: 10, - max_network_events: 1, - max_console_events: 1, - max_websocket_events: 1, - }, - }), - ); - if (!result.ok) throw result.error; - expect(result.value.completeness).toMatchObject({ - status: "truncated", - truncated_sections: [ - "accessibility", - "frames", - "resources", - "storage_keys", - "workers", - ], - }); - expect(result.value.frames).toHaveLength(1); - expect(result.value.resources).toHaveLength(1); - expect(result.value.workers).toHaveLength(1); - expect(result.value.accessibility).toMatchObject({ total_nodes: 2 }); - expect(result.value.accessibility.nodes).toHaveLength(1); - expect(result.value.storage.local_storage_keys).toHaveLength(1); - expect(result.value.storage.session_storage_keys).toHaveLength(1); - expect(result.value.storage.indexed_db_names).toHaveLength(1); - expect(result.value.storage.cache_names).toHaveLength(1); - expect(result.value.storage.content_fingerprints).toEqual([]); - }); - - it("degrades optional domains but propagates protocol and payload failures", async () => { - const optional = await startFakeCdpBrowser({ - unsupportedMethods: ["Accessibility.getFullAXTree"], - }); - browsers.push(optional); - const degraded = await new CdpBrowserProvider().inspectPage( - inspectWebPageInputSchema.parse({ - cdp_endpoint: optional.endpoint, - allowed_origins: [optional.allowedOrigin], - target_id: "allowed-page", - approved: true, - observation_ms: 0, - }), - ); - if (!degraded.ok) throw degraded.error; - expect(degraded.value.accessibility.nodes).toEqual([]); - expect(degraded.value.accessibility.text_capture.status).toBe( - "unavailable", - ); - expect(degraded.value.completeness.unavailable_sections).toContain( - "accessibility", - ); - expect(degraded.value.limitations).toContain( - "Accessibility.getFullAXTree was unavailable from this browser target.", - ); - - for (const [options, reason] of [ - [{ oversizedDiscovery: true }, "payload_limit"], - [{ invalidBrowserWebSocket: true }, "invalid_endpoint_response"], - ] as const) { - const failed = await startFakeCdpBrowser(options); - browsers.push(failed); - const result = await new CdpBrowserProvider().listTargets( - listBrowserTargetsInputSchema.parse({ - cdp_endpoint: failed.endpoint, - allowed_origins: [failed.allowedOrigin], - approved: true, - }), - ); - expect(result.ok).toBe(false); - if (!result.ok) - expect(result.error).toMatchObject({ - _tag: "BrowserObservationError", - reason, - }); - } - - const malformed = await startFakeCdpBrowser({ - malformedMessageOnMethod: "Page.getFrameTree", - }); - browsers.push(malformed); - const protocol = await new CdpBrowserProvider().inspectPage( - inspectWebPageInputSchema.parse({ - cdp_endpoint: malformed.endpoint, - allowed_origins: [malformed.allowedOrigin], - target_id: "allowed-page", - approved: true, - observation_ms: 0, - }), - ); - expect(protocol).toMatchObject({ - ok: false, - error: { _tag: "BrowserObservationError", reason: "protocol_error" }, - }); - - const malformedEvent = await startFakeCdpBrowser({ - malformedEventOnMethod: "Debugger.enable", - }); - browsers.push(malformedEvent); - const eventProtocol = await new CdpBrowserProvider().inspectPage( - inspectWebPageInputSchema.parse({ - cdp_endpoint: malformedEvent.endpoint, - allowed_origins: [malformedEvent.allowedOrigin], - target_id: "allowed-page", - approved: true, - observation_ms: 0, - }), - ); - expect(eventProtocol).toMatchObject({ - ok: false, - error: { _tag: "BrowserObservationError", reason: "protocol_error" }, - }); - - const malformedEventShape = await startFakeCdpBrowser({ - malformedEventShapeOnMethod: "Debugger.enable", - }); - browsers.push(malformedEventShape); - const eventShapeProtocol = await new CdpBrowserProvider().inspectPage( - inspectWebPageInputSchema.parse({ - cdp_endpoint: malformedEventShape.endpoint, - allowed_origins: [malformedEventShape.allowedOrigin], - target_id: "allowed-page", - approved: true, - observation_ms: 0, - }), - ); - expect(eventShapeProtocol).toMatchObject({ - ok: false, - error: { _tag: "BrowserObservationError", reason: "protocol_error" }, - }); - }); - - it("cancels observation and still detaches without closing the page", async () => { - const browser = await startFakeCdpBrowser(); - browsers.push(browser); - const controller = new AbortController(); - const pending = new CdpBrowserProvider().inspectPage( - inspectWebPageInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - approved: true, - observation_ms: 1_000, - }), - { signal: controller.signal }, - ); - // Wait until the attach response has been consumed and capture has begun. - // Seeing the attach request alone races its response under worker load. - await waitForCommand(browser, "Page.getFrameTree"); - controller.abort(); - const result = await pending; - expect(result).toMatchObject({ - ok: false, - error: { - _tag: "AnalysisCancelledError", - operation: "inspect_web_page", - }, - }); - const methods = browser.commands.map(({ method }) => method); - expect(methods).toContain("Target.detachFromTarget"); - expect(methods).not.toContain("Target.closeTarget"); - expect(methods).not.toContain("Browser.close"); - }); - - it("waits for navigation commit and rechecks the attached main-frame origin", async () => { - const transitioning = await startFakeCdpBrowser({ - transitionalFrameReads: 2, - }); - browsers.push(transitioning); - const input = (browser: FakeCdpBrowser) => - inspectWebPageInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - approved: true, - observation_ms: 0, - }); - const committed = await new CdpBrowserProvider().inspectPage( - input(transitioning), - ); - if (!committed.ok) throw committed.error; - expect(committed.value.target.origin).toBe(transitioning.allowedOrigin); - expect( - transitioning.commands.filter( - ({ method }) => method === "Page.getFrameTree", - ), - ).toHaveLength(5); - - const navigated = await startFakeCdpBrowser({ - attachedFrameUrl: "https://unapproved.example.test/after-attach", - }); - browsers.push(navigated); - const denied = await new CdpBrowserProvider().inspectPage(input(navigated)); - expect(denied).toMatchObject({ - ok: false, - error: { _tag: "BrowserObservationError", reason: "target_not_allowed" }, - }); - const methods = navigated.commands.map(({ method }) => method); - expect(methods).toContain("Target.detachFromTarget"); - expect(methods).not.toContain("DOMSnapshot.captureSnapshot"); - expect(methods).not.toContain("Storage.getUsageAndQuota"); - - const duringObservation = await startFakeCdpBrowser({ - navigateDuringObservationUrl: - "https://unapproved.example.test/during-observation", - }); - browsers.push(duringObservation); - const interrupted = await new CdpBrowserProvider().inspectPage( - input(duringObservation), - ); - expect(interrupted).toMatchObject({ - ok: false, - error: { _tag: "BrowserObservationError", reason: "target_not_allowed" }, - }); - expect( - duringObservation.commands.map(({ method }) => method), - ).not.toContain("Accessibility.getFullAXTree"); - - const captureOptions: { navigateDuringCaptureUrl?: string } = {}; - const duringCapture = await startFakeCdpBrowser(captureOptions); - captureOptions.navigateDuringCaptureUrl = `${duringCapture.allowedOrigin}/changed`; - browsers.push(duringCapture); - const discarded = await new CdpBrowserProvider().inspectPage( - input(duringCapture), - ); - expect(discarded).toMatchObject({ - ok: false, - error: { _tag: "BrowserObservationError", reason: "target_changed" }, - }); - expect(duringCapture.commands.map(({ method }) => method)).toContain( - "Target.detachFromTarget", - ); - - const crossOriginCapture = await startFakeCdpBrowser({ - navigateDuringCaptureUrl: - "https://unapproved.example.test/during-capture", - }); - browsers.push(crossOriginCapture); - const rejectedCapture = await new CdpBrowserProvider().inspectPage( - input(crossOriginCapture), - ); - expect(rejectedCapture).toMatchObject({ - ok: false, - error: { _tag: "BrowserObservationError", reason: "target_not_allowed" }, - }); - }); -}); - -const waitForCommand = async ( - browser: FakeCdpBrowser, - method: string, -): Promise => { - for (let attempt = 0; attempt < 200; attempt += 1) { - if (browser.commands.some((command) => command.method === method)) return; - await new Promise((resolve) => setTimeout(resolve, 5)); - } - throw new Error(`Timed out waiting for fake CDP command ${method}`); -}; diff --git a/tests/analysisContextQueries.test.ts b/tests/composition/analysis-sessions/analysisContextQueries.test.ts similarity index 94% rename from tests/analysisContextQueries.test.ts rename to tests/composition/analysis-sessions/analysisContextQueries.test.ts index 58a08cde7..9caf35699 100644 --- a/tests/analysisContextQueries.test.ts +++ b/tests/composition/analysis-sessions/analysisContextQueries.test.ts @@ -3,20 +3,20 @@ import { describe, expect, it } from "vitest"; import { getNavigationContext, inspectAddressContext, -} from "../src/application/AnalysisContextQueries.js"; +} from "../../../src/application/AnalysisContextQueries.js"; import { createAnalysisExecution, type AnalysisOperationPort, -} from "../src/application/AnalysisProvider.js"; +} from "../../../src/application/AnalysisProvider.js"; import { AnalysisCancelledError, AnalysisProtocolError, -} from "../src/domain/errors.js"; -import { err, ok } from "../src/domain/result.js"; +} from "../../../src/domain/errors.js"; +import { err, ok } from "../../../src/domain/result.js"; const provider = { id: "fixture", name: "Fixture", version: "1" }; -describe("analysis context queries", () => { +describe("analysis context queries: navigation", () => { it("aggregates volatile navigation and treats a missing procedure as null", async () => { const calls: { operation: string; @@ -89,7 +89,9 @@ describe("analysis context queries", () => { ).resolves.toEqual(err(failure)); expect(calls).toEqual(["current_address", "resolve_containing_procedure"]); }); +}); +describe("analysis context queries: address facets", () => { it("keeps unsupported address facets local and filters bookmarks", async () => { const bookmarkInputs: Readonly>[] = []; const analysis: AnalysisOperationPort = { diff --git a/tests/analysisProfile.test.ts b/tests/composition/analysis-sessions/analysisProfile.test.ts similarity index 97% rename from tests/analysisProfile.test.ts rename to tests/composition/analysis-sessions/analysisProfile.test.ts index 3c514a63c..0ac9eb7e1 100644 --- a/tests/analysisProfile.test.ts +++ b/tests/composition/analysis-sessions/analysisProfile.test.ts @@ -4,7 +4,7 @@ import { analysisProfileSchema, analysisProfilesEqual, createAnalysisProfile, -} from "../src/domain/analysisProfile.js"; +} from "../../../src/domain/analysisProfile.js"; const PROVIDER = { id: "fixture", diff --git a/tests/analysisProviderRegistry.test.ts b/tests/composition/analysis-sessions/analysisProviderRegistry.test.ts similarity index 95% rename from tests/analysisProviderRegistry.test.ts rename to tests/composition/analysis-sessions/analysisProviderRegistry.test.ts index b7af93ee7..31d2518f7 100644 --- a/tests/analysisProviderRegistry.test.ts +++ b/tests/composition/analysis-sessions/analysisProviderRegistry.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { AnalysisProviderRegistry, type AnalysisProviderBinding, -} from "../src/application/AnalysisProviderRegistry.js"; +} from "../../../src/application/AnalysisProviderRegistry.js"; import { createAnalysisExecution, type AnalysisClient, @@ -12,15 +12,15 @@ import { type ProviderAvailability, type ProviderIdentity, type ProviderTargetSupport, -} from "../src/application/AnalysisProvider.js"; -import { createAnalysisProfile } from "../src/domain/analysisProfile.js"; -import type { BinaryTarget } from "../src/domain/binaryTarget.js"; +} from "../../../src/application/AnalysisProvider.js"; +import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; +import type { BinaryTarget } from "../../../src/domain/binaryTarget.js"; import { ProviderAdapterError, ProviderSelectionError, projectAnalysisError, -} from "../src/domain/errors.js"; -import { err, ok } from "../src/domain/result.js"; +} from "../../../src/domain/errors.js"; +import { err, ok } from "../../../src/domain/result.js"; const DATABASE_TARGET: BinaryTarget = { path: "/tmp/fixture.hop", @@ -35,7 +35,7 @@ const ARTIFACT_TARGET: BinaryTarget = { format: "asar", }; -describe("analysis provider registry", () => { +describe("analysis provider registry: discovery and selection", () => { it("admits overlapping operations, sorts discovery, and rejects duplicate IDs", () => { const alpha = candidate("alpha"); const beta = candidate("beta"); @@ -160,7 +160,9 @@ describe("analysis provider registry", () => { expect(alpha.created).toEqual([]); expect(beta.created).toEqual([]); }); +}); +describe("analysis provider registry: binding failures and cancellation", () => { it("returns actionable unknown, unavailable, and unsupported failures", async () => { const unavailable = candidate("alpha", { available: false }); const registry = new AnalysisProviderRegistry([unavailable.provider]); @@ -200,7 +202,9 @@ describe("analysis provider registry", () => { !unsupported.ok && unsupported.error instanceof ProviderSelectionError ) { - expect(unsupported.error).toMatchObject({ reason: "target_unsupported" }); + expect(unsupported.error).toMatchObject({ + reason: "target_unsupported", + }); expect(unsupported.error.rejections.map(({ code }) => code)).toEqual([ "runtime_missing", "target_kind_unsupported", diff --git a/tests/analysisSnapshot.test.ts b/tests/composition/analysis-sessions/analysisSnapshot.test.ts similarity index 88% rename from tests/analysisSnapshot.test.ts rename to tests/composition/analysis-sessions/analysisSnapshot.test.ts index a3a1187e6..48484338d 100644 --- a/tests/analysisSnapshot.test.ts +++ b/tests/composition/analysis-sessions/analysisSnapshot.test.ts @@ -3,15 +3,15 @@ import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { createAnalysisExecution } from "../src/application/AnalysisProvider.js"; -import { AnalysisSnapshotCache } from "../src/application/AnalysisSnapshotCache.js"; +import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js"; +import { AnalysisSnapshotCache } from "../../../src/application/AnalysisSnapshotCache.js"; import { readAnalysisSnapshot, writeAnalysisSnapshot, -} from "../src/application/AnalysisSnapshotFiles.js"; -import { createAnalysisProfile } from "../src/domain/analysisProfile.js"; +} from "../../../src/application/AnalysisSnapshotFiles.js"; +import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; import { createAnalysisSnapshotEntry, parseAnalysisSnapshot, @@ -19,10 +19,10 @@ import { snapshotEvidenceForQuery, snapshotTarget, type AnalysisSnapshot, -} from "../src/domain/analysisSnapshot.js"; -import type { BinaryTarget } from "../src/domain/binaryTarget.js"; -import { createEvidence } from "../src/domain/evidence.js"; -import { createEvidenceBundle } from "../src/domain/evidenceBundle.js"; +} from "../../../src/domain/analysisSnapshot.js"; +import type { BinaryTarget } from "../../../src/domain/binaryTarget.js"; +import { createEvidence } from "../../../src/domain/evidence.js"; +import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; let directory: string | undefined; @@ -45,7 +45,7 @@ afterEach(async () => { directory = undefined; }); -describe("analysis snapshots", () => { +describe("analysis snapshots: partitioning", () => { it("returns detached cache data only from the exact provider/profile partition", () => { const cache = new AnalysisSnapshotCache(); const execution = createAnalysisExecution("main", PROVIDER, { @@ -143,7 +143,9 @@ describe("analysis snapshots", () => { }), ).toBeUndefined(); }); +}); +describe("analysis snapshots: persistence", () => { it("writes canonical private v2 JSON and rejects changed query identity", async () => { directory = await createTestTempDirectory("rea-snapshot-"); const path = join(directory, "analysis.json"); diff --git a/tests/androidApplication.test.ts b/tests/composition/analysis-sessions/androidApplication.test.ts similarity index 88% rename from tests/androidApplication.test.ts rename to tests/composition/analysis-sessions/androidApplication.test.ts index 077395318..78eda59e7 100644 --- a/tests/androidApplication.test.ts +++ b/tests/composition/analysis-sessions/androidApplication.test.ts @@ -9,12 +9,12 @@ import { } from "@zip.js/zip.js"; import { describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { projectAndroidApplicationEvidence } from "../src/application/AndroidApplicationService.js"; -import { runProviderAnalysis } from "../src/application/DirectAnalysis.js"; -import { androidApplicationProjectionResultSchema } from "../src/domain/androidApplication.js"; -import { parseEvidence } from "../src/domain/evidence.js"; +import { projectAndroidApplicationEvidence } from "../../../src/application/AndroidApplicationService.js"; +import { runProviderAnalysis } from "../../../src/application/DirectAnalysis.js"; +import { androidApplicationProjectionResultSchema } from "../../../src/domain/androidApplication.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; describe("Android application projection", () => { it("projects deterministic APK components and explicit bridge hypotheses", async () => { diff --git a/tests/appleApplication.test.ts b/tests/composition/analysis-sessions/appleApplication.test.ts similarity index 92% rename from tests/appleApplication.test.ts rename to tests/composition/analysis-sessions/appleApplication.test.ts index 518a44cc7..e55080edd 100644 --- a/tests/appleApplication.test.ts +++ b/tests/composition/analysis-sessions/appleApplication.test.ts @@ -9,12 +9,12 @@ import { } from "@zip.js/zip.js"; import { describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { projectAppleApplicationEvidence } from "../src/application/AppleApplicationService.js"; -import { runProviderAnalysis } from "../src/application/DirectAnalysis.js"; -import { appleApplicationProjectionResultSchema } from "../src/domain/appleApplication.js"; -import { parseEvidence } from "../src/domain/evidence.js"; +import { projectAppleApplicationEvidence } from "../../../src/application/AppleApplicationService.js"; +import { runProviderAnalysis } from "../../../src/application/DirectAnalysis.js"; +import { appleApplicationProjectionResultSchema } from "../../../src/domain/appleApplication.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; describe("Apple application projection", () => { it("projects deterministic IPA components and bridge hypotheses from exact inventory Evidence", async () => { diff --git a/tests/artifactComparison.test.ts b/tests/composition/analysis-sessions/artifactComparison.test.ts similarity index 93% rename from tests/artifactComparison.test.ts rename to tests/composition/analysis-sessions/artifactComparison.test.ts index 76d13ddf0..02ecbef75 100644 --- a/tests/artifactComparison.test.ts +++ b/tests/composition/analysis-sessions/artifactComparison.test.ts @@ -3,15 +3,15 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { inventoryArtifact } from "../src/application/ArtifactInventory.js"; +import { inventoryArtifact } from "../../../src/application/ArtifactInventory.js"; import { artifactComparisonResultSchema, compareArtifacts, -} from "../src/domain/artifactComparison.js"; -import { createEvidence } from "../src/domain/evidence.js"; -import { jsonValueSchema } from "../src/domain/jsonValue.js"; +} from "../../../src/domain/artifactComparison.js"; +import { createEvidence } from "../../../src/domain/evidence.js"; +import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; const LIMITS = { maxEntries: 1_000, diff --git a/tests/artifactInventorySnapshot.test.ts b/tests/composition/analysis-sessions/artifactInventorySnapshot.test.ts similarity index 91% rename from tests/artifactInventorySnapshot.test.ts rename to tests/composition/analysis-sessions/artifactInventorySnapshot.test.ts index 1ef8b37bb..44850fe75 100644 --- a/tests/artifactInventorySnapshot.test.ts +++ b/tests/composition/analysis-sessions/artifactInventorySnapshot.test.ts @@ -3,12 +3,12 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { paginateArtifactInventory, scanArtifactInventory, -} from "../src/application/ArtifactInventory.js"; +} from "../../../src/application/ArtifactInventory.js"; const LIMITS = { maxEntries: 10, diff --git a/tests/authorizedArtifactInventory.test.ts b/tests/composition/analysis-sessions/authorizedArtifactInventory.test.ts similarity index 83% rename from tests/authorizedArtifactInventory.test.ts rename to tests/composition/analysis-sessions/authorizedArtifactInventory.test.ts index 03d3ad94b..b14dceb6f 100644 --- a/tests/authorizedArtifactInventory.test.ts +++ b/tests/composition/analysis-sessions/authorizedArtifactInventory.test.ts @@ -3,9 +3,9 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { scanAuthorizedArtifactInventory } from "../src/application/AuthorizedArtifactInventory.js"; +import { scanAuthorizedArtifactInventory } from "../../../src/application/AuthorizedArtifactInventory.js"; const LIMITS = { maxEntries: 100, diff --git a/tests/composition/analysis-sessions/binarySession.01-returns-detached-provider-target-and.test.ts b/tests/composition/analysis-sessions/binarySession.01-returns-detached-provider-target-and.test.ts new file mode 100644 index 000000000..f7e450717 --- /dev/null +++ b/tests/composition/analysis-sessions/binarySession.01-returns-detached-provider-target-and.test.ts @@ -0,0 +1,133 @@ +import { rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +import type { + AnalysisProvider, + CapabilityDescriptor, +} from "../../../src/application/AnalysisProvider.js"; +import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js"; +import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; +import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; +import { createInvestigationWorkspace } from "../../../src/domain/investigationWorkspace.js"; +import { ok as resultOk } from "../../../src/domain/result.js"; +import { observed as ok } from "../../fixtures/analysisExecution.js"; + +let directory: string | undefined; +afterEach(async () => { + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + directory = undefined; +}); + +const cacheProvider = ( + calls: string[], + mayWriteFilesystem = false, +): AnalysisProvider => { + const identity = { + id: "fixture", + name: "Fixture analysis provider", + version: "1", + } as const; + return { + identity: () => identity, + resolveAnalysisProfile: () => + Promise.resolve( + resultOk({ + profile: createAnalysisProfile(identity, 1, { fixture: true }), + compatibility: {}, + }), + ), + capabilities: () => [ + cacheCapability(identity, "address_name", false, mayWriteFilesystem), + cacheCapability(identity, "set_address_name", true), + ], + createClient: () => ({ + execute: (operation) => { + calls.push(operation); + return Promise.resolve(ok(operation)); + }, + close: () => Promise.resolve(), + }), + }; +}; + +const cacheCapability = ( + provider: CapabilityDescriptor["provider"], + operation: "address_name" | "set_address_name", + mutatesArtifact: boolean, + mayWriteFilesystem = false, +): CapabilityDescriptor => ({ + provider, + operation, + inputContractVersion: 1, + outputContractVersion: 1, + available: true, + reason: null, + pagination: "none", + exhaustive: true, + effects: { + mutatesArtifact, + launchesProcess: false, + mayShowUi: false, + mayAccessNetwork: false, + mayWriteFilesystem, + changesPermissions: false, + requiresRoot: false, + }, + limits: { + maxResults: null, + maxPayloadBytes: null, + timeoutMs: null, + }, + limitations: [], +}); + +const targets = async (): Promise => { + directory ??= await createTestTempDirectory("bb-session-"); + const first = join(directory, "first.hop"); + const second = join(directory, "second.hop"); + await writeFile(first, "one"); + await writeFile(second, "two"); + return [first, second]; +}; + +describe("binary session", () => { + it("returns detached provider, target, and workspace metadata", async () => { + const [first] = await targets(); + const session = composeBinarySessionFromFactory(cacheProvider([])); + expect((await session.open(first)).ok).toBe(true); + expect(session.status()).toMatchObject({ + analysis_run: { + run_id: expect.any(String), + process_lineage: { status: "not_observed" }, + }, + }); + expect(session.listUnknowns()).toEqual([]); + const identity = session.providerIdentity(); + Reflect.set(identity, "id", "forged"); + expect(session.providerIdentity().id).toBe("fixture"); + + const active = session.activeTarget(); + expect(active).toBeDefined(); + if (active !== undefined) Reflect.set(active, "path", "/tmp/forged"); + expect(session.activeTarget()?.path).toBe(first); + + const workspace = createInvestigationWorkspace( + "Detached workspace", + createEvidenceBundle([]), + [], + ); + expect(session.retainInvestigationWorkspace(workspace)).toBe("added"); + const direct = session.investigationWorkspace(workspace.workspace_id, 1); + if (direct !== undefined) Reflect.set(direct, "revision", 999); + const listed = session.investigationWorkspaces()[0]; + if (listed !== undefined) Reflect.set(listed, "workspace_id", "forged"); + expect( + session.investigationWorkspace(workspace.workspace_id, 1), + ).toMatchObject({ revision: 1, workspace_id: workspace.workspace_id }); + await session.close(); + }); +}); diff --git a/tests/composition/analysis-sessions/binarySession.02-runs-through-a-non-hopper.test.ts b/tests/composition/analysis-sessions/binarySession.02-runs-through-a-non-hopper.test.ts new file mode 100644 index 000000000..3c48d8dae --- /dev/null +++ b/tests/composition/analysis-sessions/binarySession.02-runs-through-a-non-hopper.test.ts @@ -0,0 +1,186 @@ +import { rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +import type { AnalysisProvider } from "../../../src/application/AnalysisProvider.js"; +import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js"; +import { observed as ok } from "../../fixtures/analysisExecution.js"; + +let directory: string | undefined; +afterEach(async () => { + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + directory = undefined; +}); + +const targets = async (): Promise => { + directory ??= await createTestTempDirectory("bb-session-"); + const first = join(directory, "first.hop"); + const second = join(directory, "second.hop"); + await writeFile(first, "one"); + await writeFile(second, "two"); + return [first, second]; +}; + +const nonHopperProvider = (operations: string[]): AnalysisProvider => { + const provider: AnalysisProvider = { + identity: () => ({ id: "fixture", name: "Fixture", version: "1" }), + capabilities: () => [ + { + provider: { id: "fixture", name: "Fixture", version: "1" }, + operation: "address_name", + inputContractVersion: 1, + outputContractVersion: 1, + available: true, + reason: null, + pagination: "none", + exhaustive: true, + effects: { + mutatesArtifact: false, + launchesProcess: false, + mayShowUi: false, + mayAccessNetwork: false, + mayWriteFilesystem: false, + changesPermissions: false, + requiresRoot: false, + }, + limits: { + maxResults: null, + maxPayloadBytes: null, + timeoutMs: null, + }, + limitations: [], + }, + ], + createClient: (_target, _profile, context) => { + if (context === undefined) + throw new Error("missing analysis run context"); + return { + execute: (operation) => { + operations.push(operation); + return Promise.resolve(ok(operation)); + }, + runtimeLineageSnapshots: () => [ + { + provider: provider.identity(), + observation: { + status: "verified", + observedAt: "2026-07-22T10:00:00.000Z", + lineage: { + schemaVersion: 1, + runId: context.runId, + launcherPid: 100, + launcherParentPid: 1, + processGroupId: 100, + descendants: [ + { pid: 101, parentPid: 100, processGroupId: 100 }, + ], + }, + }, + }, + ], + requestActivitySnapshots: () => [ + { + provider: provider.identity(), + active: { + requestId: 7, + operation: "analyze_function", + elapsedMs: 31_000, + timeoutMs: 30_000, + callerState: "timed_out", + }, + queuedRequests: 2, + }, + ], + close: () => Promise.resolve(), + }; + }, + }; + return provider; +}; + +describe("binary session", () => { + it("runs through a non-Hopper analysis provider", async () => { + const [first] = await targets(); + const operations: string[] = []; + const provider = nonHopperProvider(operations); + const session = composeBinarySessionFromFactory(provider); + expect((await session.open(first)).ok).toBe(true); + expect(await session.execute("address_name", {})).toEqual({ + ok: true, + value: { + result: "address_name", + rawResult: "address_name", + provider: { + id: "fixture", + name: "Fixture analysis provider", + version: "1", + }, + limitations: [], + locations: [], + subject: null, + }, + }); + expect(provider.identity().id).toBe("fixture"); + expect(provider.capabilities()[0]?.operation).toBe("address_name"); + expect(session.status()).toMatchObject({ + provider: { id: "fixture", name: "Fixture", version: "1" }, + providers: [{ id: "fixture", name: "Fixture", version: "1" }], + capabilities: [ + { + operation: "address_name", + available: true, + reason: null, + effects: { + mutates_artifact: false, + launches_process: false, + }, + }, + ], + analysis_run: { + run_id: expect.stringMatching( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u, + ), + process_lineage: { + status: "snapshots", + snapshots: [ + { + provider: { id: "fixture", name: "Fixture", version: "1" }, + observation: { + status: "verified", + observed_at: "2026-07-22T10:00:00.000Z", + schema_version: 1, + launcher_pid: 100, + launcher_parent_pid: 1, + process_group_id: 100, + descendants: [ + { pid: 101, parent_pid: 100, process_group_id: 100 }, + ], + }, + }, + ], + }, + }, + analysis_activity: { + status: "timed_out_busy", + providers: [ + { + provider: { id: "fixture", name: "Fixture", version: "1" }, + active: { + request_id: 7, + operation: "analyze_function", + elapsed_ms: 31_000, + timeout_ms: 30_000, + caller_state: "timed_out", + }, + queued_requests: 2, + }, + ], + }, + }); + expect(operations).toEqual(["health", "address_name"]); + await session.close(); + }); +}); diff --git a/tests/composition/analysis-sessions/binarySession.03-allocates-one-fresh-run-identity.test.ts b/tests/composition/analysis-sessions/binarySession.03-allocates-one-fresh-run-identity.test.ts new file mode 100644 index 000000000..528dd8355 --- /dev/null +++ b/tests/composition/analysis-sessions/binarySession.03-allocates-one-fresh-run-identity.test.ts @@ -0,0 +1,117 @@ +import { rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +import type { + AnalysisProvider, + CapabilityDescriptor, +} from "../../../src/application/AnalysisProvider.js"; +import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js"; +import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; +import { ok as resultOk } from "../../../src/domain/result.js"; +import { observed as ok } from "../../fixtures/analysisExecution.js"; + +let directory: string | undefined; +afterEach(async () => { + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + directory = undefined; +}); + +const cacheProvider = ( + calls: string[], + mayWriteFilesystem = false, +): AnalysisProvider => { + const identity = { + id: "fixture", + name: "Fixture analysis provider", + version: "1", + } as const; + return { + identity: () => identity, + resolveAnalysisProfile: () => + Promise.resolve( + resultOk({ + profile: createAnalysisProfile(identity, 1, { fixture: true }), + compatibility: {}, + }), + ), + capabilities: () => [ + cacheCapability(identity, "address_name", false, mayWriteFilesystem), + cacheCapability(identity, "set_address_name", true), + ], + createClient: () => ({ + execute: (operation) => { + calls.push(operation); + return Promise.resolve(ok(operation)); + }, + close: () => Promise.resolve(), + }), + }; +}; + +const cacheCapability = ( + provider: CapabilityDescriptor["provider"], + operation: "address_name" | "set_address_name", + mutatesArtifact: boolean, + mayWriteFilesystem = false, +): CapabilityDescriptor => ({ + provider, + operation, + inputContractVersion: 1, + outputContractVersion: 1, + available: true, + reason: null, + pagination: "none", + exhaustive: true, + effects: { + mutatesArtifact, + launchesProcess: false, + mayShowUi: false, + mayAccessNetwork: false, + mayWriteFilesystem, + changesPermissions: false, + requiresRoot: false, + }, + limits: { + maxResults: null, + maxPayloadBytes: null, + timeoutMs: null, + }, + limitations: [], +}); + +const targets = async (): Promise => { + directory ??= await createTestTempDirectory("bb-session-"); + const first = join(directory, "first.hop"); + const second = join(directory, "second.hop"); + await writeFile(first, "one"); + await writeFile(second, "two"); + return [first, second]; +}; + +describe("binary session", () => { + it("allocates one fresh run identity per provider client lifetime", async () => { + const [first, second] = await targets(); + const provider = cacheProvider([]); + const createClient = provider.createClient.bind(provider); + const runIds: string[] = []; + provider.createClient = (target, profile, context) => { + if (context === undefined) + throw new Error("missing analysis run context"); + runIds.push(context.runId); + return createClient(target, profile, context); + }; + const session = composeBinarySessionFromFactory(provider); + + expect((await session.open(first)).ok).toBe(true); + expect((await session.open(first)).ok).toBe(true); + expect((await session.open(second)).ok).toBe(true); + + expect(runIds).toHaveLength(2); + expect(new Set(runIds).size).toBe(2); + await session.close(); + }); +}); diff --git a/tests/composition/analysis-sessions/binarySession.04-replays-exact-immutable-calls-from.test.ts b/tests/composition/analysis-sessions/binarySession.04-replays-exact-immutable-calls-from.test.ts new file mode 100644 index 000000000..f702e727c --- /dev/null +++ b/tests/composition/analysis-sessions/binarySession.04-replays-exact-immutable-calls-from.test.ts @@ -0,0 +1,226 @@ +import { rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +import type { + AnalysisProvider, + CapabilityDescriptor, +} from "../../../src/application/AnalysisProvider.js"; +import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js"; +import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; +import { ok as resultOk } from "../../../src/domain/result.js"; +import { observed as ok } from "../../fixtures/analysisExecution.js"; + +let directory: string | undefined; +afterEach(async () => { + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + directory = undefined; +}); + +const cacheProvider = ( + calls: string[], + mayWriteFilesystem = false, +): AnalysisProvider => { + const identity = { + id: "fixture", + name: "Fixture analysis provider", + version: "1", + } as const; + return { + identity: () => identity, + resolveAnalysisProfile: () => + Promise.resolve( + resultOk({ + profile: createAnalysisProfile(identity, 1, { fixture: true }), + compatibility: {}, + }), + ), + capabilities: () => [ + cacheCapability(identity, "address_name", false, mayWriteFilesystem), + cacheCapability(identity, "set_address_name", true), + ], + createClient: () => ({ + execute: (operation) => { + calls.push(operation); + return Promise.resolve(ok(operation)); + }, + close: () => Promise.resolve(), + }), + }; +}; + +const cacheCapability = ( + provider: CapabilityDescriptor["provider"], + operation: "address_name" | "set_address_name", + mutatesArtifact: boolean, + mayWriteFilesystem = false, +): CapabilityDescriptor => ({ + provider, + operation, + inputContractVersion: 1, + outputContractVersion: 1, + available: true, + reason: null, + pagination: "none", + exhaustive: true, + effects: { + mutatesArtifact, + launchesProcess: false, + mayShowUi: false, + mayAccessNetwork: false, + mayWriteFilesystem, + changesPermissions: false, + requiresRoot: false, + }, + limits: { + maxResults: null, + maxPayloadBytes: null, + timeoutMs: null, + }, + limitations: [], +}); + +const targets = async (): Promise => { + directory ??= await createTestTempDirectory("bb-session-"); + const first = join(directory, "first.hop"); + const second = join(directory, "second.hop"); + await writeFile(first, "one"); + await writeFile(second, "two"); + return [first, second]; +}; + +describe("binary session", () => { + it("replays exact immutable calls from a matching provider-neutral snapshot", async () => { + const [first, second] = await targets(); + const initialCalls: string[] = []; + const initial = composeBinarySessionFromFactory( + cacheProvider(initialCalls), + ); + expect((await initial.open(first)).ok).toBe(true); + expect( + ( + await initial.execute("address_name", { + address: "0x1000", + document: "first", + }) + ).ok, + ).toBe(true); + const snapshot = initial.exportAnalysisSnapshot(); + expect(snapshot.ok).toBe(true); + if (!snapshot.ok) return; + expect(snapshot.value.entries).toHaveLength(1); + await initial.close(); + + const replayCalls: string[] = []; + const replay = composeBinarySessionFromFactory(cacheProvider(replayCalls)); + expect(replay.importAnalysisSnapshot(snapshot.value)).toEqual({ + ok: true, + value: 1, + }); + expect((await replay.open(first)).ok).toBe(true); + const cached = await replay.execute("address_name", { + address: "0x1000", + document: "first", + }); + expect(cached.ok).toBe(true); + if (cached.ok) + expect(cached.value.limitations).toContainEqual( + expect.stringContaining("local REA analysis snapshot"), + ); + expect( + ( + await replay.execute("set_address_name", { + address: "0x1000", + name: "renamed", + document: "first", + }) + ).ok, + ).toBe(true); + expect( + ( + await replay.execute("address_name", { + address: "0x1000", + document: "first", + }) + ).ok, + ).toBe(true); + expect(replayCalls).toEqual(["health", "set_address_name", "address_name"]); + expect(replay.exportAnalysisSnapshot().ok).toBe(false); + expect((await replay.open(first)).ok).toBe(true); + expect(replay.exportAnalysisSnapshot()).toMatchObject({ + ok: true, + value: { entries: [{ operation: "address_name" }] }, + }); + await replay.close(); + + const mismatch = composeBinarySessionFromFactory(cacheProvider([])); + expect(mismatch.importAnalysisSnapshot(snapshot.value).ok).toBe(true); + const opened = await mismatch.open(second); + expect(opened.ok).toBe(false); + if (!opened.ok) expect(opened.error._tag).toBe("EvidenceIntegrityError"); + await mismatch.close(); + + const profileMismatchCalls: string[] = []; + const profileMismatchProvider = cacheProvider(profileMismatchCalls); + const identity = profileMismatchProvider.identity(); + profileMismatchProvider.resolveAnalysisProfile = () => + Promise.resolve( + resultOk({ + profile: createAnalysisProfile( + { + id: identity.id, + name: identity.name, + version: identity.version ?? "fixture-unresolved", + }, + 1, + { fixture: "different-profile" }, + ), + compatibility: {}, + }), + ); + const profileMismatch = composeBinarySessionFromFactory( + profileMismatchProvider, + ); + expect(profileMismatch.importAnalysisSnapshot(snapshot.value).ok).toBe( + true, + ); + const profileOpened = await profileMismatch.open(first); + expect(profileOpened.ok).toBe(false); + if (!profileOpened.ok) { + expect(profileOpened.error._tag).toBe("EvidenceIntegrityError"); + expect(profileOpened.error.message).toContain("profile_mismatch"); + } + expect(profileMismatchCalls).toEqual([]); + await profileMismatch.close(); + + const activeProfileMismatch = composeBinarySessionFromFactory( + profileMismatchProvider, + ); + expect((await activeProfileMismatch.open(first)).ok).toBe(true); + const activeImport = activeProfileMismatch.importAnalysisSnapshot( + snapshot.value, + ); + expect(activeImport.ok).toBe(false); + if (!activeImport.ok) + expect(activeImport.error.message).toContain("profile_mismatch"); + await activeProfileMismatch.close(); + }); + + it("does not snapshot reads that depend on the provider cursor", async () => { + const [first] = await targets(); + const calls: string[] = []; + const session = composeBinarySessionFromFactory(cacheProvider(calls)); + expect((await session.open(first)).ok).toBe(true); + expect((await session.execute("address_name", {})).ok).toBe(true); + expect((await session.execute("address_name", {})).ok).toBe(true); + expect(calls).toEqual(["health", "address_name", "address_name"]); + expect(session.exportAnalysisSnapshot()).toMatchObject({ + ok: true, + value: { entries: [] }, + }); + await session.close(); + }); +}); diff --git a/tests/composition/analysis-sessions/binarySession.05-publishes-provider-health-availability-changes.test.ts b/tests/composition/analysis-sessions/binarySession.05-publishes-provider-health-availability-changes.test.ts new file mode 100644 index 000000000..5c87d8038 --- /dev/null +++ b/tests/composition/analysis-sessions/binarySession.05-publishes-provider-health-availability-changes.test.ts @@ -0,0 +1,199 @@ +import { rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +import type { + AnalysisProvider, + CapabilityDescriptor, +} from "../../../src/application/AnalysisProvider.js"; +import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js"; +import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; +import { ProviderAdapterError } from "../../../src/domain/errors.js"; +import { err, ok as resultOk } from "../../../src/domain/result.js"; +import { observed as ok } from "../../fixtures/analysisExecution.js"; + +let directory: string | undefined; +afterEach(async () => { + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + directory = undefined; +}); + +const cacheProvider = ( + calls: string[], + mayWriteFilesystem = false, +): AnalysisProvider => { + const identity = { + id: "fixture", + name: "Fixture analysis provider", + version: "1", + } as const; + return { + identity: () => identity, + resolveAnalysisProfile: () => + Promise.resolve( + resultOk({ + profile: createAnalysisProfile(identity, 1, { fixture: true }), + compatibility: {}, + }), + ), + capabilities: () => [ + cacheCapability(identity, "address_name", false, mayWriteFilesystem), + cacheCapability(identity, "set_address_name", true), + ], + createClient: () => ({ + execute: (operation) => { + calls.push(operation); + return Promise.resolve(ok(operation)); + }, + close: () => Promise.resolve(), + }), + }; +}; + +const cacheCapability = ( + provider: CapabilityDescriptor["provider"], + operation: "address_name" | "set_address_name", + mutatesArtifact: boolean, + mayWriteFilesystem = false, +): CapabilityDescriptor => ({ + provider, + operation, + inputContractVersion: 1, + outputContractVersion: 1, + available: true, + reason: null, + pagination: "none", + exhaustive: true, + effects: { + mutatesArtifact, + launchesProcess: false, + mayShowUi: false, + mayAccessNetwork: false, + mayWriteFilesystem, + changesPermissions: false, + requiresRoot: false, + }, + limits: { + maxResults: null, + maxPayloadBytes: null, + timeoutMs: null, + }, + limitations: [], +}); + +const targets = async (): Promise => { + directory ??= await createTestTempDirectory("bb-session-"); + const first = join(directory, "first.hop"); + const second = join(directory, "second.hop"); + await writeFile(first, "one"); + await writeFile(second, "two"); + return [first, second]; +}; + +describe("binary session", () => { + it("publishes provider-health availability changes and resets on target switch", async () => { + const [first, second] = await targets(); + const provider = cacheProvider([]); + provider.createClient = () => ({ + execute: (operation) => + Promise.resolve( + operation === "health" + ? ok(null) + : err(new ProviderAdapterError("fixture", operation)), + ), + close: () => Promise.resolve(), + }); + const session = composeBinarySessionFromFactory(provider); + let changes = 0; + session.onAvailabilityChanged(() => { + changes += 1; + }); + expect((await session.open(first)).ok).toBe(true); + expect((await session.execute("address_name", {})).ok).toBe(false); + expect(session.status()).toMatchObject({ + capabilities: expect.arrayContaining([ + expect.objectContaining({ + operation: "address_name", + available: false, + reason: "Provider became unavailable during this session.", + }), + ]), + }); + expect(changes).toBe(1); + expect((await session.open(second)).ok).toBe(true); + expect(session.status()).toMatchObject({ + capabilities: expect.arrayContaining([ + expect.objectContaining({ + operation: "address_name", + available: true, + reason: null, + }), + ]), + }); + expect(changes).toBe(2); + }); + + it("isolates availability observers from execution results and session state", async () => { + const [first] = await targets(); + const provider = cacheProvider([]); + let providerCalls = 0; + provider.createClient = () => ({ + execute: (operation) => { + if (operation === "health") return Promise.resolve(ok(null)); + providerCalls += 1; + return Promise.resolve( + providerCalls === 1 + ? err(new ProviderAdapterError("fixture", operation)) + : ok(operation), + ); + }, + close: () => Promise.resolve(), + }); + const session = composeBinarySessionFromFactory(provider); + expect((await session.open(first)).ok).toBe(true); + const input = { address: "0x1000", document: "first" }; + expect((await session.execute("address_name", input)).ok).toBe(false); + + session.onAvailabilityChanged(() => { + throw new Error("external observer failed"); + }); + session.onAvailabilityChanged(() => + Promise.reject(new Error("async external observer failed")), + ); + let delivered = 0; + session.onAvailabilityChanged(() => { + delivered += 1; + }); + + await expect(session.execute("address_name", input)).resolves.toMatchObject( + { ok: true }, + ); + expect(delivered).toBe(1); + expect(session.status()).toMatchObject({ + capabilities: expect.arrayContaining([ + expect.objectContaining({ + operation: "address_name", + available: true, + reason: null, + }), + ]), + }); + expect((await session.execute("address_name", input)).ok).toBe(true); + expect(providerCalls).toBe(2); + }); + + it("does not replay operations with filesystem side effects", async () => { + const [first] = await targets(); + const calls: string[] = []; + const session = composeBinarySessionFromFactory(cacheProvider(calls, true)); + expect((await session.open(first)).ok).toBe(true); + const input = { address: "0x1000", document: "first" }; + expect((await session.execute("address_name", input)).ok).toBe(true); + expect((await session.execute("address_name", input)).ok).toBe(true); + expect(calls).toEqual(["health", "address_name", "address_name"]); + await session.close(); + }); +}); diff --git a/tests/composition/analysis-sessions/binarySession.06-returns-typed-unavailability-without-dispatching.test.ts b/tests/composition/analysis-sessions/binarySession.06-returns-typed-unavailability-without-dispatching.test.ts new file mode 100644 index 000000000..45a664153 --- /dev/null +++ b/tests/composition/analysis-sessions/binarySession.06-returns-typed-unavailability-without-dispatching.test.ts @@ -0,0 +1,158 @@ +import { rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +import type { + AnalysisClient, + AnalysisProvider, +} from "../../../src/application/AnalysisProvider.js"; +import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js"; +import { HopperStartError } from "../../../src/domain/errors.js"; +import { err } from "../../../src/domain/result.js"; +import { observed as ok } from "../../fixtures/analysisExecution.js"; + +let directory: string | undefined; +afterEach(async () => { + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + directory = undefined; +}); + +const client = (fail = false): AnalysisClient => ({ + execute: () => Promise.resolve(fail ? err(new HopperStartError()) : ok(null)), + close: () => Promise.resolve(), +}); + +class TestClient implements AnalysisClient { + closed = 0; + constructor( + readonly pendingHealth?: Promise>, + readonly failHealth = false, + readonly pendingCall?: Promise>, + ) {} + execute(name: string) { + if (name === "health") + return this.failHealth + ? Promise.resolve(err(new HopperStartError())) + : (this.pendingHealth ?? Promise.resolve(ok(null))); + return this.pendingCall ?? Promise.resolve(ok(null)); + } + close(): Promise { + this.closed += 1; + return Promise.resolve(); + } +} + +const targets = async (): Promise => { + directory ??= await createTestTempDirectory("bb-session-"); + const first = join(directory, "first.hop"); + const second = join(directory, "second.hop"); + await writeFile(first, "one"); + await writeFile(second, "two"); + return [first, second]; +}; + +describe("binary session", () => { + it("returns typed unavailability without dispatching a partial provider", async () => { + const [first] = await targets(); + const operations: string[] = []; + const provider: AnalysisProvider = { + identity: () => ({ id: "partial", name: "Partial", version: "1" }), + capabilities: () => [ + { + provider: { id: "partial", name: "Partial", version: "1" }, + operation: "address_name", + inputContractVersion: 1, + outputContractVersion: 1, + available: false, + reason: "fixture intentionally omits symbol lookup", + pagination: "none", + exhaustive: false, + effects: { + mutatesArtifact: false, + launchesProcess: false, + mayShowUi: false, + mayAccessNetwork: false, + mayWriteFilesystem: false, + changesPermissions: false, + requiresRoot: false, + }, + limits: { + maxResults: null, + maxPayloadBytes: null, + timeoutMs: null, + }, + limitations: ["No symbol lookup implementation."], + }, + ], + createClient: () => ({ + execute: (operation) => { + operations.push(operation); + return Promise.resolve(ok(operation)); + }, + close: () => Promise.resolve(), + }), + }; + const session = composeBinarySessionFromFactory(provider); + expect((await session.open(first)).ok).toBe(true); + const result = await session.execute("address_name", {}); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatchObject({ + _tag: "AnalysisCapabilityUnavailableError", + providerId: "partial", + operation: "address_name", + reason: "fixture intentionally omits symbol lookup", + }); + } + expect(operations).toEqual(["health"]); + await session.close(); + }); + + it("requires an open binary and closes idempotently", async () => { + const session = composeBinarySessionFromFactory(() => client()); + expect((await session.execute("binary_overview", {})).ok).toBe(false); + expect(await session.close()).toEqual({ ok: true, value: null }); + }); + + it("keeps the active client when a switch fails", async () => { + directory = await createTestTempDirectory("bb-session-"); + const first = join(directory, "first.hop"); + const second = join(directory, "second.hop"); + await writeFile(first, "one"); + await writeFile(second, "two"); + let created = 0; + const session = composeBinarySessionFromFactory(() => + client(created++ === 1), + ); + expect((await session.open(first)).ok).toBe(true); + expect((await session.open(second)).ok).toBe(false); + expect(session.status()).toMatchObject({ + open: true, + sha256: + "7692c3ad3540bb803c020b3aee66cd8887123234ea0c6e7143c0add73ff431ed", + architecture: null, + }); + expect(JSON.stringify(session.status())).toContain("first.hop"); + expect(created).toBe(3); + }); + + it("serializes concurrent opens and leaves the last target active", async () => { + const [first, second] = await targets(); + const clients: TestClient[] = []; + const session = composeBinarySessionFromFactory(() => { + const value = new TestClient(); + clients.push(value); + return value; + }); + const one = session.open(first); + const two = session.open(second); + expect((await one).ok).toBe(true); + expect((await two).ok).toBe(true); + expect(session.status()).toMatchObject({ open: true }); + expect(JSON.stringify(session.status())).toContain("second.hop"); + expect(clients[0]?.closed).toBe(1); + }); +}); diff --git a/tests/composition/analysis-sessions/binarySession.07-replaces-the-active-client-when.test.ts b/tests/composition/analysis-sessions/binarySession.07-replaces-the-active-client-when.test.ts new file mode 100644 index 000000000..4b078d47f --- /dev/null +++ b/tests/composition/analysis-sessions/binarySession.07-replaces-the-active-client-when.test.ts @@ -0,0 +1,167 @@ +import { rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +import type { AnalysisClient } from "../../../src/application/AnalysisProvider.js"; +import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js"; +import { HopperStartError } from "../../../src/domain/errors.js"; +import { err } from "../../../src/domain/result.js"; +import { observed as ok } from "../../fixtures/analysisExecution.js"; + +let directory: string | undefined; +afterEach(async () => { + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + directory = undefined; +}); + +class TestClient implements AnalysisClient { + closed = 0; + constructor( + readonly pendingHealth?: Promise>, + readonly failHealth = false, + readonly pendingCall?: Promise>, + ) {} + execute(name: string) { + if (name === "health") + return this.failHealth + ? Promise.resolve(err(new HopperStartError())) + : (this.pendingHealth ?? Promise.resolve(ok(null))); + return this.pendingCall ?? Promise.resolve(ok(null)); + } + close(): Promise { + this.closed += 1; + return Promise.resolve(); + } +} + +const targets = async (): Promise => { + directory ??= await createTestTempDirectory("bb-session-"); + const first = join(directory, "first.hop"); + const second = join(directory, "second.hop"); + await writeFile(first, "one"); + await writeFile(second, "two"); + return [first, second]; +}; + +const deferred = (): { + readonly promise: Promise; + resolve(value: T): void; +} => { + let resolvePromise: ((value: T) => void) | undefined; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + return { + promise, + resolve(value) { + resolvePromise?.(value); + }, + }; +}; + +describe("binary session", () => { + it("replaces the active client when a canonical path changes contents", async () => { + directory = await createTestTempDirectory("bb-session-"); + const path = join(directory, "mutable.hop"); + await writeFile(path, "one"); + const clients: Array<{ + readonly targetSha256: string; + readonly calls: string[]; + closed: number; + }> = []; + const session = composeBinarySessionFromFactory((target) => { + const state = { + targetSha256: target.sha256, + calls: [] as string[], + closed: 0, + }; + clients.push(state); + return { + execute: (operation) => { + state.calls.push(operation); + return Promise.resolve( + ok(operation === "health" ? null : target.sha256), + ); + }, + close: () => { + state.closed += 1; + return Promise.resolve(); + }, + }; + }); + + const first = await session.open(path); + expect(first.ok).toBe(true); + await writeFile(path, "two"); + const second = await session.open(path); + expect(second.ok).toBe(true); + if (!first.ok || !second.ok) return; + + expect(second.value.sha256).not.toBe(first.value.sha256); + expect(session.activeTarget()?.sha256).toBe(second.value.sha256); + expect(await session.execute("binary_overview", {})).toMatchObject({ + ok: true, + value: { result: second.value.sha256 }, + }); + expect(clients).toMatchObject([ + { + targetSha256: first.value.sha256, + calls: ["health"], + closed: 1, + }, + { + targetSha256: second.value.sha256, + calls: ["health", "binary_overview"], + closed: 0, + }, + ]); + await session.close(); + }); + + it("waits for an active call before closing its client during a switch", async () => { + const [first, second] = await targets(); + const active = deferred>(); + const clients: TestClient[] = []; + const session = composeBinarySessionFromFactory(() => { + const value = new TestClient( + undefined, + false, + clients.length === 0 ? active.promise : undefined, + ); + clients.push(value); + return value; + }); + await session.open(first); + const call = session.execute("procedure_pseudo_code", {}); + const switching = session.open(second); + await Promise.resolve(); + expect(clients[0]?.closed).toBe(0); + active.resolve(ok(null)); + await call; + await switching; + expect(clients[0]?.closed).toBe(1); + }); + + it("cancels an open queued behind another transition without creating a client", async () => { + const [first, second] = await targets(); + const health = deferred>(); + let created = 0; + const session = composeBinarySessionFromFactory(() => { + created += 1; + return new TestClient(created === 1 ? health.promise : undefined); + }); + const opening = session.open(first); + const controller = new AbortController(); + const queued = session.open(second, { signal: controller.signal }); + controller.abort(); + health.resolve(ok(null)); + await opening; + const result = await queued; + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(created).toBe(1); + }); +}); diff --git a/tests/composition/analysis-sessions/binarySession.08-cancels-legacy-profile-resolution-even.test.ts b/tests/composition/analysis-sessions/binarySession.08-cancels-legacy-profile-resolution-even.test.ts new file mode 100644 index 000000000..fc9007c7e --- /dev/null +++ b/tests/composition/analysis-sessions/binarySession.08-cancels-legacy-profile-resolution-even.test.ts @@ -0,0 +1,247 @@ +import { rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +import type { + AnalysisClient, + AnalysisProvider, + CapabilityDescriptor, +} from "../../../src/application/AnalysisProvider.js"; +import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js"; +import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; +import { HopperStartError } from "../../../src/domain/errors.js"; +import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; +import { err, ok as resultOk } from "../../../src/domain/result.js"; +import { observed as ok } from "../../fixtures/analysisExecution.js"; + +let directory: string | undefined; +afterEach(async () => { + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + directory = undefined; +}); + +const cacheProvider = ( + calls: string[], + mayWriteFilesystem = false, +): AnalysisProvider => { + const identity = { + id: "fixture", + name: "Fixture analysis provider", + version: "1", + } as const; + return { + identity: () => identity, + resolveAnalysisProfile: () => + Promise.resolve( + resultOk({ + profile: createAnalysisProfile(identity, 1, { fixture: true }), + compatibility: {}, + }), + ), + capabilities: () => [ + cacheCapability(identity, "address_name", false, mayWriteFilesystem), + cacheCapability(identity, "set_address_name", true), + ], + createClient: () => ({ + execute: (operation) => { + calls.push(operation); + return Promise.resolve(ok(operation)); + }, + close: () => Promise.resolve(), + }), + }; +}; + +const cacheCapability = ( + provider: CapabilityDescriptor["provider"], + operation: "address_name" | "set_address_name", + mutatesArtifact: boolean, + mayWriteFilesystem = false, +): CapabilityDescriptor => ({ + provider, + operation, + inputContractVersion: 1, + outputContractVersion: 1, + available: true, + reason: null, + pagination: "none", + exhaustive: true, + effects: { + mutatesArtifact, + launchesProcess: false, + mayShowUi: false, + mayAccessNetwork: false, + mayWriteFilesystem, + changesPermissions: false, + requiresRoot: false, + }, + limits: { + maxResults: null, + maxPayloadBytes: null, + timeoutMs: null, + }, + limitations: [], +}); + +class TestClient implements AnalysisClient { + closed = 0; + constructor( + readonly pendingHealth?: Promise>, + readonly failHealth = false, + readonly pendingCall?: Promise>, + ) {} + execute(name: string) { + if (name === "health") + return this.failHealth + ? Promise.resolve(err(new HopperStartError())) + : (this.pendingHealth ?? Promise.resolve(ok(null))); + return this.pendingCall ?? Promise.resolve(ok(null)); + } + close(): Promise { + this.closed += 1; + return Promise.resolve(); + } +} + +const targets = async (): Promise => { + directory ??= await createTestTempDirectory("bb-session-"); + const first = join(directory, "first.hop"); + const second = join(directory, "second.hop"); + await writeFile(first, "one"); + await writeFile(second, "two"); + return [first, second]; +}; + +const deferred = (): { + readonly promise: Promise; + resolve(value: T): void; +} => { + let resolvePromise: ((value: T) => void) | undefined; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + return { + promise, + resolve(value) { + resolvePromise?.(value); + }, + }; +}; + +describe("binary session", () => { + it("cancels legacy profile resolution even when the provider ignores its signal", async () => { + const [first] = await targets(); + const provider = cacheProvider([]); + let observedSignal: AbortSignal | undefined; + let created = 0; + provider.resolveAnalysisProfile = (_target, options) => { + observedSignal = options?.signal; + return new Promise(() => undefined); + }; + provider.createClient = () => { + created += 1; + return new TestClient(); + }; + const session = composeBinarySessionFromFactory(provider); + const controller = new AbortController(); + const opening = session.open(first, { signal: controller.signal }); + while (observedSignal === undefined) + await new Promise((resolve) => setImmediate(resolve)); + expect(observedSignal).toBe(controller.signal); + + controller.abort(); + + await expect(opening).resolves.toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError", operation: "open_binary" }, + }); + expect(created).toBe(0); + }); + + it("cancels a call while it waits for a transition", async () => { + const [first] = await targets(); + const health = deferred>(); + const session = composeBinarySessionFromFactory( + () => new TestClient(health.promise), + ); + const opening = session.open(first); + const controller = new AbortController(); + const call = session.execute( + "binary_overview", + {}, + { + signal: controller.signal, + }, + ); + controller.abort(); + const result = await call; + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error._tag).toBe("AnalysisCancelledError"); + health.resolve(ok(null)); + await opening; + }); + + it("closes a failed candidate and reopens the previous target", async () => { + const [first, second] = await targets(); + const clients: TestClient[] = []; + const session = composeBinarySessionFromFactory(() => { + const value = new TestClient(undefined, clients.length === 1); + clients.push(value); + return value; + }); + await session.open(first); + await session.open(second); + expect(clients[1]?.closed).toBe(1); + expect(clients[0]?.closed).toBe(1); + expect(clients[2]?.closed).toBe(0); + expect(JSON.stringify(session.status())).toContain("first.hop"); + await session.close(); + await session.close(); + expect(clients[2]?.closed).toBe(1); + }); + + it("closes the active bridge before starting a replacement", async () => { + const [first, second] = await targets(); + let liveClients = 0; + let overlapped = false; + const session = composeBinarySessionFromFactory(() => ({ + execute: () => { + if (liveClients > 0) overlapped = true; + liveClients += 1; + return Promise.resolve(ok(null)); + }, + close: () => { + liveClients -= 1; + return Promise.resolve(); + }, + })); + await session.open(first); + await session.open(second); + expect(overlapped).toBe(false); + await session.close(); + }); + + it("clears session state while preserving a typed provider cleanup failure", async () => { + const [first] = await targets(); + const cleanupError = new ProviderCleanupError( + "fixture", + ["fixture-document"], + { reason: "shutdown acknowledgement missing" }, + ); + const session = composeBinarySessionFromFactory(() => ({ + execute: () => Promise.resolve(ok(null)), + closeWithOutcome: () => Promise.resolve(err(cleanupError)), + close: () => Promise.resolve(), + })); + expect((await session.open(first)).ok).toBe(true); + + expect(await session.close()).toEqual(err(cleanupError)); + expect(session.status()).toMatchObject({ + open: false, + analysis_activity: { status: "not_observed", providers: [] }, + }); + }); +}); diff --git a/tests/composition/analysis-sessions/binarySession.09-does-not-start-a-replacement.test.ts b/tests/composition/analysis-sessions/binarySession.09-does-not-start-a-replacement.test.ts new file mode 100644 index 000000000..8e829e229 --- /dev/null +++ b/tests/composition/analysis-sessions/binarySession.09-does-not-start-a-replacement.test.ts @@ -0,0 +1,51 @@ +import { rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +import { composeBinarySessionFromFactory } from "../../../src/application/BinarySessionComposition.js"; +import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; +import { err } from "../../../src/domain/result.js"; +import { observed as ok } from "../../fixtures/analysisExecution.js"; + +let directory: string | undefined; +afterEach(async () => { + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + directory = undefined; +}); + +const targets = async (): Promise => { + directory ??= await createTestTempDirectory("bb-session-"); + const first = join(directory, "first.hop"); + const second = join(directory, "second.hop"); + await writeFile(first, "one"); + await writeFile(second, "two"); + return [first, second]; +}; + +describe("binary session", () => { + it("does not start a replacement after the active provider cleanup is unconfirmed", async () => { + const [first, second] = await targets(); + const cleanupError = new ProviderCleanupError( + "fixture", + ["fixture-document"], + { reason: "shutdown acknowledgement missing" }, + ); + let created = 0; + const session = composeBinarySessionFromFactory(() => { + created += 1; + return { + execute: () => Promise.resolve(ok(null)), + closeWithOutcome: () => Promise.resolve(err(cleanupError)), + close: () => Promise.resolve(), + }; + }); + expect((await session.open(first)).ok).toBe(true); + + expect(await session.open(second)).toEqual(err(cleanupError)); + expect(created).toBe(1); + expect(session.status()).toMatchObject({ open: false }); + }); +}); diff --git a/tests/binaryTarget.test.ts b/tests/composition/analysis-sessions/binaryTarget.test.ts similarity index 98% rename from tests/binaryTarget.test.ts rename to tests/composition/analysis-sessions/binaryTarget.test.ts index 88504b060..e2928208b 100644 --- a/tests/binaryTarget.test.ts +++ b/tests/composition/analysis-sessions/binaryTarget.test.ts @@ -2,12 +2,12 @@ import { mkdir, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { parseBinaryTarget, parseExecutableHeader, -} from "../src/domain/binaryTarget.js"; +} from "../../../src/domain/binaryTarget.js"; let directory: string | undefined; afterEach(async () => { diff --git a/tests/callPath.test.ts b/tests/composition/analysis-sessions/callPath.test.ts similarity index 93% rename from tests/callPath.test.ts rename to tests/composition/analysis-sessions/callPath.test.ts index 7bdc06297..b7fbf9711 100644 --- a/tests/callPath.test.ts +++ b/tests/composition/analysis-sessions/callPath.test.ts @@ -1,14 +1,14 @@ import { describe, expect, it } from "vitest"; -import { enhancedInputSchemas } from "../src/contracts/enhancedInputs.js"; +import { enhancedInputSchemas } from "../../../src/contracts/enhancedInputs.js"; import { buildCallPath, callPathInputSchema, callPathResultSchema, -} from "../src/domain/callPath.js"; -import { createEvidence, type Evidence } from "../src/domain/evidence.js"; -import { functionDossierSchema } from "../src/domain/hopperValues.js"; -import { jsonValueSchema } from "../src/domain/jsonValue.js"; +} from "../../../src/domain/callPath.js"; +import { createEvidence, type Evidence } from "../../../src/domain/evidence.js"; +import { functionDossierSchema } from "../../../src/domain/hopperValues.js"; +import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; const bounded = (items: readonly Item[], complete = true) => ({ items, diff --git a/tests/capabilityInventory.test.ts b/tests/composition/analysis-sessions/capabilityInventory.test.ts similarity index 97% rename from tests/capabilityInventory.test.ts rename to tests/composition/analysis-sessions/capabilityInventory.test.ts index 2e7f9609f..672bc63c0 100644 --- a/tests/capabilityInventory.test.ts +++ b/tests/composition/analysis-sessions/capabilityInventory.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { buildCapabilityInventory } from "../src/application/CapabilityInventory.js"; +import { buildCapabilityInventory } from "../../../src/application/CapabilityInventory.js"; const enabledPolicy: Parameters[1] = { processCaptureEnabled: true, @@ -43,7 +43,7 @@ const entry = ( return found; }; -describe("capability inventory", () => { +describe("capability inventory: provider status", () => { it.each([ { label: "requires a target", @@ -148,7 +148,9 @@ describe("capability inventory", () => { expect(availability.remediation).toEqual(expect.any(String)); expect(availability.remediation?.length).toBeGreaterThan(0); }); +}); +describe("capability inventory: caller guidance", () => { it("gives every unavailable public tool an actionable remediation", () => { const inventory = buildCapabilityInventory(status(), { ...enabledPolicy, diff --git a/tests/compositeProvider.test.ts b/tests/composition/analysis-sessions/compositeProvider.test.ts similarity index 86% rename from tests/compositeProvider.test.ts rename to tests/composition/analysis-sessions/compositeProvider.test.ts index 8256b97f2..4f79f8507 100644 --- a/tests/compositeProvider.test.ts +++ b/tests/composition/analysis-sessions/compositeProvider.test.ts @@ -1,19 +1,19 @@ -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; -import { BinarySession } from "../src/application/BinarySession.js"; -import { CompositeProvider } from "../src/application/CompositeProvider.js"; +import { composeBinarySessionFromProvider } from "../../../src/application/BinarySessionComposition.js"; +import { CompositeProvider } from "../../../src/application/CompositeProvider.js"; import type { AnalysisOperation, AnalysisProvider, CapabilityDescriptor, ProviderIdentity, -} from "../src/application/AnalysisProvider.js"; -import { createAnalysisExecution } from "../src/application/AnalysisProvider.js"; -import type { BinaryTarget } from "../src/domain/binaryTarget.js"; -import { ok } from "../src/domain/result.js"; +} from "../../../src/application/AnalysisProvider.js"; +import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js"; +import type { BinaryTarget } from "../../../src/domain/binaryTarget.js"; +import { ok } from "../../../src/domain/result.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; const target: BinaryTarget = { path: "/fixture", @@ -22,13 +22,6 @@ const target: BinaryTarget = { sha256: "0".repeat(64), }; -let directory: string | undefined; -afterEach(async () => { - if (directory !== undefined) - await rm(directory, { recursive: true, force: true }); - directory = undefined; -}); - describe("composite analysis provider", () => { it("routes disjoint operations and does not start children during health", async () => { const hopperCalls: string[] = []; @@ -77,7 +70,9 @@ describe("composite analysis provider", () => { it("publishes exact operation provenance and rejects ambiguous routes", () => { const hopper = provider("hopper", "address_name", []); const native = provider("native", "analyze_function", []); - const session = new BinarySession(new CompositeProvider([hopper, native])); + const session = composeBinarySessionFromProvider( + new CompositeProvider([hopper, native]), + ); expect(session.providerIdentity("address_name").id).toBe("hopper"); expect(session.providerIdentity("analyze_function").id).toBe("native"); expect(session.providerIdentity("binary_overview").id).toBe("hopper"); @@ -92,12 +87,14 @@ describe("composite analysis provider", () => { }); it("retains lineage snapshots from two lazily started dynamic providers", async () => { - directory = await mkdtemp(join(tmpdir(), "rea-composite-lineage-")); + const directory = await createTestTempDirectory("rea-composite-lineage-"); const targetPath = join(directory, "fixture.hop"); await writeFile(targetPath, "fixture"); const first = dynamicProvider("first", "address_name", 100); const second = dynamicProvider("second", "analyze_function", 200); - const session = new BinarySession(new CompositeProvider([second, first])); + const session = composeBinarySessionFromProvider( + new CompositeProvider([second, first]), + ); expect((await session.open(targetPath)).ok).toBe(true); expect(await session.execute("address_name", {})).toMatchObject({ diff --git a/tests/contractSnapshot.test.ts b/tests/composition/analysis-sessions/contractSnapshot.test.ts similarity index 70% rename from tests/contractSnapshot.test.ts rename to tests/composition/analysis-sessions/contractSnapshot.test.ts index d4fda25f1..5a6cec839 100644 --- a/tests/contractSnapshot.test.ts +++ b/tests/composition/analysis-sessions/contractSnapshot.test.ts @@ -6,28 +6,27 @@ import { OFFICIAL_TOOL_CONTRACTS, SESSION_TOOL_CONTRACTS, TOOL_CONTRACTS, -} from "../src/contracts/toolContracts.js"; -import { NATIVE_TOOL_CONTRACTS } from "../src/contracts/nativeToolContracts.js"; -import { ARTIFACT_TOOL_CONTRACTS } from "../src/contracts/artifactToolContracts.js"; -import { MANAGED_TOOL_CONTRACTS } from "../src/contracts/managedToolContracts.js"; -import { MANAGED_WORKFLOW_TOOL_CONTRACTS } from "../src/contracts/managedWorkflowToolContracts.js"; -import { BROWSER_TOOL_CONTRACTS } from "../src/contracts/browserToolContracts.js"; -import { BROWSER_SCENARIO_TOOL_CONTRACTS } from "../src/contracts/browserScenarioToolContracts.js"; -import { ELECTRON_TOOL_CONTRACTS } from "../src/contracts/electronToolContracts.js"; -import { JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS } from "../src/contracts/javascriptRuntimeObservationToolContracts.js"; -import { APPLICATION_TOOL_CONTRACTS } from "../src/contracts/applicationToolContracts.js"; +} from "../../../src/contracts/toolContracts.js"; +import { NATIVE_TOOL_CONTRACTS } from "../../../src/contracts/nativeToolContracts.js"; +import { ARTIFACT_TOOL_CONTRACTS } from "../../../src/contracts/artifactToolContracts.js"; +import { MANAGED_TOOL_CONTRACTS } from "../../../src/contracts/managedToolContracts.js"; +import { MANAGED_WORKFLOW_TOOL_CONTRACTS } from "../../../src/contracts/managedWorkflowToolContracts.js"; +import { BROWSER_TOOL_CONTRACTS } from "../../../src/contracts/browserToolContracts.js"; +import { BROWSER_SCENARIO_TOOL_CONTRACTS } from "../../../src/contracts/browserScenarioToolContracts.js"; +import { ELECTRON_TOOL_CONTRACTS } from "../../../src/contracts/electronToolContracts.js"; +import { JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS } from "../../../src/contracts/javascriptRuntimeObservationToolContracts.js"; +import { APPLICATION_TOOL_CONTRACTS } from "../../../src/contracts/applicationToolContracts.js"; import { enhancedOutputSchemas, managedOutputSchemas, managedWorkflowOutputSchemas, officialOutputSchemas, sessionOutputSchemas, -} from "../src/contracts/toolOutputSchemas.js"; +} from "../../../src/contracts/toolOutputSchemas.js"; import { annotationsFromEffects, TOOL_EFFECTS, -} from "../src/contracts/toolEffects.js"; -import { toolRegistrationOptions } from "../src/server/toolRegistrationOptions.js"; +} from "../../../src/contracts/toolEffects.js"; const convertContractJsonSchema = (schema: z.ZodType) => z.toJSONSchema(schema, { @@ -62,68 +61,6 @@ const emptySchemaPaths = (value: unknown, path = "$"): string[] => { }; describe("tool contract surface", () => { - it("reuses finalized advertised schemas", () => { - const contract = TOOL_CONTRACTS[0]; - expect(contract).toBeDefined(); - if (contract === undefined) return; - - const first = toolRegistrationOptions(contract); - const second = toolRegistrationOptions(contract); - expect(first.inputSchema["~standard"].jsonSchema.input()).toBe( - second.inputSchema["~standard"].jsonSchema.output(), - ); - expect(first.outputSchema["~standard"].jsonSchema.input()).toBe( - second.outputSchema["~standard"].jsonSchema.output(), - ); - }); - - it("gives fallback parameter guidance operational meaning", () => { - const contract = TOOL_CONTRACTS.find( - ({ name }) => name === "analyze_function", - ); - expect(contract).toBeDefined(); - if (contract === undefined) return; - - const advertisedSchema = - toolRegistrationOptions(contract).inputSchema[ - "~standard" - ].jsonSchema.input(); - const properties = Object.fromEntries( - Object.entries(advertisedSchema.properties ?? {}), - ); - expect(properties.include_assembly).toMatchObject({ - description: "Whether to include assembly in the result.", - }); - expect(properties.max_instructions).toMatchObject({ - description: "Maximum permitted instructions for this operation.", - }); - expect(properties.pseudocode_offset).toMatchObject({ - description: "Zero-based index of the first pseudocode to return.", - }); - }); - - it("advertises the validated contract examples on the MCP input schema", () => { - for (const name of [ - "record_unknown", - "run_replay_machine", - "build_reconstruction_obligation_ledger", - ]) { - const contract = TOOL_CONTRACTS.find( - (candidate) => candidate.name === name, - ); - expect(contract).toBeDefined(); - if (contract === undefined) continue; - - const schema = - toolRegistrationOptions(contract).inputSchema[ - "~standard" - ].jsonSchema.input(); - expect(schema.examples).toEqual( - contract.examples.map(({ input }) => input), - ); - } - }); - it("advertises complete typed schemas and annotations for every public tool", () => { const contracts = TOOL_CONTRACTS; for (const contract of contracts) { diff --git a/tests/crossVersionInventory.test.ts b/tests/composition/analysis-sessions/crossVersionInventory.test.ts similarity index 93% rename from tests/crossVersionInventory.test.ts rename to tests/composition/analysis-sessions/crossVersionInventory.test.ts index 0d1701408..9c28f2f36 100644 --- a/tests/crossVersionInventory.test.ts +++ b/tests/composition/analysis-sessions/crossVersionInventory.test.ts @@ -3,13 +3,13 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { scanArtifactInventory } from "../src/application/ArtifactInventory.js"; +import { scanArtifactInventory } from "../../../src/application/ArtifactInventory.js"; import { scanVersionInventories, type VersionInventoryScanner, -} from "../src/application/CrossVersionInventory.js"; +} from "../../../src/application/CrossVersionInventory.js"; const LIMITS = { maxEntries: 10, diff --git a/tests/errors.test.ts b/tests/composition/analysis-sessions/errors.test.ts similarity index 94% rename from tests/errors.test.ts rename to tests/composition/analysis-sessions/errors.test.ts index ea26e6610..e15694f14 100644 --- a/tests/errors.test.ts +++ b/tests/composition/analysis-sessions/errors.test.ts @@ -30,14 +30,23 @@ import { projectAnalysisError, type AnalysisError, type AnalysisErrorTag, -} from "../src/domain/errors.js"; -import { ProcessCaptureError } from "../src/application/ProcessHarness.js"; +} from "../../../src/domain/errors.js"; +import { ProcessCaptureError } from "../../../src/application/ProcessHarness.js"; import { analysisErrorJsonSchema, analysisErrorProjectionSchema, -} from "../src/contracts/errorSchemas.js"; +} from "../../../src/contracts/errorSchemas.js"; -describe("analysis error projection", () => { +const expectSecretsHidden = (serialized: string): void => { + for (const hidden of [ + "secret-token", + "AnalysisCapabilityUnavailableError", + "declaredSha256", + ]) + expect(serialized).not.toContain(hidden); +}; + +describe("analysis error projection: provider failures", () => { it("uses the correct provider-neutral protocol tag", () => { expect(new AnalysisProtocolError("invalid")._tag).toBe( "AnalysisProtocolError", @@ -88,7 +97,9 @@ describe("analysis error projection", () => { expect(JSON.stringify(projected)).not.toContain("/proc/"); } }); +}); +describe("analysis error projection: typed failures", () => { it("projects every typed failure without secrets", () => { const secretCause = new Error("secret-token"); const byTag = { @@ -181,12 +192,7 @@ describe("analysis error projection", () => { const projected = errors.map(projectAnalysisError); expect(projected).toHaveLength(27); const serialized = JSON.stringify(projected); - for (const hidden of [ - "secret-token", - "AnalysisCapabilityUnavailableError", - "declaredSha256", - ]) - expect(serialized).not.toContain(hidden); + expectSecretsHidden(serialized); expect(serialized).toContain("/secret/local/path"); expect(serialized).toContain('"environment_names":["TOKEN_NAME"]'); expect( @@ -241,7 +247,9 @@ describe("analysis error projection", () => { ), ).toEqual([]); }); +}); +describe("analysis error projection: remediation", () => { it("gives missing residual unknowns a lookup-specific remediation", () => { expect( projectAnalysisError(new UnknownRegistryError("not-found")), @@ -296,7 +304,9 @@ describe("analysis error projection", () => { ), }); }); +}); +describe("analysis error projection: contract variants", () => { it("validates every closed error-reason variant against the generated contract", () => { const variants: AnalysisError[] = [ ...( diff --git a/tests/functionComparison.test.ts b/tests/composition/analysis-sessions/functionComparison.test.ts similarity index 95% rename from tests/functionComparison.test.ts rename to tests/composition/analysis-sessions/functionComparison.test.ts index 9f24d1b31..bad0fedbd 100644 --- a/tests/functionComparison.test.ts +++ b/tests/composition/analysis-sessions/functionComparison.test.ts @@ -1,14 +1,14 @@ import { describe, expect, it } from "vitest"; -import { FUNCTION_COMPARISON_EXAMPLE } from "../src/contracts/functionComparisonExample.js"; -import { enhancedInputSchemas } from "../src/contracts/enhancedInputs.js"; +import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js"; +import { enhancedInputSchemas } from "../../../src/contracts/enhancedInputs.js"; import { compareFunctions, functionComparisonResultSchema, -} from "../src/domain/functionComparison.js"; -import { createEvidence, type Evidence } from "../src/domain/evidence.js"; -import { functionDossierSchema } from "../src/domain/hopperValues.js"; -import { jsonValueSchema } from "../src/domain/jsonValue.js"; +} from "../../../src/domain/functionComparison.js"; +import { createEvidence, type Evidence } from "../../../src/domain/evidence.js"; +import { functionDossierSchema } from "../../../src/domain/hopperValues.js"; +import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; const bounded = (items: readonly Item[]) => ({ items, diff --git a/tests/hopperProviderSelection.test.ts b/tests/composition/analysis-sessions/hopperProviderSelection.test.ts similarity index 92% rename from tests/hopperProviderSelection.test.ts rename to tests/composition/analysis-sessions/hopperProviderSelection.test.ts index 62cc52aca..ae1b40573 100644 --- a/tests/hopperProviderSelection.test.ts +++ b/tests/composition/analysis-sessions/hopperProviderSelection.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; -import { parseConfig } from "../src/config.js"; -import type { BinaryTarget } from "../src/domain/binaryTarget.js"; -import { HopperProvider } from "../src/hopper/HopperProvider.js"; -import { silentLogger } from "../src/logger.js"; +import { parseConfig } from "../../../src/config.js"; +import type { BinaryTarget } from "../../../src/domain/binaryTarget.js"; +import { HopperProvider } from "../../../src/hopper/HopperProvider.js"; +import { silentLogger } from "../../../src/logger.js"; describe("Hopper provider discovery", () => { it("reports bounded launcher availability without starting Hopper", () => { diff --git a/tests/identity.test.ts b/tests/composition/analysis-sessions/identity.test.ts similarity index 93% rename from tests/identity.test.ts rename to tests/composition/analysis-sessions/identity.test.ts index 87f3234f5..63136a992 100644 --- a/tests/identity.test.ts +++ b/tests/composition/analysis-sessions/identity.test.ts @@ -3,7 +3,7 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { PRODUCT_IDENTITY } from "../src/identity.js"; +import { PRODUCT_IDENTITY } from "../../../src/identity.js"; describe("package identity", () => { it("keeps the manifest, executable, and pinned MCP command aligned", async () => { @@ -24,7 +24,7 @@ describe("package identity", () => { .parse( JSON.parse( await readFile( - fileURLToPath(new URL("../package.json", import.meta.url)), + fileURLToPath(new URL("../../../package.json", import.meta.url)), "utf8", ), ), diff --git a/tests/inputIssueProjection.test.ts b/tests/composition/analysis-sessions/inputIssueProjection.test.ts similarity index 86% rename from tests/inputIssueProjection.test.ts rename to tests/composition/analysis-sessions/inputIssueProjection.test.ts index 7a6727615..9bb1cdf99 100644 --- a/tests/inputIssueProjection.test.ts +++ b/tests/composition/analysis-sessions/inputIssueProjection.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { artifactExtractionInputSchema } from "../src/contracts/artifactToolContracts.js"; -import { projectInputIssues } from "../src/domain/inputIssueProjection.js"; +import { artifactExtractionInputSchema } from "../../../src/contracts/artifactToolContracts.js"; +import { projectInputIssues } from "../../../src/domain/inputIssueProjection.js"; describe("input issue projection", () => { it("preserves schema-authored custom correction guidance", () => { diff --git a/tests/jsonLimits.test.ts b/tests/composition/analysis-sessions/jsonLimits.test.ts similarity index 92% rename from tests/jsonLimits.test.ts rename to tests/composition/analysis-sessions/jsonLimits.test.ts index 603e92763..4d45508c1 100644 --- a/tests/jsonLimits.test.ts +++ b/tests/composition/analysis-sessions/jsonLimits.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { isJsonWithinLimits } from "../src/domain/jsonLimits.js"; +import { isJsonWithinLimits } from "../../../src/domain/jsonLimits.js"; const limits = { maxDepth: 3, diff --git a/tests/progressReporter.test.ts b/tests/composition/analysis-sessions/progressReporter.test.ts similarity index 93% rename from tests/progressReporter.test.ts rename to tests/composition/analysis-sessions/progressReporter.test.ts index 50aab0565..d04f3ea53 100644 --- a/tests/progressReporter.test.ts +++ b/tests/composition/analysis-sessions/progressReporter.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { createProgressReporter } from "../src/application/ProgressReporter.js"; +import { createProgressReporter } from "../../../src/application/ProgressReporter.js"; describe("progress reporter", () => { it("preserves monotonic order, rate bounds intermediate updates, and always emits terminal state", async () => { diff --git a/tests/promptContracts.test.ts b/tests/composition/analysis-sessions/promptContracts.test.ts similarity index 96% rename from tests/promptContracts.test.ts rename to tests/composition/analysis-sessions/promptContracts.test.ts index db2d6a372..cc8680091 100644 --- a/tests/promptContracts.test.ts +++ b/tests/composition/analysis-sessions/promptContracts.test.ts @@ -3,8 +3,8 @@ import { describe, expect, it } from "vitest"; import { PROMPT_CONTRACTS, renderGuidedPrompt, -} from "../src/contracts/promptContracts.js"; -import { TOOL_CONTRACTS } from "../src/contracts/toolContracts.js"; +} from "../../../src/contracts/promptContracts.js"; +import { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js"; const promptNames = [ "investigate_feature", diff --git a/tests/providerArchitecture.test.ts b/tests/composition/analysis-sessions/providerArchitecture.test.ts similarity index 89% rename from tests/providerArchitecture.test.ts rename to tests/composition/analysis-sessions/providerArchitecture.test.ts index c18abad65..a6957e3bc 100644 --- a/tests/providerArchitecture.test.ts +++ b/tests/composition/analysis-sessions/providerArchitecture.test.ts @@ -4,14 +4,14 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { parseConfig } from "../src/config.js"; -import { HopperProvider } from "../src/hopper/HopperProvider.js"; -import { GhidraProvider } from "../src/ghidra/GhidraProvider.js"; -import { GHIDRA_INVENTORY_OPERATIONS } from "../src/ghidra/GhidraInventoryValues.js"; -import { GHIDRA_FUNCTION_OPERATIONS } from "../src/ghidra/GhidraFunctionValues.js"; -import { silentLogger } from "../src/logger.js"; +import { parseConfig } from "../../../src/config.js"; +import { HopperProvider } from "../../../src/hopper/HopperProvider.js"; +import { GhidraProvider } from "../../../src/ghidra/GhidraProvider.js"; +import { GHIDRA_INVENTORY_OPERATIONS } from "../../../src/ghidra/GhidraInventoryValues.js"; +import { GHIDRA_FUNCTION_OPERATIONS } from "../../../src/ghidra/GhidraFunctionValues.js"; +import { silentLogger } from "../../../src/logger.js"; -const sourceRoot = new URL("../src/", import.meta.url); +const sourceRoot = new URL("../../../src/", import.meta.url); describe("provider-neutral architecture", () => { it("keeps deep-provider imports behind adapters and the composition root", async () => { diff --git a/tests/providerBinding.test.ts b/tests/composition/analysis-sessions/providerBinding.test.ts similarity index 93% rename from tests/providerBinding.test.ts rename to tests/composition/analysis-sessions/providerBinding.test.ts index 3aba0d721..e241fd389 100644 --- a/tests/providerBinding.test.ts +++ b/tests/composition/analysis-sessions/providerBinding.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { z } from "zod"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { createAnalysisExecution, @@ -13,16 +13,17 @@ import { type AnalysisProviderCandidate, type CapabilityDescriptor, type ProviderIdentity, -} from "../src/application/AnalysisProvider.js"; -import { AnalysisProviderRegistry } from "../src/application/AnalysisProviderRegistry.js"; -import { BinarySession } from "../src/application/BinarySession.js"; -import { SessionProviderRouter } from "../src/application/SessionProviderRouter.js"; -import { createAnalysisProfile } from "../src/domain/analysisProfile.js"; +} from "../../../src/application/AnalysisProvider.js"; +import { AnalysisProviderRegistry } from "../../../src/application/AnalysisProviderRegistry.js"; +import type { BinarySession } from "../../../src/application/BinarySession.js"; +import { composeBinarySession } from "../../../src/application/BinarySessionComposition.js"; +import { SessionProviderRouter } from "../../../src/application/SessionProviderRouter.js"; +import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; import { AnalysisCancelledError, ProviderAdapterError, -} from "../src/domain/errors.js"; -import { err, ok } from "../src/domain/result.js"; +} from "../../../src/domain/errors.js"; +import { err, ok } from "../../../src/domain/result.js"; let directory: string | undefined; afterEach(async () => { @@ -290,11 +291,9 @@ const selectableSession = ( providers: readonly AnalysisProviderCandidate[], auxiliary?: AnalysisProvider, ): BinarySession => - new BinarySession( - SessionProviderRouter.selectable( - new AnalysisProviderRegistry(providers), - auxiliary === undefined ? [] : [auxiliary], - ), + composeBinarySession( + new AnalysisProviderRegistry(providers), + auxiliary === undefined ? [] : [auxiliary], ); const capability = ( diff --git a/tests/runtime.test.ts b/tests/composition/analysis-sessions/runtime.test.ts similarity index 94% rename from tests/runtime.test.ts rename to tests/composition/analysis-sessions/runtime.test.ts index e5b2a09c4..0ecde8adc 100644 --- a/tests/runtime.test.ts +++ b/tests/composition/analysis-sessions/runtime.test.ts @@ -5,11 +5,13 @@ import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { CATALOG_IDENTITY } from "../src/catalogIdentity.js"; +import { CATALOG_IDENTITY } from "../../../src/catalogIdentity.js"; -const mainPath = fileURLToPath(new URL("../dist/main.js", import.meta.url)); +const mainPath = fileURLToPath( + new URL("../../../dist/main.js", import.meta.url), +); const fixturePath = fileURLToPath( - new URL("./fixtures/fakeLauncher.mjs", import.meta.url), + new URL("../../fixtures/fakeLauncher.mjs", import.meta.url), ); const expectAvailableToolInventory = async (client: Client): Promise => { diff --git a/tests/toolContracts.test.ts b/tests/composition/analysis-sessions/toolContracts.test.ts similarity index 83% rename from tests/toolContracts.test.ts rename to tests/composition/analysis-sessions/toolContracts.test.ts index c90254d72..74953fca4 100644 --- a/tests/toolContracts.test.ts +++ b/tests/composition/analysis-sessions/toolContracts.test.ts @@ -5,14 +5,14 @@ import { OFFICIAL_TOOL_CONTRACTS, SESSION_TOOL_CONTRACTS, TOOL_CONTRACTS, -} from "../src/contracts/toolContracts.js"; -import { ARTIFACT_TOOL_CONTRACTS } from "../src/contracts/artifactToolContracts.js"; -import { MANAGED_TOOL_CONTRACTS } from "../src/contracts/managedToolContracts.js"; -import { MANAGED_WORKFLOW_TOOL_CONTRACTS } from "../src/contracts/managedWorkflowToolContracts.js"; -import { NATIVE_TOOL_CONTRACTS } from "../src/contracts/nativeToolContracts.js"; -import { BROWSER_TOOL_CONTRACTS } from "../src/contracts/browserToolContracts.js"; -import { ELECTRON_TOOL_CONTRACTS } from "../src/contracts/electronToolContracts.js"; -import { APPLICATION_TOOL_CONTRACTS } from "../src/contracts/applicationToolContracts.js"; +} from "../../../src/contracts/toolContracts.js"; +import { ARTIFACT_TOOL_CONTRACTS } from "../../../src/contracts/artifactToolContracts.js"; +import { MANAGED_TOOL_CONTRACTS } from "../../../src/contracts/managedToolContracts.js"; +import { MANAGED_WORKFLOW_TOOL_CONTRACTS } from "../../../src/contracts/managedWorkflowToolContracts.js"; +import { NATIVE_TOOL_CONTRACTS } from "../../../src/contracts/nativeToolContracts.js"; +import { BROWSER_TOOL_CONTRACTS } from "../../../src/contracts/browserToolContracts.js"; +import { ELECTRON_TOOL_CONTRACTS } from "../../../src/contracts/electronToolContracts.js"; +import { APPLICATION_TOOL_CONTRACTS } from "../../../src/contracts/applicationToolContracts.js"; describe("tool contract inventory", () => { it("publishes the canonical analysis and session tool inventory", () => { diff --git a/tests/changedBehavior.test.ts b/tests/composition/evidence-investigations/changedBehavior.test.ts similarity index 97% rename from tests/changedBehavior.test.ts rename to tests/composition/evidence-investigations/changedBehavior.test.ts index 00776c3e3..bd0ce3981 100644 --- a/tests/changedBehavior.test.ts +++ b/tests/composition/evidence-investigations/changedBehavior.test.ts @@ -3,9 +3,9 @@ import { describe, expect, it } from "vitest"; import { changedBehaviorResultSchema, findChangedBehavior, -} from "../src/domain/changedBehavior.js"; -import { createEvidence, type Evidence } from "../src/domain/evidence.js"; -import type { JsonValue } from "../src/domain/jsonValue.js"; +} from "../../../src/domain/changedBehavior.js"; +import { createEvidence, type Evidence } from "../../../src/domain/evidence.js"; +import type { JsonValue } from "../../../src/domain/jsonValue.js"; const source = (digit: string): Evidence => createEvidence( diff --git a/tests/completionLedgerGeneration.test.ts b/tests/composition/evidence-investigations/completionLedgerGeneration.test.ts similarity index 95% rename from tests/completionLedgerGeneration.test.ts rename to tests/composition/evidence-investigations/completionLedgerGeneration.test.ts index 50f686248..4f6d652a9 100644 --- a/tests/completionLedgerGeneration.test.ts +++ b/tests/composition/evidence-investigations/completionLedgerGeneration.test.ts @@ -5,8 +5,8 @@ import { createCompletionLedgerArtifacts, parseCompletionManifest, type CompletionVerifierReport, -} from "../src/domain/completionLedgerGeneration.js"; -import { parseEvidenceCompletionLedger } from "../src/domain/evidenceCompletionLedger.js"; +} from "../../../src/domain/completionLedgerGeneration.js"; +import { parseEvidenceCompletionLedger } from "../../../src/domain/evidenceCompletionLedger.js"; const digest = (digit: string): string => digit.repeat(64); const evidenceId = (digit: string): string => `ev_${digest(digit)}`; @@ -120,7 +120,9 @@ describe("completion ledger generation", () => { expect(generate(second)).toEqual(generate(first)); }); +}); +describe("completion verifier report validation", () => { it("requires a typed verifier-process identity", () => { const input = report(); expect( @@ -153,7 +155,9 @@ describe("completion ledger generation", () => { }).success, ).toBe(false); }); +}); +describe("completion ledger commitment drift", () => { it.each([ [ "artifact", @@ -210,7 +214,9 @@ describe("completion ledger generation", () => { baseline.manifest.manifest_id, ); }); +}); +describe("completion ledger identity validation", () => { it("rejects duplicate claim, artifact, Evidence, and skill identities", () => { const duplicateClaim = report(); duplicateClaim.claims.push(structuredClone(firstClaim(duplicateClaim))); diff --git a/tests/evidence.test.ts b/tests/composition/evidence-investigations/evidence.test.ts similarity index 95% rename from tests/evidence.test.ts rename to tests/composition/evidence-investigations/evidence.test.ts index d86cf10e5..708db714c 100644 --- a/tests/evidence.test.ts +++ b/tests/composition/evidence-investigations/evidence.test.ts @@ -1,16 +1,16 @@ import { fc, it } from "@fast-check/vitest"; import { describe, expect } from "vitest"; -import { EvidenceLedger } from "../src/application/EvidenceLedger.js"; -import type { BinaryTarget } from "../src/domain/binaryTarget.js"; +import { EvidenceLedger } from "../../../src/application/EvidenceLedger.js"; +import type { BinaryTarget } from "../../../src/domain/binaryTarget.js"; import { createEvidence, evidenceSchema, parseEvidence, -} from "../src/domain/evidence.js"; -import { createEvidenceBundle } from "../src/domain/evidenceBundle.js"; -import { recordUnknownInputSchema } from "../src/domain/residualUnknown.js"; -import { createAnalysisProfile } from "../src/domain/analysisProfile.js"; +} from "../../../src/domain/evidence.js"; +import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; +import { recordUnknownInputSchema } from "../../../src/domain/residualUnknown.js"; +import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; const TARGET: BinaryTarget = { path: "/tmp/fixture", @@ -103,7 +103,9 @@ describe("analysis evidence", () => { expect(evidence.evidence_id).toMatch(/^ev_[a-f0-9]{64}$/u); expect(createEvidence(TARGET, PROVIDER, observation)).toEqual(evidence); }); +}); +describe("analysis evidence validation", () => { it("preserves legacy IDs while committing profiles on new observations", () => { const legacy = createEvidence(TARGET, PROVIDER, { operation: "procedure_info", @@ -172,7 +174,9 @@ describe("analysis evidence", () => { "Evidence v1 is not accepted. Produce Evidence v2.", ); }); +}); +describe("evidence ledger recording", () => { it("deduplicates and atomically imports bounded bundles", () => { const evidence = createEvidence(TARGET, PROVIDER, { operation: "health", @@ -290,7 +294,9 @@ describe("analysis evidence", () => { unknowns: [expect.objectContaining({ domain: "record-limit-test" })], }); }); +}); +describe("evidence bundle imports", () => { it("derives byte-stable manifests independent of record insertion order", () => { const artifactEvidence = createEvidence(TARGET, PROVIDER, { operation: "health", diff --git a/tests/evidenceCompletionLedger.test.ts b/tests/composition/evidence-investigations/evidenceCompletionLedger.test.ts similarity index 98% rename from tests/evidenceCompletionLedger.test.ts rename to tests/composition/evidence-investigations/evidenceCompletionLedger.test.ts index 2c96bc9c6..cc3ae3a27 100644 --- a/tests/evidenceCompletionLedger.test.ts +++ b/tests/composition/evidence-investigations/evidenceCompletionLedger.test.ts @@ -4,7 +4,7 @@ import { createEvidenceCompletionLedger, parseEvidenceCompletionLedger, type EvidenceCompletionStatus, -} from "../src/domain/evidenceCompletionLedger.js"; +} from "../../../src/domain/evidenceCompletionLedger.js"; const evidenceId = (digit: string): string => `ev_${digit.repeat(64)}`; diff --git a/tests/composition/evidence-investigations/investigationWorkspaceHistory.test.ts b/tests/composition/evidence-investigations/investigationWorkspaceHistory.test.ts new file mode 100644 index 000000000..0040608cf --- /dev/null +++ b/tests/composition/evidence-investigations/investigationWorkspaceHistory.test.ts @@ -0,0 +1,278 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createPackageWithOptions } from "@electron/asar"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +import { runCrossVersionInvestigation } from "../../../src/application/CrossVersionInvestigation.js"; +import { + readInvestigationWorkspace, + writeInvestigationWorkspace, +} from "../../../src/application/InvestigationWorkspaceStore.js"; +import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; +import type { EvidenceFilePolicy } from "../../../src/domain/evidenceBundle.js"; +import { + createInvestigationWorkspace, + crossVersionInvestigationInputSchema, + investigationRunSchema, +} from "../../../src/domain/investigationWorkspace.js"; +import { ok } from "../../../src/domain/result.js"; + +let directory: string | undefined; + +afterEach(async () => { + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + directory = undefined; +}); + +const policy = (root: string): EvidenceFilePolicy => ({ + roots: [root], + maxBytes: 64 * 1024 * 1024, + maxDepth: 64, + maxStringLength: 1024 * 1024, + maxNodes: 1_000_000, +}); + +const fixture = async () => { + directory = await createTestTempDirectory("rea-workspace-"); + const left = join(directory, "left"); + const right = join(directory, "right"); + await Promise.all([mkdir(left), mkdir(right)]); + await Promise.all([ + writeFile(join(left, "app.txt"), "version one\n"), + writeFile(join(right, "app.txt"), "version two\n"), + writeFile(join(right, "added.txt"), "new behavior candidate\n"), + ]); + const path = join(directory, "workspace.json"); + const input = crossVersionInvestigationInputSchema.parse({ + approved: true, + workspace_path: path, + workspace_name: "release-diff", + left_path: left, + right_path: right, + options: { page_size: 2, change_limit: 100 }, + }); + return { left, right, path, input }; +}; + +describe("persistent cross-version investigation workspace", () => { + it("enforces shared JSON depth limits while reading workspaces", async () => { + const { path, input } = await fixture(); + if (directory === undefined) throw new Error("missing fixture root"); + const completed = await runCrossVersionInvestigation( + input, + policy(directory), + { inputRoots: [directory] }, + ); + if (!completed.ok) throw completed.error; + + await expect( + readInvestigationWorkspace(path, { + ...policy(directory), + maxDepth: 1, + }), + ).resolves.toMatchObject({ + ok: false, + error: { _tag: "InvestigationWorkspaceError", reason: "too-large" }, + }); + }); + + it("resumes from a comparison checkpoint without recomputing its identity", async () => { + const { input } = await fixture(); + if (directory === undefined) throw new Error("missing fixture root"); + const completed = await runCrossVersionInvestigation( + input, + policy(directory), + { inputRoots: [directory] }, + ); + if (!completed.ok) throw completed.error; + const run = completed.value.workspace.runs[0]; + if (run === undefined || run.comparison_evidence_id === null) + throw new Error("missing completed run"); + const partialRun = investigationRunSchema.parse({ + ...run, + status: "running", + completed_stages: [ + "inventory_left", + "inventory_right", + "compare_artifacts", + ], + result_evidence_id: null, + }); + const records = completed.value.workspace.bundle.records.filter( + ({ evidence_id: id }) => id !== run.result_evidence_id, + ); + const partial = createInvestigationWorkspace( + "release-diff", + createEvidenceBundle(records), + [partialRun], + ); + const resumePath = join(directory, "resume.json"); + expect( + await writeInvestigationWorkspace( + partial, + resumePath, + null, + policy(directory), + ), + ).toMatchObject({ ok: true }); + + const replayAuthorization = vi.fn(() => Promise.resolve(ok(null))); + await expect( + runCrossVersionInvestigation( + { + ...input, + workspace_path: resumePath, + replay_run_id: partialRun.run_id, + }, + policy(directory), + { inputRoots: [], authorizeInputRead: replayAuthorization }, + ), + ).resolves.toMatchObject({ + ok: false, + error: { + _tag: "InvestigationWorkspaceError", + reason: "revision-conflict", + }, + }); + expect(replayAuthorization).not.toHaveBeenCalled(); + + const resumed = await runCrossVersionInvestigation( + { ...input, workspace_path: resumePath }, + policy(directory), + { inputRoots: [directory] }, + ); + expect(resumed).toMatchObject({ + ok: true, + value: { + reused: false, + evidence: { evidence_id: completed.value.evidence.evidence_id }, + workspace: { revision: 2 }, + }, + }); + }); +}); + +describe("persistent investigation workspace history", () => { + it("appends a changed-content run without replacing earlier Evidence", async () => { + const { right, input } = await fixture(); + if (directory === undefined) throw new Error("missing fixture root"); + const first = await runCrossVersionInvestigation(input, policy(directory), { + inputRoots: [directory], + }); + if (!first.ok) throw first.error; + const firstRun = first.value.workspace.runs[0]; + if (firstRun === undefined || firstRun.result_evidence_id === null) + throw new Error("missing first completed run"); + + await writeFile(join(right, "app.txt"), "version three\n"); + const second = await runCrossVersionInvestigation( + input, + policy(directory), + { inputRoots: [directory] }, + ); + expect(second).toMatchObject({ + ok: true, + value: { + reused: false, + workspace: { + revision: 6, + runs: [{ status: "complete" }, { status: "complete" }], + }, + }, + }); + if (!second.ok) throw second.error; + expect(second.value.workspace.runs).toHaveLength(2); + expect(second.value.workspace.runs.map(({ run_id: id }) => id)).toContain( + firstRun.run_id, + ); + expect( + second.value.workspace.bundle.records.some( + ({ evidence_id: id }) => id === firstRun.result_evidence_id, + ), + ).toBe(true); + }); + + it("checkpoints bounded integrity contradictions and safely reuses them", async () => { + directory = await createTestTempDirectory("rea-workspace-integrity-"); + const source = join(directory, "source"); + await mkdir(source); + await writeFile(join(source, "addon.node"), "verified native addon\n"); + const left = join(directory, "left.asar"); + const right = join(directory, "right.asar"); + await Promise.all([ + createPackageWithOptions(source, left, { unpack: "*.node" }), + createPackageWithOptions(source, right, { unpack: "*.node" }), + ]); + await writeFile(join(`${right}.unpacked`, "addon.node"), "tampered\n"); + const base = { + approved: true as const, + workspace_path: join(directory, "strict.json"), + workspace_name: "integrity-diff", + left_path: left, + right_path: right, + options: { page_size: 500, change_limit: 100 }, + }; + const strict = await runCrossVersionInvestigation( + crossVersionInvestigationInputSchema.parse(base), + policy(directory), + { inputRoots: [directory] }, + ); + expect(strict).toMatchObject({ + ok: false, + error: { _tag: "ArtifactOperationError", reason: "integrity" }, + }); + + const continuedInput = crossVersionInvestigationInputSchema.parse({ + ...base, + workspace_path: join(directory, "continued.json"), + integrity_policy: "record-and-continue", + integrity_continue_approved: true, + max_integrity_mismatches: 2, + }); + const continued = await runCrossVersionInvestigation( + continuedInput, + policy(directory), + { inputRoots: [directory], integrityContinueEnabled: true }, + ); + expect(continued).toMatchObject({ + ok: true, + value: { reused: false, workspace: { revision: 3 } }, + }); + if (!continued.ok) throw continued.error; + expect( + continued.value.workspace.bundle.records.some((record) => + JSON.stringify(record.normalized_result).includes( + '"integrity_contradictions":[{', + ), + ), + ).toBe(true); + expect( + continued.value.workspace.bundle.records.find( + ({ operation }) => operation === "compare_artifacts", + )?.normalized_result, + ).toMatchObject({ status: "contradiction" }); + await expect( + runCrossVersionInvestigation(continuedInput, policy(directory), { + inputRoots: [directory], + integrityContinueEnabled: true, + }), + ).resolves.toMatchObject({ ok: true, value: { reused: true } }); + + const strictAfterContinuation = crossVersionInvestigationInputSchema.parse({ + ...base, + workspace_path: continuedInput.workspace_path, + }); + await expect( + runCrossVersionInvestigation(strictAfterContinuation, policy(directory), { + inputRoots: [directory], + }), + ).resolves.toMatchObject({ + ok: false, + error: { _tag: "ArtifactOperationError", reason: "integrity" }, + }); + }); +}); diff --git a/tests/composition/evidence-investigations/investigationWorkspaceIntegrity.test.ts b/tests/composition/evidence-investigations/investigationWorkspaceIntegrity.test.ts new file mode 100644 index 000000000..147c6eba5 --- /dev/null +++ b/tests/composition/evidence-investigations/investigationWorkspaceIntegrity.test.ts @@ -0,0 +1,184 @@ +import { + chmod, + mkdir, + readFile, + rm, + stat, + symlink, + writeFile, +} from "node:fs/promises"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +import { runCrossVersionInvestigation } from "../../../src/application/CrossVersionInvestigation.js"; +import { readInvestigationWorkspace } from "../../../src/application/InvestigationWorkspaceStore.js"; +import type { EvidenceFilePolicy } from "../../../src/domain/evidenceBundle.js"; +import { crossVersionInvestigationInputSchema } from "../../../src/domain/investigationWorkspace.js"; + +let directory: string | undefined; + +afterEach(async () => { + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + directory = undefined; +}); + +const policy = (root: string): EvidenceFilePolicy => ({ + roots: [root], + maxBytes: 64 * 1024 * 1024, + maxDepth: 64, + maxStringLength: 1024 * 1024, + maxNodes: 1_000_000, +}); + +const fixture = async () => { + directory = await createTestTempDirectory("rea-workspace-"); + const left = join(directory, "left"); + const right = join(directory, "right"); + await Promise.all([mkdir(left), mkdir(right)]); + await Promise.all([ + writeFile(join(left, "app.txt"), "version one\n"), + writeFile(join(right, "app.txt"), "version two\n"), + writeFile(join(right, "added.txt"), "new behavior candidate\n"), + ]); + const path = join(directory, "workspace.json"); + const input = crossVersionInvestigationInputSchema.parse({ + approved: true, + workspace_path: path, + workspace_name: "release-diff", + left_path: left, + right_path: right, + options: { page_size: 2, change_limit: 100 }, + }); + return { left, right, path, input }; +}; + +describe("persistent cross-version investigation workspace", () => { + it("fails closed on locks, CAS conflicts, tampering, and cancellation", async () => { + const { path, input } = await fixture(); + if (directory === undefined) throw new Error("missing fixture root"); + await writeFile(`${path}.lock`, `${String(process.pid)}\n`, { + mode: 0o600, + }); + expect( + await runCrossVersionInvestigation(input, policy(directory), { + inputRoots: [directory], + }), + ).toMatchObject({ + ok: false, + error: { _tag: "InvestigationWorkspaceError", reason: "locked" }, + }); + await rm(`${path}.lock`); + await writeFile(`${path}.lock`, "occupied\n", { mode: 0o600 }); + expect( + await runCrossVersionInvestigation(input, policy(directory), { + inputRoots: [directory], + }), + ).toMatchObject({ + ok: false, + error: { _tag: "InvestigationWorkspaceError", reason: "locked" }, + }); + await rm(`${path}.lock`); + await writeFile(`${path}.lock`, "2147483647\n", { mode: 0o600 }); + const completed = await runCrossVersionInvestigation( + input, + policy(directory), + { inputRoots: [directory] }, + ); + if (!completed.ok) throw completed.error; + await expect(stat(`${path}.lock`)).rejects.toMatchObject({ + code: "ENOENT", + }); + expect( + await runCrossVersionInvestigation( + { ...input, expected_workspace_revision: 2 }, + policy(directory), + { inputRoots: [directory] }, + ), + ).toMatchObject({ + ok: false, + error: { + _tag: "InvestigationWorkspaceError", + reason: "revision-conflict", + }, + }); + + const decoded = JSON.parse(await readFile(path, "utf8")) as unknown; + if (typeof decoded !== "object" || decoded === null) + throw new Error("invalid workspace fixture"); + await chmod(path, 0o600); + await writeFile(path, JSON.stringify({ ...decoded, revision: 99 })); + expect( + await readInvestigationWorkspace(path, policy(directory)), + ).toMatchObject({ + ok: false, + error: { _tag: "InvestigationWorkspaceError", reason: "integrity" }, + }); + + const cancelledPath = join(directory, "cancelled.json"); + const controller = new AbortController(); + controller.abort(); + expect( + await runCrossVersionInvestigation( + { ...input, workspace_path: cancelledPath }, + policy(directory), + { inputRoots: [directory], signal: controller.signal }, + ), + ).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + }); + + it("rejects workspace paths and symlinks outside approved roots", async () => { + const { input } = await fixture(); + if (directory === undefined) throw new Error("missing fixture root"); + const approved = join(directory, "approved"); + await mkdir(approved); + expect( + await runCrossVersionInvestigation(input, policy(approved), { + inputRoots: [directory], + }), + ).toMatchObject({ + ok: false, + error: { + _tag: "InvestigationWorkspaceError", + reason: "outside-root", + }, + }); + + const outside = join(directory, "outside.json"); + await writeFile(outside, "{}", { mode: 0o600 }); + const escaped = join(approved, "escaped.json"); + await symlink(outside, escaped); + expect( + await runCrossVersionInvestigation( + { ...input, workspace_path: escaped }, + policy(approved), + { inputRoots: [directory] }, + ), + ).toMatchObject({ + ok: false, + error: { _tag: "InvestigationWorkspaceError", reason: "not-file" }, + }); + }); + + it("rejects artifact inputs outside independently approved roots", async () => { + const { input, path } = await fixture(); + if (directory === undefined) throw new Error("missing fixture root"); + const approvedInputs = join(directory, "approved-inputs"); + await mkdir(approvedInputs); + expect( + await runCrossVersionInvestigation(input, policy(directory), { + inputRoots: [approvedInputs], + }), + ).toMatchObject({ + ok: false, + error: { _tag: "ArtifactOperationError", reason: "path" }, + }); + await expect(stat(path)).rejects.toMatchObject({ code: "ENOENT" }); + }); +}); diff --git a/tests/investigationWorkspace.test.ts b/tests/composition/evidence-investigations/investigationWorkspaceMigration.test.ts similarity index 51% rename from tests/investigationWorkspace.test.ts rename to tests/composition/evidence-investigations/investigationWorkspaceMigration.test.ts index e83962bfc..0ea354172 100644 --- a/tests/investigationWorkspace.test.ts +++ b/tests/composition/evidence-investigations/investigationWorkspaceMigration.test.ts @@ -1,37 +1,27 @@ -import { - chmod, - mkdir, - readFile, - rm, - stat, - symlink, - writeFile, -} from "node:fs/promises"; +import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { createPackageWithOptions } from "@electron/asar"; import canonicalize from "canonicalize"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { runCrossVersionInvestigation } from "../src/application/CrossVersionInvestigation.js"; +import { runCrossVersionInvestigation } from "../../../src/application/CrossVersionInvestigation.js"; import { readInvestigationWorkspace, writeInvestigationWorkspace, -} from "../src/application/InvestigationWorkspaceStore.js"; -import { changedBehaviorResultSchema } from "../src/domain/changedBehavior.js"; -import { createEvidenceBundle } from "../src/domain/evidenceBundle.js"; -import type { EvidenceFilePolicy } from "../src/domain/evidenceBundle.js"; +} from "../../../src/application/InvestigationWorkspaceStore.js"; +import { changedBehaviorResultSchema } from "../../../src/domain/changedBehavior.js"; +import type { EvidenceFilePolicy } from "../../../src/domain/evidenceBundle.js"; import { createInvestigationWorkspace, crossVersionInvestigationInputSchema, investigationRunSchema, parseInvestigationWorkspace, serializeInvestigationWorkspace, -} from "../src/domain/investigationWorkspace.js"; -import { ok } from "../src/domain/result.js"; +} from "../../../src/domain/investigationWorkspace.js"; +import { ok } from "../../../src/domain/result.js"; const digestCanonical = (value: unknown): string => { const encoded = canonicalize(value); @@ -189,7 +179,9 @@ describe("persistent cross-version investigation workspace", () => { ), ).resolves.toMatchObject({ ok: true, value: { reused: false } }); }); +}); +describe("persistent investigation workspace reuse", () => { it("checkpoints, validates, and reuses a completed deterministic run", async () => { const { path, input } = await fixture(); if (directory === undefined) throw new Error("missing fixture root"); @@ -298,7 +290,9 @@ describe("persistent cross-version investigation workspace", () => { ).toBe(true); } }); +}); +describe("persistent investigation workspace replay", () => { it("replays only an explicitly selected and fully verified complete run", async () => { const { left, right, input } = await fixture(); if (directory === undefined) throw new Error("missing fixture root"); @@ -402,344 +396,4 @@ describe("persistent cross-version investigation workspace", () => { }); expect(replayAuthorization).not.toHaveBeenCalled(); }); - - it("enforces shared JSON depth limits while reading workspaces", async () => { - const { path, input } = await fixture(); - if (directory === undefined) throw new Error("missing fixture root"); - const completed = await runCrossVersionInvestigation( - input, - policy(directory), - { inputRoots: [directory] }, - ); - if (!completed.ok) throw completed.error; - - await expect( - readInvestigationWorkspace(path, { - ...policy(directory), - maxDepth: 1, - }), - ).resolves.toMatchObject({ - ok: false, - error: { _tag: "InvestigationWorkspaceError", reason: "too-large" }, - }); - }); - - it("resumes from a comparison checkpoint without recomputing its identity", async () => { - const { input } = await fixture(); - if (directory === undefined) throw new Error("missing fixture root"); - const completed = await runCrossVersionInvestigation( - input, - policy(directory), - { inputRoots: [directory] }, - ); - if (!completed.ok) throw completed.error; - const run = completed.value.workspace.runs[0]; - if (run === undefined || run.comparison_evidence_id === null) - throw new Error("missing completed run"); - const partialRun = investigationRunSchema.parse({ - ...run, - status: "running", - completed_stages: [ - "inventory_left", - "inventory_right", - "compare_artifacts", - ], - result_evidence_id: null, - }); - const records = completed.value.workspace.bundle.records.filter( - ({ evidence_id: id }) => id !== run.result_evidence_id, - ); - const partial = createInvestigationWorkspace( - "release-diff", - createEvidenceBundle(records), - [partialRun], - ); - const resumePath = join(directory, "resume.json"); - expect( - await writeInvestigationWorkspace( - partial, - resumePath, - null, - policy(directory), - ), - ).toMatchObject({ ok: true }); - - const replayAuthorization = vi.fn(() => Promise.resolve(ok(null))); - await expect( - runCrossVersionInvestigation( - { - ...input, - workspace_path: resumePath, - replay_run_id: partialRun.run_id, - }, - policy(directory), - { inputRoots: [], authorizeInputRead: replayAuthorization }, - ), - ).resolves.toMatchObject({ - ok: false, - error: { - _tag: "InvestigationWorkspaceError", - reason: "revision-conflict", - }, - }); - expect(replayAuthorization).not.toHaveBeenCalled(); - - const resumed = await runCrossVersionInvestigation( - { ...input, workspace_path: resumePath }, - policy(directory), - { inputRoots: [directory] }, - ); - expect(resumed).toMatchObject({ - ok: true, - value: { - reused: false, - evidence: { evidence_id: completed.value.evidence.evidence_id }, - workspace: { revision: 2 }, - }, - }); - }); - - it("appends a changed-content run without replacing earlier Evidence", async () => { - const { right, input } = await fixture(); - if (directory === undefined) throw new Error("missing fixture root"); - const first = await runCrossVersionInvestigation(input, policy(directory), { - inputRoots: [directory], - }); - if (!first.ok) throw first.error; - const firstRun = first.value.workspace.runs[0]; - if (firstRun === undefined || firstRun.result_evidence_id === null) - throw new Error("missing first completed run"); - - await writeFile(join(right, "app.txt"), "version three\n"); - const second = await runCrossVersionInvestigation( - input, - policy(directory), - { inputRoots: [directory] }, - ); - expect(second).toMatchObject({ - ok: true, - value: { - reused: false, - workspace: { - revision: 6, - runs: [{ status: "complete" }, { status: "complete" }], - }, - }, - }); - if (!second.ok) throw second.error; - expect(second.value.workspace.runs).toHaveLength(2); - expect(second.value.workspace.runs.map(({ run_id: id }) => id)).toContain( - firstRun.run_id, - ); - expect( - second.value.workspace.bundle.records.some( - ({ evidence_id: id }) => id === firstRun.result_evidence_id, - ), - ).toBe(true); - }); - - it("checkpoints bounded integrity contradictions and safely reuses them", async () => { - directory = await createTestTempDirectory("rea-workspace-integrity-"); - const source = join(directory, "source"); - await mkdir(source); - await writeFile(join(source, "addon.node"), "verified native addon\n"); - const left = join(directory, "left.asar"); - const right = join(directory, "right.asar"); - await Promise.all([ - createPackageWithOptions(source, left, { unpack: "*.node" }), - createPackageWithOptions(source, right, { unpack: "*.node" }), - ]); - await writeFile(join(`${right}.unpacked`, "addon.node"), "tampered\n"); - const base = { - approved: true as const, - workspace_path: join(directory, "strict.json"), - workspace_name: "integrity-diff", - left_path: left, - right_path: right, - options: { page_size: 500, change_limit: 100 }, - }; - const strict = await runCrossVersionInvestigation( - crossVersionInvestigationInputSchema.parse(base), - policy(directory), - { inputRoots: [directory] }, - ); - expect(strict).toMatchObject({ - ok: false, - error: { _tag: "ArtifactOperationError", reason: "integrity" }, - }); - - const continuedInput = crossVersionInvestigationInputSchema.parse({ - ...base, - workspace_path: join(directory, "continued.json"), - integrity_policy: "record-and-continue", - integrity_continue_approved: true, - max_integrity_mismatches: 2, - }); - const continued = await runCrossVersionInvestigation( - continuedInput, - policy(directory), - { inputRoots: [directory], integrityContinueEnabled: true }, - ); - expect(continued).toMatchObject({ - ok: true, - value: { reused: false, workspace: { revision: 3 } }, - }); - if (!continued.ok) throw continued.error; - expect( - continued.value.workspace.bundle.records.some((record) => - JSON.stringify(record.normalized_result).includes( - '"integrity_contradictions":[{', - ), - ), - ).toBe(true); - expect( - continued.value.workspace.bundle.records.find( - ({ operation }) => operation === "compare_artifacts", - )?.normalized_result, - ).toMatchObject({ status: "contradiction" }); - await expect( - runCrossVersionInvestigation(continuedInput, policy(directory), { - inputRoots: [directory], - integrityContinueEnabled: true, - }), - ).resolves.toMatchObject({ ok: true, value: { reused: true } }); - - const strictAfterContinuation = crossVersionInvestigationInputSchema.parse({ - ...base, - workspace_path: continuedInput.workspace_path, - }); - await expect( - runCrossVersionInvestigation(strictAfterContinuation, policy(directory), { - inputRoots: [directory], - }), - ).resolves.toMatchObject({ - ok: false, - error: { _tag: "ArtifactOperationError", reason: "integrity" }, - }); - }); - - it("fails closed on locks, CAS conflicts, tampering, and cancellation", async () => { - const { path, input } = await fixture(); - if (directory === undefined) throw new Error("missing fixture root"); - await writeFile(`${path}.lock`, `${String(process.pid)}\n`, { - mode: 0o600, - }); - expect( - await runCrossVersionInvestigation(input, policy(directory), { - inputRoots: [directory], - }), - ).toMatchObject({ - ok: false, - error: { _tag: "InvestigationWorkspaceError", reason: "locked" }, - }); - await rm(`${path}.lock`); - await writeFile(`${path}.lock`, "occupied\n", { mode: 0o600 }); - expect( - await runCrossVersionInvestigation(input, policy(directory), { - inputRoots: [directory], - }), - ).toMatchObject({ - ok: false, - error: { _tag: "InvestigationWorkspaceError", reason: "locked" }, - }); - await rm(`${path}.lock`); - await writeFile(`${path}.lock`, "2147483647\n", { mode: 0o600 }); - const completed = await runCrossVersionInvestigation( - input, - policy(directory), - { inputRoots: [directory] }, - ); - if (!completed.ok) throw completed.error; - await expect(stat(`${path}.lock`)).rejects.toMatchObject({ - code: "ENOENT", - }); - expect( - await runCrossVersionInvestigation( - { ...input, expected_workspace_revision: 2 }, - policy(directory), - { inputRoots: [directory] }, - ), - ).toMatchObject({ - ok: false, - error: { - _tag: "InvestigationWorkspaceError", - reason: "revision-conflict", - }, - }); - - const decoded = JSON.parse(await readFile(path, "utf8")) as unknown; - if (typeof decoded !== "object" || decoded === null) - throw new Error("invalid workspace fixture"); - await chmod(path, 0o600); - await writeFile(path, JSON.stringify({ ...decoded, revision: 99 })); - expect( - await readInvestigationWorkspace(path, policy(directory)), - ).toMatchObject({ - ok: false, - error: { _tag: "InvestigationWorkspaceError", reason: "integrity" }, - }); - - const cancelledPath = join(directory, "cancelled.json"); - const controller = new AbortController(); - controller.abort(); - expect( - await runCrossVersionInvestigation( - { ...input, workspace_path: cancelledPath }, - policy(directory), - { inputRoots: [directory], signal: controller.signal }, - ), - ).toMatchObject({ - ok: false, - error: { _tag: "AnalysisCancelledError" }, - }); - }); - - it("rejects workspace paths and symlinks outside approved roots", async () => { - const { input } = await fixture(); - if (directory === undefined) throw new Error("missing fixture root"); - const approved = join(directory, "approved"); - await mkdir(approved); - expect( - await runCrossVersionInvestigation(input, policy(approved), { - inputRoots: [directory], - }), - ).toMatchObject({ - ok: false, - error: { - _tag: "InvestigationWorkspaceError", - reason: "outside-root", - }, - }); - - const outside = join(directory, "outside.json"); - await writeFile(outside, "{}", { mode: 0o600 }); - const escaped = join(approved, "escaped.json"); - await symlink(outside, escaped); - expect( - await runCrossVersionInvestigation( - { ...input, workspace_path: escaped }, - policy(approved), - { inputRoots: [directory] }, - ), - ).toMatchObject({ - ok: false, - error: { _tag: "InvestigationWorkspaceError", reason: "not-file" }, - }); - }); - - it("rejects artifact inputs outside independently approved roots", async () => { - const { input, path } = await fixture(); - if (directory === undefined) throw new Error("missing fixture root"); - const approvedInputs = join(directory, "approved-inputs"); - await mkdir(approvedInputs); - expect( - await runCrossVersionInvestigation(input, policy(directory), { - inputRoots: [approvedInputs], - }), - ).toMatchObject({ - ok: false, - error: { _tag: "ArtifactOperationError", reason: "path" }, - }); - await expect(stat(path)).rejects.toMatchObject({ code: "ENOENT" }); - }); }); diff --git a/tests/reconstructionCoverage.test.ts b/tests/composition/evidence-investigations/reconstructionCoverage.test.ts similarity index 95% rename from tests/reconstructionCoverage.test.ts rename to tests/composition/evidence-investigations/reconstructionCoverage.test.ts index b2e08053b..42522696f 100644 --- a/tests/reconstructionCoverage.test.ts +++ b/tests/composition/evidence-investigations/reconstructionCoverage.test.ts @@ -3,25 +3,25 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { readReconstructionCoverageWorkspace, writeReconstructionCoverageWorkspace, -} from "../src/application/ReconstructionCoverageWorkspaceStore.js"; +} from "../../../src/application/ReconstructionCoverageWorkspaceStore.js"; import { commitReconstructionCoverage, queryReconstructionCoverage, -} from "../src/application/ReconstructionCoverageService.js"; -import type { EvidenceFilePolicy } from "../src/domain/evidenceBundle.js"; +} from "../../../src/application/ReconstructionCoverageService.js"; +import type { EvidenceFilePolicy } from "../../../src/domain/evidenceBundle.js"; import { createReconstructionCoverageWorkspace, createReconstructionVerifierContract, evaluateReconstructionClosure, parseReconstructionCoverageWorkspace, -} from "../src/domain/reconstructionCoverage.js"; -import { createEvidence } from "../src/domain/evidence.js"; -import { createEvidenceBundle } from "../src/domain/evidenceBundle.js"; +} from "../../../src/domain/reconstructionCoverage.js"; +import { createEvidence } from "../../../src/domain/evidence.js"; +import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; const digest = (character: string): string => character.repeat(64); const evidenceRecords = ["1", "2", "3", "4", "5"].map((character) => @@ -287,7 +287,9 @@ describe("reconstruction coverage closure", () => { reasons: [expect.objectContaining({ code: "verifier-result-stale" })], }); }); +}); +describe("reconstruction coverage verifier observations", () => { it("orders offset-bearing verifier timestamps chronologically", () => { const workspace = completeWorkspace(); const result = workspace.verifier_results[0]; @@ -384,7 +386,9 @@ describe("reconstruction coverage closure", () => { ]), ); }); +}); +describe("reconstruction coverage persistence", () => { it("persists owner-only revisions with CAS and digest-chain enforcement", async () => { const root = await createTestTempDirectory("rea-coverage-workspace-"); const path = join(root, "coverage.json"); diff --git a/tests/composition/evidence-investigations/reconstructionObligationLedgerAuthority.test.ts b/tests/composition/evidence-investigations/reconstructionObligationLedgerAuthority.test.ts new file mode 100644 index 000000000..7c4b1f781 --- /dev/null +++ b/tests/composition/evidence-investigations/reconstructionObligationLedgerAuthority.test.ts @@ -0,0 +1,350 @@ +import { describe, expect, it } from "vitest"; + +import { PROCESS_PROVIDER } from "../../../src/application/ProcessEvidence.js"; +import { + buildReconstructionObligationLedgerEvidenceValidated, + resolveReconstructionObligationLedgerRequest, +} from "../../../src/application/ReconstructionObligationLedgerService.js"; +import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../../../src/contracts/processCaptureExample.js"; +import { createEvidence, type Evidence } from "../../../src/domain/evidence.js"; +import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; +import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; +import { processCaptureSchema } from "../../../src/domain/processCapture.js"; +import { + reconstructionObligationLedgerPageSchema, + type ReconstructionObligationLedgerInput, + type ReconstructionObligationLedgerPage, + type ReviewedReconstructionObligation, +} from "../../../src/domain/reconstructionObligationLedgerSchemas.js"; + +const proofEvidence = (id: string) => + createEvidence( + undefined, + { id: `fixture-${id}`, name: "Fixture verifier", version: "1" }, + { + predicateType: "rea.fixture-verification/v1", + operation: "run_fixture_verifier", + parameters: { id }, + result: { passed: true }, + confidence: "observed", + authority: "controlled-replay", + }, + ); + +const processEvidence = () => + createEvidence(undefined, PROCESS_PROVIDER, { + predicateType: "rea.process-capture/v4", + operation: "capture_process_scenario", + parameters: {}, + result: jsonValueSchema.parse( + processCaptureSchema.parse(EMPTY_PROCESS_CAPTURE_EXAMPLE), + ), + confidence: "observed", + authority: "controlled-replay", + }); + +const boundProofEvidence = ( + id: string, + result: { + readonly obligation_ids: readonly string[]; + readonly fixture_ids: readonly string[]; + readonly case_kinds: readonly string[]; + readonly verifier_ids: readonly string[]; + readonly claim_ids: readonly string[]; + }, + authority: "shipped-artifact" | "controlled-replay" = "controlled-replay", +) => + createEvidence( + undefined, + { id: `proof-${id}`, name: "Reconstruction proof", version: "1" }, + { + predicateType: "rea.reconstruction-proof/v1", + operation: "verify_reconstruction_obligations", + parameters: {}, + result: { + passed: true, + obligation_ids: [...result.obligation_ids], + fixture_ids: [...result.fixture_ids], + case_kinds: [...result.case_kinds], + verifier_ids: [...result.verifier_ids], + claim_ids: [...result.claim_ids], + }, + confidence: "observed", + authority, + }, + ); + +const request = ( + records: readonly Evidence[], + overrides: Partial = {}, +): ReconstructionObligationLedgerInput => ({ + evidence_bundle: createEvidenceBundle(records), + reviewed_obligations: [], + manifest: { schema_version: 1, bindings: [], contradictions: [] }, + limits: { max_obligations: 100 }, + page: { offset: 0, limit: 50 }, + ...overrides, +}); + +const build = ( + input: ReconstructionObligationLedgerInput, +): ReconstructionObligationLedgerPage => { + const parsed = resolveReconstructionObligationLedgerRequest(input); + if (!parsed.ok) throw parsed.error; + const result = buildReconstructionObligationLedgerEvidenceValidated( + parsed.value, + ); + if (!result.ok) throw result.error; + return reconstructionObligationLedgerPageSchema.parse( + result.value.normalized_result, + ); +}; + +const originalCases = ( + obligation: ReconstructionObligationLedgerPage["obligations"][number], + evidenceId: string, +) => + obligation.required_case_kinds.map((caseKind) => ({ + kind: caseKind, + evidence_id: evidenceId, + location: `/normalized_result/cases/${caseKind}`, + })); + +describe("reconstruction obligation ledger", () => { + it("does not authenticate packaged-process proof with generic replay evidence", () => { + const capture = processEvidence(); + const proof = proofEvidence("generic-packaged"); + const initial = build(request([capture, proof])); + const obligation = initial.obligations[0]; + if (obligation === undefined) + throw new Error("Expected process obligation"); + const result = build( + request([capture, proof], { + manifest: { + schema_version: 1, + bindings: [ + { + obligation_id: obligation.obligation_id, + owner: { + module_path: "src/processOwner.ts", + symbol: "runProcess", + owner_sha256: "5".repeat(64), + }, + parser_type: null, + original_cases: originalCases(obligation, capture.evidence_id), + fixtures: obligation.required_case_kinds.map((caseKind) => ({ + fixture_id: `packaged.${caseKind}`, + case_kind: caseKind, + authority: "packaged-process", + evidence_ids: [proof.evidence_id], + })), + verifier: { + verifier_id: "verifier.packaged", + claim_id: "claim.process", + command: "npm run verify:package -- process", + authority: "packaged-process", + status: "pass", + result_evidence_id: proof.evidence_id, + enumerated_obligation_ids: [obligation.obligation_id], + nondeterminism: { + mode: "partial-order", + specification: "Fixture scheduling is partially ordered.", + }, + }, + }, + ], + contradictions: [], + }, + }), + ); + + expect(result.obligations[0]?.diagnostics.map(({ code }) => code)).toEqual( + expect.arrayContaining([ + "weak-fixture-authority", + "weak-verifier-authority", + ]), + ); + }); + + it("propagates an open dependency through the full obligation chain", () => { + const ids = ["obl.chain.a", "obl.chain.b", "obl.chain.c"] as const; + const evidence = boundProofEvidence( + "fixture-chain", + { + obligation_ids: ids, + fixture_ids: ids.map((id) => `fixture.${id}`), + case_kinds: ["positive"], + verifier_ids: ids.map((id) => `verifier.${id}`), + claim_ids: ids.map((id) => `claim.${id}`), + }, + "shipped-artifact", + ); + const reviewed = ids.map((id, index) => ({ + ...reviewedObligation(id, evidence.evidence_id), + dependency_obligation_ids: + index === 0 ? [ids[1]] : index === 1 ? [ids[2]] : ["obl.chain.missing"], + })); + const bindings = reviewed.map((obligation) => ({ + obligation_id: obligation.obligation_id, + owner: { + module_path: `src/${obligation.obligation_id}.ts`, + symbol: "verify", + owner_sha256: "6".repeat(64), + }, + parser_type: null, + original_cases: [ + { + kind: "positive" as const, + evidence_id: evidence.evidence_id, + location: "/fixture/positive", + }, + ], + fixtures: [ + { + fixture_id: `fixture.${obligation.obligation_id}`, + case_kind: "positive" as const, + authority: "unit" as const, + evidence_ids: [evidence.evidence_id], + }, + ], + verifier: { + verifier_id: `verifier.${obligation.obligation_id}`, + claim_id: `claim.${obligation.obligation_id}`, + command: "npm test -- fixture-chain", + authority: "unit" as const, + status: "pass" as const, + result_evidence_id: evidence.evidence_id, + enumerated_obligation_ids: [obligation.obligation_id], + nondeterminism: { + mode: "total-order" as const, + specification: "Fixture execution is ordered.", + }, + }, + })); + const result = build( + request([evidence], { + reviewed_obligations: reviewed, + manifest: { schema_version: 1, bindings, contradictions: [] }, + }), + ); + + expect(result.obligations.map(({ status }) => status)).toEqual([ + "blocked", + "blocked", + "blocked", + ]); + expect( + result.obligations.map(({ diagnostics }) => + diagnostics.map(({ code }) => code), + ), + ).toEqual([ + ["dependency-open"], + ["dependency-open"], + ["dependency-missing"], + ]); + }); +}); + +describe("reconstruction obligation ledger fail-closed behavior", () => { + it("fails closed on parser gaps, ambiguous ownership, and contradiction", () => { + const original = createEvidence( + undefined, + { id: "external", name: "External protocol observer", version: "1" }, + { + predicateType: "rea.protocol-observation/v1", + operation: "observe_protocol", + parameters: {}, + result: { status: 200 }, + confidence: "observed", + authority: "external-service", + }, + ); + const proof = proofEvidence("protocol"); + const reviewed: ReviewedReconstructionObligation = { + ...reviewedObligation("obl.protocol", original.evidence_id), + application_layer: "protocol" as const, + family: "json-rpc", + required_original_authority: "external" as const, + required_fixture_authority: "protocol" as const, + required_verifier_authority: "protocol" as const, + requires_parser_type: true, + required_case_kinds: ["positive", "negative", "malformed"], + }; + const binding = { + obligation_id: reviewed.obligation_id, + owner: { + module_path: "src/protocol.ts", + symbol: "handleRequest", + owner_sha256: "3".repeat(64), + }, + parser_type: null, + original_cases: reviewed.required_case_kinds.map((caseKind) => ({ + kind: caseKind, + evidence_id: original.evidence_id, + location: `/protocol/${caseKind}`, + })), + fixtures: [ + { + fixture_id: "fixture.positive", + case_kind: "positive" as const, + authority: "unit" as const, + evidence_ids: [proof.evidence_id], + }, + ], + verifier: null, + }; + const result = build( + request([original, proof], { + reviewed_obligations: [reviewed], + manifest: { + schema_version: 1, + bindings: [binding, binding], + contradictions: [ + { + obligation_id: reviewed.obligation_id, + evidence_ids: [original.evidence_id, proof.evidence_id], + }, + ], + }, + }), + ); + + expect(result.status).toBe("failed"); + expect(result.obligations[0]?.status).toBe("contradicted"); + expect(result.obligations[0]?.diagnostics.map(({ code }) => code)).toEqual( + expect.arrayContaining(["ambiguous-owner", "contradiction"]), + ); + expect(result.reports.contradicted_obligation_ids).toEqual([ + reviewed.obligation_id, + ]); + }); +}); + +const reviewedObligation = ( + obligationId: string, + evidenceId: string, +): ReviewedReconstructionObligation => ({ + obligation_id: obligationId, + obligation_version: 1, + title: `Reviewed obligation ${obligationId}`, + application_layer: "other", + family: "reviewed", + target: { + artifact_sha256: null, + application_node_id: null, + semantic_node_id: null, + location: `/reviewed/${obligationId}`, + }, + required: true, + required_case_kinds: ["positive"], + required_original_authority: "static", + required_fixture_authority: "unit", + required_verifier_authority: "unit", + requires_parser_type: false, + dependency_obligation_ids: [], + residual_unknown_ids: [], + unavailable_authority: [], + required_next_evidence: ["Bind an owner and verifier."], + disposition: "active", + review_evidence_ids: [evidenceId], +}); diff --git a/tests/reconstructionObligationLedger.test.ts b/tests/composition/evidence-investigations/reconstructionObligationLedgerClosure.test.ts similarity index 62% rename from tests/reconstructionObligationLedger.test.ts rename to tests/composition/evidence-investigations/reconstructionObligationLedgerClosure.test.ts index e1339d0f9..07b30eb1f 100644 --- a/tests/reconstructionObligationLedger.test.ts +++ b/tests/composition/evidence-investigations/reconstructionObligationLedgerClosure.test.ts @@ -1,22 +1,22 @@ import { describe, expect, it } from "vitest"; -import { PROCESS_PROVIDER } from "../src/application/ProcessEvidence.js"; +import { PROCESS_PROVIDER } from "../../../src/application/ProcessEvidence.js"; import { buildReconstructionObligationLedgerEvidenceValidated, resolveReconstructionObligationLedgerRequest, -} from "../src/application/ReconstructionObligationLedgerService.js"; -import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../src/contracts/processCaptureExample.js"; -import { createEvidence, type Evidence } from "../src/domain/evidence.js"; -import { createEvidenceBundle } from "../src/domain/evidenceBundle.js"; -import { jsonValueSchema } from "../src/domain/jsonValue.js"; -import { processCaptureSchema } from "../src/domain/processCapture.js"; +} from "../../../src/application/ReconstructionObligationLedgerService.js"; +import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../../../src/contracts/processCaptureExample.js"; +import { createEvidence, type Evidence } from "../../../src/domain/evidence.js"; +import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; +import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; +import { processCaptureSchema } from "../../../src/domain/processCapture.js"; import { reconstructionObligationLedgerPageSchema, serializeReconstructionObligationLedger, type ReconstructionObligationLedgerInput, type ReconstructionObligationLedgerPage, type ReviewedReconstructionObligation, -} from "../src/domain/reconstructionObligationLedgerSchemas.js"; +} from "../../../src/domain/reconstructionObligationLedgerSchemas.js"; const proofEvidence = (id: string) => createEvidence( @@ -121,6 +121,35 @@ const originalCases = ( location: `/normalized_result/cases/${caseKind}`, })); +const reviewedObligation = ( + obligationId: string, + evidenceId: string, +): ReviewedReconstructionObligation => ({ + obligation_id: obligationId, + obligation_version: 1, + title: `Reviewed obligation ${obligationId}`, + application_layer: "other", + family: "reviewed", + target: { + artifact_sha256: null, + application_node_id: null, + semantic_node_id: null, + location: `/reviewed/${obligationId}`, + }, + required: true, + required_case_kinds: ["positive"], + required_original_authority: "static", + required_fixture_authority: "unit", + required_verifier_authority: "unit", + requires_parser_type: false, + dependency_obligation_ids: [], + residual_unknown_ids: [], + unavailable_authority: [], + required_next_evidence: ["Bind an owner and verifier."], + disposition: "active", + review_evidence_ids: [evidenceId], +}); + describe("reconstruction obligation ledger", () => { it("emits deterministic closure identity and paging", () => { const evidence = proofEvidence("review"); @@ -220,7 +249,9 @@ describe("reconstruction obligation ledger", () => { ); expect(result.status).toBe("open"); }); +}); +describe("reconstruction obligation ledger case closure", () => { it("keeps unobserved original cases open", () => { const capture = processEvidence(); const proof = proofEvidence("packaged-original-gap"); @@ -349,239 +380,4 @@ describe("reconstruction obligation ledger", () => { expect(result.status).toBe("ready"); expect(result.summary.required_open).toBe(0); }); - - it("does not authenticate packaged-process proof with generic replay evidence", () => { - const capture = processEvidence(); - const proof = proofEvidence("generic-packaged"); - const initial = build(request([capture, proof])); - const obligation = initial.obligations[0]; - if (obligation === undefined) - throw new Error("Expected process obligation"); - const result = build( - request([capture, proof], { - manifest: { - schema_version: 1, - bindings: [ - { - obligation_id: obligation.obligation_id, - owner: { - module_path: "src/processOwner.ts", - symbol: "runProcess", - owner_sha256: "5".repeat(64), - }, - parser_type: null, - original_cases: originalCases(obligation, capture.evidence_id), - fixtures: obligation.required_case_kinds.map((caseKind) => ({ - fixture_id: `packaged.${caseKind}`, - case_kind: caseKind, - authority: "packaged-process", - evidence_ids: [proof.evidence_id], - })), - verifier: { - verifier_id: "verifier.packaged", - claim_id: "claim.process", - command: "npm run verify:package -- process", - authority: "packaged-process", - status: "pass", - result_evidence_id: proof.evidence_id, - enumerated_obligation_ids: [obligation.obligation_id], - nondeterminism: { - mode: "partial-order", - specification: "Fixture scheduling is partially ordered.", - }, - }, - }, - ], - contradictions: [], - }, - }), - ); - - expect(result.obligations[0]?.diagnostics.map(({ code }) => code)).toEqual( - expect.arrayContaining([ - "weak-fixture-authority", - "weak-verifier-authority", - ]), - ); - }); - - it("propagates an open dependency through the full obligation chain", () => { - const ids = ["obl.chain.a", "obl.chain.b", "obl.chain.c"] as const; - const evidence = boundProofEvidence( - "fixture-chain", - { - obligation_ids: ids, - fixture_ids: ids.map((id) => `fixture.${id}`), - case_kinds: ["positive"], - verifier_ids: ids.map((id) => `verifier.${id}`), - claim_ids: ids.map((id) => `claim.${id}`), - }, - "shipped-artifact", - ); - const reviewed = ids.map((id, index) => ({ - ...reviewedObligation(id, evidence.evidence_id), - dependency_obligation_ids: - index === 0 ? [ids[1]] : index === 1 ? [ids[2]] : ["obl.chain.missing"], - })); - const bindings = reviewed.map((obligation) => ({ - obligation_id: obligation.obligation_id, - owner: { - module_path: `src/${obligation.obligation_id}.ts`, - symbol: "verify", - owner_sha256: "6".repeat(64), - }, - parser_type: null, - original_cases: [ - { - kind: "positive" as const, - evidence_id: evidence.evidence_id, - location: "/fixture/positive", - }, - ], - fixtures: [ - { - fixture_id: `fixture.${obligation.obligation_id}`, - case_kind: "positive" as const, - authority: "unit" as const, - evidence_ids: [evidence.evidence_id], - }, - ], - verifier: { - verifier_id: `verifier.${obligation.obligation_id}`, - claim_id: `claim.${obligation.obligation_id}`, - command: "npm test -- fixture-chain", - authority: "unit" as const, - status: "pass" as const, - result_evidence_id: evidence.evidence_id, - enumerated_obligation_ids: [obligation.obligation_id], - nondeterminism: { - mode: "total-order" as const, - specification: "Fixture execution is ordered.", - }, - }, - })); - const result = build( - request([evidence], { - reviewed_obligations: reviewed, - manifest: { schema_version: 1, bindings, contradictions: [] }, - }), - ); - - expect(result.obligations.map(({ status }) => status)).toEqual([ - "blocked", - "blocked", - "blocked", - ]); - expect( - result.obligations.map(({ diagnostics }) => - diagnostics.map(({ code }) => code), - ), - ).toEqual([ - ["dependency-open"], - ["dependency-open"], - ["dependency-missing"], - ]); - }); - - it("fails closed on parser gaps, ambiguous ownership, and contradiction", () => { - const original = createEvidence( - undefined, - { id: "external", name: "External protocol observer", version: "1" }, - { - predicateType: "rea.protocol-observation/v1", - operation: "observe_protocol", - parameters: {}, - result: { status: 200 }, - confidence: "observed", - authority: "external-service", - }, - ); - const proof = proofEvidence("protocol"); - const reviewed: ReviewedReconstructionObligation = { - ...reviewedObligation("obl.protocol", original.evidence_id), - application_layer: "protocol" as const, - family: "json-rpc", - required_original_authority: "external" as const, - required_fixture_authority: "protocol" as const, - required_verifier_authority: "protocol" as const, - requires_parser_type: true, - required_case_kinds: ["positive", "negative", "malformed"], - }; - const binding = { - obligation_id: reviewed.obligation_id, - owner: { - module_path: "src/protocol.ts", - symbol: "handleRequest", - owner_sha256: "3".repeat(64), - }, - parser_type: null, - original_cases: reviewed.required_case_kinds.map((caseKind) => ({ - kind: caseKind, - evidence_id: original.evidence_id, - location: `/protocol/${caseKind}`, - })), - fixtures: [ - { - fixture_id: "fixture.positive", - case_kind: "positive" as const, - authority: "unit" as const, - evidence_ids: [proof.evidence_id], - }, - ], - verifier: null, - }; - const result = build( - request([original, proof], { - reviewed_obligations: [reviewed], - manifest: { - schema_version: 1, - bindings: [binding, binding], - contradictions: [ - { - obligation_id: reviewed.obligation_id, - evidence_ids: [original.evidence_id, proof.evidence_id], - }, - ], - }, - }), - ); - - expect(result.status).toBe("failed"); - expect(result.obligations[0]?.status).toBe("contradicted"); - expect(result.obligations[0]?.diagnostics.map(({ code }) => code)).toEqual( - expect.arrayContaining(["ambiguous-owner", "contradiction"]), - ); - expect(result.reports.contradicted_obligation_ids).toEqual([ - reviewed.obligation_id, - ]); - }); -}); - -const reviewedObligation = ( - obligationId: string, - evidenceId: string, -): ReviewedReconstructionObligation => ({ - obligation_id: obligationId, - obligation_version: 1, - title: `Reviewed obligation ${obligationId}`, - application_layer: "other", - family: "reviewed", - target: { - artifact_sha256: null, - application_node_id: null, - semantic_node_id: null, - location: `/reviewed/${obligationId}`, - }, - required: true, - required_case_kinds: ["positive"], - required_original_authority: "static", - required_fixture_authority: "unit", - required_verifier_authority: "unit", - requires_parser_type: false, - dependency_obligation_ids: [], - residual_unknown_ids: [], - unavailable_authority: [], - required_next_evidence: ["Bind an owner and verifier."], - disposition: "active", - review_evidence_ids: [evidenceId], }); diff --git a/tests/reconstructionReadiness.test.ts b/tests/composition/evidence-investigations/reconstructionReadiness.test.ts similarity index 94% rename from tests/reconstructionReadiness.test.ts rename to tests/composition/evidence-investigations/reconstructionReadiness.test.ts index ad2b2c7ef..9c6b276c7 100644 --- a/tests/reconstructionReadiness.test.ts +++ b/tests/composition/evidence-investigations/reconstructionReadiness.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; -import { RECONSTRUCTION_READINESS_EXAMPLE } from "../src/contracts/reconstructionReadinessExample.js"; -import { createReconstructionReadinessReport } from "../src/domain/reconstructionReadiness.js"; -import type { ReconstructionReadinessInput } from "../src/domain/reconstructionReadinessSchemas.js"; +import { RECONSTRUCTION_READINESS_EXAMPLE } from "../../../src/contracts/reconstructionReadinessExample.js"; +import { createReconstructionReadinessReport } from "../../../src/domain/reconstructionReadiness.js"; +import type { ReconstructionReadinessInput } from "../../../src/domain/reconstructionReadinessSchemas.js"; const input = ( overrides: Partial = {}, diff --git a/tests/reconstructionVerification.test.ts b/tests/composition/evidence-investigations/reconstructionVerification.test.ts similarity index 94% rename from tests/reconstructionVerification.test.ts rename to tests/composition/evidence-investigations/reconstructionVerification.test.ts index 9d2900272..f0e5f047d 100644 --- a/tests/reconstructionVerification.test.ts +++ b/tests/composition/evidence-investigations/reconstructionVerification.test.ts @@ -1,15 +1,15 @@ import { describe, expect, it } from "vitest"; -import { createEvidence, type Evidence } from "../src/domain/evidence.js"; -import { createEvidenceBundle } from "../src/domain/evidenceBundle.js"; +import { createEvidence, type Evidence } from "../../../src/domain/evidence.js"; +import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; import { reconstructionVerificationResultSchema, verifyReconstruction, -} from "../src/domain/reconstructionVerification.js"; -import type { JsonValue } from "../src/domain/jsonValue.js"; -import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../src/contracts/processCaptureExample.js"; -import { ARTIFACT_COMPARISON_EXAMPLE } from "../src/contracts/artifactComparisonExample.js"; -import { createResidualUnknown } from "../src/domain/residualUnknown.js"; +} from "../../../src/domain/reconstructionVerification.js"; +import type { JsonValue } from "../../../src/domain/jsonValue.js"; +import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../../../src/contracts/processCaptureExample.js"; +import { ARTIFACT_COMPARISON_EXAMPLE } from "../../../src/contracts/artifactComparisonExample.js"; +import { createResidualUnknown } from "../../../src/domain/residualUnknown.js"; const environment = { id: "fixture-linux", @@ -295,7 +295,9 @@ describe("reconstruction verification", () => { expect(result.status).toBe("unknown"); expect(result.recommended_probes[0]?.operation).toBe("inventory_artifact"); }); +}); +describe("reconstruction verification integrity", () => { it("rejects dangling or extra comparison closure", () => { const left = source("1", "controlled-replay"); const right = source("2", "controlled-replay"); diff --git a/tests/residualUnknown.test.ts b/tests/composition/evidence-investigations/residualUnknown.test.ts similarity index 96% rename from tests/residualUnknown.test.ts rename to tests/composition/evidence-investigations/residualUnknown.test.ts index 46dc86c47..7aaa2ffba 100644 --- a/tests/residualUnknown.test.ts +++ b/tests/composition/evidence-investigations/residualUnknown.test.ts @@ -1,19 +1,19 @@ import { describe, expect, it } from "vitest"; -import { EvidenceLedger } from "../src/application/EvidenceLedger.js"; -import { createEvidence } from "../src/domain/evidence.js"; +import { EvidenceLedger } from "../../../src/application/EvidenceLedger.js"; +import { createEvidence } from "../../../src/domain/evidence.js"; import { createEvidenceBundle, parseEvidenceBundle, serializeEvidenceBundle, -} from "../src/domain/evidenceBundle.js"; +} from "../../../src/domain/evidenceBundle.js"; import { createResidualUnknown, recordUnknownInputSchema, updateUnknownInputSchema, type RecordUnknownInput, type ResidualUnknown, -} from "../src/domain/residualUnknown.js"; +} from "../../../src/domain/residualUnknown.js"; const provider = { id: "fixture", name: "Fixture", version: "1" }; const ledger = (): EvidenceLedger => @@ -77,7 +77,7 @@ const update = ( ...overrides, }); -describe("residual unknown registry", () => { +describe("residual unknown registry reads and identity", () => { it("returns detached unknowns and evidence bundles from every read surface", () => { const store = ledger(); expect(store.record(evidence("detached-record")).ok).toBe(true); @@ -174,7 +174,9 @@ describe("residual unknown registry", () => { 1, 2, ]); }); +}); +describe("residual unknown registry resolution and history", () => { it("rejects inference for observed resolution, then accepts matching live evidence", () => { const store = ledger(); const inferred = evidence("inference", "inferred"); diff --git a/tests/bundleComparison.test.ts b/tests/composition/javascript-applications/bundleComparison.test.ts similarity index 95% rename from tests/bundleComparison.test.ts rename to tests/composition/javascript-applications/bundleComparison.test.ts index 69e0f5a3e..6e669ee44 100644 --- a/tests/bundleComparison.test.ts +++ b/tests/composition/javascript-applications/bundleComparison.test.ts @@ -3,15 +3,15 @@ import { describe, expect, it } from "vitest"; import { bundleComparisonResultSchema, compareBundles, -} from "../src/domain/bundleComparison.js"; -import { createEvidence } from "../src/domain/evidence.js"; -import { createEvidenceBundle } from "../src/domain/evidenceBundle.js"; +} from "../../../src/domain/bundleComparison.js"; +import { createEvidence } from "../../../src/domain/evidence.js"; +import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; import { createResidualUnknown, recordUnknownInputSchema, updateResidualUnknown, updateUnknownInputSchema, -} from "../src/domain/residualUnknown.js"; +} from "../../../src/domain/residualUnknown.js"; const PROVIDER = { id: "fixture", name: "Fixture", version: "1" } as const; const evidence = (label: string) => @@ -123,7 +123,9 @@ describe("bundle comparison", () => { ), ).toThrow(/duplicate record IDs/u); }); +}); +describe("bundle comparison history", () => { it("distinguishes advanced and missing unknown histories from equality", () => { const mutationOne = evidence("mutation-one"); const initial = createResidualUnknown( diff --git a/tests/composition/javascript-applications/electronApplicationAnalysis.test.ts b/tests/composition/javascript-applications/electronApplicationAnalysis.test.ts new file mode 100644 index 000000000..96f1ba8e3 --- /dev/null +++ b/tests/composition/javascript-applications/electronApplicationAnalysis.test.ts @@ -0,0 +1,340 @@ +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +import { analyzeJavaScriptApplication } from "../../../src/application/JavaScriptApplicationService.js"; +import { PermissionAuthority } from "../../../src/application/PermissionAuthority.js"; +import { reconstructJavaScriptArtifact } from "../../../src/application/JavaScriptArtifactReconstruction.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { parseJavaScriptApplicationGraph } from "../../../src/domain/javascriptApplicationGraph.js"; +import { analyzeJavaScriptApplicationInputSchema } from "../../../src/domain/javascriptApplicationAnalysis.js"; +import { createPermissionPolicy } from "../../../src/domain/permissionPolicy.js"; +import { writeElectronBoundaryFixture } from "../../fixtures/electronBoundaryApplication.js"; + +describe("static Electron application analysis", () => { + it("requires explicit approval, an absolute path, and graph-safe combined bounds", () => { + expect( + analyzeJavaScriptApplicationInputSchema.safeParse({ + input_path: "relative/app.asar", + approved: true, + }).success, + ).toBe(false); + expect( + analyzeJavaScriptApplicationInputSchema.safeParse({ + input_path: "/tmp/app.asar", + }).success, + ).toBe(false); + expect( + analyzeJavaScriptApplicationInputSchema.safeParse({ + input_path: "/tmp/app.asar", + approved: true, + limits: { + max_entries: 100_000, + max_findings: 200_000, + }, + }).success, + ).toBe(false); + expect( + analyzeJavaScriptApplicationInputSchema.parse({ + input_path: "/tmp/app.asar", + approved: true, + }), + ).toMatchObject({ + format: "auto", + source_map_read_approved: false, + limits: { max_findings: 8_000 }, + }); + }); + + it("maps windows, preload, contextBridge, IPC, validations, utility, and native boundaries without execution", async () => { + const root = await fixtureDirectory(); + Reflect.deleteProperty(globalThis, "__rea_electron_fixture_executed"); + + const first = await reconstructJavaScriptArtifact({ input_path: root }); + const second = await reconstructJavaScriptArtifact({ input_path: root }); + const graph = parseJavaScriptApplicationGraph(first.graph); + + expect( + Reflect.get(globalThis, "__rea_electron_fixture_executed"), + ).toBeUndefined(); + expect(first.graph).toEqual(second.graph); + expectElectronSummary(first); + expectElectronRoles(graph); + expectElectronBoundaries(graph); + }); + + it("returns Evidence v2 only after exact investigation-input authorization", async () => { + const root = await fixtureDirectory(); + const authority = permissionAuthority(root); + + const result = await analyzeJavaScriptApplication(authority, { + input_path: root, + approved: true, + }); + + if (!result.ok) throw result.error; + const evidence = parseEvidence(result.value); + expect(evidence).toMatchObject({ + operation: "analyze_javascript_application", + predicate_type: "rea.javascript-application-analysis/v2", + provider: { id: "rea-javascript-application" }, + authority: "shipped-artifact", + confidence: "derived", + subject: { local_path: root, format: "directory" }, + normalized_result: { + schema_version: 2, + input_path: root, + summary: { browser_windows: 3 }, + }, + }); + + const denied = await analyzeJavaScriptApplication( + permissionAuthority(root), + { + input_path: join(root, ".."), + approved: true, + }, + ); + expect(denied).toMatchObject({ + ok: false, + error: { _tag: "PermissionRequiredError" }, + }); + }); + + it("returns a tagged cancellation without executing application code", async () => { + const root = await fixtureDirectory(); + const controller = new AbortController(); + controller.abort(); + Reflect.deleteProperty(globalThis, "__rea_electron_fixture_executed"); + + const result = await analyzeJavaScriptApplication( + permissionAuthority(root), + { input_path: root, approved: true }, + { signal: controller.signal }, + ); + + expect(result).toMatchObject({ + ok: false, + error: { _tag: "ArtifactOperationError", reason: "cancelled" }, + }); + expect( + Reflect.get(globalThis, "__rea_electron_fixture_executed"), + ).toBeUndefined(); + }); +}); + +type Reconstruction = Awaited>; +type ApplicationGraph = ReturnType; + +const expectElectronSummary = (result: Reconstruction): void => { + expect(result.electron_summary).toMatchObject({ + browser_windows: 3, + explicit_web_preferences: 8, + preload_entrypoints: 1, + context_bridge_apis: 2, + exposed_api_members: 5, + ipc: { + literal_channels: 5, + dynamic_channel_operations: 2, + paired_renderer_transmissions: 4, + ambiguous_renderer_transmissions: 1, + unpaired_literal_renderer_transmissions: 1, + }, + sender_validation_observations: 2, + utility_processes: 1, + resolved_utility_entrypoints: 1, + native_addon_bindings: 4, + resolved_native_addon_bindings: 4, + }); +}; + +const expectElectronRoles = (graph: ApplicationGraph): void => { + expect(graph.nodes.map(({ kind }) => kind)).toEqual( + expect.arrayContaining([ + "browser-window", + "electron-preload", + "context-bridge-api", + "ipc-channel", + "ipc-handler", + "electron-utility", + "native-addon", + "native-export", + ]), + ); + const roles = graph.nodes.filter(({ kind }) => + ["electron-main", "electron-preload", "electron-renderer"].includes(kind), + ); + expect(roles.length).toBeGreaterThanOrEqual(3); + for (const role of roles) { + for (const observation of role.observations) + expect(observation.properties).toMatchObject({ + declared_path: expect.any(String), + resolution_context: expect.stringMatching( + /^(package-entrypoint|filesystem-expression|module-specifier|html-reference)$/u, + ), + resolution_status: expect.stringMatching( + /^(resolved|not-found|unavailable|external|rejected)$/u, + ), + limitations: expect.any(Array), + }); + if ( + role.observations.some( + ({ properties }) => properties.resolution_status === "resolved", + ) + ) + expect( + graph.edges.some( + (edge) => + edge.source_node_id === role.node_id && edge.relation === "maps_to", + ), + ).toBe(true); + } + const dirnamePreload = roles.find( + ({ kind, observations }) => + kind === "electron-preload" && + observations.some( + ({ properties }) => + properties.declared_path === "preload.js" && + properties.resolution_context === "filesystem-expression", + ), + ); + expect(dirnamePreload?.observations[0]?.properties).toMatchObject({ + declared_path: "preload.js", + resolution_context: "filesystem-expression", + resolved_path: "preload.js", + resolution_status: "resolved", + limitations: [], + }); +}; + +const expectElectronBoundaries = (graph: ApplicationGraph): void => { + expect(graph.nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "browser-window", + observations: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + preload_path: "preload.js", + absence_means_default: false, + web_preferences: expect.arrayContaining([ + expect.objectContaining({ + name: "nodeIntegration", + value: expect.objectContaining({ + status: "literal", + value: true, + }), + }), + ]), + }), + }), + ]), + }), + expect.objectContaining({ + kind: "browser-window", + observations: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + web_preferences: expect.arrayContaining([ + expect.objectContaining({ + name: "nodeIntegration", + value: expect.objectContaining({ + status: "literal", + value: false, + }), + }), + expect.objectContaining({ + name: "contextIsolation", + value: expect.objectContaining({ + status: "literal", + value: true, + }), + }), + ]), + }), + }), + ]), + }), + expect.objectContaining({ + kind: "context-bridge-api", + observations: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + api_key: "reaApi", + members: expect.arrayContaining([ + "nested", + "nested.write", + "read", + ]), + }), + }), + ]), + }), + expect.objectContaining({ + kind: "ipc-handler", + observations: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + fact_kind: "sender-validation-candidate", + enforcement: "unknown", + }), + }), + ]), + }), + expect.objectContaining({ + kind: "native-export", + observations: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + native_export_verification: "not-performed", + }), + }), + ]), + }), + ]), + ); + const pairingEdges = graph.edges.filter( + ({ properties }) => + properties.pairing_basis === "unique-exact-literal-channel", + ); + expect(pairingEdges).toHaveLength(4); + expect( + pairingEdges.some( + ({ properties }) => properties.channel === "rea:ambiguous", + ), + ).toBe(false); + expect(graph.limitations.join(" ")).toMatch(/dynamic IPC|ambiguous/iu); +}; + +const fixtureDirectory = async (): Promise => { + const root = await createTestTempDirectory("rea-electron-boundaries-"); + await writeElectronBoundaryFixture(root); + return root; +}; + +const permissionAuthority = (root: string): PermissionAuthority => + new PermissionAuthority( + createPermissionPolicy( + [scope(root)], + [ + { + ...scope(root), + grant_id: "test:investigation-input", + lifetime: "session", + operation_identity: null, + expires_at: null, + }, + ], + ), + ); + +const scope = (root: string) => ({ + capability: "investigation_input" as const, + roots: [root], + executables: [], + environment_names: [], + network: "none" as const, + mount: false, +}); diff --git a/tests/composition/javascript-applications/javascriptApplicationGraph.01-defines-the-complete-provider-neutral.test.ts b/tests/composition/javascript-applications/javascriptApplicationGraph.01-defines-the-complete-provider-neutral.test.ts new file mode 100644 index 000000000..bdbf8b93b --- /dev/null +++ b/tests/composition/javascript-applications/javascriptApplicationGraph.01-defines-the-complete-provider-neutral.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; + +import { + computeJavaScriptApplicationGraphSha256, + createJavaScriptApplicationGraph, + javascriptApplicationGraphSchema, + parseJavaScriptApplicationGraph, + serializeJavaScriptApplicationGraph, + type ApplicationNode, + type JavaScriptApplicationGraph, +} from "../../../src/domain/javascriptApplicationGraph.js"; +import { + JAVASCRIPT_APPLICATION_NODE_KINDS, + JAVASCRIPT_APPLICATION_RELATIONS, +} from "../../../src/domain/javascriptApplicationGraphSchemas.js"; +import { buildSyntheticJavaScriptApplicationGraph } from "../../fixtures/javascriptApplicationGraph.js"; + +const nodeByLabel = ( + graph: JavaScriptApplicationGraph, + label: string, +): ApplicationNode => { + const node = graph.nodes.find(({ observations }) => + observations.some((observation) => observation.label === label), + ); + if (node === undefined) throw new TypeError(`Missing fixture node: ${label}`); + return node; +}; + +describe("JavaScript Application Graph", () => { + it("defines the complete provider-neutral v1 node and relation vocabulary", () => { + expect(JAVASCRIPT_APPLICATION_NODE_KINDS).toEqual([ + "package", + "installer", + "artifact", + "asar-entry", + "electron-main", + "electron-preload", + "electron-renderer", + "electron-utility", + "javascript-asset", + "javascript-chunk", + "javascript-module", + "source-map", + "source-module", + "browser-window", + "frame", + "target", + "context-bridge-api", + "ipc-channel", + "ipc-handler", + "worker", + "service-worker", + "endpoint", + "storage", + "native-addon", + "native-export", + "managed-assembly", + "managed-module", + "managed-type", + "managed-method", + "managed-field", + "managed-pinvoke-import", + "managed-native-implementation", + "runtime-script-instance", + "unknown", + ]); + expect(JAVASCRIPT_APPLICATION_RELATIONS).toEqual([ + "contains", + "loads", + "imports", + "maps_to", + "exposes", + "sends", + "invokes", + "handles", + "calls", + "persists_to", + "observed_as", + "changed_from", + ]); + }); + + it("round-trips one canonical, versioned, byte-stable graph", () => { + const graph = buildSyntheticJavaScriptApplicationGraph(); + const serialized = serializeJavaScriptApplicationGraph(graph); + const decoded: unknown = JSON.parse(serialized); + + expect(parseJavaScriptApplicationGraph(decoded)).toEqual(graph); + expect(javascriptApplicationGraphSchema.parse(decoded)).toEqual(graph); + expect(serializeJavaScriptApplicationGraph(decoded)).toBe(serialized); + expect(computeJavaScriptApplicationGraphSha256(decoded)).toMatch( + /^[a-f0-9]{64}$/u, + ); + + const { graph_id: _graphId, ...semantic } = graph; + expect( + createJavaScriptApplicationGraph({ + ...semantic, + nodes: semantic.nodes.toReversed(), + edges: semantic.edges.toReversed(), + }), + ).toEqual(graph); + }); + + it("represents the synthetic ASAR to preload to IPC to native chain", () => { + const graph = buildSyntheticJavaScriptApplicationGraph(); + const labels = [ + "resources/app.asar", + "desktop preload", + "desktopApi", + "project:open", + "project open handler", + "synthetic.node", + "openProject", + ]; + const chain = labels.map((label) => nodeByLabel(graph, label)); + const expectedRelations = [ + "contains", + "exposes", + "invokes", + "handles", + "loads", + "contains", + ]; + + for (let index = 0; index < expectedRelations.length; index += 1) + expect(graph.edges).toContainEqual( + expect.objectContaining({ + source_node_id: chain[index]?.node_id, + target_node_id: chain[index + 1]?.node_id, + relation: expectedRelations[index], + }), + ); + expect(chain.at(-2)?.kind).toBe("native-addon"); + expect(chain.at(-1)?.kind).toBe("native-export"); + }); +}); diff --git a/tests/composition/javascript-applications/javascriptApplicationGraph.02-keeps-static-observations-static-inferences.test.ts b/tests/composition/javascript-applications/javascriptApplicationGraph.02-keeps-static-observations-static-inferences.test.ts new file mode 100644 index 000000000..556b9c5d0 --- /dev/null +++ b/tests/composition/javascript-applications/javascriptApplicationGraph.02-keeps-static-observations-static-inferences.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from "vitest"; + +import { + createJavaScriptApplicationGraph, + createJavaScriptApplicationNode, + type ApplicationGraphEvidence, + type ApplicationNode, + type JavaScriptApplicationGraph, +} from "../../../src/domain/javascriptApplicationGraph.js"; +import { + APPLICATION_GRAPH_DIGESTS, + artifactEvidence, + buildSyntheticJavaScriptApplicationGraph, +} from "../../fixtures/javascriptApplicationGraph.js"; + +const completeCoverage = { + status: "complete" as const, + truncated: false, + omitted_count: 0, + limits: [], +}; + +const unknownEvidence = ( + limitations: string[] = ["The extractor could not classify this fact."], +): ApplicationGraphEvidence => ({ + authority: "unknown", + state: "unknown", + confidence: "unknown", + artifact: { + available: false, + reason: "unknown", + detail: "Artifact provenance is unknown.", + }, + location: { + available: false, + reason: "unknown", + detail: "Source location is unknown.", + }, + extractor: { + name: "test", + version: "1", + operation: "unknown", + executable_sha256: null, + }, + coverage: { + status: "unknown", + truncated: false, + omitted_count: null, + limits: [], + }, + limitations, + evidence_ids: [], +}); + +const nodeByLabel = ( + graph: JavaScriptApplicationGraph, + label: string, +): ApplicationNode => { + const node = graph.nodes.find(({ observations }) => + observations.some((observation) => observation.label === label), + ); + if (node === undefined) throw new TypeError(`Missing fixture node: ${label}`); + return node; +}; + +const graphForNode = (node: ApplicationNode): JavaScriptApplicationGraph => + createJavaScriptApplicationGraph({ + schema: "JavaScriptApplicationGraph", + schema_version: 1, + root_node_ids: [node.node_id], + nodes: [node], + edges: [], + coverage: completeCoverage, + limitations: [], + }); + +describe("JavaScript Application Graph", () => { + it("keeps static observations, static inferences, runtime facts, and native facts distinct", () => { + const graph = buildSyntheticJavaScriptApplicationGraph(); + const preload = nodeByLabel(graph, "desktop preload"); + const runtime = nodeByLabel(graph, "preload runtime script"); + const nativeExport = nodeByLabel(graph, "openProject"); + const invoke = graph.edges.find(({ relation }) => relation === "invokes"); + const observedAs = graph.edges.find( + ({ relation }) => relation === "observed_as", + ); + + expect(preload.observations[0]?.evidence).toMatchObject({ + authority: "ast-static-analysis", + state: "observed", + }); + expect(invoke?.evidence).toMatchObject({ + authority: "static-relationship-inference", + state: "inferred", + }); + expect(runtime.observations[0]?.evidence).toMatchObject({ + authority: "passive-cdp-runtime", + state: "observed", + }); + expect(observedAs?.evidence.authority).toBe("passive-cdp-runtime"); + expect(observedAs?.identifier_strategy).toEqual({ + strategy: "semantic-content-sha256", + stability: "relationship-exact", + }); + expect(runtime.observations[0]?.identifier_strategy).toEqual({ + strategy: "semantic-content-sha256", + stability: "observation-exact", + }); + expect(nativeExport.observations[0]?.evidence.authority).toBe( + "native-analysis-provider", + ); + expect(preload.node_id).not.toBe(runtime.node_id); + }); + + it("retains explicit unknown observations without using them as identity proof", () => { + const node = createJavaScriptApplicationNode({ + kind: "javascript-module", + identity: { + strategy: "content-digest", + stability: "global-exact", + sha256: APPLICATION_GRAPH_DIGESTS.asar, + }, + observations: [ + { + label: "observed module", + properties: {}, + evidence: artifactEvidence( + APPLICATION_GRAPH_DIGESTS.asar, + "dist/module.js", + ), + }, + { + label: null, + properties: { question: "runtime correspondence" }, + evidence: unknownEvidence(), + }, + ], + }); + + const graph = graphForNode(node); + expect( + graph.nodes[0]?.observations.find( + ({ evidence }) => evidence.state === "unknown", + )?.evidence, + ).toMatchObject({ authority: "unknown", confidence: "unknown" }); + }); + + it("separates stable entity identity from changing observations", () => { + const identity = { + strategy: "canonical-path" as const, + stability: "artifact-version" as const, + artifact_sha256: APPLICATION_GRAPH_DIGESTS.asar, + path: "dist/chunk.js", + }; + const first = createJavaScriptApplicationNode({ + kind: "javascript-chunk", + identity, + observations: [ + { + label: "first label", + properties: { modules: 10 }, + evidence: artifactEvidence( + APPLICATION_GRAPH_DIGESTS.asar, + "dist/chunk.js", + "ast-static-analysis", + ), + }, + ], + }); + const second = createJavaScriptApplicationNode({ + kind: "javascript-chunk", + identity, + observations: [ + { + label: "renamed chunk", + properties: { modules: 11 }, + evidence: artifactEvidence( + APPLICATION_GRAPH_DIGESTS.asar, + "dist/chunk.js", + "ast-static-analysis", + ), + }, + ], + }); + + expect(first.node_id).toBe(second.node_id); + expect(first.observations[0]?.observation_id).not.toBe( + second.observations[0]?.observation_id, + ); + }); +}); diff --git a/tests/composition/javascript-applications/javascriptApplicationGraph.03-normalizes-structural-identity-and-evidence.test.ts b/tests/composition/javascript-applications/javascriptApplicationGraph.03-normalizes-structural-identity-and-evidence.test.ts new file mode 100644 index 000000000..8d0a292dd --- /dev/null +++ b/tests/composition/javascript-applications/javascriptApplicationGraph.03-normalizes-structural-identity-and-evidence.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from "vitest"; + +import { + createJavaScriptApplicationGraph, + createJavaScriptApplicationNode, + type ApplicationNode, + type JavaScriptApplicationGraph, +} from "../../../src/domain/javascriptApplicationGraph.js"; +import { + APPLICATION_GRAPH_DIGESTS, + artifactEvidence, + buildSyntheticJavaScriptApplicationGraph, + inferredArtifactEvidence, + runtimeEvidence, +} from "../../fixtures/javascriptApplicationGraph.js"; + +const completeCoverage = { + status: "complete" as const, + truncated: false, + omitted_count: 0, + limits: [], +}; + +const nodeByLabel = ( + graph: JavaScriptApplicationGraph, + label: string, +): ApplicationNode => { + const node = graph.nodes.find(({ observations }) => + observations.some((observation) => observation.label === label), + ); + if (node === undefined) throw new TypeError(`Missing fixture node: ${label}`); + return node; +}; + +const graphForNode = (node: ApplicationNode): JavaScriptApplicationGraph => + createJavaScriptApplicationGraph({ + schema: "JavaScriptApplicationGraph", + schema_version: 1, + root_node_ids: [node.node_id], + nodes: [node], + edges: [], + coverage: completeCoverage, + limitations: [], + }); + +describe("JavaScript Application Graph", () => { + it("normalizes structural identity and evidence set ordering", () => { + const evidence = inferredArtifactEvidence( + APPLICATION_GRAPH_DIGESTS.asar, + "dist/module.js", + ); + evidence.limitations = ["z limitation", "a limitation", "a limitation"]; + evidence.evidence_ids = [ + `ev_${"b".repeat(64)}`, + `ev_${"a".repeat(64)}`, + `ev_${"a".repeat(64)}`, + ]; + evidence.coverage.limits = [ + { name: "modules", value: 100, unit: "items" }, + { name: "modules", value: 100, unit: "items" }, + ]; + const node = createJavaScriptApplicationNode({ + kind: "javascript-module", + identity: { + strategy: "structural-fingerprint", + stability: "cross-version-inference", + algorithm: "synthetic-v1", + fingerprint_sha256: "5".repeat(64), + basis: ["syntax-tree", "imports", "syntax-tree"], + }, + observations: [{ label: "module", properties: {}, evidence }], + }); + + expect(node.identity).toMatchObject({ basis: ["imports", "syntax-tree"] }); + expect(node.observations[0]?.evidence.limitations).toEqual([ + "a limitation", + "z limitation", + ]); + expect(node.observations[0]?.evidence.evidence_ids).toEqual([ + `ev_${"a".repeat(64)}`, + `ev_${"b".repeat(64)}`, + ]); + expect(node.observations[0]?.evidence.coverage.limits).toEqual([ + { name: "modules", value: 100, unit: "items" }, + ]); + expect(graphForNode(node).nodes).toEqual([node]); + }); + + it("scopes runtime identities to a capture and artifact paths to a version", () => { + const runtime = nodeByLabel( + buildSyntheticJavaScriptApplicationGraph(), + "preload runtime script", + ); + const differentCapture = createJavaScriptApplicationNode({ + kind: "runtime-script-instance", + identity: { + strategy: "runtime-instance", + stability: "capture-only", + capture_sha256: "6".repeat(64), + runtime_key: "script-preload", + }, + observations: [ + { + label: "other capture", + properties: {}, + evidence: { + ...runtimeEvidence("script-preload"), + location: { + available: true, + value: { + kind: "runtime", + capture_sha256: "6".repeat(64), + target_key: "target-main", + frame_key: "frame-main", + script_key: "script-preload", + }, + }, + }, + }, + ], + }); + expect(differentCapture.node_id).not.toBe(runtime.node_id); + + const firstArtifact = createJavaScriptApplicationNode({ + kind: "javascript-asset", + identity: { + strategy: "canonical-path", + stability: "artifact-version", + artifact_sha256: APPLICATION_GRAPH_DIGESTS.asar, + path: "dist/app.js", + }, + observations: [ + { + label: null, + properties: {}, + evidence: artifactEvidence( + APPLICATION_GRAPH_DIGESTS.asar, + "dist/app.js", + ), + }, + ], + }); + const secondArtifact = createJavaScriptApplicationNode({ + kind: "javascript-asset", + identity: { + strategy: "canonical-path", + stability: "artifact-version", + artifact_sha256: "7".repeat(64), + path: "dist/app.js", + }, + observations: [ + { + label: null, + properties: {}, + evidence: artifactEvidence("7".repeat(64), "dist/app.js"), + }, + ], + }); + expect(firstArtifact.node_id).not.toBe(secondArtifact.node_id); + }); +}); diff --git a/tests/composition/javascript-applications/javascriptApplicationGraph.04-admits-source-map-and-observation.test.ts b/tests/composition/javascript-applications/javascriptApplicationGraph.04-admits-source-map-and-observation.test.ts new file mode 100644 index 000000000..2fc2df81e --- /dev/null +++ b/tests/composition/javascript-applications/javascriptApplicationGraph.04-admits-source-map-and-observation.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; + +import { + createJavaScriptApplicationGraph, + createJavaScriptApplicationNode, + parseJavaScriptApplicationGraph, + type ApplicationNode, + type JavaScriptApplicationGraph, +} from "../../../src/domain/javascriptApplicationGraph.js"; +import { + APPLICATION_GRAPH_DIGESTS, + artifactEvidence, + buildSyntheticJavaScriptApplicationGraph, +} from "../../fixtures/javascriptApplicationGraph.js"; + +const completeCoverage = { + status: "complete" as const, + truncated: false, + omitted_count: 0, + limits: [], +}; + +const firstOf = (values: readonly Value[], label: string): Value => { + const value = values[0]; + if (value === undefined) throw new TypeError(`Missing fixture ${label}`); + return value; +}; + +const graphForNode = (node: ApplicationNode): JavaScriptApplicationGraph => + createJavaScriptApplicationGraph({ + schema: "JavaScriptApplicationGraph", + schema_version: 1, + root_node_ids: [node.node_id], + nodes: [node], + edges: [], + coverage: completeCoverage, + limitations: [], + }); + +describe("JavaScript Application Graph", () => { + it("admits source-map and observation-scoped identities only with supporting observations", () => { + const sourceModule = createJavaScriptApplicationNode({ + kind: "source-module", + identity: { + strategy: "source-map-original", + stability: "source-map-exact", + source_map_sha256: APPLICATION_GRAPH_DIGESTS.asar, + original_source: "webpack:///src/editor.ts", + source_sha256: null, + }, + observations: [ + { + label: "src/editor.ts", + properties: { recovered_from: "source-map" }, + evidence: artifactEvidence( + APPLICATION_GRAPH_DIGESTS.asar, + "dist/app.js.map", + "ast-static-analysis", + ), + }, + ], + }); + const observationScoped = createJavaScriptApplicationNode({ + kind: "unknown", + identity: { + strategy: "observation-fingerprint", + stability: "observation-only", + observation_sha256: "8".repeat(64), + scope: "synthetic static inventory", + }, + observations: [ + { + label: "unclassified entity", + properties: {}, + evidence: artifactEvidence( + APPLICATION_GRAPH_DIGESTS.asar, + "dist/app.js", + ), + }, + ], + }); + + expect(graphForNode(sourceModule).nodes).toEqual([sourceModule]); + expect(graphForNode(observationScoped).nodes).toEqual([observationScoped]); + }); + + it("rejects unsupported versions, unknown fields, and stale commitments", () => { + const graph = buildSyntheticJavaScriptApplicationGraph(); + expect(() => + parseJavaScriptApplicationGraph({ ...graph, schema_version: 2 }), + ).toThrow(/Unsupported JavaScript Application Graph schema version: 2/u); + expect(() => + parseJavaScriptApplicationGraph({ ...graph, surprise: true }), + ).toThrow(); + + const staleGraph = structuredClone(graph); + staleGraph.graph_id = `jag_${"f".repeat(64)}`; + expect(() => parseJavaScriptApplicationGraph(staleGraph)).toThrow( + /Graph identifier/u, + ); + + const staleNode = structuredClone(graph); + firstOf(staleNode.nodes, "node").node_id = `jag_node_${"f".repeat(64)}`; + expect(() => parseJavaScriptApplicationGraph(staleNode)).toThrow( + /Node identifier/u, + ); + + const staleObservation = structuredClone(graph); + firstOf( + firstOf(staleObservation.nodes, "node").observations, + "observation", + ).observation_id = `jag_observation_${"f".repeat(64)}`; + expect(() => parseJavaScriptApplicationGraph(staleObservation)).toThrow( + /Observation identifier/u, + ); + + const staleEdge = structuredClone(graph); + firstOf(staleEdge.edges, "edge").edge_id = `jag_edge_${"f".repeat(64)}`; + expect(() => parseJavaScriptApplicationGraph(staleEdge)).toThrow( + /Edge identifier/u, + ); + }); +}); diff --git a/tests/composition/javascript-applications/javascriptApplicationGraph.05-rejects-duplicate-nodes-missing-roots.test.ts b/tests/composition/javascript-applications/javascriptApplicationGraph.05-rejects-duplicate-nodes-missing-roots.test.ts new file mode 100644 index 000000000..6eddca17a --- /dev/null +++ b/tests/composition/javascript-applications/javascriptApplicationGraph.05-rejects-duplicate-nodes-missing-roots.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "vitest"; + +import { + createJavaScriptApplicationEdge, + createJavaScriptApplicationGraph, + createJavaScriptApplicationNode, + type ApplicationNode, + type JavaScriptApplicationGraph, +} from "../../../src/domain/javascriptApplicationGraph.js"; +import { + APPLICATION_GRAPH_DIGESTS, + artifactEvidence, + buildSyntheticJavaScriptApplicationGraph, + inferredArtifactEvidence, +} from "../../fixtures/javascriptApplicationGraph.js"; + +const completeCoverage = { + status: "complete" as const, + truncated: false, + omitted_count: 0, + limits: [], +}; + +const nodeByLabel = ( + graph: JavaScriptApplicationGraph, + label: string, +): ApplicationNode => { + const node = graph.nodes.find(({ observations }) => + observations.some((observation) => observation.label === label), + ); + if (node === undefined) throw new TypeError(`Missing fixture node: ${label}`); + return node; +}; + +const firstOf = (values: readonly Value[], label: string): Value => { + const value = values[0]; + if (value === undefined) throw new TypeError(`Missing fixture ${label}`); + return value; +}; + +describe("JavaScript Application Graph", () => { + it("rejects duplicate nodes, missing roots, dangling edges, and self edges", () => { + const graph = buildSyntheticJavaScriptApplicationGraph(); + const { graph_id: _graphId, ...semantic } = graph; + const source = firstOf(semantic.nodes, "node"); + expect(() => + createJavaScriptApplicationGraph({ + ...semantic, + nodes: [...semantic.nodes, source], + }), + ).toThrow(/Nodes must be sorted and unique/u); + expect(() => + createJavaScriptApplicationGraph({ + ...semantic, + root_node_ids: [`jag_node_${"9".repeat(64)}`], + }), + ).toThrow(/Root identifier/u); + + const missingId = `jag_node_${"9".repeat(64)}`; + const dangling = createJavaScriptApplicationEdge({ + source_node_id: source.node_id, + target_node_id: missingId, + relation: "loads", + properties: {}, + evidence: inferredArtifactEvidence( + APPLICATION_GRAPH_DIGESTS.asar, + "dist/main.js", + ), + }); + expect(() => + createJavaScriptApplicationGraph({ + ...semantic, + edges: [...semantic.edges, dangling], + }), + ).toThrow(/endpoints must name graph nodes/u); + + const selfEdge = createJavaScriptApplicationEdge({ + source_node_id: source.node_id, + target_node_id: source.node_id, + relation: "imports", + properties: {}, + evidence: inferredArtifactEvidence( + APPLICATION_GRAPH_DIGESTS.asar, + "dist/main.js", + ), + }); + expect(() => + createJavaScriptApplicationGraph({ + ...semantic, + edges: [...semantic.edges, selfEdge], + }), + ).toThrow(/self-referential/u); + }); + + it("allows changed_from only between entities of the same kind", () => { + const graph = buildSyntheticJavaScriptApplicationGraph(); + const preload = nodeByLabel(graph, "desktop preload"); + const runtime = nodeByLabel(graph, "preload runtime script"); + const earlierPreload = createJavaScriptApplicationNode({ + kind: "electron-preload", + identity: { + strategy: "canonical-path", + stability: "artifact-version", + artifact_sha256: "7".repeat(64), + path: "dist/preload.js", + }, + observations: [ + { + label: "earlier preload", + properties: {}, + evidence: artifactEvidence("7".repeat(64), "dist/preload.js"), + }, + ], + }); + const changedFrom = createJavaScriptApplicationEdge({ + source_node_id: preload.node_id, + target_node_id: earlierPreload.node_id, + relation: "changed_from", + properties: {}, + evidence: inferredArtifactEvidence( + APPLICATION_GRAPH_DIGESTS.asar, + "dist/preload.js", + ), + }); + expect( + createJavaScriptApplicationGraph({ + schema: "JavaScriptApplicationGraph", + schema_version: 1, + root_node_ids: [preload.node_id], + nodes: [preload, earlierPreload], + edges: [changedFrom], + coverage: completeCoverage, + limitations: [], + }).edges, + ).toEqual([changedFrom]); + + const invalid = createJavaScriptApplicationEdge({ + source_node_id: preload.node_id, + target_node_id: runtime.node_id, + relation: "changed_from", + properties: {}, + evidence: inferredArtifactEvidence( + APPLICATION_GRAPH_DIGESTS.asar, + "dist/preload.js", + ), + }); + expect(() => + createJavaScriptApplicationGraph({ + schema: "JavaScriptApplicationGraph", + schema_version: 1, + root_node_ids: [preload.node_id], + nodes: [preload, runtime], + edges: [invalid], + coverage: completeCoverage, + limitations: [], + }), + ).toThrow(/changed_from endpoints must have the same node kind/u); + }); +}); diff --git a/tests/composition/javascript-applications/javascriptApplicationGraph.06-requires-every-identity-strategy-to.test.ts b/tests/composition/javascript-applications/javascriptApplicationGraph.06-requires-every-identity-strategy-to.test.ts new file mode 100644 index 000000000..d117c9e4f --- /dev/null +++ b/tests/composition/javascript-applications/javascriptApplicationGraph.06-requires-every-identity-strategy-to.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from "vitest"; + +import { + createJavaScriptApplicationGraph, + createJavaScriptApplicationNode, + type ApplicationGraphEvidence, + type ApplicationNode, + type JavaScriptApplicationGraph, +} from "../../../src/domain/javascriptApplicationGraph.js"; +import { + APPLICATION_GRAPH_DIGESTS, + artifactEvidence, + inferredArtifactEvidence, + runtimeEvidence, +} from "../../fixtures/javascriptApplicationGraph.js"; + +const completeCoverage = { + status: "complete" as const, + truncated: false, + omitted_count: 0, + limits: [], +}; + +const unknownEvidence = ( + limitations: string[] = ["The extractor could not classify this fact."], +): ApplicationGraphEvidence => ({ + authority: "unknown", + state: "unknown", + confidence: "unknown", + artifact: { + available: false, + reason: "unknown", + detail: "Artifact provenance is unknown.", + }, + location: { + available: false, + reason: "unknown", + detail: "Source location is unknown.", + }, + extractor: { + name: "test", + version: "1", + operation: "unknown", + executable_sha256: null, + }, + coverage: { + status: "unknown", + truncated: false, + omitted_count: null, + limits: [], + }, + limitations, + evidence_ids: [], +}); + +const graphForNode = (node: ApplicationNode): JavaScriptApplicationGraph => + createJavaScriptApplicationGraph({ + schema: "JavaScriptApplicationGraph", + schema_version: 1, + root_node_ids: [node.node_id], + nodes: [node], + edges: [], + coverage: completeCoverage, + limitations: [], + }); + +const contentNode = ( + evidence: ApplicationGraphEvidence, + properties: Record = {}, +): ApplicationNode => + createJavaScriptApplicationNode({ + kind: "javascript-module", + identity: { + strategy: "content-digest", + stability: "global-exact", + sha256: APPLICATION_GRAPH_DIGESTS.asar, + }, + observations: [{ label: "module", properties, evidence }], + }); + +describe("JavaScript Application Graph", () => { + it("requires every identity strategy to have compatible supporting evidence", () => { + const mismatchedPath = createJavaScriptApplicationNode({ + kind: "javascript-asset", + identity: { + strategy: "canonical-path", + stability: "artifact-version", + artifact_sha256: APPLICATION_GRAPH_DIGESTS.asar, + path: "dist/expected.js", + }, + observations: [ + { + label: null, + properties: {}, + evidence: artifactEvidence( + APPLICATION_GRAPH_DIGESTS.asar, + "dist/other.js", + ), + }, + ], + }); + expect(() => graphForNode(mismatchedPath)).toThrow( + /identity is not supported/u, + ); + + const runtimeWithStaticEvidence = createJavaScriptApplicationNode({ + kind: "runtime-script-instance", + identity: { + strategy: "runtime-instance", + stability: "capture-only", + capture_sha256: APPLICATION_GRAPH_DIGESTS.capture, + runtime_key: "script-preload", + }, + observations: [ + { + label: null, + properties: {}, + evidence: artifactEvidence( + APPLICATION_GRAPH_DIGESTS.asar, + "dist/preload.js", + "ast-static-analysis", + ), + }, + ], + }); + expect(() => graphForNode(runtimeWithStaticEvidence)).toThrow( + /identity is not supported/u, + ); + + const mismatchedDigest = contentNode( + artifactEvidence("7".repeat(64), "dist/module.js"), + ); + expect(() => graphForNode(mismatchedDigest)).toThrow( + /identity is not supported/u, + ); + }); + + it("prevents static authorities from claiming runtime locations", () => { + const evidence = artifactEvidence( + APPLICATION_GRAPH_DIGESTS.asar, + "dist/module.js", + "ast-static-analysis", + ); + evidence.location = runtimeEvidence("script-module").location; + expect(() => graphForNode(contentNode(evidence))).toThrow( + /cannot claim a runtime location/u, + ); + }); + + it("enforces epistemic truthfulness and content-addressed artifacts", () => { + const inferredAsObserved = inferredArtifactEvidence( + APPLICATION_GRAPH_DIGESTS.asar, + "dist/module.js", + ); + inferredAsObserved.state = "observed"; + inferredAsObserved.confidence = "exact"; + expect(() => contentNode(inferredAsObserved)).toThrow( + /must remain inferred/u, + ); + + const inferenceWithoutLimit = inferredArtifactEvidence( + APPLICATION_GRAPH_DIGESTS.asar, + "dist/module.js", + ); + inferenceWithoutLimit.limitations = []; + expect(() => contentNode(inferenceWithoutLimit)).toThrow( + /Inferred facts require an explicit limitation/u, + ); + + const unknownWithoutLimit = unknownEvidence([]); + expect(() => contentNode(unknownWithoutLimit)).toThrow( + /require unknown confidence and an explicit limitation/u, + ); + + const missingArtifact = artifactEvidence( + APPLICATION_GRAPH_DIGESTS.asar, + "dist/module.js", + ); + missingArtifact.artifact = { + available: false, + reason: "not-observed", + detail: "Bytes were not observed.", + }; + expect(() => contentNode(missingArtifact)).toThrow( + /require a content digest/u, + ); + + const mismatchedArtifact = artifactEvidence( + APPLICATION_GRAPH_DIGESTS.asar, + "dist/module.js", + ); + if (mismatchedArtifact.artifact.available) + mismatchedArtifact.artifact.artifact_id = `art_${"8".repeat(64)}`; + expect(() => contentNode(mismatchedArtifact)).toThrow( + /same SHA-256 digest/u, + ); + + const unlocatedObservation = artifactEvidence( + APPLICATION_GRAPH_DIGESTS.asar, + "dist/module.js", + ); + unlocatedObservation.location = { + available: false, + reason: "not-observed", + detail: "The exact location was not recorded.", + }; + expect(() => contentNode(unlocatedObservation)).toThrow( + /Observed facts require an actionable location/u, + ); + }); +}); diff --git a/tests/composition/javascript-applications/javascriptApplicationGraph.07-rejects-false-truncation-claims-and.test.ts b/tests/composition/javascript-applications/javascriptApplicationGraph.07-rejects-false-truncation-claims-and.test.ts new file mode 100644 index 000000000..35dbe6501 --- /dev/null +++ b/tests/composition/javascript-applications/javascriptApplicationGraph.07-rejects-false-truncation-claims-and.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; + +import { + createJavaScriptApplicationGraph, + createJavaScriptApplicationNode, + type ApplicationGraphEvidence, + type ApplicationNode, +} from "../../../src/domain/javascriptApplicationGraph.js"; +import { + APPLICATION_GRAPH_DIGESTS, + artifactEvidence, +} from "../../fixtures/javascriptApplicationGraph.js"; + +const contentNode = ( + evidence: ApplicationGraphEvidence, + properties: Record = {}, +): ApplicationNode => + createJavaScriptApplicationNode({ + kind: "javascript-module", + identity: { + strategy: "content-digest", + stability: "global-exact", + sha256: APPLICATION_GRAPH_DIGESTS.asar, + }, + observations: [{ label: "module", properties, evidence }], + }); + +describe("JavaScript Application Graph", () => { + it("rejects false truncation claims and unbounded properties", () => { + const truncated = artifactEvidence( + APPLICATION_GRAPH_DIGESTS.asar, + "dist/module.js", + ); + truncated.coverage = { + status: "partial", + truncated: true, + omitted_count: 0, + limits: [], + }; + expect(() => contentNode(truncated)).toThrow( + /must be partial, name a limit, and not claim zero omissions/u, + ); + + const nonComplete = artifactEvidence( + APPLICATION_GRAPH_DIGESTS.asar, + "dist/module.js", + ); + nonComplete.coverage = { + status: "partial", + truncated: false, + omitted_count: null, + limits: [], + }; + expect(() => contentNode(nonComplete)).toThrow( + /Non-complete coverage requires an explicit limitation/u, + ); + + const validNode = contentNode( + artifactEvidence(APPLICATION_GRAPH_DIGESTS.asar, "dist/module.js"), + ); + expect(() => + createJavaScriptApplicationGraph({ + schema: "JavaScriptApplicationGraph", + schema_version: 1, + root_node_ids: [validNode.node_id], + nodes: [validNode], + edges: [], + coverage: { + status: "partial", + truncated: false, + omitted_count: null, + limits: [], + }, + limitations: [], + }), + ).toThrow(/Non-complete graph coverage requires an explicit limitation/u); + + const properties = Object.fromEntries( + Array.from({ length: 65 }, (_, index) => [`key-${String(index)}`, index]), + ); + expect(() => + contentNode( + artifactEvidence(APPLICATION_GRAPH_DIGESTS.asar, "dist/module.js"), + properties, + ), + ).toThrow(/exceed 64 keys/u); + }); +}); diff --git a/tests/javascriptArtifactPathResolution.test.ts b/tests/composition/javascript-applications/javascriptArtifactPathResolution.test.ts similarity index 95% rename from tests/javascriptArtifactPathResolution.test.ts rename to tests/composition/javascript-applications/javascriptArtifactPathResolution.test.ts index c8d2b1b10..591d65c5d 100644 --- a/tests/javascriptArtifactPathResolution.test.ts +++ b/tests/composition/javascript-applications/javascriptArtifactPathResolution.test.ts @@ -3,9 +3,9 @@ import { describe, expect, it } from "vitest"; import { resolveArtifactPathByContext, type ResolveArtifactPathInput, -} from "../src/application/JavaScriptArtifactPathResolution.js"; -import type { JavaScriptArtifactFile } from "../src/application/JavaScriptArtifactFiles.js"; -import { analyzeJavaScriptStaticSource } from "../src/domain/javascriptStaticAnalysis.js"; +} from "../../../src/application/JavaScriptArtifactPathResolution.js"; +import type { JavaScriptArtifactFile } from "../../../src/application/JavaScriptArtifactFiles.js"; +import { analyzeJavaScriptStaticSource } from "../../../src/domain/javascriptStaticAnalysis.js"; describe("contextual JavaScript artifact path resolution", () => { it.each([ @@ -135,7 +135,9 @@ describe("contextual JavaScript artifact path resolution", () => { }), ).toMatchObject({ resolution_status: "not-found", resolved_path: null }); }); +}); +describe("contextual JavaScript package path resolution", () => { it("keeps an inventoried but unreadable directory package unavailable", () => { const files = fileMap([ file("app/package.json", "root"), @@ -226,7 +228,9 @@ describe("contextual JavaScript artifact path resolution", () => { limitations: [expect.stringContaining("subpaths remain unresolved")], }); }); +}); +describe("contextual JavaScript module identity", () => { it("retains CommonJS and ESM file-base identity during inert extraction", () => { const analysis = analyzeJavaScriptStaticSource( ` diff --git a/tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts b/tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts new file mode 100644 index 000000000..c65d67999 --- /dev/null +++ b/tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts @@ -0,0 +1,331 @@ +import { mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; + +import { createPackageWithOptions } from "@electron/asar"; +import { expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +import { reconstructJavaScriptArtifact } from "../../../src/application/JavaScriptArtifactReconstruction.js"; +import { scanArtifactInventory } from "../../../src/application/ArtifactInventory.js"; +import { parseJavaScriptApplicationGraph } from "../../../src/domain/javascriptApplicationGraph.js"; +import { javaScriptApplicationAnalysisResultV2Schema } from "../../../src/domain/javascriptApplicationAnalysis.js"; +import { createJavaScriptSemanticGraph } from "../../../src/domain/javascriptSemanticGraph.js"; +import { parseJavaScriptSemanticGraph } from "../../../src/domain/javascriptSemanticGraphSerialization.js"; +import { writeJavaScriptArtifactFixture } from "../../fixtures/javascriptArtifactApplication.js"; + +it("reconstructs package, Electron roles, Webpack/Rspack modules, and cross-layer facts without execution", async () => { + const root = await fixtureDirectory(); + Reflect.deleteProperty(globalThis, "__rea_bundle_executed"); + + const result = await reconstructJavaScriptArtifact({ + input_path: root, + source_map_read_approved: true, + }); + const graph = parseJavaScriptApplicationGraph(result.graph); + const semanticGraph = parseJavaScriptSemanticGraph(result.semantic_graph); + + expect(Reflect.get(globalThis, "__rea_bundle_executed")).toBeUndefined(); + expect(result.input_path).toBe(root); + expect(result.statistics).toMatchObject({ + modules: 4, + parse_failures: 0, + omitted_text_files: 0, + policy_filtered_text_files: 0, + }); + expect(graph.nodes.map(({ kind }) => kind)).toEqual( + expect.arrayContaining([ + "package", + "artifact", + "electron-main", + "electron-preload", + "electron-renderer", + "javascript-asset", + "javascript-chunk", + "javascript-module", + "worker", + "service-worker", + "endpoint", + "storage", + "source-map", + "source-module", + "native-addon", + ]), + ); + expect(graph.nodes.filter(({ kind }) => kind === "javascript-chunk")).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + observations: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ bundler: "webpack" }), + }), + ]), + }), + expect.objectContaining({ + observations: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ bundler: "rspack" }), + }), + ]), + }), + ]), + ); + expect( + graph.nodes.filter(({ kind }) => kind === "javascript-module"), + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + observations: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + module_key: "1", + structural_fingerprint_algorithm: "babel-ast-v1", + }), + }), + ]), + }), + ]), + ); + expect(graph.edges.map(({ relation }) => relation)).toEqual( + expect.arrayContaining([ + "contains", + "loads", + "imports", + "maps_to", + "exposes", + "calls", + "persists_to", + ]), + ); + assertSemanticLinks(result, graph, semanticGraph); +}); + +it("produces deterministic ASAR graphs with unpacked native linkage and complete paths/digests", async () => { + const root = await createTestTempDirectory("rea-javascript-asar-"); + const source = join(root, "source"); + await mkdir(source); + await writeJavaScriptArtifactFixture(source); + const archive = join(root, "app.asar"); + await createPackageWithOptions(source, archive, { unpack: "**/*.node" }); + + const first = await reconstructJavaScriptArtifact({ + input_path: archive, + format: "asar", + source_map_read_approved: true, + }); + const second = await reconstructJavaScriptArtifact({ + input_path: archive, + format: "asar", + source_map_read_approved: true, + }); + + expect(first.graph).toEqual(second.graph); + expect(first.inventory_graph_sha256).toBe(second.inventory_graph_sha256); + expect(first.graph.graph_id).toBe(second.graph.graph_id); + expect(first.graph.nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "asar-entry", + observations: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + entry_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u), + inventory_artifact_id: + expect.stringMatching(/^art_[a-f0-9]{64}$/u), + }), + }), + ]), + }), + expect.objectContaining({ + kind: "native-addon", + observations: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + path: "native/addon.node", + unpacked: true, + }), + }), + ]), + }), + ]), + ); + expect(first.graph.edges).toContainEqual( + expect.objectContaining({ + relation: "loads", + properties: expect.objectContaining({ + resolved_path: "native/addon.node", + }), + }), + ); +}); + +it("keeps ASAR analysis usable when unpacked native companion bytes are absent", async () => { + const root = await createTestTempDirectory("rea-javascript-asar-missing-"); + const source = join(root, "source"); + await mkdir(source); + await writeJavaScriptArtifactFixture(source); + const archive = join(root, "app.asar"); + await createPackageWithOptions(source, archive, { unpack: "**/*.node" }); + await rm(join(`${archive}.unpacked`, "native", "addon.node")); + + const snapshot = await scanArtifactInventory(archive, { + maxEntries: 8_000, + maxTotalBytes: 512 * 1024 * 1024, + maxEntryBytes: 128 * 1024 * 1024, + maxCompressionRatio: 1_000, + maxDepth: 64, + maxPathBytes: 4_096, + }); + const missingNative = snapshot.occurrences.find( + ({ logical_path }) => logical_path === "native/addon.node", + ); + + expect(missingNative).toMatchObject({ + artifact_id: null, + hash_status: "unavailable", + limitations: expect.arrayContaining([ + "ASAR unpacked companion bytes were unavailable; no content hash or child artifact was produced.", + ]), + }); + + const result = await reconstructJavaScriptArtifact({ + input_path: archive, + format: "asar", + source_map_read_approved: true, + }); + + expect(result.statistics.parsed_javascript_files).toBeGreaterThan(0); + expect(result.graph.nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: "javascript-asset" }), + ]), + ); +}); + +it("recurses into filesystem-backed ASAR containers without losing container-relative paths", async () => { + const root = await createTestTempDirectory("rea-javascript-nested-asar-"); + const source = await fixtureDirectory(); + const outer = join(root, "outer"); + const resources = join(outer, "resources"); + await mkdir(resources, { recursive: true }); + await createPackageWithOptions(source, join(resources, "app.asar"), { + unpack: "**/*.node", + }); + + const result = await reconstructJavaScriptArtifact({ + input_path: outer, + source_map_read_approved: true, + }); + + expect(result.statistics.nested_asar_containers).toBe(1); + expect(result.statistics.modules).toBe(4); + expect(result.graph.nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "artifact", + observations: expect.arrayContaining([ + expect.objectContaining({ + label: "resources/app.asar", + properties: expect.objectContaining({ format: "asar" }), + }), + ]), + }), + expect.objectContaining({ + kind: "javascript-asset", + observations: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + path: "resources/app.asar/main.js", + }), + }), + ]), + }), + ]), + ); +}); + +const assertSemanticLinks = ( + result: Awaited>, + graph: ReturnType, + semanticGraph: ReturnType, +) => { + expect(semanticGraph.application_graph_id).toBe(graph.graph_id); + const { graph_id: _semanticGraphId, ...semanticGraphInput } = semanticGraph; + const mismatchedSemanticGraph = createJavaScriptSemanticGraph({ + ...semanticGraphInput, + application_graph_id: `jag_${"f".repeat(64)}`, + }); + const { electron_summary: summary, ...analysisResult } = result; + expect(() => + javaScriptApplicationAnalysisResultV2Schema.parse({ + ...analysisResult, + schema_version: 2, + summary, + semantic_graph: mismatchedSemanticGraph, + }), + ).toThrow(/must commit the containing application graph/u); + expect( + semanticGraph.nodes.some( + ({ application_node_ids: identifiers }) => identifiers.length > 0, + ), + ).toBe(true); + expect(semanticGraph.relations.length).toBeGreaterThan(0); + const roles = ["electron-main", "electron-preload"].map((kind) => + graph.nodes.find((node) => node.kind === kind), + ); + expect(roles[0]?.observations[0]?.properties).toMatchObject({ + declared_path: "main.js", + resolution_context: "package-entrypoint", + resolved_path: "main.js", + resolution_status: "resolved", + limitations: [], + }); + expect(roles[1]?.observations[0]?.properties).toMatchObject({ + declared_path: "preload.js", + resolution_context: "filesystem-expression", + resolved_path: "preload.js", + resolution_status: "resolved", + limitations: [], + }); + const mainAsset = graph.nodes.find( + (node) => + node.kind === "javascript-asset" && + node.observations.some( + ({ evidence }) => + evidence.location.available && + evidence.location.value.kind === "artifact-path" && + evidence.location.value.path === "main.js", + ), + ); + if (mainAsset === undefined) + throw new Error("Expected main JavaScript asset"); + const mainSemanticNodes = semanticGraph.nodes.filter( + ({ identity }) => identity.module_path === "main.js", + ); + const linkedMainSemanticNodes = mainSemanticNodes.filter( + ({ application_node_ids: identifiers }) => + identifiers.includes(mainAsset.node_id), + ); + expect(linkedMainSemanticNodes.length).toBeGreaterThan(0); + expect(linkedMainSemanticNodes.length).toBeLessThan(mainSemanticNodes.length); + for (const role of roles) { + expect(role).toBeDefined(); + expect( + graph.edges.some( + (edge) => + edge.source_node_id === role?.node_id && edge.relation === "maps_to", + ), + ).toBe(true); + } + const endpointJson = JSON.stringify( + graph.nodes.filter(({ kind }) => kind === "endpoint"), + ); + expect(endpointJson).toContain("token=%5BREDACTED%5D"); + expect(endpointJson).not.toContain("fixture-secret"); +}; + +const fixtureDirectory = async (): Promise => { + const root = await createTestTempDirectory("rea-javascript-artifact-"); + await writeJavaScriptArtifactFixture(root); + return root; +}; diff --git a/tests/composition/javascript-applications/javascriptArtifactReconstructionLimits.test.ts b/tests/composition/javascript-applications/javascriptArtifactReconstructionLimits.test.ts new file mode 100644 index 000000000..c335a9a65 --- /dev/null +++ b/tests/composition/javascript-applications/javascriptArtifactReconstructionLimits.test.ts @@ -0,0 +1,344 @@ +import { Readable } from "node:stream"; +import { symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +import { readJavaScriptArtifactFiles } from "../../../src/application/JavaScriptArtifactFiles.js"; +import { reconstructJavaScriptArtifact } from "../../../src/application/JavaScriptArtifactReconstruction.js"; +import { + artifactLimitsForReconstruction, + javascriptArtifactReconstructionInputSchema, +} from "../../../src/application/JavaScriptArtifactReconstructionInput.js"; +import { scanArtifactInventory } from "../../../src/application/ArtifactInventory.js"; +import { + type ArtifactEntry, + type ArtifactReader, +} from "../../../src/artifacts/ArtifactReader.js"; +import { writeJavaScriptArtifactFixture } from "../../fixtures/javascriptArtifactApplication.js"; + +it("discovers source maps without reading them until separately approved", async () => { + const root = await fixtureDirectory(); + const unapproved = await reconstructJavaScriptArtifact({ + input_path: root, + source_map_read_approved: false, + }); + const approved = await reconstructJavaScriptArtifact({ + input_path: root, + source_map_read_approved: true, + }); + + expect(unapproved.graph.nodes.some(({ kind }) => kind === "source-map")).toBe( + true, + ); + expect( + unapproved.graph.nodes.some(({ kind }) => kind === "source-module"), + ).toBe(false); + expect(unapproved.graph.coverage).toMatchObject({ + status: "partial", + truncated: false, + omitted_count: null, + }); + expect(unapproved.statistics.policy_filtered_text_files).toBe(1); + expect(unapproved.graph.nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "unknown", + observations: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + operation: "parse-local-source-map", + }), + evidence: expect.objectContaining({ state: "unavailable" }), + }), + ]), + }), + ]), + ); + expect( + approved.graph.nodes.some(({ kind }) => kind === "source-module"), + ).toBe(true); +}); + +it("reports text and AST limits as truncation instead of absence", async () => { + const root = await fixtureDirectory(); + const result = await reconstructJavaScriptArtifact({ + input_path: root, + source_map_read_approved: true, + limits: { + max_text_file_bytes: 128, + max_total_text_bytes: 1_024, + max_ast_nodes: 50, + }, + }); + + expect(result.statistics.limit_omitted_text_files).toBeGreaterThan(0); + expect(result.graph.coverage).toMatchObject({ + status: "partial", + truncated: true, + }); + expect(result.graph.coverage.omitted_count).toBeGreaterThan(0); + expect(result.graph.nodes.some(({ kind }) => kind === "unknown")).toBe(true); + expect(result.graph.limitations.join(" ")).toMatch(/incomplete|bound/iu); +}); + +it("uses an unknown omission count when AST traversal reaches its bound", async () => { + const root = await createTestTempDirectory("rea-javascript-ast-limit-"); + await writeFile( + join(root, "large.js"), + [ + 'import "./unobserved.js";', + ...Array.from( + { length: 100 }, + (_, index) => `export const value${String(index)} = ${String(index)};`, + ), + ].join("\n"), + ); + + const result = await reconstructJavaScriptArtifact({ + input_path: root, + limits: { max_ast_nodes: 50 }, + }); + + expect(result.statistics.limit_omitted_text_files).toBe(0); + expect(result.statistics.truncated_scopes).toBe(1); + expect(result.graph.coverage).toMatchObject({ + status: "partial", + truncated: true, + omitted_count: null, + }); + const derivedImport = result.graph.edges.find( + ({ relation, properties }) => + relation === "imports" && properties.specifier === "./unobserved.js", + ); + expect(derivedImport?.evidence.coverage).toEqual({ + status: "partial", + truncated: true, + omitted_count: null, + limits: expect.arrayContaining([ + { name: "max-ast-nodes", value: 50, unit: "items" }, + ]), + }); +}); + +it("does not follow symlinks or read oversized JavaScript text", async () => { + const root = await fixtureDirectory(); + const outside = await createTestTempDirectory("rea-javascript-outside-"); + const outsideFile = join(outside, "secret.js"); + await writeFile( + outsideFile, + 'fetch("https://outside-secret.invalid/credential");', + ); + await symlink(outsideFile, join(root, "escape.js")); + await writeFile( + join(root, "oversized.js"), + `const oversizedSecret = "${"private-marker-".repeat(256)}";`, + ); + + const result = await reconstructJavaScriptArtifact({ + input_path: root, + limits: { max_text_file_bytes: 512 }, + }); + const encoded = JSON.stringify(result); + + expect(result.statistics.limit_omitted_text_files).toBeGreaterThan(0); + expect(result.graph.coverage).toMatchObject({ + status: "partial", + truncated: true, + }); + expect(encoded).not.toContain(outsideFile); + expect(encoded).not.toContain("outside-secret.invalid"); + expect(encoded).not.toContain("private-marker-private-marker"); +}); + +it("keeps malformed JavaScript, package metadata, and source maps as explicit unknowns", async () => { + const root = await fixtureDirectory(); + await Promise.all([ + writeFile(join(root, "package.json"), "{"), + writeFile(join(root, "broken.js"), "function broken( {"), + writeFile(join(root, "renderer", "renderer.js.map"), "{"), + ]); + + const result = await reconstructJavaScriptArtifact({ + input_path: root, + source_map_read_approved: true, + }); + const unknownOperations = result.graph.nodes + .filter(({ kind }) => kind === "unknown") + .flatMap(({ observations }) => + observations.map(({ properties }) => properties.operation), + ); + + expect(result.statistics.parse_failures).toBeGreaterThan(0); + expect(result.graph.coverage).toMatchObject({ + status: "partial", + truncated: false, + omitted_count: null, + }); + expect(unknownOperations).toEqual( + expect.arrayContaining([ + "parse-javascript", + "parse-package-json", + "parse-local-source-map", + ]), + ); +}); + +it("applies one source-map source budget across every local map", async () => { + const root = await createTestTempDirectory("rea-javascript-source-maps-"); + await Promise.all([ + writeFile( + join(root, "a.js.map"), + JSON.stringify({ + version: 3, + sources: ["a-one.ts", "a-two.ts"], + sourcesContent: ["one", "two"], + names: [], + mappings: "", + }), + ), + writeFile( + join(root, "b.js.map"), + JSON.stringify({ + version: 3, + sources: ["b-one.ts", "b-two.ts"], + sourcesContent: ["three", "four"], + names: [], + mappings: "", + }), + ), + ]); + + const result = await reconstructJavaScriptArtifact({ + input_path: root, + source_map_read_approved: true, + limits: { max_source_map_sources: 3 }, + }); + + expect( + result.graph.nodes.filter(({ kind }) => kind === "source-module"), + ).toHaveLength(3); + expect(result.graph.coverage).toMatchObject({ + status: "partial", + truncated: true, + omitted_count: null, + }); +}); + +it("bounds repeated content observations while retaining every containment path", async () => { + const root = await createTestTempDirectory("rea-javascript-observations-"); + await Promise.all( + Array.from({ length: 70 }, (_, index) => + writeFile( + join(root, `duplicate-${String(index).padStart(2, "0")}.js`), + "export const same = 1;\n", + ), + ), + ); + + const result = await reconstructJavaScriptArtifact({ input_path: root }); + const duplicateNode = result.graph.nodes.find( + ({ kind, observations }) => + kind === "javascript-asset" && + observations.some(({ properties }) => + String(properties.path).startsWith("duplicate-"), + ), + ); + const duplicateEdges = result.graph.edges.filter( + ({ relation, properties }) => + relation === "contains" && + String(properties.path).startsWith("duplicate-"), + ); + + expect(duplicateNode?.observations).toHaveLength(64); + expect(duplicateEdges).toHaveLength(70); + expect(result.graph.coverage).toMatchObject({ + status: "partial", + truncated: true, + omitted_count: 6, + }); +}, 15_000); + +it("rejects traversal from a production reader seam and malformed ASAR containers", async () => { + const root = await fixtureDirectory(); + const input = javascriptArtifactReconstructionInputSchema.parse({ + input_path: root, + }); + const snapshot = await scanArtifactInventory( + root, + artifactLimitsForReconstruction(input), + ); + await expect( + readJavaScriptArtifactFiles(new TraversalReader(), snapshot, input), + ).rejects.toMatchObject({ reason: "path" }); + + const malformed = join(root, "malformed.asar"); + await writeFile(malformed, "not an asar"); + await expect( + reconstructJavaScriptArtifact({ input_path: malformed }), + ).rejects.toMatchObject({ + name: "ArtifactReaderFailure", + reason: "format", + }); +}); + +it("preserves cancellation and explicit format diagnostics", async () => { + const root = await fixtureDirectory(); + const controller = new AbortController(); + controller.abort(); + await expect( + reconstructJavaScriptArtifact({ input_path: root }, controller.signal), + ).rejects.toMatchObject({ reason: "cancelled" }); + await expect( + reconstructJavaScriptArtifact({ input_path: root, format: "asar" }), + ).rejects.toMatchObject({ + reason: "format", + message: expect.stringContaining(root), + }); + expect(() => + javascriptArtifactReconstructionInputSchema.parse({ + input_path: root, + limits: { max_entries: 100_000 }, + }), + ).toThrow(/graph contract/iu); +}); + +class TraversalReader implements ArtifactReader { + readonly format = "directory" as const; + + async *entries(): AsyncIterable { + yield { + path: "../escape.js", + kind: "file", + declaredSize: 1, + compressedSize: null, + executable: false, + encrypted: false, + byteOffset: null, + declaredSha256: null, + unpacked: false, + limitations: [], + adapterKey: "/tmp/escape.js", + }; + } + + open(): Promise { + return Promise.resolve(Readable.from("x")); + } + + close(): Promise { + return Promise.resolve(); + } + + provenance(): readonly [] { + return []; + } +} + +const fixtureDirectory = async (): Promise => { + const root = await createTestTempDirectory("rea-javascript-artifact-"); + await writeJavaScriptArtifactFixture(root); + return root; +}; diff --git a/tests/javascriptBundlerAdapters.test.ts b/tests/composition/javascript-applications/javascriptBundlerAdapters.test.ts similarity index 97% rename from tests/javascriptBundlerAdapters.test.ts rename to tests/composition/javascript-applications/javascriptBundlerAdapters.test.ts index a941d8533..124c70f01 100644 --- a/tests/javascriptBundlerAdapters.test.ts +++ b/tests/composition/javascript-applications/javascriptBundlerAdapters.test.ts @@ -3,14 +3,14 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { reconstructJavaScriptArtifact } from "../src/application/JavaScriptArtifactReconstruction.js"; +import { reconstructJavaScriptArtifact } from "../../../src/application/JavaScriptArtifactReconstruction.js"; import type { ApplicationEdge, ApplicationNode, JavaScriptApplicationGraph, -} from "../src/domain/javascriptApplicationGraph.js"; +} from "../../../src/domain/javascriptApplicationGraph.js"; describe("Webpack and Rspack runtime adapters", () => { it("recovers runtime entry modules, async chunks, and factory require aliases", async () => { diff --git a/tests/javascriptExportInstrumentation.test.ts b/tests/composition/javascript-applications/javascriptExportInstrumentation.test.ts similarity index 96% rename from tests/javascriptExportInstrumentation.test.ts rename to tests/composition/javascript-applications/javascriptExportInstrumentation.test.ts index 8236ed776..e5b24b29b 100644 --- a/tests/javascriptExportInstrumentation.test.ts +++ b/tests/composition/javascript-applications/javascriptExportInstrumentation.test.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import { describe, expect, it } from "vitest"; -import { instrumentJavaScriptExport } from "../src/domain/javascriptExportInstrumentation.js"; +import { instrumentJavaScriptExport } from "../../../src/domain/javascriptExportInstrumentation.js"; const encode = (value: string): Uint8Array => new TextEncoder().encode(value); const sha256 = (value: Uint8Array): string => diff --git a/tests/javascriptExportShapeComparison.test.ts b/tests/composition/javascript-applications/javascriptExportShapeComparison.test.ts similarity index 91% rename from tests/javascriptExportShapeComparison.test.ts rename to tests/composition/javascript-applications/javascriptExportShapeComparison.test.ts index 59ba6caae..983925db5 100644 --- a/tests/javascriptExportShapeComparison.test.ts +++ b/tests/composition/javascript-applications/javascriptExportShapeComparison.test.ts @@ -3,29 +3,29 @@ import { join, resolve } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { analyzeJavaScriptApplication } from "../src/application/JavaScriptApplicationService.js"; -import { parseApplicationGraphEvidence } from "../src/application/JavaScriptApplicationEvidenceGraph.js"; -import { compareJavaScriptExportShapes } from "../src/domain/javascriptExportShapeComparison.js"; -import { javaScriptExportShapeComparisonResultSchema } from "../src/domain/javascriptExportShapeComparisonSchemas.js"; +import { analyzeJavaScriptApplication } from "../../../src/application/JavaScriptApplicationService.js"; +import { parseApplicationGraphEvidence } from "../../../src/application/JavaScriptApplicationEvidenceGraph.js"; +import { compareJavaScriptExportShapes } from "../../../src/domain/javascriptExportShapeComparison.js"; +import { javaScriptExportShapeComparisonResultSchema } from "../../../src/domain/javascriptExportShapeComparisonSchemas.js"; import { createJavaScriptApplicationGraph, createJavaScriptApplicationNode, -} from "../src/domain/javascriptApplicationGraph.js"; -import { permissionAuthorityForRoot } from "./fixtures/permissionAuthority.js"; +} from "../../../src/domain/javascriptApplicationGraph.js"; +import { permissionAuthorityForRoot } from "../../fixtures/permissionAuthority.js"; + +const temporary: string[] = []; + +afterEach(async () => { + await Promise.all( + temporary + .splice(0) + .map(async (path) => rm(path, { recursive: true, force: true })), + ); +}); describe("JavaScript export return-shape comparison", () => { - const temporary: string[] = []; - - afterEach(async () => { - await Promise.all( - temporary - .splice(0) - .map(async (path) => rm(path, { recursive: true, force: true })), - ); - }); - it("reports exactly the heading depth addition from source-owned parser fixtures", async () => { const root = await temporaryRoot(temporary); const leftRoot = join(root, "left"); @@ -129,7 +129,9 @@ describe("JavaScript export return-shape comparison", () => { expect.objectContaining({ status: "unknown", path: "" }), ]); }); +}); +describe("JavaScript export return-shape selection", () => { it("reports selector candidates and enforces candidate, variant, and change limits", async () => { const candidates = await analyzeSources(temporary, { left: ` @@ -220,7 +222,9 @@ describe("JavaScript export return-shape comparison", () => { expect.objectContaining({ status: "unknown", path: "" }), ]); }); +}); +describe("JavaScript export return-shape projection", () => { it("marks graph field projection omissions as truncated", async () => { const properties = [ 'a_type: "item"', diff --git a/tests/javascriptModuleRelationships.test.ts b/tests/composition/javascript-applications/javascriptModuleRelationships.test.ts similarity index 96% rename from tests/javascriptModuleRelationships.test.ts rename to tests/composition/javascript-applications/javascriptModuleRelationships.test.ts index bac5561ca..434d90ffe 100644 --- a/tests/javascriptModuleRelationships.test.ts +++ b/tests/composition/javascript-applications/javascriptModuleRelationships.test.ts @@ -3,14 +3,14 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { reconstructJavaScriptArtifact } from "../src/application/JavaScriptArtifactReconstruction.js"; +import { reconstructJavaScriptArtifact } from "../../../src/application/JavaScriptArtifactReconstruction.js"; import type { ApplicationEdge, ApplicationNode, JavaScriptApplicationGraph, -} from "../src/domain/javascriptApplicationGraph.js"; +} from "../../../src/domain/javascriptApplicationGraph.js"; describe("CommonJS and ESM module relationships", () => { it("composes bindings, re-exports, dynamic imports, and JSON modules", async () => { @@ -151,7 +151,9 @@ describe("CommonJS and ESM module relationships", () => { await rm(root, { recursive: true, force: true }); } }); +}); +describe("CommonJS and ESM relationship limits", () => { it("retains exact semantic omissions when the relationship budget truncates", async () => { const root = await createTestTempDirectory("rea-module-limit-"); try { diff --git a/tests/composition/javascript-applications/javascriptSemanticAnalysis.calls.test.ts b/tests/composition/javascript-applications/javascriptSemanticAnalysis.calls.test.ts new file mode 100644 index 000000000..41d7982db --- /dev/null +++ b/tests/composition/javascript-applications/javascriptSemanticAnalysis.calls.test.ts @@ -0,0 +1,241 @@ +import { it } from "@fast-check/vitest"; +import { describe, expect } from "vitest"; + +import { analyzeJavaScriptSemantics } from "../../../src/domain/javascriptSemanticAnalysis.js"; +import { semanticBinding } from "../../../src/domain/javascriptSemanticIr.js"; +import { + onlyCallable, + topLevelBinding, +} from "./javascriptSemanticAnalysis.support.js"; + +describe("JavaScript semantic analysis: calls 1", () => { + it("links exact local calls to parameters, returns, and closure captures", () => { + const ir = analyzeJavaScriptSemantics(` + const prefix = "rea"; + function render(value, { mode }) { + return prefix + value + mode; + } + const alias = render; + const result = alias(input, { mode: "fast" }); + `); + + expect(ir.schemaVersion).toBe(4); + const render = onlyCallable(ir, "render"); + const call = ir.callSites.find( + ({ calleeCallableIds }) => calleeCallableIds[0] === render.callableId, + ); + expect(call).toMatchObject({ + kind: "call", + callerCallableId: null, + resolution: "exact", + calleeCallableIds: [render.callableId], + arguments: [ + { index: 0, spread: false }, + { index: 1, spread: false }, + ], + }); + if (call === undefined) throw new Error("Missing render call"); + expect( + ir.argumentFlows + .filter(({ callSiteId }) => callSiteId === call.callSiteId) + .map(({ argumentIndex, parameterBindingId }) => ({ + argumentIndex, + parameter: semanticBinding(ir, parameterBindingId)?.name, + })), + ).toEqual([ + { argumentIndex: 0, parameter: "value" }, + { argumentIndex: 1, parameter: "mode" }, + ]); + expect(ir.callReturnFlows).toEqual([ + expect.objectContaining({ + callSiteId: call.callSiteId, + callableId: render.callableId, + returnSiteId: render.returnSites[0]?.returnSiteId, + }), + ]); + expect(ir.closureCaptures).toEqual([ + expect.objectContaining({ + callableId: render.callableId, + bindingId: topLevelBinding(ir, "prefix").bindingId, + }), + ]); + expect(ir.frontiers).toEqual([]); + }); + + it("recovers explicit Promise ownership, chains, aggregation, and awaits", () => { + const ir = analyzeJavaScriptSemantics(` + async function run(value) { + const base = Promise.resolve(value); + const chained = base.then(work).finally(cleanup); + await chained; + Promise.resolve(value).then(work); + return Promise.all([base, Promise.resolve(value)]); + } + const settled = Promise.allSettled([Promise.resolve(1)]); + `); + + expect( + ir.promiseOperations.map( + ({ kind, method, ownership, sourceResolution }) => ({ + kind, + method, + ownership, + sourceResolution, + }), + ), + ).toEqual( + expect.arrayContaining([ + { + kind: "static", + method: "resolve", + ownership: "assigned", + sourceResolution: "complete", + }, + { + kind: "chain", + method: "then", + ownership: "chained", + sourceResolution: "complete", + }, + { + kind: "chain", + method: "finally", + ownership: "assigned", + sourceResolution: "complete", + }, + { + kind: "awaited-expression", + method: "await", + ownership: "awaited", + sourceResolution: "complete", + }, + { + kind: "chain", + method: "then", + ownership: "detached", + sourceResolution: "complete", + }, + { + kind: "aggregate", + method: "all", + ownership: "returned", + sourceResolution: "complete", + }, + { + kind: "aggregate", + method: "allSettled", + ownership: "assigned", + sourceResolution: "complete", + }, + ]), + ); + const aggregate = ir.promiseOperations.find( + ({ method }) => method === "all", + ); + expect(aggregate?.sourcePromiseIds).toHaveLength(2); + expect(aggregate?.returnSiteId).not.toBeNull(); + }); +}); + +describe("JavaScript semantic analysis: calls 2", () => { + it("does not treat a shadowed Promise binding as the intrinsic", () => { + const ir = analyzeJavaScriptSemantics(` + function run(Promise) { + return Promise.resolve(1).then(work); + } + `); + + expect(ir.promiseOperations).toEqual([ + expect.objectContaining({ + kind: "chain", + method: "then", + ownership: "returned", + sourcePromiseIds: [], + sourceResolution: "unresolved", + }), + ]); + }); + + it("bounds Promise recovery with an explicit frontier", () => { + const ir = analyzeJavaScriptSemantics( + ` + Promise.resolve(1); + Promise.resolve(2); + Promise.resolve(3); + `, + { maxPromiseOperations: 1 }, + ); + + expect(ir.promiseOperations).toHaveLength(1); + expect(ir.coverage).toMatchObject({ + status: "truncated", + limitsReached: ["maxPromiseOperations"], + }); + }); + + it("fingerprints formatting and local-name changes identically", () => { + const left = analyzeJavaScriptSemantics(` + function calculate(value) { + const result = value + 1; + return result; + } + `); + const right = analyzeJavaScriptSemantics( + "function renamed(input){const output=input+1;return output}", + ); + const changed = analyzeJavaScriptSemantics( + "function renamed(input){const output=input+2;return output}", + ); + + expect(left.functionFingerprints).toHaveLength(1); + expect(right.functionFingerprints).toHaveLength(1); + expect(left.functionFingerprints[0]?.components).toEqual( + right.functionFingerprints[0]?.components, + ); + expect( + changed.functionFingerprints[0]?.components.literalSetSha256, + ).not.toBe(left.functionFingerprints[0]?.components.literalSetSha256); + }); + + it("retains ambiguous local callees and explicit dynamic frontiers", () => { + const ir = analyzeJavaScriptSemantics(` + function left(value) { return value; } + function right(value) { return value; } + const selected = chooseLeft ? left : right; + selected(input); + receiver[key](input); + external(input); + `); + + const selected = ir.callSites.find( + ({ resolution }) => resolution === "ambiguous", + ); + expect(selected?.calleeCallableIds).toEqual( + [ + onlyCallable(ir, "left").callableId, + onlyCallable(ir, "right").callableId, + ].sort(), + ); + expect( + ir.argumentFlows.filter( + ({ callSiteId }) => callSiteId === selected?.callSiteId, + ), + ).toHaveLength(2); + expect(ir.frontiers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "dynamic-call", + reason: expect.stringMatching(/ambiguous/iu), + }), + expect.objectContaining({ + kind: "dynamic-property", + reason: expect.stringMatching(/computed member/iu), + }), + expect.objectContaining({ + kind: "dynamic-call", + reason: "Unresolved call target external.", + }), + ]), + ); + }); +}); diff --git a/tests/composition/javascript-applications/javascriptSemanticAnalysis.dataflow.test.ts b/tests/composition/javascript-applications/javascriptSemanticAnalysis.dataflow.test.ts new file mode 100644 index 000000000..59ee275b5 --- /dev/null +++ b/tests/composition/javascript-applications/javascriptSemanticAnalysis.dataflow.test.ts @@ -0,0 +1,264 @@ +import { it } from "@fast-check/vitest"; +import { describe, expect } from "vitest"; + +import { analyzeJavaScriptSemantics } from "../../../src/domain/javascriptSemanticAnalysis.js"; +import { + onlyCallable, + topLevelBinding, +} from "./javascriptSemanticAnalysis.support.js"; + +describe("JavaScript semantic analysis: dataflow 1", () => { + it("recovers static object reads, writes, spreads, and destructuring", () => { + const ir = analyzeJavaScriptSemantics(` + const source = { token: "TOKEN", count: 1 }; + const { token } = source; + const copy = { ...source }; + source.count = 2; + const read = source.token; + `); + + expect( + ir.objectOperations.map(({ kind, propertyName, resolution }) => ({ + kind, + propertyName, + resolution, + })), + ).toEqual( + expect.arrayContaining([ + { + kind: "destructure", + propertyName: "token", + resolution: "complete", + }, + { kind: "spread", propertyName: null, resolution: "complete" }, + { kind: "write", propertyName: "count", resolution: "complete" }, + { kind: "read", propertyName: "token", resolution: "complete" }, + ]), + ); + }); + + it("does not project positional argument flow after a spread", () => { + const ir = analyzeJavaScriptSemantics(` + function target(first, second, third) { return third; } + target(one, ...rest, three); + `); + + expect(ir.argumentFlows.map(({ argumentIndex }) => argumentIndex)).toEqual([ + 0, + ]); + }); + + it("applies independent hard bounds to every new semantic fact family", () => { + const ir = analyzeJavaScriptSemantics( + ` + const captured = 1; + function target(value, other) { return value + other + captured; } + target(1, 2); + target(3, 4); + object[first]; + object[second]; + `, + { + maxCallSites: 1, + maxCallArguments: 1, + maxArgumentFlows: 0, + maxCallReturnFlows: 0, + maxClosureCaptures: 0, + maxFrontiers: 1, + }, + ); + + expect(ir.callSites).toHaveLength(1); + expect(ir.callSites[0]?.arguments).toEqual([ + expect.objectContaining({ index: 0, spread: false }), + ]); + expect(ir.argumentFlows).toEqual([]); + expect(ir.callReturnFlows).toEqual([]); + expect(ir.closureCaptures).toEqual([]); + expect(ir.frontiers).toHaveLength(1); + expect(ir.coverage).toMatchObject({ + status: "truncated", + limitsReached: expect.arrayContaining([ + "maxCallSites", + "maxCallArguments", + "maxArgumentFlows", + "maxCallReturnFlows", + "maxClosureCaptures", + "maxFrontiers", + ]), + }); + }); + + it("recovers only direct return sites with literal and unknown object fields", () => { + const ir = analyzeJavaScriptSemantics(` + export default function parseMarkdown(value) { + if (value.startsWith("# ")) + return { type: "heading", depth: 1, text: value.slice(2) }; + function nested() { return { type: "nested" }; } + const arrow = () => ({ type: "arrow" }); + void nested; void arrow; + return { type: "paragraph", text: value.replaceAll("x", "y") }; + } + `); + + const parse = onlyCallable(ir, "parseMarkdown"); + expect(parse.returnCoverage).toEqual({ + status: "complete", + retainedCount: 2, + omittedCount: 0, + limitsReached: [], + }); + expect(parse.returnSites).toHaveLength(2); + expect(parse.returnSites[0]?.value).toMatchObject({ + status: "object", + unknownProperties: false, + omittedProperties: 0, + properties: expect.arrayContaining([ + { name: "depth", value: { status: "literal", value: 1 } }, + { name: "type", value: { status: "literal", value: "heading" } }, + { + name: "text", + value: { + status: "unknown", + reason: "Unsupported CallExpression value.", + }, + }, + ]), + }); + expect(onlyCallable(ir, "nested").returnSites).toHaveLength(1); + expect(onlyCallable(ir, "arrow").returnSites).toHaveLength(1); + expect(ir.moduleLinks).toContainEqual( + expect.objectContaining({ + exportedName: "default", + callableId: parse.callableId, + }), + ); + }); +}); + +describe("JavaScript semantic analysis: dataflow 2", () => { + it("retains partial property coverage, empty returns, and return limits", () => { + const partial = analyzeJavaScriptSemantics(` + const spread = () => ({ type: "spread", ...dynamic }); + const computed = () => ({ type: "computed", [key]: 1 }); + function noReturn() { throw new Error("stop"); } + function emptyReturn() { return; } + `); + expect(onlyCallable(partial, "spread").returnSites[0]?.value).toMatchObject( + { + status: "object", + unknownProperties: true, + omittedProperties: null, + }, + ); + expect( + onlyCallable(partial, "computed").returnSites[0]?.value, + ).toMatchObject({ + status: "object", + unknownProperties: true, + omittedProperties: 1, + }); + expect(onlyCallable(partial, "noReturn").returnSites).toEqual([]); + expect(onlyCallable(partial, "emptyReturn").returnSites[0]?.value).toEqual({ + status: "unknown", + reason: "Return has no value.", + }); + + const limited = analyzeJavaScriptSemantics( + "function many(value) { if (value) return 1; return 2; }", + { maxReturnSites: 1 }, + ); + expect(onlyCallable(limited, "many")).toMatchObject({ + returnSites: [{ value: { status: "literal", value: 1 } }], + returnCoverage: { + status: "truncated", + retainedCount: 1, + omittedCount: 1, + limitsReached: ["maxReturnSites"], + }, + }); + expect(limited.coverage.limitsReached).toContain("maxReturnSites"); + }); + + it("fails closed on assignment ambiguity, alias cycles, and lattice limits", () => { + const ambiguous = analyzeJavaScriptSemantics(` + let current = "first"; + current = "second"; + const left = right; + const right = left; + `); + expect(topLevelBinding(ambiguous, "current").value).toMatchObject({ + status: "ambiguous", + }); + expect(topLevelBinding(ambiguous, "left").value).toMatchObject({ + status: "cycle", + }); + expect(topLevelBinding(ambiguous, "right").provenance).toMatchObject({ + status: "cycle", + }); + + const limited = analyzeJavaScriptSemantics( + 'const channel = enabled ? "one" : "two";', + { maxUnionValues: 1 }, + ); + expect(topLevelBinding(limited, "channel").value).toEqual({ + status: "limit-reached", + reason: "maxUnionValues reached.", + }); + expect(limited.coverage).toMatchObject({ + status: "truncated", + limitsReached: ["maxUnionValues"], + }); + expect(limited.coverage.omittedCount).toBeGreaterThan(0); + }); + + it("does not invent exact module paths for dynamic property access", () => { + const ir = analyzeJavaScriptSemantics(` + const key = getKey(); + const dynamicMember = require("electron")[key]; + const { [key]: dynamicBinding } = require("electron"); + `); + + expect(topLevelBinding(ir, "dynamicMember").provenance).toMatchObject({ + status: "unknown", + origins: [], + }); + expect(topLevelBinding(ir, "dynamicBinding").provenance).toMatchObject({ + status: "unknown", + origins: [], + }); + expect( + ir.moduleLinks.filter(({ localName }) => + ["dynamicMember", "dynamicBinding"].includes(localName ?? ""), + ), + ).toEqual([]); + }); + + it("bounds retained scopes and bindings without corrupting outer resolution", () => { + const ir = analyzeJavaScriptSemantics( + ` + const retained = "yes"; + const omitted = "no"; + retained; + function nested(value) { return retained + value; } + `, + { maxBindings: 1, maxCallables: 0, maxScopes: 1 }, + ); + + expect(ir.bindings.map(({ name }) => name)).toEqual(["retained"]); + expect(ir.callables).toEqual([]); + expect(ir.coverage.status).toBe("truncated"); + expect(ir.coverage.limitsReached).toEqual( + expect.arrayContaining(["maxBindings", "maxCallables", "maxScopes"]), + ); + expect(ir.references.filter(({ name }) => name === "retained")).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resolution: "resolved", + bindingId: topLevelBinding(ir, "retained").bindingId, + }), + expect.objectContaining({ resolution: "unknown", bindingId: null }), + ]), + ); + }); +}); diff --git a/tests/composition/javascript-applications/javascriptSemanticAnalysis.native_bindings.test.ts b/tests/composition/javascript-applications/javascriptSemanticAnalysis.native_bindings.test.ts new file mode 100644 index 000000000..3ddaf81be --- /dev/null +++ b/tests/composition/javascript-applications/javascriptSemanticAnalysis.native_bindings.test.ts @@ -0,0 +1,173 @@ +import { it } from "@fast-check/vitest"; +import { describe, expect } from "vitest"; + +import { analyzeJavaScriptSemantics } from "../../../src/domain/javascriptSemanticAnalysis.js"; + +describe("JavaScript semantic analysis: native bindings 1", () => { + it("recovers child-process argv, env, stdio, listeners, and signals", () => { + const ir = analyzeJavaScriptSemantics(` + import { spawn as launch } from "node:child_process"; + function run() { + const child = launch( + "/bin/tool", + ["--mode", "fast"], + { env: { MODE: "test" }, stdio: ["ignore", "pipe", "pipe"] }, + ); + child.on("exit", onExit); + child.once("error", onError); + child.kill("SIGINT"); + } + `); + + expect(ir.childProcessSpawns).toEqual([ + expect.objectContaining({ + method: "spawn", + command: "/bin/tool", + argvCount: 2, + environmentSupplied: true, + stdioMode: "array", + resolution: "complete", + }), + ]); + expect( + ir.childProcessInteractions.map( + ({ kind, eventName, signalName, resolution }) => ({ + kind, + eventName, + signalName, + resolution, + }), + ), + ).toEqual([ + { + kind: "listener", + eventName: "exit", + signalName: null, + resolution: "complete", + }, + { + kind: "listener", + eventName: "error", + signalName: null, + resolution: "complete", + }, + { + kind: "signal", + eventName: null, + signalName: "SIGINT", + resolution: "complete", + }, + ]); + expect(ir.functionFingerprints[0]?.components.effects).toContain( + "child-process", + ); + }); + + it("recovers configuration, requests, response consumers, and boundaries", () => { + const ir = analyzeJavaScriptSemantics(` + import { readFileSync } from "node:fs"; + import * as http from "node:http"; + async function run(schema) { + const endpoint = process.env.API_URL ?? "https://fallback.test"; + const mode = process.argv[2]; + const raw = readFileSync("./config.json", "utf8"); + const parsed = JSON.parse(raw); + const port = Number(process.env.PORT); + const validated = schema.parse(parsed); + const response = await fetch(endpoint, { + method: "POST", + body: validated, + }); + const body = await response.json(); + http.request("https://audit.test", { body }); + return { mode, port, body }; + } + `); + + expect( + ir.configurationOperations.map(({ kind, key }) => ({ kind, key })), + ).toEqual( + expect.arrayContaining([ + { kind: "environment", key: "API_URL" }, + { kind: "default", key: "API_URL" }, + { kind: "argv", key: "2" }, + { kind: "file", key: "./config.json" }, + { kind: "environment", key: "PORT" }, + ]), + ); + expect( + ir.requestOperations.map(({ kind, method, endpoint, resolution }) => ({ + kind, + method, + endpoint, + resolution, + })), + ).toEqual( + expect.arrayContaining([ + { + kind: "request", + method: "fetch", + endpoint: null, + resolution: "partial", + }, + { + kind: "response-consumer", + method: "json", + endpoint: null, + resolution: "complete", + }, + { + kind: "request", + method: "request", + endpoint: "https://audit.test", + resolution: "complete", + }, + ]), + ); + expect( + ir.boundaryOperations.map(({ kind, method, resolution }) => ({ + kind, + method, + resolution, + })), + ).toEqual( + expect.arrayContaining([ + { kind: "parse", method: "JSON.parse", resolution: "complete" }, + { kind: "coerce", method: "Number", resolution: "complete" }, + { kind: "parse", method: "parse", resolution: "partial" }, + ]), + ); + expect(ir.functionFingerprints[0]?.components.effects).toContain("network"); + }); +}); + +describe("JavaScript semantic analysis: native bindings 2", () => { + it("recovers built-in resource acquisition and exact local release", () => { + const ir = analyzeJavaScriptSemantics(` + import { open } from "node:fs/promises"; + import { connect as dial } from "node:net"; + async function run(path) { + const file = await open(path); + const socket = dial({ port: 9000 }); + await file.close(); + socket.destroy(); + } + `); + + expect( + ir.resourceOperations.map(({ kind, method, resolution }) => ({ + kind, + method, + resolution, + })), + ).toEqual([ + { kind: "acquire", method: "open", resolution: "complete" }, + { kind: "acquire", method: "connect", resolution: "complete" }, + { kind: "release", method: "close", resolution: "complete" }, + { kind: "release", method: "destroy", resolution: "complete" }, + ]); + expect(ir.functionFingerprints[0]?.components.effects).toContain( + "resource", + ); + }); +}); diff --git a/tests/composition/javascript-applications/javascriptSemanticAnalysis.rejection.test.ts b/tests/composition/javascript-applications/javascriptSemanticAnalysis.rejection.test.ts new file mode 100644 index 000000000..1c5d80b58 --- /dev/null +++ b/tests/composition/javascript-applications/javascriptSemanticAnalysis.rejection.test.ts @@ -0,0 +1,128 @@ +import { fc, it } from "@fast-check/vitest"; +import { describe, expect } from "vitest"; + +import { analyzeJavaScriptSemantics } from "../../../src/domain/javascriptSemanticAnalysis.js"; +import { + onlyCallable, + topLevelBinding, +} from "./javascriptSemanticAnalysis.support.js"; + +describe("JavaScript semantic analysis: rejection 1", () => { + it("reports reference, module-link, depth, and object-property limits", () => { + const retained = analyzeJavaScriptSemantics( + ` + const first = require("first"); + const second = require("second"); + first; + second; + `, + { maxModuleLinks: 1, maxReferences: 1 }, + ); + expect(retained.moduleLinks).toHaveLength(1); + expect(retained.references).toHaveLength(1); + expect(retained.coverage.limitsReached).toEqual( + expect.arrayContaining(["maxModuleLinks", "maxReferences"]), + ); + + const object = analyzeJavaScriptSemantics( + "const object = { first: 1, second: 2 };", + { maxObjectProperties: 1 }, + ); + expect(topLevelBinding(object, "object").value).toMatchObject({ + status: "object", + unknownProperties: true, + }); + expect(object.coverage.limitsReached).toContain("maxObjectProperties"); + + const depth = analyzeJavaScriptSemantics( + 'const first = "value"; const second = first;', + { maxValueDepth: 1 }, + ); + expect(topLevelBinding(depth, "second").value).toMatchObject({ + status: "limit-reached", + }); + expect(depth.coverage.limitsReached).toContain("maxValueDepth"); + }); + + it("is deterministic and returns failed coverage for an unparseable source", () => { + const source = 'const { value: renamed } = require("fixture");'; + expect(analyzeJavaScriptSemantics(source)).toEqual( + analyzeJavaScriptSemantics(source), + ); + expect(analyzeJavaScriptSemantics("function {")).toMatchObject({ + scopes: [], + bindings: [], + callables: [], + references: [], + coverage: { status: "failed", omittedCount: null }, + }); + }); + + it("does not mark parser-recovered duplicate bindings complete", () => { + const ir = analyzeJavaScriptSemantics( + "const duplicate = 'first'; const duplicate = 'second';", + ); + + expect(ir.coverage).toMatchObject({ + status: "partial", + omittedCount: 0, + }); + expect(topLevelBinding(ir, "duplicate").value).toMatchObject({ + status: "ambiguous", + }); + expect(ir.limitations.join(" ")).toMatch(/parser recovered/iu); + }); + + it("keeps parser-recovered return shapes partial", () => { + const ir = analyzeJavaScriptSemantics( + ` + export default function recovered(value) { + const duplicate = 1; + const duplicate = 2; + return { type: "item", text: render(value) }; + } + `, + ); + const recovered = onlyCallable(ir, "recovered"); + + expect(ir.coverage.status).toBe("partial"); + expect(recovered.returnCoverage).toMatchObject({ + status: "partial", + retainedCount: 1, + omittedCount: 0, + }); + expect(recovered.returnSites[0]?.value).toMatchObject({ + status: "object", + properties: expect.arrayContaining([ + { name: "type", value: { status: "literal", value: "item" } }, + expect.objectContaining({ + name: "text", + value: expect.objectContaining({ status: "unknown" }), + }), + ]), + }); + }); + + it.prop([fc.string({ maxLength: 512 })])( + "fails closed for arbitrary bounded source text", + (source) => { + const ir = analyzeJavaScriptSemantics(source, { + maxBindings: 64, + maxCallables: 64, + maxModuleLinks: 64, + maxReferences: 256, + maxScopes: 64, + }); + + expect(ir.schema).toBe("JavaScriptSemanticIR"); + expect(["complete", "partial", "truncated", "failed"]).toContain( + ir.coverage.status, + ); + if (ir.coverage.status === "failed") { + expect(ir.scopes).toEqual([]); + expect(ir.bindings).toEqual([]); + expect(ir.callables).toEqual([]); + } + }, + ); +}); diff --git a/tests/composition/javascript-applications/javascriptSemanticAnalysis.runtime_boundaries.test.ts b/tests/composition/javascript-applications/javascriptSemanticAnalysis.runtime_boundaries.test.ts new file mode 100644 index 000000000..1f349e200 --- /dev/null +++ b/tests/composition/javascript-applications/javascriptSemanticAnalysis.runtime_boundaries.test.ts @@ -0,0 +1,129 @@ +import { it } from "@fast-check/vitest"; +import { describe, expect } from "vitest"; + +import { analyzeJavaScriptSemantics } from "../../../src/domain/javascriptSemanticAnalysis.js"; + +describe("JavaScript semantic analysis: runtime boundaries 1", () => { + it("recovers EventEmitter candidates and imported timer handle cancellation", () => { + const ir = analyzeJavaScriptSemantics(` + import { + setTimeout as later, + clearTimeout as cancel, + } from "node:timers"; + function run(bus, dynamicName) { + const handle = later(work, 25); + bus.on("ready", handler); + bus.once(dynamicName, handler); + bus.emit("ready"); + bus.off("ready", handler); + cancel(handle); + } + `); + + expect( + ir.eventOperations.map(({ kind, method, eventName, resolution }) => ({ + kind, + method, + eventName, + resolution, + })), + ).toEqual([ + { + kind: "register", + method: "on", + eventName: "ready", + resolution: "complete", + }, + { + kind: "register", + method: "once", + eventName: null, + resolution: "unresolved", + }, + { + kind: "dispatch", + method: "emit", + eventName: "ready", + resolution: "complete", + }, + { + kind: "remove", + method: "off", + eventName: "ready", + resolution: "complete", + }, + ]); + expect(ir.timerOperations).toEqual([ + expect.objectContaining({ + kind: "schedule", + method: "setTimeout", + delayMilliseconds: 25, + resolution: "complete", + }), + expect.objectContaining({ + kind: "cancel", + method: "clearTimeout", + delayMilliseconds: null, + resolution: "complete", + linkedTimerId: ir.timerOperations[0]?.timerId, + }), + ]); + expect(ir.functionFingerprints[0]?.components.effects).toEqual([ + "event", + "timer", + ]); + }); + + it("does not treat shadowed timer globals as Node timers", () => { + const ir = analyzeJavaScriptSemantics(` + function run(setTimeout, clearTimeout) { + const handle = setTimeout(work, 1); + clearTimeout(handle); + } + `); + + expect(ir.timerOperations).toEqual([]); + }); + + it("bounds every added semantic effect family independently", () => { + const ir = analyzeJavaScriptSemantics( + ` + import { spawn } from "node:child_process"; + import { open } from "node:fs/promises"; + Promise.resolve(1); + bus.on("ready", handler); + setTimeout(work, 1); + spawn("/bin/tool"); + const endpoint = process.env.API_URL; + fetch("https://example.test"); + JSON.parse(raw); + open(path); + `, + { + maxPromiseOperations: 0, + maxEventOperations: 0, + maxTimerOperations: 0, + maxChildProcessOperations: 0, + maxConfigurationOperations: 0, + maxRequestOperations: 0, + maxBoundaryOperations: 0, + maxResourceOperations: 0, + maxObjectOperations: 0, + }, + ); + + expect(ir.coverage.limitsReached).toEqual( + expect.arrayContaining([ + "maxBoundaryOperations", + "maxChildProcessOperations", + "maxConfigurationOperations", + "maxEventOperations", + "maxPromiseOperations", + "maxRequestOperations", + "maxResourceOperations", + "maxTimerOperations", + "maxObjectOperations", + ]), + ); + }); +}); diff --git a/tests/composition/javascript-applications/javascriptSemanticAnalysis.structure.test.ts b/tests/composition/javascript-applications/javascriptSemanticAnalysis.structure.test.ts new file mode 100644 index 000000000..15e1eeb99 --- /dev/null +++ b/tests/composition/javascript-applications/javascriptSemanticAnalysis.structure.test.ts @@ -0,0 +1,209 @@ +import { it } from "@fast-check/vitest"; +import { describe, expect } from "vitest"; + +import { analyzeJavaScriptSemantics } from "../../../src/domain/javascriptSemanticAnalysis.js"; +import { + semanticBinding, + semanticReferenceAt, +} from "../../../src/domain/javascriptSemanticIr.js"; +import { + programScope, + bindingsNamed, + onlyBinding, + topLevelBinding, + origin, +} from "./javascriptSemanticAnalysis.support.js"; + +describe("JavaScript semantic analysis: structure 1", () => { + it("resolves imports, require destructuring, aliases, assignments, and shadowing", () => { + const ir = analyzeJavaScriptSemantics(` + import { ipcRenderer as ir } from "electron"; + const { ipcMain: bus } = require("electron"); + const forwarded = bus; + let assigned; + assigned = ir; + ir.invoke("outside"); + bus.handle("main", handler); + function local(ipcRenderer) { + const bus = require("./local-bus.js"); + ipcRenderer.send("shadowed"); + return bus; + } + `); + + expect(ir.coverage).toEqual({ + status: "complete", + omittedCount: 0, + limitsReached: [], + }); + expect(origin(topLevelBinding(ir, "ir"))).toEqual({ + specifier: "electron", + importedPath: ["ipcRenderer"], + }); + expect(origin(topLevelBinding(ir, "bus"))).toEqual({ + specifier: "electron", + importedPath: ["ipcMain"], + }); + expect(origin(topLevelBinding(ir, "forwarded"))).toEqual({ + specifier: "electron", + importedPath: ["ipcMain"], + }); + expect(origin(topLevelBinding(ir, "assigned"))).toEqual({ + specifier: "electron", + importedPath: ["ipcRenderer"], + }); + expect(ir.moduleLinks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "require", + specifier: "electron", + importedName: "ipcMain", + localName: "bus", + }), + ]), + ); + + const innerBus = bindingsNamed(ir, "bus").find( + ({ scopeId }) => scopeId !== programScope(ir).scopeId, + ); + expect(innerBus).toBeDefined(); + if (innerBus === undefined) return; + expect(origin(innerBus)).toEqual({ + specifier: "./local-bus.js", + importedPath: [], + }); + const shadow = onlyBinding(ir, "ipcRenderer"); + expect(shadow.provenance).toEqual({ + status: "local", + origins: [], + reason: null, + }); + const shadowReference = ir.references.find( + ({ name, bindingId, role }) => + name === "ipcRenderer" && + bindingId === shadow.bindingId && + role === "read", + ); + expect(shadowReference?.resolution).toBe("resolved"); + expect(shadowReference?.bindingId).not.toBe( + topLevelBinding(ir, "ir").bindingId, + ); + if (shadowReference === undefined || shadowReference.bindingId === null) + throw new Error("Missing shadow reference"); + expect(semanticBinding(ir, shadowReference.bindingId)).toEqual(shadow); + expect( + semanticReferenceAt( + ir, + shadowReference.location.start.line, + shadowReference.location.start.column, + ), + ).toEqual(shadowReference); + }); + + it("propagates literal, template, object, conditional, and destructured values", () => { + const ir = analyzeJavaScriptSemantics(` + const prefix = "rea"; + const suffix = "open"; + const channel = \`${"${prefix}"}:${"${suffix}"}\`; + const options = { + channel, + mode: enabled ? "read" : "write", + }; + const selected = options.channel; + const { mode } = options; + const [first] = ["zero", "one"]; + `); + + expect(topLevelBinding(ir, "channel").value).toEqual({ + status: "literal", + value: "rea:open", + }); + expect(topLevelBinding(ir, "selected").value).toEqual({ + status: "literal", + value: "rea:open", + }); + expect(topLevelBinding(ir, "mode").value).toEqual({ + status: "union", + values: ["read", "write"], + }); + expect(topLevelBinding(ir, "first").value).toEqual({ + status: "literal", + value: "zero", + }); + }); +}); + +describe("JavaScript semantic analysis: structure 2", () => { + it("retains ESM, re-export, require, and CommonJS export relationships", () => { + const ir = analyzeJavaScriptSemantics(` + export { ipcRenderer as bridge } from "electron"; + export * from "./wrapper.js"; + export const localValue = 1; + const addon = require("./native.node"); + module.exports.addon = addon; + export default function namedDefault() {} + `); + + expect(ir.moduleLinks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "re-export", + specifier: "electron", + importedName: "ipcRenderer", + exportedName: "bridge", + }), + expect.objectContaining({ + kind: "re-export", + specifier: "./wrapper.js", + importedName: "*", + exportedName: "*", + }), + expect.objectContaining({ + kind: "export", + localName: "localValue", + exportedName: "localValue", + }), + expect.objectContaining({ + kind: "require", + specifier: "./native.node", + localName: "addon", + }), + expect.objectContaining({ + kind: "commonjs-export", + localName: "addon", + exportedName: "addon", + }), + expect.objectContaining({ + kind: "export", + localName: "namedDefault", + exportedName: "default", + }), + ]), + ); + }); + + it("keeps function, class, and method identities separate from bindings", () => { + const ir = analyzeJavaScriptSemantics(` + class Service { + run() {} + get value() { return 1; } + #privateMethod() {} + } + const arrow = () => 1; + const object = { method() {} }; + `); + + expect(ir.callables).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: "class", name: "Service" }), + expect.objectContaining({ kind: "method", name: "run" }), + expect.objectContaining({ kind: "method", name: "value" }), + expect.objectContaining({ kind: "method", name: "#privateMethod" }), + expect.objectContaining({ kind: "function", name: "arrow" }), + expect.objectContaining({ kind: "method", name: "method" }), + ]), + ); + expect(bindingsNamed(ir, "run")).toEqual([]); + expect(bindingsNamed(ir, "method")).toEqual([]); + }); +}); diff --git a/tests/composition/javascript-applications/javascriptSemanticAnalysis.support.ts b/tests/composition/javascript-applications/javascriptSemanticAnalysis.support.ts new file mode 100644 index 000000000..d9bfc1b50 --- /dev/null +++ b/tests/composition/javascript-applications/javascriptSemanticAnalysis.support.ts @@ -0,0 +1,61 @@ +import { expect } from "vitest"; + +import type { + JavaScriptSemanticBinding, + JavaScriptSemanticIr, +} from "../../../src/domain/javascriptSemanticIr.js"; + +export const programScope = (ir: JavaScriptSemanticIr) => { + const scope = ir.scopes.find(({ kind }) => kind === "program"); + if (scope === undefined) throw new Error("Missing program scope"); + return scope; +}; + +export const bindingsNamed = ( + ir: JavaScriptSemanticIr, + name: string, +): JavaScriptSemanticBinding[] => + ir.bindings.filter(({ name: candidate }) => candidate === name); + +export const onlyBinding = ( + ir: JavaScriptSemanticIr, + name: string, +): JavaScriptSemanticBinding => { + const bindings = bindingsNamed(ir, name); + expect(bindings).toHaveLength(1); + const binding = bindings[0]; + if (binding === undefined) throw new Error(`Missing binding ${name}`); + return binding; +}; + +export const onlyCallable = (ir: JavaScriptSemanticIr, name: string) => { + const callables = ir.callables.filter( + ({ name: candidate }) => candidate === name, + ); + if (callables.length !== 1 || callables[0] === undefined) + throw new Error(`Expected one callable named ${name}`); + return callables[0]; +}; + +export const topLevelBinding = ( + ir: JavaScriptSemanticIr, + name: string, +): JavaScriptSemanticBinding => { + const scopeId = programScope(ir).scopeId; + const binding = ir.bindings.find( + ({ name: candidate, scopeId: candidateScope }) => + candidate === name && candidateScope === scopeId, + ); + if (binding === undefined) + throw new Error(`Missing top-level binding ${name}`); + return binding; +}; + +export const origin = (binding: JavaScriptSemanticBinding) => { + expect(binding.provenance.status).toBe("module"); + expect(binding.provenance.origins).toHaveLength(1); + const value = binding.provenance.origins[0]; + if (value === undefined) + throw new Error(`Missing origin for ${binding.name}`); + return value; +}; diff --git a/tests/composition/javascript-applications/javascriptSemanticGraphBuilder.test.ts b/tests/composition/javascript-applications/javascriptSemanticGraphBuilder.test.ts new file mode 100644 index 000000000..52e220f57 --- /dev/null +++ b/tests/composition/javascript-applications/javascriptSemanticGraphBuilder.test.ts @@ -0,0 +1,317 @@ +import { expect, it } from "vitest"; + +import { buildJavaScriptSemanticGraph } from "../../../src/application/JavaScriptSemanticGraphBuilder.js"; +import type { JavaScriptArtifactAnalysis } from "../../../src/application/JavaScriptArtifactAnalysisTypes.js"; +import type { JavaScriptArtifactFile } from "../../../src/application/JavaScriptArtifactFiles.js"; +import { queryJavaScriptSemanticGraph } from "../../../src/domain/javascriptSemanticQuery.js"; +import { analyzeJavaScriptSemantics } from "../../../src/domain/javascriptSemanticAnalysis.js"; +import { + DEFAULT_JAVASCRIPT_SEMANTIC_LIMITS, + type JavaScriptSemanticLimits, +} from "../../../src/domain/javascriptSemanticIr.js"; + +const SHA256 = "a".repeat(64); +const GRAPH_ID = `jag_${"b".repeat(64)}`; + +it("projects closure and direct interprocedural flow without execution", () => { + const graph = graphFor(` + const outer = 2; + function add(value) { return value + outer; } + const input = 40; + const answer = add(input); + `); + + const relations = new Set(graph.relations.map(({ relation }) => relation)); + for (const expected of [ + "argument-to-parameter", + "calls", + "captures", + "defines", + "reads", + "returns-to-call", + ] as const) + expect(relations.has(expected)).toBe(true); + expect(graph.coverage.families).toHaveLength(12); + expect(graph.limitations).toContain( + "The semantic graph contains static syntax observations and conservative relationship candidates; it does not claim runtime execution.", + ); + const parameter = graph.nodes.find( + ({ kind, label }) => kind === "parameter" && label === "value", + ); + if (parameter === undefined) throw new Error("Expected parameter node"); + const provenance = queryJavaScriptSemanticGraph(graph, { + seed: { kind: "semantic-node", node_id: parameter.node_id }, + direction: "backward-provenance", + include_ambiguous_dynamic_edges: true, + }); + expect(provenance.relations.map(({ relation }) => relation)).toEqual( + expect.arrayContaining([ + "aliases", + "argument-to-parameter", + "defines", + "reads", + ]), + ); +}); + +it("keeps dynamic calls explicit and returns bounded deterministic pages", () => { + const graph = graphFor(` + const handlers = { ready() { return 1; } }; + const key = process.argv[2]; + handlers[key](); + `); + expect(graph.unknowns.some(({ reason }) => reason === "dynamic-call")).toBe( + true, + ); + const seedNodeId = graph.relations.find( + ({ relation }) => relation === "defines", + )?.source_node_id; + expect(seedNodeId).toBeDefined(); + if (seedNodeId === undefined) throw new Error("Expected a definition seed"); + + const first = queryJavaScriptSemanticGraph(graph, { + seed: { kind: "semantic-node", node_id: seedNodeId }, + direction: "forward-influence", + include_ambiguous_dynamic_edges: true, + limits: { + max_seed_matches: 25, + max_nodes: 2_000, + max_edges: 4_000, + max_depth: 12, + max_functions: 500, + max_modules: 500, + page_size: 1, + }, + }); + const repeated = queryJavaScriptSemanticGraph(graph, { + seed: { kind: "semantic-node", node_id: seedNodeId }, + direction: "forward-influence", + include_ambiguous_dynamic_edges: true, + limits: first.applied_limits, + }); + expect(repeated).toEqual(first); +}); + +it("keeps ambiguous interprocedural flow out of the default traversal", () => { + const graph = graphFor(` + function left(value) { return value; } + function right(value) { return value; } + const input = 1; + const selected = Math.random() ? left : right; + selected(input); + `); + const input = graph.nodes.find( + ({ kind, label }) => kind === "binding" && label === "input", + ); + if (input === undefined) throw new Error("Expected input binding"); + const query = { + seed: { kind: "semantic-node" as const, node_id: input.node_id }, + direction: "forward-influence" as const, + allowed_relations: [ + "reads" as const, + "aliases" as const, + "argument-to-parameter" as const, + ], + }; + const strict = queryJavaScriptSemanticGraph(graph, query); + expect( + strict.relations.some( + ({ relation }) => relation === "argument-to-parameter", + ), + ).toBe(false); + expect(strict.status).toBe("ambiguous"); + const admitted = queryJavaScriptSemanticGraph(graph, { + ...query, + include_ambiguous_dynamic_edges: true, + }); + expect( + admitted.relations.filter( + ({ relation }) => relation === "argument-to-parameter", + ), + ).toHaveLength(2); + expect(admitted.status).toBe("ambiguous"); +}); + +it("connects caller results, direct returns, parameters, and captures", () => { + const graph = graphFor(` + const outer = 2; + function identity(value) { return value; } + function readOuter() { return outer; } + const input = 40; + const output = identity(input); + `); + const binding = (label: string) => + graph.nodes.find( + ({ kind, label: nodeLabel }) => + ["binding", "parameter"].includes(kind) && nodeLabel === label, + ); + const output = binding("output"); + const input = binding("input"); + const outer = binding("outer"); + const readOuter = graph.nodes.find( + ({ kind, label }) => kind === "function" && label === "readOuter", + ); + if ( + output === undefined || + input === undefined || + outer === undefined || + readOuter === undefined + ) + throw new Error("Expected semantic flow nodes"); + const provenance = queryJavaScriptSemanticGraph(graph, { + seed: { kind: "semantic-node", node_id: output.node_id }, + direction: "backward-provenance", + }); + expect(provenance.nodes.map(({ node_id }) => node_id)).toContain( + input.node_id, + ); + const influence = queryJavaScriptSemanticGraph(graph, { + seed: { kind: "semantic-node", node_id: outer.node_id }, + direction: "forward-influence", + allowed_relations: ["captures"], + }); + expect(influence.nodes.map(({ node_id }) => node_id)).toContain( + readOuter.node_id, + ); + const capture = graph.relations.find( + ({ relation }) => relation === "captures", + ); + expect(capture?.evidence.location).toMatchObject({ + available: true, + value: { kind: "source-range", source: "app.js" }, + }); + expect(capture?.evidence.location).not.toEqual(readOuter.evidence.location); +}); + +it("preserves semantic analyzer truncation in graph coverage", () => { + const graph = graphFor( + "const first = 1; const second = first; const third = second;", + { maxReferences: 1 }, + ); + expect(graph.coverage).toMatchObject({ + status: "partial", + truncated: true, + omitted_nodes: null, + omitted_relations: null, + limits: expect.arrayContaining([ + { name: "semantic.maxReferences", value: 1, unit: "items" }, + ]), + }); + expect( + graph.coverage.families.find( + ({ family }) => family === "promise-ownership", + ), + ).toMatchObject({ status: "unknown", retained_relations: 0 }); +}); + +it("projects bounded Promise ownership and unresolved sources", () => { + const graph = graphFor(` + async function run(value) { + const owned = new Promise((resolve) => resolve(value)); + await Promise.resolve(owned); + Promise.resolve(value).then(work).finally(cleanup); + value.then(work); + return Promise.all([owned, Promise.resolve(value)]); + } + `); + + const relations = new Set(graph.relations.map(({ relation }) => relation)); + for (const expected of [ + "aggregates", + "awaits", + "chains", + "creates-promise", + "detaches-task", + "owns", + "returns-task", + ] as const) + expect(relations.has(expected)).toBe(true); + expect(graph.nodes.filter(({ kind }) => kind === "promise")).not.toHaveLength( + 0, + ); + expect( + graph.nodes.find( + ({ kind, properties }) => + kind === "promise" && + properties.method === "all" && + properties.ownership === "returned", + ), + ).toBeDefined(); + expect( + graph.unknowns.some( + ({ family, reason }) => + family === "promise-ownership" && reason === "ambiguous-target", + ), + ).toBe(true); + expect( + graph.coverage.families.find( + ({ family }) => family === "promise-ownership", + ), + ).toMatchObject({ status: "partial" }); +}); + +it("keeps duplicate function fingerprints ambiguous", () => { + const graph = graphFor(` + function first(value) { return value + 1; } + function second(input) { return input + 1; } + `); + + expect(graph.fingerprints).toHaveLength(2); + const digest = graph.fingerprints[0]?.fingerprint_sha256; + expect(digest).toBeDefined(); + expect(graph.fingerprints[1]?.fingerprint_sha256).toBe(digest); + if (digest === undefined) throw new Error("Expected fingerprint"); + const query = queryJavaScriptSemanticGraph(graph, { + seed: { kind: "function", fingerprint_sha256: digest }, + direction: "forward-influence", + }); + expect(query.status).toBe("ambiguous"); + expect(query.summary.total_seed_matches).toBe(2); +}); + +const graphFor = ( + source: string, + inputLimits: Partial = {}, +) => { + const file: JavaScriptArtifactFile = { + path: "app.js", + container_sha256: SHA256, + sha256: SHA256, + bytes: Buffer.byteLength(source), + inventory_artifact_id: `art_${SHA256}`, + kind: "javascript", + unpacked: false, + text: { included: true, value: source }, + }; + const semanticLimits = { + ...DEFAULT_JAVASCRIPT_SEMANTIC_LIMITS, + ...inputLimits, + }; + const analysis: JavaScriptArtifactAnalysis = { + files: [ + { + file, + javascript: null, + semantic: { + ir: analyzeJavaScriptSemantics(source, inputLimits), + limits: semanticLimits, + }, + }, + ], + packages: [], + json_modules: [], + html_scripts: [], + source_maps: [], + visited_ast_nodes: 0, + findings: 0, + modules: 0, + parse_failures: 0, + truncated_scopes: 0, + limitations: [], + }; + return buildJavaScriptSemanticGraph({ + rootArtifactSha256: SHA256, + applicationGraph: { graph_id: GRAPH_ID, nodes: [] }, + analysis, + }); +}; diff --git a/tests/composition/javascript-applications/javascriptSemanticGraphQuery.test.ts b/tests/composition/javascript-applications/javascriptSemanticGraphQuery.test.ts new file mode 100644 index 000000000..ca8b086ab --- /dev/null +++ b/tests/composition/javascript-applications/javascriptSemanticGraphQuery.test.ts @@ -0,0 +1,278 @@ +import { expect, it } from "vitest"; + +import type { ApplicationGraphEvidence } from "../../../src/domain/javascriptApplicationEvidenceSchemas.js"; +import { + JAVASCRIPT_SEMANTIC_NODE_KINDS, + JAVASCRIPT_SEMANTIC_RELATION_FAMILIES, + JAVASCRIPT_SEMANTIC_RELATIONS, +} from "../../../src/domain/javascriptSemanticGraphSchemas.js"; +import { + createJavaScriptSemanticFingerprint, + createJavaScriptSemanticGraph, + createJavaScriptSemanticGraphNode, + createJavaScriptSemanticGraphRelation, + createJavaScriptSemanticGraphUnknown, + type JavaScriptSemanticGraph, + type JavaScriptSemanticGraphNode, +} from "../../../src/domain/javascriptSemanticGraph.js"; +import { + parseJavaScriptSemanticGraph, + serializeJavaScriptSemanticGraph, +} from "../../../src/domain/javascriptSemanticGraphSerialization.js"; + +const SHA = "a".repeat(64); +const JAG_ID = `jag_${"b".repeat(64)}`; +const completeCoverage = { + status: "complete" as const, + truncated: false, + omitted_count: 0, + limits: [], +}; + +const evidence = ( + state: "observed" | "inferred" = "observed", +): ApplicationGraphEvidence => ({ + authority: + state === "observed" + ? "ast-static-analysis" + : "static-relationship-inference", + state, + confidence: state === "observed" ? "exact" : "high", + artifact: { available: true, artifact_id: `art_${SHA}`, sha256: SHA }, + location: { + available: true, + value: { + kind: "source-range", + source: "bundle.js", + start: { line: 1, column: 0 }, + end: { line: 1, column: 10 }, + }, + }, + extractor: { + name: "test", + version: "1", + operation: "recover-semantic-relation", + executable_sha256: null, + }, + coverage: completeCoverage, + limitations: + state === "observed" + ? [] + : ["Static reachability does not prove runtime execution."], + evidence_ids: [], +}); + +const unknownEvidence = (): ApplicationGraphEvidence => ({ + authority: "unknown", + state: "unknown", + confidence: "unknown", + artifact: { + available: false, + reason: "unknown", + detail: "Unknown artifact.", + }, + location: { + available: false, + reason: "unresolved", + detail: "Dynamic call target.", + }, + extractor: { + name: "test", + version: "1", + operation: "retain-dynamic-call", + executable_sha256: null, + }, + coverage: { + status: "unknown", + truncated: false, + omitted_count: null, + limits: [], + }, + limitations: ["Dynamic call target remains unknown."], + evidence_ids: [], +}); + +const node = ( + kind: (typeof JAVASCRIPT_SEMANTIC_NODE_KINDS)[number], + role: string, + properties: Record = {}, + functionNodeId: string | null = null, +): JavaScriptSemanticGraphNode => + createJavaScriptSemanticGraphNode({ + kind, + identity: { + artifact_sha256: SHA, + module_path: "bundle.js", + source_range: { + start: { line: 1, column: role.length }, + end: { line: 1, column: role.length + 1 }, + }, + role_key: role, + }, + function_node_id: functionNodeId, + application_node_ids: [], + label: role, + properties, + evidence: evidence(), + }); + +const fixtureGraph = (withUnknown = false): JavaScriptSemanticGraph => { + const module = node("module", "module"); + const literal = node("literal", "literal", { value: "TOKEN" }); + const binding = node("binding", "binding"); + const callable = node("function", "function"); + const request = node("request", "request", { + endpoint: "https://example.invalid/v1", + }); + const relations = [ + createJavaScriptSemanticGraphRelation({ + source_node_id: literal.node_id, + target_node_id: binding.node_id, + relation: "defines", + resolution: "resolved", + properties: {}, + evidence: evidence("inferred"), + }), + createJavaScriptSemanticGraphRelation({ + source_node_id: binding.node_id, + target_node_id: callable.node_id, + relation: "captures", + resolution: "resolved", + properties: {}, + evidence: evidence("inferred"), + }), + createJavaScriptSemanticGraphRelation({ + source_node_id: callable.node_id, + target_node_id: request.node_id, + relation: "constructs-request", + resolution: "resolved", + properties: {}, + evidence: evidence("inferred"), + }), + ]; + const dynamic = createJavaScriptSemanticGraphUnknown({ + node_id: callable.node_id, + family: "call-flow", + relation_kinds: ["calls"], + reason: "dynamic-call", + detail: "Computed callee is unresolved.", + candidate_node_ids: [], + evidence: unknownEvidence(), + }); + const unknowns = withUnknown ? [dynamic] : []; + const coverageFamilies = JAVASCRIPT_SEMANTIC_RELATION_FAMILIES.map( + (family) => ({ + family, + status: + withUnknown && family === "call-flow" + ? ("unknown" as const) + : ("complete" as const), + retained_relations: relations.filter(({ relation }) => + relation === "defines" + ? family === "data-flow" + : relation === "captures" + ? family === "closure" + : family === "request", + ).length, + omitted_relations: withUnknown && family === "call-flow" ? null : 0, + unknown_ids: + withUnknown && family === "call-flow" ? [dynamic.unknown_id] : [], + }), + ); + const fingerprint = createJavaScriptSemanticFingerprint({ + function_node_id: callable.node_id, + algorithm: "rea.javascript-semantic-function/v1", + status: "complete", + components: { + parameter_arity: 0, + normalized_ast_sha256: "1".repeat(64), + control_flow_sha256: "2".repeat(64), + relation_shape_sha256: "3".repeat(64), + literal_set_sha256: "4".repeat(64), + effects: ["network"], + }, + limitations: [], + evidence: evidence("inferred"), + }); + return createJavaScriptSemanticGraph({ + schema: "JavaScriptSemanticRelationGraph", + schema_version: 1, + root_artifact_sha256: SHA, + application_graph_id: JAG_ID, + root_node_ids: [module.node_id], + nodes: [request, callable, binding, literal, module], + relations, + fingerprints: [fingerprint], + unknowns, + coverage: { + status: withUnknown ? "partial" : "complete", + truncated: false, + omitted_nodes: withUnknown ? null : 0, + omitted_relations: withUnknown ? null : 0, + limits: [], + families: coverageFamilies, + }, + limitations: withUnknown ? ["Dynamic call target remains unknown."] : [], + }); +}; + +it("defines every required v1 relation family and canonicalizes records", () => { + expect(JAVASCRIPT_SEMANTIC_RELATIONS).toContain("argument-to-parameter"); + expect(JAVASCRIPT_SEMANTIC_RELATIONS).toContain("detaches-task"); + expect(JAVASCRIPT_SEMANTIC_RELATIONS).toContain("forwards-signal"); + expect(JAVASCRIPT_SEMANTIC_RELATIONS).toContain("validates"); + const graph = fixtureGraph(); + expect( + parseJavaScriptSemanticGraph( + JSON.parse(serializeJavaScriptSemanticGraph(graph)), + ), + ).toEqual(graph); + expect(graph.nodes.map(({ node_id }) => node_id)).toEqual( + graph.nodes.map(({ node_id }) => node_id).toSorted(), + ); +}); + +it("rejects stale identities, dangling endpoints, and incomplete coverage claims", () => { + const graph = fixtureGraph(); + const { graph_id: _graphId, ...graphInput } = graph; + expect(() => + parseJavaScriptSemanticGraph({ + ...graph, + graph_id: `jsrg_${"0".repeat(64)}`, + }), + ).toThrow(); + expect(() => + createJavaScriptSemanticGraph({ + ...graphInput, + relations: [ + { + ...graph.relations[0], + target_node_id: `jsrg_node_${"f".repeat(64)}`, + }, + ], + }), + ).toThrow(); + expect(() => + createJavaScriptSemanticGraph({ + ...graphInput, + coverage: { ...graph.coverage, omitted_nodes: 1 }, + }), + ).toThrow(/Complete graph coverage/u); + const graphWithUnknown = fixtureGraph(true); + const { graph_id: _unknownGraphId, ...unknownGraphInput } = graphWithUnknown; + const originalUnknown = graphWithUnknown.unknowns[0]; + if (originalUnknown === undefined) + throw new Error("Expected unknown frontier"); + const { unknown_id: _unknownId, ...unknownInput } = originalUnknown; + expect(() => + createJavaScriptSemanticGraph({ + ...unknownGraphInput, + unknowns: [ + createJavaScriptSemanticGraphUnknown({ + ...unknownInput, + candidate_node_ids: [`jsrg_node_${"f".repeat(64)}`], + }), + ], + }), + ).toThrow(/candidate node is absent/u); +}); diff --git a/tests/composition/javascript-applications/javascriptSemanticGraphResources.test.ts b/tests/composition/javascript-applications/javascriptSemanticGraphResources.test.ts new file mode 100644 index 000000000..812ff4067 --- /dev/null +++ b/tests/composition/javascript-applications/javascriptSemanticGraphResources.test.ts @@ -0,0 +1,318 @@ +import { expect, it } from "vitest"; + +import { buildJavaScriptSemanticGraph } from "../../../src/application/JavaScriptSemanticGraphBuilder.js"; +import type { JavaScriptArtifactAnalysis } from "../../../src/application/JavaScriptArtifactAnalysisTypes.js"; +import type { JavaScriptArtifactFile } from "../../../src/application/JavaScriptArtifactFiles.js"; +import { queryJavaScriptSemanticGraph } from "../../../src/domain/javascriptSemanticQuery.js"; +import { analyzeJavaScriptSemantics } from "../../../src/domain/javascriptSemanticAnalysis.js"; +import { + DEFAULT_JAVASCRIPT_SEMANTIC_LIMITS, + type JavaScriptSemanticLimits, +} from "../../../src/domain/javascriptSemanticIr.js"; + +const SHA256 = "a".repeat(64); +const GRAPH_ID = `jag_${"b".repeat(64)}`; + +it("projects literal events and exact timer cancellation", () => { + const graph = graphFor(` + function run(bus, dynamicName) { + const handle = setInterval(tick, 10); + bus.on("ready", handler); + bus.once(dynamicName, handler); + bus.emit("ready"); + bus.removeListener("ready", handler); + clearInterval(handle); + } + `); + + const relations = new Set(graph.relations.map(({ relation }) => relation)); + for (const expected of [ + "cancels-timer", + "dispatches-candidate", + "registers-listener", + "removes-listener", + "schedules-timer", + ] as const) + expect(relations.has(expected)).toBe(true); + expect( + graph.nodes.find( + ({ kind, properties }) => + kind === "event" && properties.event_name === "ready", + ), + ).toBeDefined(); + expect( + graph.unknowns.some( + ({ family, detail }) => + family === "event" && detail.includes("Dynamic once event name"), + ), + ).toBe(true); + expect( + graph.coverage.families.find(({ family }) => family === "event"), + ).toMatchObject({ status: "partial" }); + expect( + graph.coverage.families.find(({ family }) => family === "timer"), + ).toMatchObject({ status: "partial" }); +}); + +it("projects child-process creation, I/O, listeners, and signals", () => { + const graph = graphFor(` + import { spawn } from "node:child_process"; + function run(command) { + const child = spawn( + command, + ["--mode", "fast"], + { env: process.env, stdio: "pipe" }, + ); + child.on("exit", onExit); + child.once("error", onError); + child.kill("SIGTERM"); + } + `); + + const relations = new Set(graph.relations.map(({ relation }) => relation)); + for (const expected of [ + "connects-stdio", + "forwards-signal", + "listens-error", + "listens-exit", + "spawns", + "supplies-argv", + "supplies-env", + ] as const) + expect(relations.has(expected)).toBe(true); + expect( + graph.nodes.find( + ({ kind, properties }) => + kind === "child-process" && + properties.method === "spawn" && + properties.command === null, + ), + ).toBeDefined(); + expect( + graph.unknowns.some( + ({ family, detail }) => + family === "child-process" && + detail.includes("command remains unresolved"), + ), + ).toBe(true); + expect( + graph.coverage.families.find(({ family }) => family === "child-process"), + ).toMatchObject({ status: "partial" }); +}); + +it("projects config precedence, request fields, and boundaries", () => { + const graph = graphFor(` + import { readFileSync } from "node:fs"; + async function run(schema) { + const endpoint = process.env.API_URL ?? "https://fallback.test"; + const raw = readFileSync("./config.json", "utf8"); + const parsed = JSON.parse(raw); + const port = Number(process.argv[2]); + const validated = schema.parse(parsed); + const response = await fetch(endpoint, { + method: "POST", + body: validated, + }); + return response.json(); + } + `); + + const relations = new Set(graph.relations.map(({ relation }) => relation)); + for (const expected of [ + "coerces", + "constructs-request", + "consumed-by", + "defaults", + "overrides", + "parses", + "reads-argv", + "reads-config", + "reads-environment", + "supplies-request-field", + ] as const) + expect(relations.has(expected)).toBe(true); + expect( + graph.nodes.find( + ({ kind, properties }) => + kind === "request" && properties.method === "fetch", + ), + ).toBeDefined(); + expect( + graph.nodes.filter(({ kind }) => kind === "boundary"), + ).not.toHaveLength(0); + expect( + graph.coverage.families.find(({ family }) => family === "configuration"), + ).toMatchObject({ status: "partial" }); + expect( + graph.coverage.families.find(({ family }) => family === "request"), + ).toMatchObject({ status: "partial" }); + expect( + graph.coverage.families.find(({ family }) => family === "boundary"), + ).toMatchObject({ status: "partial" }); +}); + +it("projects built-in resource acquisition and release", () => { + const graph = graphFor(` + import { open } from "node:fs/promises"; + import { connect } from "node:net"; + async function run(path) { + const file = await open(path); + const socket = connect({ port: 9000 }); + await file.close(); + socket.end(); + } + `); + + const relations = new Set(graph.relations.map(({ relation }) => relation)); + expect(relations.has("acquires")).toBe(true); + expect(relations.has("releases")).toBe(true); + expect(graph.nodes.filter(({ kind }) => kind === "resource")).toHaveLength(2); + expect( + graph.coverage.families.find( + ({ family }) => family === "resource-lifecycle", + ), + ).toMatchObject({ status: "partial" }); +}); + +it("projects literal seeds and static object flow", () => { + const graph = graphFor(` + const source = { token: "TOKEN", count: 1 }; + const { token } = source; + const copy = { ...source }; + source.count = 2; + const read = source.token; + `); + + const relations = new Set(graph.relations.map(({ relation }) => relation)); + for (const expected of [ + "destructures", + "reads-property", + "spreads", + "writes-property", + ] as const) + expect(relations.has(expected)).toBe(true); + expect( + graph.nodes.find( + ({ kind, properties }) => + kind === "property-slot" && properties.name === "token", + ), + ).toBeDefined(); + const literal = queryJavaScriptSemanticGraph(graph, { + seed: { kind: "literal", value: "TOKEN" }, + direction: "forward-influence", + }); + expect(literal.status).toBe("ambiguous"); + expect(literal.summary.total_seed_matches).toBe(3); + const property = queryJavaScriptSemanticGraph(graph, { + seed: { kind: "property", name: "token" }, + direction: "forward-influence", + }); + expect(property.status).toBe("found"); +}); + +it("does not promote transformed return expressions to resolved aliases", () => { + const graph = graphFor(` + function increment(value) { return value + 1; } + const input = 1; + const output = increment(input); + `); + const returnNode = graph.nodes.find(({ kind }) => kind === "return-site"); + if (returnNode === undefined) throw new Error("Expected return site"); + expect( + graph.relations.filter( + ({ relation, target_node_id }) => + relation === "aliases" && target_node_id === returnNode.node_id, + ), + ).toEqual([expect.objectContaining({ resolution: "candidate" })]); + const output = graph.nodes.find( + ({ kind, label }) => kind === "binding" && label === "output", + ); + const input = graph.nodes.find( + ({ kind, label }) => kind === "binding" && label === "input", + ); + if (output === undefined || input === undefined) + throw new Error("Expected caller bindings"); + const strict = queryJavaScriptSemanticGraph(graph, { + seed: { kind: "semantic-node", node_id: output.node_id }, + direction: "backward-provenance", + }); + expect(strict.nodes.map(({ node_id }) => node_id)).not.toContain( + input.node_id, + ); + expect(strict.status).toBe("ambiguous"); +}); + +it("uses unavailable evidence when no source produced semantic IR", () => { + const graph = buildJavaScriptSemanticGraph({ + rootArtifactSha256: SHA256, + applicationGraph: { graph_id: GRAPH_ID, nodes: [] }, + analysis: emptyAnalysis(), + }); + expect(graph.nodes).toHaveLength(1); + expect(graph.nodes[0]?.evidence).toMatchObject({ + authority: "unknown", + state: "unavailable", + location: { available: false, reason: "not-observed" }, + }); +}); + +const emptyAnalysis = (): JavaScriptArtifactAnalysis => ({ + files: [], + packages: [], + json_modules: [], + html_scripts: [], + source_maps: [], + visited_ast_nodes: 0, + findings: 0, + modules: 0, + parse_failures: 0, + truncated_scopes: 0, + limitations: [], +}); + +const graphFor = ( + source: string, + inputLimits: Partial = {}, +) => { + const file: JavaScriptArtifactFile = { + path: "app.js", + container_sha256: SHA256, + sha256: SHA256, + bytes: Buffer.byteLength(source), + inventory_artifact_id: `art_${SHA256}`, + kind: "javascript", + unpacked: false, + text: { included: true, value: source }, + }; + const semanticLimits = { + ...DEFAULT_JAVASCRIPT_SEMANTIC_LIMITS, + ...inputLimits, + }; + const analysis: JavaScriptArtifactAnalysis = { + files: [ + { + file, + javascript: null, + semantic: { + ir: analyzeJavaScriptSemantics(source, inputLimits), + limits: semanticLimits, + }, + }, + ], + packages: [], + json_modules: [], + html_scripts: [], + source_maps: [], + visited_ast_nodes: 0, + findings: 0, + modules: 0, + parse_failures: 0, + truncated_scopes: 0, + limitations: [], + }; + return buildJavaScriptSemanticGraph({ + rootArtifactSha256: SHA256, + applicationGraph: { graph_id: GRAPH_ID, nodes: [] }, + analysis, + }); +}; diff --git a/tests/composition/javascript-applications/javascriptSemanticGraphTraversal.test.ts b/tests/composition/javascript-applications/javascriptSemanticGraphTraversal.test.ts new file mode 100644 index 000000000..e1ed7254b --- /dev/null +++ b/tests/composition/javascript-applications/javascriptSemanticGraphTraversal.test.ts @@ -0,0 +1,446 @@ +import { expect, it } from "vitest"; + +import type { ApplicationGraphEvidence } from "../../../src/domain/javascriptApplicationEvidenceSchemas.js"; +import { + JAVASCRIPT_SEMANTIC_NODE_KINDS, + JAVASCRIPT_SEMANTIC_RELATION_FAMILIES, +} from "../../../src/domain/javascriptSemanticGraphSchemas.js"; +import { + createJavaScriptSemanticFingerprint, + createJavaScriptSemanticGraph, + createJavaScriptSemanticGraphNode, + createJavaScriptSemanticGraphRelation, + createJavaScriptSemanticGraphUnknown, + type JavaScriptSemanticGraph, + type JavaScriptSemanticGraphNode, +} from "../../../src/domain/javascriptSemanticGraph.js"; +import { queryJavaScriptSemanticGraph } from "../../../src/domain/javascriptSemanticQuery.js"; +import { javaScriptSemanticQueryInputSchema } from "../../../src/domain/javascriptSemanticQuerySchemas.js"; + +const SHA = "a".repeat(64); +const JAG_ID = `jag_${"b".repeat(64)}`; +const completeCoverage = { + status: "complete" as const, + truncated: false, + omitted_count: 0, + limits: [], +}; + +const evidence = ( + state: "observed" | "inferred" = "observed", +): ApplicationGraphEvidence => ({ + authority: + state === "observed" + ? "ast-static-analysis" + : "static-relationship-inference", + state, + confidence: state === "observed" ? "exact" : "high", + artifact: { available: true, artifact_id: `art_${SHA}`, sha256: SHA }, + location: { + available: true, + value: { + kind: "source-range", + source: "bundle.js", + start: { line: 1, column: 0 }, + end: { line: 1, column: 10 }, + }, + }, + extractor: { + name: "test", + version: "1", + operation: "recover-semantic-relation", + executable_sha256: null, + }, + coverage: completeCoverage, + limitations: + state === "observed" + ? [] + : ["Static reachability does not prove runtime execution."], + evidence_ids: [], +}); + +const unknownEvidence = (): ApplicationGraphEvidence => ({ + authority: "unknown", + state: "unknown", + confidence: "unknown", + artifact: { + available: false, + reason: "unknown", + detail: "Unknown artifact.", + }, + location: { + available: false, + reason: "unresolved", + detail: "Dynamic call target.", + }, + extractor: { + name: "test", + version: "1", + operation: "retain-dynamic-call", + executable_sha256: null, + }, + coverage: { + status: "unknown", + truncated: false, + omitted_count: null, + limits: [], + }, + limitations: ["Dynamic call target remains unknown."], + evidence_ids: [], +}); + +const node = ( + kind: (typeof JAVASCRIPT_SEMANTIC_NODE_KINDS)[number], + role: string, + properties: Record = {}, + functionNodeId: string | null = null, +): JavaScriptSemanticGraphNode => + createJavaScriptSemanticGraphNode({ + kind, + identity: { + artifact_sha256: SHA, + module_path: "bundle.js", + source_range: { + start: { line: 1, column: role.length }, + end: { line: 1, column: role.length + 1 }, + }, + role_key: role, + }, + function_node_id: functionNodeId, + application_node_ids: [], + label: role, + properties, + evidence: evidence(), + }); + +const fixtureGraph = (withUnknown = false): JavaScriptSemanticGraph => { + const module = node("module", "module"); + const literal = node("literal", "literal", { value: "TOKEN" }); + const binding = node("binding", "binding"); + const callable = node("function", "function"); + const request = node("request", "request", { + endpoint: "https://example.invalid/v1", + }); + const relations = [ + createJavaScriptSemanticGraphRelation({ + source_node_id: literal.node_id, + target_node_id: binding.node_id, + relation: "defines", + resolution: "resolved", + properties: {}, + evidence: evidence("inferred"), + }), + createJavaScriptSemanticGraphRelation({ + source_node_id: binding.node_id, + target_node_id: callable.node_id, + relation: "captures", + resolution: "resolved", + properties: {}, + evidence: evidence("inferred"), + }), + createJavaScriptSemanticGraphRelation({ + source_node_id: callable.node_id, + target_node_id: request.node_id, + relation: "constructs-request", + resolution: "resolved", + properties: {}, + evidence: evidence("inferred"), + }), + ]; + const dynamic = createJavaScriptSemanticGraphUnknown({ + node_id: callable.node_id, + family: "call-flow", + relation_kinds: ["calls"], + reason: "dynamic-call", + detail: "Computed callee is unresolved.", + candidate_node_ids: [], + evidence: unknownEvidence(), + }); + const unknowns = withUnknown ? [dynamic] : []; + const coverageFamilies = JAVASCRIPT_SEMANTIC_RELATION_FAMILIES.map( + (family) => ({ + family, + status: + withUnknown && family === "call-flow" + ? ("unknown" as const) + : ("complete" as const), + retained_relations: relations.filter(({ relation }) => + relation === "defines" + ? family === "data-flow" + : relation === "captures" + ? family === "closure" + : family === "request", + ).length, + omitted_relations: withUnknown && family === "call-flow" ? null : 0, + unknown_ids: + withUnknown && family === "call-flow" ? [dynamic.unknown_id] : [], + }), + ); + const fingerprint = createJavaScriptSemanticFingerprint({ + function_node_id: callable.node_id, + algorithm: "rea.javascript-semantic-function/v1", + status: "complete", + components: { + parameter_arity: 0, + normalized_ast_sha256: "1".repeat(64), + control_flow_sha256: "2".repeat(64), + relation_shape_sha256: "3".repeat(64), + literal_set_sha256: "4".repeat(64), + effects: ["network"], + }, + limitations: [], + evidence: evidence("inferred"), + }); + return createJavaScriptSemanticGraph({ + schema: "JavaScriptSemanticRelationGraph", + schema_version: 1, + root_artifact_sha256: SHA, + application_graph_id: JAG_ID, + root_node_ids: [module.node_id], + nodes: [request, callable, binding, literal, module], + relations, + fingerprints: [fingerprint], + unknowns, + coverage: { + status: withUnknown ? "partial" : "complete", + truncated: false, + omitted_nodes: withUnknown ? null : 0, + omitted_relations: withUnknown ? null : 0, + limits: [], + families: coverageFamilies, + }, + limitations: withUnknown ? ["Dynamic call target remains unknown."] : [], + }); +}; + +const graphWithCandidateEdge = (): JavaScriptSemanticGraph => { + const graph = fixtureGraph(); + const literal = graph.nodes.find(({ kind }) => kind === "literal"); + const request = graph.nodes.find(({ kind }) => kind === "request"); + if (literal === undefined || request === undefined) + throw new TypeError("Semantic fixture nodes are missing"); + const candidate = createJavaScriptSemanticGraphRelation({ + source_node_id: literal.node_id, + target_node_id: request.node_id, + relation: "supplies-request-field", + resolution: "candidate", + properties: { field: "authorization" }, + evidence: evidence("inferred"), + }); + const { graph_id: _graphId, ...input } = graph; + return createJavaScriptSemanticGraph({ + ...input, + relations: [...graph.relations, candidate], + coverage: { + ...graph.coverage, + families: graph.coverage.families.map((family) => + family.family === "request" + ? { + ...family, + retained_relations: family.retained_relations + 1, + } + : family, + ), + }, + }); +}; + +const graphWithDuplicateLiteral = (): JavaScriptSemanticGraph => { + const graph = fixtureGraph(); + const duplicate = node("literal", "literal-copy", { value: "TOKEN" }); + const { graph_id: _graphId, ...input } = graph; + return createJavaScriptSemanticGraph({ + ...input, + nodes: [...graph.nodes, duplicate], + }); +}; + +it("bounds relevant unknown frontiers independently from relation pages", () => { + const graph = fixtureGraph(true); + const callable = graph.nodes.find(({ kind }) => kind === "function"); + if (callable === undefined) throw new Error("Expected callable node"); + const result = queryJavaScriptSemanticGraph(graph, { + seed: { kind: "semantic-node", node_id: callable.node_id }, + direction: "forward-influence", + limits: { max_unknowns: 0 }, + }); + expect(result).toMatchObject({ + status: "truncated", + unknowns: [], + summary: { relevant_unknowns: 1, retained_unknowns: 0 }, + coverage: { status: "truncated" }, + }); +}); + +it("traces deterministic forward influence and pages without changing identity", () => { + const graph = fixtureGraph(); + const input = { + seed: { kind: "literal" as const, value: "TOKEN" }, + direction: "forward-influence" as const, + expected: { role: "sink" as const, classes: ["request" as const] }, + limits: { + max_seed_matches: 25, + max_nodes: 20, + max_edges: 20, + max_depth: 12, + max_functions: 10, + max_modules: 10, + page_size: 2, + }, + }; + const first = queryJavaScriptSemanticGraph(graph, input); + expect(first).toMatchObject({ + status: "found", + summary: { traversed_nodes: 4, traversed_relations: 3 }, + page: { offset: 0, size: 2 }, + }); + expect(first.expected_match_node_ids).toHaveLength(1); + expect(first.page.next_cursor).not.toBeNull(); + const second = queryJavaScriptSemanticGraph(graph, { + ...input, + cursor: first.page.next_cursor, + }); + expect(second.query_id).toBe(first.query_id); + expect(second.page).toMatchObject({ + offset: 2, + size: 1, + next_cursor: null, + }); + expect(second.relations[0]?.relation_id).not.toBe( + first.relations[0]?.relation_id, + ); +}); + +it("keeps relevant dynamic frontiers unknown", () => { + const result = queryJavaScriptSemanticGraph(fixtureGraph(true), { + seed: { kind: "literal", value: "TOKEN" }, + direction: "forward-influence", + }); + expect(result.status).toBe("found"); + expect(result.coverage.status).toBe("partial"); + expect(result.unknowns).toMatchObject([ + { reason: "dynamic-call", relation_kinds: ["calls"] }, + ]); +}); + +it("excludes candidate edges unless the caller explicitly opts in", () => { + const graph = graphWithCandidateEdge(); + const input = { + seed: { kind: "literal" as const, value: "TOKEN" }, + direction: "forward-influence" as const, + allowed_relations: ["supplies-request-field" as const], + }; + const conservative = queryJavaScriptSemanticGraph(graph, input); + const optedIn = queryJavaScriptSemanticGraph(graph, { + ...input, + include_ambiguous_dynamic_edges: true, + }); + expect(conservative.summary.traversed_relations).toBe(0); + expect(optedIn.summary.traversed_relations).toBe(1); + expect(conservative.status).toBe("ambiguous"); + expect(conservative.coverage.status).toBe("partial"); + expect(optedIn.status).toBe("ambiguous"); + expect(optedIn.relations).toMatchObject([ + { relation: "supplies-request-field", resolution: "candidate" }, + ]); +}); + +it("bounds seed ambiguity before traversal", () => { + const result = queryJavaScriptSemanticGraph(graphWithDuplicateLiteral(), { + seed: { kind: "literal", value: "TOKEN" }, + direction: "forward-influence", + limits: { + max_seed_matches: 1, + max_nodes: 20, + max_edges: 20, + max_depth: 12, + max_functions: 10, + max_modules: 10, + page_size: 10, + }, + }); + expect(result.summary).toMatchObject({ + total_seed_matches: 2, + retained_seed_matches: 1, + }); + expect(result.status).toBe("truncated"); + expect(result.coverage.frontier).toContainEqual( + expect.objectContaining({ reason: "max-seed-matches", depth: 0 }), + ); +}); + +it("reports exact caller limits and a deterministic truncation frontier", () => { + const result = queryJavaScriptSemanticGraph(fixtureGraph(), { + seed: { kind: "literal", value: "TOKEN" }, + direction: "forward-influence", + limits: { + max_seed_matches: 1, + max_nodes: 2, + max_edges: 10, + max_depth: 12, + max_functions: 10, + max_modules: 10, + page_size: 10, + }, + }); + expect(result.status).toBe("truncated"); + expect(result.coverage.frontier).toMatchObject([{ reason: "max-nodes" }]); + expect(result.applied_limits.max_nodes).toBe(2); + expect(result.accepted_limit_ranges.max_nodes).toEqual({ + minimum: 1, + maximum: 50_000, + }); +}); + +it.each([ + { + limit: { max_edges: 1 }, + reason: "max-edges", + }, + { + limit: { max_depth: 0 }, + reason: "max-depth", + }, +])("reports the $reason frontier", ({ limit, reason }) => { + const result = queryJavaScriptSemanticGraph(fixtureGraph(), { + seed: { kind: "literal", value: "TOKEN" }, + direction: "forward-influence", + limits: { + max_seed_matches: 10, + max_nodes: 20, + max_edges: limit.max_edges ?? 20, + max_depth: limit.max_depth ?? 12, + max_functions: 10, + max_modules: 10, + page_size: 10, + }, + }); + expect(result.status).toBe("truncated"); + expect(result.coverage.frontier).toContainEqual( + expect.objectContaining({ reason }), + ); +}); + +it("rejects out-of-range limits and cursors from another query", () => { + expect(() => + javaScriptSemanticQueryInputSchema.parse({ + seed: { kind: "literal", value: "TOKEN" }, + direction: "forward-influence", + limits: { max_nodes: 50_001 }, + }), + ).toThrow(); + const graph = fixtureGraph(); + const first = queryJavaScriptSemanticGraph(graph, { + seed: { kind: "literal", value: "TOKEN" }, + direction: "forward-influence", + limits: { page_size: 1 }, + }); + expect(() => + queryJavaScriptSemanticGraph(graph, { + seed: { kind: "endpoint", value: "https://example.invalid/v1" }, + direction: "backward-provenance", + limits: { page_size: 1 }, + cursor: first.page.next_cursor, + }), + ).toThrow(/cursor does not match/u); +}); diff --git a/tests/referenceSourceGraph.test.ts b/tests/composition/javascript-applications/referenceSourceGraph.test.ts similarity index 98% rename from tests/referenceSourceGraph.test.ts rename to tests/composition/javascript-applications/referenceSourceGraph.test.ts index bf3a35953..0aa98cc2d 100644 --- a/tests/referenceSourceGraph.test.ts +++ b/tests/composition/javascript-applications/referenceSourceGraph.test.ts @@ -9,7 +9,7 @@ import { parseHistoricalSourceGraph, parseHistoricalSourceManifest, type HistoricalSourceGraphInput, -} from "../src/domain/referenceSourceGraph.js"; +} from "../../../src/domain/referenceSourceGraph.js"; const HASH_A = "a".repeat(64); const HASH_B = "b".repeat(64); @@ -155,7 +155,9 @@ describe("historical source graph", () => { }), ).toThrow(/Unicode code point/u); }); +}); +describe("partial historical source graphs", () => { it("preserves partial observations but rejects every partial complete graph", () => { const partial = graphInput(); partial.inventory_state = "partial"; @@ -215,7 +217,9 @@ describe("historical source graph", () => { historicalSourceParseFailureKey(unexpected), ); }); +}); +describe("historical source graph validation", () => { it("rejects strict unknown fields, invalid VCS, dangling internal edges, and false manifests", () => { expect(() => createHistoricalSourceGraph({ ...graphInput(), surprise: true }), diff --git a/tests/sourceToBundleComparison.test.ts b/tests/composition/javascript-applications/sourceToBundleComparison.test.ts similarity index 95% rename from tests/sourceToBundleComparison.test.ts rename to tests/composition/javascript-applications/sourceToBundleComparison.test.ts index 12840a26d..a6ea99e80 100644 --- a/tests/sourceToBundleComparison.test.ts +++ b/tests/composition/javascript-applications/sourceToBundleComparison.test.ts @@ -3,11 +3,11 @@ import { describe, expect, it } from "vitest"; import { createJavaScriptApplicationGraph, createJavaScriptApplicationNode, -} from "../src/domain/javascriptApplicationGraph.js"; -import { createHistoricalSourceGraph } from "../src/domain/referenceSourceGraph.js"; -import { compareSourceToBundle } from "../src/domain/sourceToBundleComparison.js"; -import { sourceToBundleComparisonResultSchema } from "../src/domain/sourceToBundleComparisonSchemas.js"; -import { artifactEvidence } from "./fixtures/javascriptApplicationGraph.js"; +} from "../../../src/domain/javascriptApplicationGraph.js"; +import { createHistoricalSourceGraph } from "../../../src/domain/referenceSourceGraph.js"; +import { compareSourceToBundle } from "../../../src/domain/sourceToBundleComparison.js"; +import { sourceToBundleComparisonResultSchema } from "../../../src/domain/sourceToBundleComparisonSchemas.js"; +import { artifactEvidence } from "../../fixtures/javascriptApplicationGraph.js"; const HASH = { artifact: "1".repeat(64), diff --git a/tests/webBundleAnalyzer.test.ts b/tests/composition/javascript-applications/webBundleAnalyzer.test.ts similarity index 96% rename from tests/webBundleAnalyzer.test.ts rename to tests/composition/javascript-applications/webBundleAnalyzer.test.ts index 5f6fc3a74..605f1b68c 100644 --- a/tests/webBundleAnalyzer.test.ts +++ b/tests/composition/javascript-applications/webBundleAnalyzer.test.ts @@ -3,13 +3,13 @@ import { describe, expect, it } from "vitest"; import { inspectWebPageInputSchema, webPageInspectionSchema, -} from "../src/domain/browserObservation.js"; -import { analyzeCapturedWebBundle } from "../src/domain/webBundleAnalyzer.js"; +} from "../../../src/domain/browserObservation.js"; +import { analyzeCapturedWebBundle } from "../../../src/domain/webBundleAnalyzer.js"; import { analyzeWebBundleInputSchema, webBundleAnalysisSchema, -} from "../src/domain/webBundleAnalysis.js"; -import { createWebTextArtifact } from "../src/domain/webContentArtifact.js"; +} from "../../../src/domain/webBundleAnalysis.js"; +import { createWebTextArtifact } from "../../../src/domain/webContentArtifact.js"; const origin = "https://app.example.test"; diff --git a/tests/webInventory.test.ts b/tests/composition/javascript-applications/webInventory.test.ts similarity index 97% rename from tests/webInventory.test.ts rename to tests/composition/javascript-applications/webInventory.test.ts index ceec1f55e..e3bcb50c1 100644 --- a/tests/webInventory.test.ts +++ b/tests/composition/javascript-applications/webInventory.test.ts @@ -5,11 +5,11 @@ import { reconcileWebScript, stableWebResources, stableWebScriptKey, -} from "../src/domain/webInventory.js"; +} from "../../../src/domain/webInventory.js"; import { createWebTextArtifact, webTextArtifactSchema, -} from "../src/domain/webContentArtifact.js"; +} from "../../../src/domain/webContentArtifact.js"; describe("web content artifacts", () => { it("binds UTF-8 content, size, digest, and URI", () => { diff --git a/tests/processCapturePermission.test.ts b/tests/composition/process-capture/processCapturePermission.test.ts similarity index 83% rename from tests/processCapturePermission.test.ts rename to tests/composition/process-capture/processCapturePermission.test.ts index 172dae49f..5dd549e3f 100644 --- a/tests/processCapturePermission.test.ts +++ b/tests/composition/process-capture/processCapturePermission.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { processCapturePermissionRequest } from "../src/application/ProcessCapturePermission.js"; -import { parseProcessScenario } from "../src/domain/processCapture.js"; +import { processCapturePermissionRequest } from "../../../src/application/ProcessCapturePermission.js"; +import { parseProcessScenario } from "../../../src/domain/processCapture.js"; describe("process capture permission request", () => { it("projects the complete adapter-neutral authority scope", () => { diff --git a/tests/processPairedExperiment.test.ts b/tests/composition/process-capture/processPairedExperiment.test.ts similarity index 93% rename from tests/processPairedExperiment.test.ts rename to tests/composition/process-capture/processPairedExperiment.test.ts index a36553dbc..9a692a206 100644 --- a/tests/processPairedExperiment.test.ts +++ b/tests/composition/process-capture/processPairedExperiment.test.ts @@ -3,15 +3,15 @@ import { describe, expect, it } from "vitest"; import { runPairedProcessExperiment, type ProcessCapturePort, -} from "../src/application/ProcessPairedExperiment.js"; -import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../src/contracts/processCaptureExample.js"; -import { parseProcessCapture } from "../src/domain/processCapture.js"; +} from "../../../src/application/ProcessPairedExperiment.js"; +import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../../../src/contracts/processCaptureExample.js"; +import { parseProcessCapture } from "../../../src/domain/processCapture.js"; import { analyzeProcessRepeatability, bindProcessScenario, processPairedExperimentSchema, -} from "../src/domain/processPairedExperiment.js"; -import { ok } from "../src/domain/result.js"; +} from "../../../src/domain/processPairedExperiment.js"; +import { ok } from "../../../src/domain/result.js"; const experiment = () => processPairedExperimentSchema.parse({ @@ -137,7 +137,9 @@ describe("paired process experiments", () => { expect(result.value.cross_side).toBeNull(); expect(result.value.cross_side_blocked_reason).toMatch(/unstable/u); }); +}); +describe("paired process experiment comparisons", () => { it("distinguishes normalized timing variance from semantic output drift", () => { const base = parseProcessCapture(EMPTY_PROCESS_CAPTURE_EXAMPLE); const early = { @@ -206,7 +208,9 @@ describe("paired process experiments", () => { }, }); }); +}); +describe("paired process experiment freshness", () => { it("compares the newest stable captures under the freshness policy", async () => { const base = parseProcessCapture(EMPTY_PROCESS_CAPTURE_EXAMPLE); let captureIndex = 0; diff --git a/tests/processReactiveCoordinator.test.ts b/tests/composition/process-capture/processReactiveCoordinator.test.ts similarity index 57% rename from tests/processReactiveCoordinator.test.ts rename to tests/composition/process-capture/processReactiveCoordinator.test.ts index 58e138565..97f760057 100644 --- a/tests/processReactiveCoordinator.test.ts +++ b/tests/composition/process-capture/processReactiveCoordinator.test.ts @@ -1,27 +1,20 @@ import { describe, expect, it } from "vitest"; - import { ProcessReactiveCoordinator, type ProcessReactiveTimerHost, -} from "../src/application/ProcessReactiveCoordinator.js"; -import { - executeProcessReactiveEffects, - unsupportedProcessReactiveFeatures, -} from "../src/application/ProcessReactiveEffects.js"; -import { createProcessCaptureJournal } from "../src/application/ProcessCaptureJournal.js"; -import { subscribeProcessReactiveObservations } from "../src/application/ProcessReactiveObservations.js"; +} from "../../../src/application/ProcessReactiveCoordinator.js"; +import { executeProcessReactiveEffects } from "../../../src/application/ProcessReactiveEffects.js"; +import { createProcessCaptureJournal } from "../../../src/application/ProcessCaptureJournal.js"; +import { subscribeProcessReactiveObservations } from "../../../src/application/ProcessReactiveObservations.js"; import type { FilesystemCheckpoint, InteractionEvent, TerminalFrame, -} from "../src/domain/processCapture.js"; -import { createProcessObservation } from "../src/domain/processObservation.js"; -import type { ProcessReactiveEffectResult } from "../src/domain/processReactiveRuntime.js"; +} from "../../../src/domain/processCapture.js"; import { processReactiveScenarioSchema, type ProcessReactiveScenario, -} from "../src/domain/processReactiveScenario.js"; - +} from "../../../src/domain/processReactiveScenario.js"; const terminalTrigger = () => ({ kind: "terminal_text" as const, view: "decoded" as const, @@ -33,7 +26,6 @@ const terminalTrigger = () => ({ since: { kind: "scenario_start" as const }, consume: true, }); - const timerHost = () => { const scheduled: Array<{ readonly callback: () => void; @@ -49,17 +41,16 @@ const timerHost = () => { }; return { host, scheduled }; }; - const twoStateScenario = (): ProcessReactiveScenario => processReactiveScenarioSchema.parse({ version: 1, initial_state: "starting", - deadline_ms: 30_000, + deadline_ms: 30000, states: [ { id: "starting", max_visits: 1, - deadline_ms: 5_000, + deadline_ms: 5000, on: [ { id: "send", @@ -74,7 +65,7 @@ const twoStateScenario = (): ProcessReactiveScenario => { id: "sent", max_visits: 1, - deadline_ms: 5_000, + deadline_ms: 5000, on: [ { id: "confirm", @@ -100,7 +91,6 @@ const twoStateScenario = (): ProcessReactiveScenario => }, ], }); - const createHarness = (scenario: ProcessReactiveScenario) => { const journal = createProcessCaptureJournal(); const frames: TerminalFrame[] = []; @@ -172,15 +162,12 @@ const createHarness = (scenario: ProcessReactiveScenario) => { unsubscribe, }; }; - -describe("process reactive coordinator", () => { +describe("process reactive coordinator ordering", () => { it("commits terminal effects before evaluating their journal observations", async () => { const harness = createHarness(twoStateScenario()); harness.frames.push({ sequence: 0, at_ms: 0, data: "Ready" }); harness.journal.record("frames", 0); - await harness.coordinator.drain(); - expect(harness.coordinator.snapshot).toMatchObject({ status: "finished", outcome: "passed", @@ -204,17 +191,16 @@ describe("process reactive coordinator", () => { harness.unsubscribe(); await harness.coordinator.close(); }); - it("executes input, resize, root signal, and checkpoint in journal order", async () => { const scenario = processReactiveScenarioSchema.parse({ version: 1, initial_state: "ready", - deadline_ms: 30_000, + deadline_ms: 30000, states: [ { id: "ready", max_visits: 1, - deadline_ms: 5_000, + deadline_ms: 5000, on: [ { id: "act", @@ -242,9 +228,7 @@ describe("process reactive coordinator", () => { harness.setNow(125); harness.frames.push({ sequence: 0, at_ms: 20, data: "Ready" }); harness.journal.record("frames", 0); - await harness.coordinator.drain(); - expect(harness.terminalCalls).toEqual([ "write:secret", "resize:100x40", @@ -270,7 +254,6 @@ describe("process reactive coordinator", () => { ]); await harness.coordinator.close(); }); - it("ignores a stale state timer after a transition", async () => { const base = twoStateScenario(); const first = base.states[0]; @@ -298,15 +281,13 @@ describe("process reactive coordinator", () => { }); const harness = createHarness(scenario); const oldStateTimer = harness.timers[1]; - expect(oldStateTimer?.delayMs).toBe(5_000); + expect(oldStateTimer?.delayMs).toBe(5000); harness.frames.push({ sequence: 0, at_ms: 0, data: "Ready" }); harness.journal.record("frames", 0); await harness.coordinator.drain(); expect(oldStateTimer?.cancelled).toBe(true); - oldStateTimer?.callback(); await harness.coordinator.drain(); - expect(harness.coordinator.snapshot).toMatchObject({ status: "running", active_state: "sent", @@ -318,194 +299,4 @@ describe("process reactive coordinator", () => { expect(harness.coordinator.snapshot.outcome).toBe("predicate_timeout"); await harness.coordinator.close(); }); - - it("preflights signal selectors the host cannot execute", () => { - const input = twoStateScenario(); - const first = input.states[0]; - const second = input.states[1]; - const transition = first?.on[0]; - if (first === undefined || second === undefined || transition === undefined) - throw new Error("two-state fixture is incomplete"); - const scenario = processReactiveScenarioSchema.parse({ - ...input, - states: [ - { - ...first, - on: [ - { - ...transition, - actions: [ - { - type: "send_signal", - target: { kind: "process_group" }, - signal: "SIGTERM", - }, - ], - }, - ], - }, - second, - ], - }); - expect(unsupportedProcessReactiveFeatures(scenario)).toEqual([ - "send:target:process_group", - ]); - }); - - it("orders delayed effect observations before later producer records", async () => { - const timers = timerHost(); - let executions = 0; - let resolveEffects: - | ((results: readonly ProcessReactiveEffectResult[]) => void) - | undefined; - const effects = new Promise( - (resolve) => (resolveEffects = resolve), - ); - const coordinator = new ProcessReactiveCoordinator({ - scenario: twoStateScenario(), - executor: { - execute: () => { - executions += 1; - return executions === 1 ? effects : Promise.resolve([]); - }, - }, - timerHost: timers.host, - }); - coordinator.enqueue({ - kind: "observation", - observation: createProcessObservation({ - source: "terminal_raw", - source_sequence: 0, - captured_at_ms: 0, - subject_id: null, - location: { collection: "frames", index: 0, capture_order: 0 }, - payload: { sequence: 0, at_ms: 0, data: "Ready" }, - }), - }); - await Promise.resolve(); - coordinator.enqueue({ - kind: "observation", - observation: createProcessObservation({ - source: "terminal_raw", - source_sequence: 1, - captured_at_ms: 2, - subject_id: null, - location: { collection: "frames", index: 1, capture_order: 2 }, - payload: { sequence: 1, at_ms: 2, data: "later" }, - }), - }); - if (resolveEffects === undefined) - throw new Error("effect executor did not start"); - resolveEffects([ - { - status: "succeeded", - observation: createProcessObservation({ - source: "interaction", - source_sequence: 0, - captured_at_ms: 1, - subject_id: null, - location: { - collection: "interaction_events", - index: 0, - capture_order: 1, - }, - payload: { - sequence: 0, - scheduled_at_ms: 1, - dispatched_at_ms: 1, - type: "input", - data: "go", - outcome: "dispatched", - }, - }), - }, - ]); - - await coordinator.drain(); - - expect(coordinator.snapshot).toMatchObject({ - status: "finished", - outcome: "passed", - }); - expect( - coordinator.snapshot.transitions.map( - ({ transition_id }) => transition_id, - ), - ).toEqual(["send", "confirm"]); - await coordinator.close(); - }); - - it("cancels timers and rejects later work after an executor failure", async () => { - const timers = timerHost(); - let calls = 0; - const coordinator = new ProcessReactiveCoordinator({ - scenario: twoStateScenario(), - executor: { - execute: () => { - calls += 1; - throw new Error("executor failed"); - }, - }, - timerHost: timers.host, - }); - const ready = createProcessObservation({ - source: "terminal_raw", - source_sequence: 0, - captured_at_ms: 0, - subject_id: null, - location: { collection: "frames", index: 0, capture_order: 0 }, - payload: { sequence: 0, at_ms: 0, data: "Ready" }, - }); - coordinator.enqueue({ kind: "observation", observation: ready }); - await expect(coordinator.drain()).rejects.toThrow("executor failed"); - coordinator.enqueue({ kind: "observation", observation: ready }); - expect(calls).toBe(1); - expect(timers.scheduled.every(({ cancelled }) => cancelled)).toBe(true); - }); - - it("aborts an in-flight effect before committing a state deadline", async () => { - const timers = timerHost(); - let effectAborted = false; - const coordinator = new ProcessReactiveCoordinator({ - scenario: twoStateScenario(), - executor: { - execute: (_actions, signal) => - new Promise((resolve) => { - signal.addEventListener( - "abort", - () => { - effectAborted = true; - resolve([]); - }, - { once: true }, - ); - }), - }, - timerHost: timers.host, - }); - coordinator.enqueue({ - kind: "observation", - observation: createProcessObservation({ - source: "terminal_raw", - source_sequence: 0, - captured_at_ms: 0, - subject_id: null, - location: { collection: "frames", index: 0, capture_order: 0 }, - payload: { sequence: 0, at_ms: 0, data: "Ready" }, - }), - }); - await Promise.resolve(); - timers.scheduled[1]?.callback(); - - await coordinator.drain(); - - expect(effectAborted).toBe(true); - expect(coordinator.snapshot).toMatchObject({ - status: "finished", - outcome: "predicate_timeout", - active_state: "starting", - transitions: [], - }); - await coordinator.close(); - }); }); diff --git a/tests/composition/process-capture/processReactiveCoordinatorFailures.test.ts b/tests/composition/process-capture/processReactiveCoordinatorFailures.test.ts new file mode 100644 index 000000000..94bbfcd61 --- /dev/null +++ b/tests/composition/process-capture/processReactiveCoordinatorFailures.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from "vitest"; +import { + ProcessReactiveCoordinator, + type ProcessReactiveTimerHost, +} from "../../../src/application/ProcessReactiveCoordinator.js"; +import { unsupportedProcessReactiveFeatures } from "../../../src/application/ProcessReactiveEffects.js"; +import { createProcessObservation } from "../../../src/domain/processObservation.js"; +import type { ProcessReactiveEffectResult } from "../../../src/domain/processReactiveRuntime.js"; +import { + processReactiveScenarioSchema, + type ProcessReactiveScenario, +} from "../../../src/domain/processReactiveScenario.js"; +const terminalTrigger = () => ({ + kind: "terminal_text" as const, + view: "decoded" as const, + encoding: "utf8" as const, + literal: "Ready", + case_sensitive: true, + control_sequences: "include" as const, + occurrence: 1, + since: { kind: "scenario_start" as const }, + consume: true, +}); +const timerHost = () => { + const scheduled: Array<{ + readonly callback: () => void; + readonly delayMs: number; + cancelled: boolean; + }> = []; + const host: ProcessReactiveTimerHost = { + schedule: (callback, delayMs) => { + const timer = { callback, delayMs, cancelled: false }; + scheduled.push(timer); + return { cancel: () => (timer.cancelled = true) }; + }, + }; + return { host, scheduled }; +}; +const twoStateScenario = (): ProcessReactiveScenario => + processReactiveScenarioSchema.parse({ + version: 1, + initial_state: "starting", + deadline_ms: 30000, + states: [ + { + id: "starting", + max_visits: 1, + deadline_ms: 5000, + on: [ + { + id: "send", + priority: 1, + max_uses: 1, + when: terminalTrigger(), + actions: [{ type: "send_input", data: "go", sensitive: false }], + target: { kind: "goto", state: "sent" }, + }, + ], + }, + { + id: "sent", + max_visits: 1, + deadline_ms: 5000, + on: [ + { + id: "confirm", + priority: 1, + max_uses: 1, + when: { + kind: "event", + source: "interaction", + exact: { type: "input", data: "go", outcome: "dispatched" }, + ignore_fields: [ + "sequence", + "scheduled_at_ms", + "dispatched_at_ms", + ], + since: { kind: "scenario_start" }, + consume: true, + cardinality: { min: 1, max: 1 }, + }, + actions: [], + target: { kind: "finish", outcome: "passed" }, + }, + ], + }, + ], + }); +describe("process reactive coordinator failures", () => { + it("preflights signal selectors the host cannot execute", () => { + const input = twoStateScenario(); + const first = input.states[0]; + const second = input.states[1]; + const transition = first?.on[0]; + if (first === undefined || second === undefined || transition === undefined) + throw new Error("two-state fixture is incomplete"); + const scenario = processReactiveScenarioSchema.parse({ + ...input, + states: [ + { + ...first, + on: [ + { + ...transition, + actions: [ + { + type: "send_signal", + target: { kind: "process_group" }, + signal: "SIGTERM", + }, + ], + }, + ], + }, + second, + ], + }); + expect(unsupportedProcessReactiveFeatures(scenario)).toEqual([ + "send:target:process_group", + ]); + }); + it("orders delayed effect observations before later producer records", async () => { + const timers = timerHost(); + let executions = 0; + let resolveEffects: + | ((results: readonly ProcessReactiveEffectResult[]) => void) + | undefined; + const effects = new Promise( + (resolve) => (resolveEffects = resolve), + ); + const coordinator = new ProcessReactiveCoordinator({ + scenario: twoStateScenario(), + executor: { + execute: () => { + executions += 1; + return executions === 1 ? effects : Promise.resolve([]); + }, + }, + timerHost: timers.host, + }); + coordinator.enqueue({ + kind: "observation", + observation: createProcessObservation({ + source: "terminal_raw", + source_sequence: 0, + captured_at_ms: 0, + subject_id: null, + location: { collection: "frames", index: 0, capture_order: 0 }, + payload: { sequence: 0, at_ms: 0, data: "Ready" }, + }), + }); + await Promise.resolve(); + coordinator.enqueue({ + kind: "observation", + observation: createProcessObservation({ + source: "terminal_raw", + source_sequence: 1, + captured_at_ms: 2, + subject_id: null, + location: { collection: "frames", index: 1, capture_order: 2 }, + payload: { sequence: 1, at_ms: 2, data: "later" }, + }), + }); + if (resolveEffects === undefined) + throw new Error("effect executor did not start"); + resolveEffects([ + { + status: "succeeded", + observation: createProcessObservation({ + source: "interaction", + source_sequence: 0, + captured_at_ms: 1, + subject_id: null, + location: { + collection: "interaction_events", + index: 0, + capture_order: 1, + }, + payload: { + sequence: 0, + scheduled_at_ms: 1, + dispatched_at_ms: 1, + type: "input", + data: "go", + outcome: "dispatched", + }, + }), + }, + ]); + await coordinator.drain(); + expect(coordinator.snapshot).toMatchObject({ + status: "finished", + outcome: "passed", + }); + expect( + coordinator.snapshot.transitions.map( + ({ transition_id }) => transition_id, + ), + ).toEqual(["send", "confirm"]); + await coordinator.close(); + }); + it("cancels timers and rejects later work after an executor failure", async () => { + const timers = timerHost(); + let calls = 0; + const coordinator = new ProcessReactiveCoordinator({ + scenario: twoStateScenario(), + executor: { + execute: () => { + calls += 1; + throw new Error("executor failed"); + }, + }, + timerHost: timers.host, + }); + const ready = createProcessObservation({ + source: "terminal_raw", + source_sequence: 0, + captured_at_ms: 0, + subject_id: null, + location: { collection: "frames", index: 0, capture_order: 0 }, + payload: { sequence: 0, at_ms: 0, data: "Ready" }, + }); + coordinator.enqueue({ kind: "observation", observation: ready }); + await expect(coordinator.drain()).rejects.toThrow("executor failed"); + coordinator.enqueue({ kind: "observation", observation: ready }); + expect(calls).toBe(1); + expect(timers.scheduled.every(({ cancelled }) => cancelled)).toBe(true); + }); +}); diff --git a/tests/composition/process-capture/processReactiveCoordinatorFailuresPart2.test.ts b/tests/composition/process-capture/processReactiveCoordinatorFailuresPart2.test.ts new file mode 100644 index 000000000..95d10b055 --- /dev/null +++ b/tests/composition/process-capture/processReactiveCoordinatorFailuresPart2.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import { + ProcessReactiveCoordinator, + type ProcessReactiveTimerHost, +} from "../../../src/application/ProcessReactiveCoordinator.js"; +import { createProcessObservation } from "../../../src/domain/processObservation.js"; +import { + processReactiveScenarioSchema, + type ProcessReactiveScenario, +} from "../../../src/domain/processReactiveScenario.js"; +const terminalTrigger = () => ({ + kind: "terminal_text" as const, + view: "decoded" as const, + encoding: "utf8" as const, + literal: "Ready", + case_sensitive: true, + control_sequences: "include" as const, + occurrence: 1, + since: { kind: "scenario_start" as const }, + consume: true, +}); +const timerHost = () => { + const scheduled: Array<{ + readonly callback: () => void; + readonly delayMs: number; + cancelled: boolean; + }> = []; + const host: ProcessReactiveTimerHost = { + schedule: (callback, delayMs) => { + const timer = { callback, delayMs, cancelled: false }; + scheduled.push(timer); + return { cancel: () => (timer.cancelled = true) }; + }, + }; + return { host, scheduled }; +}; +const twoStateScenario = (): ProcessReactiveScenario => + processReactiveScenarioSchema.parse({ + version: 1, + initial_state: "starting", + deadline_ms: 30000, + states: [ + { + id: "starting", + max_visits: 1, + deadline_ms: 5000, + on: [ + { + id: "send", + priority: 1, + max_uses: 1, + when: terminalTrigger(), + actions: [{ type: "send_input", data: "go", sensitive: false }], + target: { kind: "goto", state: "sent" }, + }, + ], + }, + { + id: "sent", + max_visits: 1, + deadline_ms: 5000, + on: [ + { + id: "confirm", + priority: 1, + max_uses: 1, + when: { + kind: "event", + source: "interaction", + exact: { type: "input", data: "go", outcome: "dispatched" }, + ignore_fields: [ + "sequence", + "scheduled_at_ms", + "dispatched_at_ms", + ], + since: { kind: "scenario_start" }, + consume: true, + cardinality: { min: 1, max: 1 }, + }, + actions: [], + target: { kind: "finish", outcome: "passed" }, + }, + ], + }, + ], + }); +describe("process reactive coordinator failures", () => { + it("aborts an in-flight effect before committing a state deadline", async () => { + const timers = timerHost(); + let effectAborted = false; + const coordinator = new ProcessReactiveCoordinator({ + scenario: twoStateScenario(), + executor: { + execute: (_actions, signal) => + new Promise((resolve) => { + signal.addEventListener( + "abort", + () => { + effectAborted = true; + resolve([]); + }, + { once: true }, + ); + }), + }, + timerHost: timers.host, + }); + coordinator.enqueue({ + kind: "observation", + observation: createProcessObservation({ + source: "terminal_raw", + source_sequence: 0, + captured_at_ms: 0, + subject_id: null, + location: { collection: "frames", index: 0, capture_order: 0 }, + payload: { sequence: 0, at_ms: 0, data: "Ready" }, + }), + }); + await Promise.resolve(); + timers.scheduled[1]?.callback(); + await coordinator.drain(); + expect(effectAborted).toBe(true); + expect(coordinator.snapshot).toMatchObject({ + status: "finished", + outcome: "predicate_timeout", + active_state: "starting", + transitions: [], + }); + await coordinator.close(); + }); +}); diff --git a/tests/composition/process-capture/processReactiveRuntime.test.ts b/tests/composition/process-capture/processReactiveRuntime.test.ts new file mode 100644 index 000000000..642433f93 --- /dev/null +++ b/tests/composition/process-capture/processReactiveRuntime.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it } from "vitest"; +import { + createProcessObservation, + type ProcessObservationSource, +} from "../../../src/domain/processObservation.js"; +import { + commitProcessReactiveProposal, + createProcessReactiveSnapshot, + reduceProcessReactiveScenario, +} from "../../../src/domain/processReactiveRuntime.js"; +import { + processReactiveScenarioSchema, + type ProcessReactiveAction, + type ProcessReactiveScenario, +} from "../../../src/domain/processReactiveScenario.js"; +const terminalTrigger = (literal = "Ready") => ({ + kind: "terminal_text" as const, + view: "decoded" as const, + encoding: "utf8" as const, + literal, + case_sensitive: true, + control_sequences: "include" as const, + occurrence: 1, + since: { kind: "scenario_start" as const }, + consume: false, +}); +const scenarioWith = ( + transitions: readonly unknown[], +): ProcessReactiveScenario => + processReactiveScenarioSchema.parse({ + version: 1, + initial_state: "starting", + deadline_ms: 30000, + states: [ + { + id: "starting", + max_visits: 4, + deadline_ms: 5000, + on: transitions, + }, + ], + }); +const finish = ( + id: string, + when: unknown, + priority = 100, + actions: readonly unknown[] = [], +) => ({ + id, + priority, + max_uses: 4, + when, + actions, + target: { kind: "finish", outcome: "passed" }, +}); +const collectionFor = (source: ProcessObservationSource) => { + switch (source) { + case "terminal_raw": + return "frames" as const; + case "terminal_rendered": + return "rendered_frames" as const; + case "process": + return "process_samples" as const; + case "filesystem": + return "filesystem_checkpoints" as const; + case "shim": + return "shim_events" as const; + case "http": + case "websocket": + return "protocol_events" as const; + case "replay_transition": + return "replay_transitions" as const; + case "interaction": + return "interaction_events" as const; + case "lifecycle": + return "lifecycle" as const; + } +}; +const observation = ( + source: ProcessObservationSource, + order: number, + payload: unknown, +) => + createProcessObservation({ + source, + source_sequence: order, + captured_at_ms: order, + subject_id: null, + location: { + collection: collectionFor(source), + index: order, + capture_order: order, + }, + payload, + }); +const succeededEffect = (action: ProcessReactiveAction, order: number) => ({ + status: "succeeded" as const, + observation: + action.type === "checkpoint" + ? observation("filesystem", order, { name: action.name }) + : observation("interaction", order, { + type: + action.type === "send_input" + ? "input" + : action.type === "resize" + ? "resize" + : action.type === "close_stdin" + ? "stdin_close" + : "signal", + data: + action.type === "send_input" + ? action.sensitive + ? `` + : action.data + : action.type === "resize" + ? `${String(action.columns)}x${String(action.rows)}` + : action.type === "close_stdin" + ? "" + : action.signal, + outcome: "dispatched", + }), +}); +const offer = ( + scenario: ProcessReactiveScenario, + snapshot: ReturnType, + value: ReturnType, +) => { + const proposed = reduceProcessReactiveScenario(scenario, snapshot, { + kind: "observation", + observation: value, + }); + return proposed.kind === "proposal" + ? commitProcessReactiveProposal( + scenario, + proposed, + proposed.effects.map((effect, index) => ({ + ...succeededEffect( + effect, + (proposed.snapshot.observations.at(-1)?.capture_order ?? -1) + + index + + 1, + ), + })), + ) + : proposed; +}; +describe("process reactive runtime lifecycle", () => { + it("matches decoded terminal text across PTY chunks and emits declarative effects", () => { + const scenario = scenarioWith([ + finish("ready", terminalTrigger(), 100, [ + { type: "send_signal", target: { kind: "root" }, signal: "SIGINT" }, + { type: "checkpoint", name: "ready" }, + ]), + ]); + const first = offer( + scenario, + createProcessReactiveSnapshot(scenario), + observation("terminal_raw", 0, { + sequence: 0, + at_ms: 0, + data: "Re", + }), + ); + expect(first.kind).toBe("waiting"); + const second = offer( + scenario, + first.snapshot, + observation("terminal_raw", 1, { + sequence: 1, + at_ms: 1, + data: "ady\n", + }), + ); + expect(second.kind).toBe("transition"); + if (second.kind !== "transition") return; + expect(second.record).toEqual({ + sequence: 0, + transition_id: "ready", + state_before: "starting", + state_after: null, + outcome: "passed", + trigger_event_ids: ["obs.frames.0", "obs.frames.1"], + action_event_ids: [ + "obs.interaction_events.2", + "obs.filesystem_checkpoints.3", + ], + action_types: ["send_signal", "checkpoint"], + }); + }); + it("treats redaction-shaped live input as ordinary sensitive data", () => { + const scenario = scenarioWith([ + finish("ready", terminalTrigger(), 100, [ + { + type: "send_input", + data: "", + sensitive: true, + }, + ]), + ]); + const decision = offer( + scenario, + createProcessReactiveSnapshot(scenario), + observation("terminal_raw", 0, { data: "Ready" }), + ); + expect(decision).toMatchObject({ + kind: "transition", + record: { outcome: "passed" }, + }); + }); + it("reports only the minimal terminal chunk span containing the occurrence", () => { + const scenario = scenarioWith([finish("ready", terminalTrigger())]); + let snapshot = createProcessReactiveSnapshot(scenario); + for (const [order, data] of ["unrelated\n", "Re"].entries()) { + const decision = offer( + scenario, + snapshot, + observation("terminal_raw", order, { data }), + ); + expect(decision.kind).toBe("waiting"); + snapshot = decision.snapshot; + } + const matched = offer( + scenario, + snapshot, + observation("terminal_raw", 2, { data: "ady" }), + ); + expect(matched.kind).toBe("transition"); + if (matched.kind === "transition") + expect(matched.record.trigger_event_ids).toEqual([ + "obs.frames.1", + "obs.frames.2", + ]); + }); +}); diff --git a/tests/composition/process-capture/processReactiveRuntimePart2.test.ts b/tests/composition/process-capture/processReactiveRuntimePart2.test.ts new file mode 100644 index 000000000..f4f9bb331 --- /dev/null +++ b/tests/composition/process-capture/processReactiveRuntimePart2.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it } from "vitest"; +import { + createProcessObservation, + type ProcessObservationSource, +} from "../../../src/domain/processObservation.js"; +import { + commitProcessReactiveProposal, + createProcessReactiveSnapshot, + reduceProcessReactiveScenario, +} from "../../../src/domain/processReactiveRuntime.js"; +import { + processReactiveScenarioSchema, + type ProcessReactiveAction, + type ProcessReactiveScenario, +} from "../../../src/domain/processReactiveScenario.js"; +const terminalTrigger = (literal = "Ready") => ({ + kind: "terminal_text" as const, + view: "decoded" as const, + encoding: "utf8" as const, + literal, + case_sensitive: true, + control_sequences: "include" as const, + occurrence: 1, + since: { kind: "scenario_start" as const }, + consume: false, +}); +const scenarioWith = ( + transitions: readonly unknown[], +): ProcessReactiveScenario => + processReactiveScenarioSchema.parse({ + version: 1, + initial_state: "starting", + deadline_ms: 30000, + states: [ + { + id: "starting", + max_visits: 4, + deadline_ms: 5000, + on: transitions, + }, + ], + }); +const finish = ( + id: string, + when: unknown, + priority = 100, + actions: readonly unknown[] = [], +) => ({ + id, + priority, + max_uses: 4, + when, + actions, + target: { kind: "finish", outcome: "passed" }, +}); +const collectionFor = (source: ProcessObservationSource) => { + switch (source) { + case "terminal_raw": + return "frames" as const; + case "terminal_rendered": + return "rendered_frames" as const; + case "process": + return "process_samples" as const; + case "filesystem": + return "filesystem_checkpoints" as const; + case "shim": + return "shim_events" as const; + case "http": + case "websocket": + return "protocol_events" as const; + case "replay_transition": + return "replay_transitions" as const; + case "interaction": + return "interaction_events" as const; + case "lifecycle": + return "lifecycle" as const; + } +}; +const observation = ( + source: ProcessObservationSource, + order: number, + payload: unknown, +) => + createProcessObservation({ + source, + source_sequence: order, + captured_at_ms: order, + subject_id: null, + location: { + collection: collectionFor(source), + index: order, + capture_order: order, + }, + payload, + }); +const succeededEffect = (action: ProcessReactiveAction, order: number) => ({ + status: "succeeded" as const, + observation: + action.type === "checkpoint" + ? observation("filesystem", order, { name: action.name }) + : observation("interaction", order, { + type: + action.type === "send_input" + ? "input" + : action.type === "resize" + ? "resize" + : action.type === "close_stdin" + ? "stdin_close" + : "signal", + data: + action.type === "send_input" + ? action.sensitive + ? `` + : action.data + : action.type === "resize" + ? `${String(action.columns)}x${String(action.rows)}` + : action.type === "close_stdin" + ? "" + : action.signal, + outcome: "dispatched", + }), +}); +describe("process reactive runtime lifecycle", () => { + it("does not commit state, checkpoints, consumption, or history before effects succeed", () => { + const scenario = scenarioWith([ + finish("ready", { ...terminalTrigger(), consume: true }, 100, [ + { type: "checkpoint", name: "ready" }, + { type: "send_signal", target: { kind: "root" }, signal: "SIGINT" }, + ]), + ]); + const proposed = reduceProcessReactiveScenario( + scenario, + createProcessReactiveSnapshot(scenario), + { + kind: "observation", + observation: observation("terminal_raw", 0, { data: "Ready" }), + }, + ); + expect(proposed.kind).toBe("proposal"); + if (proposed.kind !== "proposal") return; + expect(proposed.snapshot).toMatchObject({ + status: "running", + checkpoints: [], + consumed_event_ids: [], + transitions: [], + }); + const rejected = commitProcessReactiveProposal(scenario, proposed, [ + { + status: "succeeded", + observation: observation("filesystem", 1, { name: "ready" }), + }, + { status: "rejected" }, + ]); + expect(rejected).toMatchObject({ + kind: "finished", + outcome: "action_rejected", + snapshot: { + checkpoints: [], + consumed_event_ids: [], + transitions: [], + }, + }); + expect( + commitProcessReactiveProposal(scenario, proposed, [ + { status: "target_lost" }, + { + status: "succeeded", + observation: observation("interaction", 2, { + type: "signal", + data: "SIGINT", + outcome: "dispatched", + }), + }, + ]), + ).toMatchObject({ kind: "finished", outcome: "target_lost" }); + const committed = commitProcessReactiveProposal(scenario, proposed, [ + { + status: "succeeded", + observation: observation("filesystem", 1, { name: "ready" }), + }, + { + status: "succeeded", + observation: observation("interaction", 2, { + type: "signal", + data: "SIGINT", + outcome: "dispatched", + }), + }, + ]); + expect(committed.snapshot).toMatchObject({ + checkpoints: [ + { + name: "ready", + event_id: "obs.filesystem_checkpoints.1", + capture_order: 1, + }, + ], + }); + expect( + commitProcessReactiveProposal(scenario, proposed, [ + { + status: "succeeded", + observation: observation("filesystem", 1, { name: "other" }), + }, + { + status: "succeeded", + observation: observation("interaction", 2, { + type: "signal", + data: "SIGINT", + outcome: "dispatched", + }), + }, + ]), + ).toMatchObject({ kind: "finished", outcome: "action_rejected" }); + const changedScenario = scenarioWith([ + finish("ready", { ...terminalTrigger(), consume: true }, 100, [ + { type: "checkpoint", name: "changed" }, + { type: "send_signal", target: { kind: "root" }, signal: "SIGINT" }, + ]), + ]); + expect( + commitProcessReactiveProposal(changedScenario, proposed, [ + succeededEffect({ type: "checkpoint", name: "ready" }, 1), + succeededEffect( + { + type: "send_signal", + target: { kind: "root" }, + signal: "SIGINT", + }, + 2, + ), + ]), + ).toMatchObject({ kind: "finished", outcome: "target_lost" }); + expect( + commitProcessReactiveProposal(scenario, proposed, [ + { + status: "succeeded", + observation: observation("filesystem", 1, { name: "ready" }), + }, + { + status: "succeeded", + observation: { + ...observation("filesystem", 2, { name: "ready" }), + event_id: "obs.filesystem_checkpoints.1", + }, + }, + ]), + ).toMatchObject({ kind: "finished", outcome: "action_rejected" }); + }); +}); diff --git a/tests/composition/process-capture/processReactiveRuntimePart3.test.ts b/tests/composition/process-capture/processReactiveRuntimePart3.test.ts new file mode 100644 index 000000000..60c6c3d9b --- /dev/null +++ b/tests/composition/process-capture/processReactiveRuntimePart3.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from "vitest"; + +import { + createProcessObservation, + type ProcessObservationSource, +} from "../../../src/domain/processObservation.js"; +import { + commitProcessReactiveProposal, + createProcessReactiveSnapshot, + reduceProcessReactiveScenario, +} from "../../../src/domain/processReactiveRuntime.js"; +import { + PROCESS_REACTIVE_LIMITS, + processReactiveScenarioSchema, + type ProcessReactiveAction, + type ProcessReactiveScenario, +} from "../../../src/domain/processReactiveScenario.js"; + +const terminalTrigger = (literal = "Ready") => ({ + kind: "terminal_text" as const, + view: "decoded" as const, + encoding: "utf8" as const, + literal, + case_sensitive: true, + control_sequences: "include" as const, + occurrence: 1, + since: { kind: "scenario_start" as const }, + consume: false, +}); + +const scenarioWith = ( + transitions: readonly unknown[], +): ProcessReactiveScenario => + processReactiveScenarioSchema.parse({ + version: 1, + initial_state: "starting", + deadline_ms: 30_000, + states: [ + { + id: "starting", + max_visits: 4, + deadline_ms: 5_000, + on: transitions, + }, + ], + }); + +const finish = ( + id: string, + when: unknown, + priority = 100, + actions: readonly unknown[] = [], +) => ({ + id, + priority, + max_uses: 4, + when, + actions, + target: { kind: "finish", outcome: "passed" }, +}); + +const collectionFor = (source: ProcessObservationSource) => { + switch (source) { + case "terminal_raw": + return "frames" as const; + case "terminal_rendered": + return "rendered_frames" as const; + case "process": + return "process_samples" as const; + case "filesystem": + return "filesystem_checkpoints" as const; + case "shim": + return "shim_events" as const; + case "http": + case "websocket": + return "protocol_events" as const; + case "replay_transition": + return "replay_transitions" as const; + case "interaction": + return "interaction_events" as const; + case "lifecycle": + return "lifecycle" as const; + } +}; + +const observation = ( + source: ProcessObservationSource, + order: number, + payload: unknown, +) => + createProcessObservation({ + source, + source_sequence: order, + captured_at_ms: order, + subject_id: null, + location: { + collection: collectionFor(source), + index: order, + capture_order: order, + }, + payload, + }); + +const succeededEffect = (action: ProcessReactiveAction, order: number) => ({ + status: "succeeded" as const, + observation: + action.type === "checkpoint" + ? observation("filesystem", order, { name: action.name }) + : observation("interaction", order, { + type: + action.type === "send_input" + ? "input" + : action.type === "resize" + ? "resize" + : action.type === "close_stdin" + ? "stdin_close" + : "signal", + data: + action.type === "send_input" + ? action.sensitive + ? `` + : action.data + : action.type === "resize" + ? `${String(action.columns)}x${String(action.rows)}` + : action.type === "close_stdin" + ? "" + : action.signal, + outcome: "dispatched", + }), +}); + +const offer = ( + scenario: ProcessReactiveScenario, + snapshot: ReturnType, + value: ReturnType, +) => { + const proposed = reduceProcessReactiveScenario(scenario, snapshot, { + kind: "observation", + observation: value, + }); + return proposed.kind === "proposal" + ? commitProcessReactiveProposal( + scenario, + proposed, + proposed.effects.map((effect, index) => ({ + ...succeededEffect( + effect, + (proposed.snapshot.observations.at(-1)?.capture_order ?? -1) + + index + + 1, + ), + })), + ) + : proposed; +}; + +describe("process reactive runtime lifecycle", () => { + it("preserves overflow when no any branch matches", () => { + const scenario = scenarioWith([ + finish("bounded_any", { + kind: "any", + triggers: [ + terminalTrigger("never"), + { + kind: "event", + source: "shim", + exact: { name: "also-never" }, + ignore_fields: [], + since: { kind: "scenario_start" }, + consume: false, + cardinality: { min: 1, max: 1 }, + }, + ], + }), + ]); + const decision = reduceProcessReactiveScenario( + scenario, + createProcessReactiveSnapshot(scenario), + { + kind: "observation", + observation: observation("terminal_raw", 0, { + data: "x".repeat(PROCESS_REACTIVE_LIMITS.evaluationWork + 1), + }), + }, + ); + expect(decision).toMatchObject({ + kind: "finished", + outcome: "capture_incomplete", + }); + }); + it("fails closed at the deterministic per-reduction work budget", () => { + const transitions = Array.from( + { length: PROCESS_REACTIVE_LIMITS.predicates }, + (_, index) => + finish(`event_${String(index)}`, { + kind: "event", + source: "shim", + exact: { name: `expected_${String(index)}` }, + ignore_fields: ["sequence"], + since: { kind: "scenario_start" }, + consume: false, + cardinality: { min: 1, max: 1 }, + }), + ); + const scenario = scenarioWith(transitions); + const retained = Array.from({ length: 128 }, (_, order) => + observation("shim", order, { name: "other", sequence: order }), + ); + const snapshot = { + ...createProcessReactiveSnapshot(scenario), + observations: retained, + }; + const decision = offer( + scenario, + snapshot, + observation("shim", retained.length, { + name: "other", + sequence: retained.length, + }), + ); + expect(decision).toMatchObject({ + kind: "finished", + outcome: "capture_incomplete", + }); + }); +}); diff --git a/tests/composition/process-capture/processReactiveRuntimePredicates.test.ts b/tests/composition/process-capture/processReactiveRuntimePredicates.test.ts new file mode 100644 index 000000000..cb3edddba --- /dev/null +++ b/tests/composition/process-capture/processReactiveRuntimePredicates.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, it } from "vitest"; +import { + createProcessObservation, + type ProcessObservationSource, +} from "../../../src/domain/processObservation.js"; +import { + commitProcessReactiveProposal, + createProcessReactiveSnapshot, + reduceProcessReactiveScenario, +} from "../../../src/domain/processReactiveRuntime.js"; +import { + processReactiveScenarioSchema, + type ProcessReactiveAction, + type ProcessReactiveScenario, +} from "../../../src/domain/processReactiveScenario.js"; +const terminalTrigger = (literal = "Ready") => ({ + kind: "terminal_text" as const, + view: "decoded" as const, + encoding: "utf8" as const, + literal, + case_sensitive: true, + control_sequences: "include" as const, + occurrence: 1, + since: { kind: "scenario_start" as const }, + consume: false, +}); +const scenarioWith = ( + transitions: readonly unknown[], +): ProcessReactiveScenario => + processReactiveScenarioSchema.parse({ + version: 1, + initial_state: "starting", + deadline_ms: 30000, + states: [ + { + id: "starting", + max_visits: 4, + deadline_ms: 5000, + on: transitions, + }, + ], + }); +const finish = ( + id: string, + when: unknown, + priority = 100, + actions: readonly unknown[] = [], +) => ({ + id, + priority, + max_uses: 4, + when, + actions, + target: { kind: "finish", outcome: "passed" }, +}); +const collectionFor = (source: ProcessObservationSource) => { + switch (source) { + case "terminal_raw": + return "frames" as const; + case "terminal_rendered": + return "rendered_frames" as const; + case "process": + return "process_samples" as const; + case "filesystem": + return "filesystem_checkpoints" as const; + case "shim": + return "shim_events" as const; + case "http": + case "websocket": + return "protocol_events" as const; + case "replay_transition": + return "replay_transitions" as const; + case "interaction": + return "interaction_events" as const; + case "lifecycle": + return "lifecycle" as const; + } +}; +const observation = ( + source: ProcessObservationSource, + order: number, + payload: unknown, +) => + createProcessObservation({ + source, + source_sequence: order, + captured_at_ms: order, + subject_id: null, + location: { + collection: collectionFor(source), + index: order, + capture_order: order, + }, + payload, + }); +const succeededEffect = (action: ProcessReactiveAction, order: number) => ({ + status: "succeeded" as const, + observation: + action.type === "checkpoint" + ? observation("filesystem", order, { name: action.name }) + : observation("interaction", order, { + type: + action.type === "send_input" + ? "input" + : action.type === "resize" + ? "resize" + : action.type === "close_stdin" + ? "stdin_close" + : "signal", + data: + action.type === "send_input" + ? action.sensitive + ? `` + : action.data + : action.type === "resize" + ? `${String(action.columns)}x${String(action.rows)}` + : action.type === "close_stdin" + ? "" + : action.signal, + outcome: "dispatched", + }), +}); +const offer = ( + scenario: ProcessReactiveScenario, + snapshot: ReturnType, + value: ReturnType, +) => { + const proposed = reduceProcessReactiveScenario(scenario, snapshot, { + kind: "observation", + observation: value, + }); + return proposed.kind === "proposal" + ? commitProcessReactiveProposal( + scenario, + proposed, + proposed.effects.map((effect, index) => ({ + ...succeededEffect( + effect, + (proposed.snapshot.observations.at(-1)?.capture_order ?? -1) + + index + + 1, + ), + })), + ) + : proposed; +}; +describe("process reactive runtime predicates", () => { + it("selects a unique priority and reports equal-priority ambiguity", () => { + const preferred = scenarioWith([ + finish("fallback", terminalTrigger(), 20), + finish("preferred", terminalTrigger(), 10), + ]); + const selected = offer( + preferred, + createProcessReactiveSnapshot(preferred), + observation("terminal_raw", 0, { data: "Ready" }), + ); + expect(selected.kind).toBe("transition"); + if (selected.kind === "transition") + expect(selected.record.transition_id).toBe("preferred"); + for (const transitions of [ + [finish("one", terminalTrigger()), finish("two", terminalTrigger())], + [finish("two", terminalTrigger()), finish("one", terminalTrigger())], + ]) { + const ambiguous = scenarioWith(transitions); + const decision = offer( + ambiguous, + createProcessReactiveSnapshot(ambiguous), + observation("terminal_raw", 0, { data: "Ready" }), + ); + expect(decision).toMatchObject({ + kind: "finished", + outcome: "ambiguous_match", + }); + } + }); + it("evaluates ordered and repeated predicates without array-order arbitration", () => { + const event = (name: string) => ({ + kind: "event" as const, + source: "shim" as const, + exact: { name }, + ignore_fields: ["sequence" as const], + since: { kind: "scenario_start" as const }, + consume: true, + cardinality: { min: 1, max: 1 }, + }); + const scenario = scenarioWith([ + finish("sequence", { + kind: "sequence", + triggers: [ + event("start"), + { kind: "repeat", trigger: event("tick"), min: 2, max: 2 }, + event("done"), + ], + }), + ]); + let snapshot = createProcessReactiveSnapshot(scenario); + for (const [order, name] of ["start", "tick", "tick"].entries()) { + const decision = offer( + scenario, + snapshot, + observation("shim", order, { name, sequence: order }), + ); + expect(decision.kind).toBe("waiting"); + snapshot = decision.snapshot; + } + const decision = offer( + scenario, + snapshot, + observation("shim", 3, { name: "done", sequence: 3 }), + ); + expect(decision.kind).toBe("transition"); + if (decision.kind === "transition") + expect(decision.record.trigger_event_ids).toEqual([ + "obs.shim_events.0", + "obs.shim_events.1", + "obs.shim_events.2", + "obs.shim_events.3", + ]); + }); + it("applies state-entry frontiers and explicit consumption", () => { + const secondTrigger = { + ...terminalTrigger(), + since: { kind: "state_entry" as const }, + consume: true, + }; + const scenario = processReactiveScenarioSchema.parse({ + version: 1, + initial_state: "one", + deadline_ms: 30000, + states: [ + { + id: "one", + max_visits: 1, + deadline_ms: 5000, + on: [ + { + ...finish("advance", terminalTrigger()), + target: { kind: "goto", state: "two" }, + }, + ], + }, + { + id: "two", + max_visits: 1, + deadline_ms: 5000, + on: [finish("finish", secondTrigger)], + }, + ], + }); + const advanced = offer( + scenario, + createProcessReactiveSnapshot(scenario), + observation("terminal_raw", 0, { data: "Ready" }), + ); + expect(advanced.kind).toBe("transition"); + const waiting = offer( + scenario, + advanced.snapshot, + observation("shim", 1, { name: "unrelated" }), + ); + expect(waiting.kind).toBe("waiting"); + const finished = offer( + scenario, + waiting.snapshot, + observation("terminal_raw", 2, { data: "Ready" }), + ); + expect(finished.kind).toBe("transition"); + expect(finished.snapshot.consumed_event_ids).toEqual(["obs.frames.2"]); + }); +}); diff --git a/tests/composition/process-capture/processReactiveRuntimePredicatesPart2.test.ts b/tests/composition/process-capture/processReactiveRuntimePredicatesPart2.test.ts new file mode 100644 index 000000000..61e8beb39 --- /dev/null +++ b/tests/composition/process-capture/processReactiveRuntimePredicatesPart2.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it } from "vitest"; +import { + createProcessObservation, + type ProcessObservationSource, +} from "../../../src/domain/processObservation.js"; +import { + commitProcessReactiveProposal, + createProcessReactiveSnapshot, + reduceProcessReactiveScenario, +} from "../../../src/domain/processReactiveRuntime.js"; +import { + processReactiveScenarioSchema, + type ProcessReactiveAction, + type ProcessReactiveScenario, +} from "../../../src/domain/processReactiveScenario.js"; +const terminalTrigger = (literal = "Ready") => ({ + kind: "terminal_text" as const, + view: "decoded" as const, + encoding: "utf8" as const, + literal, + case_sensitive: true, + control_sequences: "include" as const, + occurrence: 1, + since: { kind: "scenario_start" as const }, + consume: false, +}); +const scenarioWith = ( + transitions: readonly unknown[], +): ProcessReactiveScenario => + processReactiveScenarioSchema.parse({ + version: 1, + initial_state: "starting", + deadline_ms: 30000, + states: [ + { + id: "starting", + max_visits: 4, + deadline_ms: 5000, + on: transitions, + }, + ], + }); +const finish = ( + id: string, + when: unknown, + priority = 100, + actions: readonly unknown[] = [], +) => ({ + id, + priority, + max_uses: 4, + when, + actions, + target: { kind: "finish", outcome: "passed" }, +}); +const collectionFor = (source: ProcessObservationSource) => { + switch (source) { + case "terminal_raw": + return "frames" as const; + case "terminal_rendered": + return "rendered_frames" as const; + case "process": + return "process_samples" as const; + case "filesystem": + return "filesystem_checkpoints" as const; + case "shim": + return "shim_events" as const; + case "http": + case "websocket": + return "protocol_events" as const; + case "replay_transition": + return "replay_transitions" as const; + case "interaction": + return "interaction_events" as const; + case "lifecycle": + return "lifecycle" as const; + } +}; +const observation = ( + source: ProcessObservationSource, + order: number, + payload: unknown, +) => + createProcessObservation({ + source, + source_sequence: order, + captured_at_ms: order, + subject_id: null, + location: { + collection: collectionFor(source), + index: order, + capture_order: order, + }, + payload, + }); +const succeededEffect = (action: ProcessReactiveAction, order: number) => ({ + status: "succeeded" as const, + observation: + action.type === "checkpoint" + ? observation("filesystem", order, { name: action.name }) + : observation("interaction", order, { + type: + action.type === "send_input" + ? "input" + : action.type === "resize" + ? "resize" + : action.type === "close_stdin" + ? "stdin_close" + : "signal", + data: + action.type === "send_input" + ? action.sensitive + ? `` + : action.data + : action.type === "resize" + ? `${String(action.columns)}x${String(action.rows)}` + : action.type === "close_stdin" + ? "" + : action.signal, + outcome: "dispatched", + }), +}); +const offer = ( + scenario: ProcessReactiveScenario, + snapshot: ReturnType, + value: ReturnType, +) => { + const proposed = reduceProcessReactiveScenario(scenario, snapshot, { + kind: "observation", + observation: value, + }); + return proposed.kind === "proposal" + ? commitProcessReactiveProposal( + scenario, + proposed, + proposed.effects.map((effect, index) => ({ + ...succeededEffect( + effect, + (proposed.snapshot.observations.at(-1)?.capture_order ?? -1) + + index + + 1, + ), + })), + ) + : proposed; +}; +describe("process reactive runtime predicates", () => { + it("rejects an exhausted state visit before proposing effects", () => { + const scenario = processReactiveScenarioSchema.parse({ + version: 1, + initial_state: "loop", + deadline_ms: 30000, + states: [ + { + id: "loop", + max_visits: 1, + deadline_ms: 5000, + on: [ + { + ...finish("again", terminalTrigger(), 100, [ + { + type: "send_signal", + target: { kind: "root" }, + signal: "SIGKILL", + }, + ]), + target: { kind: "goto", state: "loop" }, + }, + ], + }, + ], + }); + const decision = reduceProcessReactiveScenario( + scenario, + createProcessReactiveSnapshot(scenario), + { + kind: "observation", + observation: observation("terminal_raw", 0, { data: "Ready" }), + }, + ); + expect(decision).toMatchObject({ + kind: "finished", + outcome: "capture_incomplete", + }); + expect(decision).not.toHaveProperty("effects"); + }); + it("classifies deadlines and non-monotonic observation input", () => { + const scenario = scenarioWith([finish("ready", terminalTrigger())]); + const initial = createProcessReactiveSnapshot(scenario); + expect( + reduceProcessReactiveScenario(scenario, initial, { + kind: "state_deadline", + state_id: "starting", + state_entry_capture_order: 0, + }), + ).toMatchObject({ kind: "finished", outcome: "predicate_timeout" }); + expect( + reduceProcessReactiveScenario(scenario, initial, { + kind: "state_deadline", + state_id: "previous", + state_entry_capture_order: 0, + }), + ).toMatchObject({ kind: "waiting", snapshot: initial }); + expect( + reduceProcessReactiveScenario(scenario, initial, { + kind: "scenario_deadline", + }), + ).toMatchObject({ kind: "finished", outcome: "scenario_deadline" }); + expect( + reduceProcessReactiveScenario(scenario, initial, { kind: "cancelled" }), + ).toMatchObject({ kind: "finished", outcome: "cancelled" }); + expect( + reduceProcessReactiveScenario(scenario, initial, { + kind: "cleanup_failed", + }), + ).toMatchObject({ kind: "finished", outcome: "cleanup_failed" }); + const passed = offer( + scenario, + initial, + observation("terminal_raw", 0, { data: "Ready" }), + ); + expect( + reduceProcessReactiveScenario(scenario, passed.snapshot, { + kind: "cleanup_failed", + }), + ).toMatchObject({ kind: "finished", outcome: "cleanup_failed" }); + const first = offer( + scenario, + initial, + observation("shim", 1, { name: "later" }), + ); + const invalid = offer( + scenario, + first.snapshot, + observation("shim", 0, { name: "earlier" }), + ); + expect(invalid).toMatchObject({ + kind: "finished", + outcome: "capture_incomplete", + }); + const duplicate = observation("shim", 1, { name: "later" }); + expect(offer(scenario, first.snapshot, duplicate)).toMatchObject({ + kind: "finished", + outcome: "capture_incomplete", + }); + expect( + offer(scenario, first.snapshot, { + ...duplicate, + payload: { name: "changed" }, + }), + ).toMatchObject({ kind: "finished", outcome: "capture_incomplete" }); + }); +}); diff --git a/tests/processReactiveScenario.test.ts b/tests/composition/process-capture/processReactiveScenario.test.ts similarity index 97% rename from tests/processReactiveScenario.test.ts rename to tests/composition/process-capture/processReactiveScenario.test.ts index 3a37d09a6..74cd030b2 100644 --- a/tests/processReactiveScenario.test.ts +++ b/tests/composition/process-capture/processReactiveScenario.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { PROCESS_REACTIVE_LIMITS, processReactiveScenarioSchema, -} from "../src/domain/processReactiveScenario.js"; +} from "../../../src/domain/processReactiveScenario.js"; const terminalTrigger = () => ({ kind: "terminal_text" as const, @@ -136,7 +136,9 @@ describe("process reactive scenario schema", () => { ])("rejects %s", (_name, input) => { expect(processReactiveScenarioSchema.safeParse(input).success).toBe(false); }); +}); +describe("process reactive scenario graph validation", () => { it("rejects unknown targets, unreachable states, and duplicate transition ids", () => { const input = { ...baseScenario(), @@ -220,7 +222,9 @@ describe("process reactive scenario schema", () => { false, ); }); +}); +describe("process reactive scenario checkpoint validation", () => { it("rejects impossible checkpoint frontiers and volatile exact fields", () => { const exact = { kind: "event" as const, @@ -303,7 +307,9 @@ describe("process reactive scenario schema", () => { ), ).toHaveLength(2); }); +}); +describe("process reactive scenario nesting validation", () => { it("rejects trigger trees beyond the declared depth", () => { let trigger: unknown = terminalTrigger(); for ( diff --git a/tests/processTraceAdapters.test.ts b/tests/composition/process-capture/processTraceAdapters.test.ts similarity index 66% rename from tests/processTraceAdapters.test.ts rename to tests/composition/process-capture/processTraceAdapters.test.ts index 8607a24e9..8e2ab10f8 100644 --- a/tests/processTraceAdapters.test.ts +++ b/tests/composition/process-capture/processTraceAdapters.test.ts @@ -1,26 +1,11 @@ -import { rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; -import { afterEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; -import { BinarySession } from "../src/application/BinarySession.js"; -import type { AnalysisClient } from "../src/application/AnalysisProvider.js"; -import { compareProcessEvidenceFiles } from "../src/application/ProcessCli.js"; -import { PROCESS_PROVIDER } from "../src/application/ProcessEvidence.js"; -import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../src/contracts/processCaptureExample.js"; -import { createEvidence, parseEvidence } from "../src/domain/evidence.js"; -import { jsonValueSchema } from "../src/domain/jsonValue.js"; +import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../../../src/contracts/processCaptureExample.js"; import { compareProcessCaptures, processCaptureSchema, -} from "../src/domain/processCapture.js"; -import type { ProcessTraceSpecification } from "../src/domain/processTraceComparison.js"; -import { observed as ok } from "./fixtures/analysisExecution.js"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; -import { createServer } from "../src/server/createServer.js"; - -const resources: Array<{ close(): Promise }> = []; -const temporaryRoots: string[] = []; +} from "../../../src/domain/processCapture.js"; +import type { ProcessTraceSpecification } from "../../../src/domain/processTraceComparison.js"; const beforeCheckpoint = { name: "before", at_ms: 0, @@ -36,15 +21,6 @@ const afterCheckpoint = { truncated: false, }; -afterEach(async () => { - await Promise.all(resources.splice(0).map((resource) => resource.close())); - await Promise.all( - temporaryRoots - .splice(0) - .map((root) => rm(root, { recursive: true, force: true })), - ); -}); - const capture = { ...EMPTY_PROCESS_CAPTURE_EXAMPLE, event_journal: [ @@ -94,17 +70,6 @@ const traceSpecification: ProcessTraceSpecification = { }, }; -const captureEvidence = (side: "left" | "right") => - createEvidence(undefined, PROCESS_PROVIDER, { - predicateType: "rea.process-capture/v4", - operation: "capture_process_scenario", - parameters: { side }, - result: jsonValueSchema.parse(capture), - confidence: "observed", - authority: "controlled-replay", - locations: [{ kind: "artifact-path", path: `/fixture/${side}` }], - }); - describe("declared trace comparison adapters", () => { it("treats only explicitly ignored schedule metadata as reorderable", () => { const httpPayload = { @@ -199,7 +164,9 @@ describe("declared trace comparison adapters", () => { }, }); }); +}); +describe("declared trace comparison coverage", () => { it("does not mask an unselected source in a covered legacy dimension", () => { const http = { sequence: 0, @@ -268,7 +235,9 @@ describe("declared trace comparison adapters", () => { trace: { verdict: "equivalent" }, }); }); +}); +describe("declared trace comparison nonconformance", () => { it("does not call identical captures changed when both violate the language", () => { const parsedCapture = processCaptureSchema.parse(capture); const specification: ProcessTraceSpecification = { @@ -341,69 +310,4 @@ describe("declared trace comparison adapters", () => { trace: { verdict: "nonconforming" }, }); }); - - it("returns identical CLI and MCP results and derived Evidence identity", async () => { - const left = captureEvidence("left"); - const right = captureEvidence("right"); - const root = await createTestTempDirectory("rea-trace-adapters-"); - temporaryRoots.push(root); - const leftPath = join(root, "left.json"); - const rightPath = join(root, "right.json"); - const specificationPath = join(root, "trace.json"); - await Promise.all([ - writeFile(leftPath, JSON.stringify(left)), - writeFile(rightPath, JSON.stringify(right)), - writeFile(specificationPath, JSON.stringify(traceSpecification)), - ]); - const cliEvidence = parseEvidence( - await compareProcessEvidenceFiles(leftPath, rightPath, specificationPath), - ); - - const session = new BinarySession( - (_path) => - ({ - execute: () => Promise.resolve(ok(null)), - close: () => Promise.resolve(), - }) satisfies AnalysisClient, - ); - expect(session.recordEvidence(left).ok).toBe(true); - expect(session.recordEvidence(right).ok).toBe(true); - const server = createServer( - { execute: () => Promise.resolve(ok(null)) }, - session, - ); - const client = new Client({ name: "trace-parity", version: "1" }); - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - resources.push(client, server); - await server.connect(serverTransport); - await client.connect(clientTransport); - - const response = await client.callTool({ - name: "compare_process_captures", - arguments: { - left_evidence_id: left.evidence_id, - right_evidence_id: right.evidence_id, - trace_spec: traceSpecification, - }, - }); - expect(response.isError).not.toBe(true); - expect(response.structuredContent).toMatchObject({ - result: { trace: { verdict: "equivalent" } }, - evidence_id: cliEvidence.evidence_id, - }); - const structuredResult = - typeof response.structuredContent === "object" && - response.structuredContent !== null - ? Reflect.get(response.structuredContent, "result") - : undefined; - expect(structuredResult).toEqual(cliEvidence.normalized_result); - expect(cliEvidence).toMatchObject({ - predicate_type: "rea.process-comparison/v4", - locations: [ - { kind: "artifact-path", path: "/fixture/left" }, - { kind: "artifact-path", path: "/fixture/right" }, - ], - }); - }); }); diff --git a/tests/processTraceComparison.test.ts b/tests/composition/process-capture/processTraceComparison.test.ts similarity index 53% rename from tests/processTraceComparison.test.ts rename to tests/composition/process-capture/processTraceComparison.test.ts index d4b577555..e8b00756f 100644 --- a/tests/processTraceComparison.test.ts +++ b/tests/composition/process-capture/processTraceComparison.test.ts @@ -1,18 +1,14 @@ import { describe, expect, it } from "vitest"; - -import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../src/contracts/processCaptureExample.js"; +import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../../../src/contracts/processCaptureExample.js"; import { processCaptureSchema, type ProcessCapture, -} from "../src/domain/processCapture.js"; +} from "../../../src/domain/processCapture.js"; import { compareProcessTraces, - processTraceSpecificationSchema, type ProcessTraceSpecification, -} from "../src/domain/processTraceComparison.js"; - +} from "../../../src/domain/processTraceComparison.js"; const emptyCapture = processCaptureSchema.parse(EMPTY_PROCESS_CAPTURE_EXAMPLE); - const capture = ( values: Pick< ProcessCapture, @@ -64,7 +60,6 @@ const capture = ( truncated: options.truncated ?? false, residual_unknowns: options.residualUnknowns ?? [], }); - const terminal = { sequence: 0, at_ms: 900, data: "Ready" }; const processStarted = { at_ms: 1, @@ -94,7 +89,6 @@ const websocket = { data: "done", outcome: "matched" as const, }; - const values = ( order: readonly ("terminal" | "process" | "http" | "websocket")[], ): Parameters[0] => ({ @@ -114,7 +108,6 @@ const values = ( index: event === "websocket" ? 1 : 0, })), }); - const partialSpecification = (): ProcessTraceSpecification => ({ version: 1, events: [ @@ -157,117 +150,6 @@ const partialSpecification = (): ProcessTraceSpecification => ({ suffix: ["done"], }, }); - -describe("process trace specification", () => { - it("rejects cycles, implicit concurrency, overlap, and unsatisfiable variants", () => { - const base = partialSpecification(); - expect( - processTraceSpecificationSchema.safeParse({ - ...base, - language: { - kind: "partial_order", - happens_before: [ - { before: "ready", after: "worker" }, - { before: "worker", after: "ready" }, - ], - unordered_groups: [{ events: ["status", "done"] }], - }, - }).success, - ).toBe(false); - expect( - processTraceSpecificationSchema.safeParse({ - version: 1, - events: base.events.slice(0, 3), - language: { - kind: "partial_order", - happens_before: [{ before: "ready", after: "status" }], - not_before: [{ event: "worker", anchor: "ready" }], - unordered_groups: [], - }, - }).success, - ).toBe(false); - expect( - processTraceSpecificationSchema.safeParse({ - version: 1, - events: [ - { - id: "invalid-ignore", - source: "terminal_raw", - exact: "ready", - ignore_fields: ["at_ms"], - }, - ], - language: { - kind: "finite_traces", - variants: [{ id: "one", trace: ["invalid-ignore"] }], - }, - }).success, - ).toBe(false); - expect( - processTraceSpecificationSchema.safeParse({ - version: 1, - events: [ - { - id: "invalid-ignore", - source: "terminal_raw", - exact: terminal, - ignore_fields: ["at_ms"], - }, - ], - language: { - kind: "finite_traces", - variants: [{ id: "one", trace: ["invalid-ignore"] }], - }, - }).success, - ).toBe(false); - expect( - processTraceSpecificationSchema.safeParse({ - version: 1, - events: base.events.slice(0, 2), - language: { kind: "partial_order" }, - }).success, - ).toBe(false); - expect( - processTraceSpecificationSchema.safeParse({ - version: 1, - events: [base.events[0], { ...base.events[0], id: "duplicate" }], - language: { - kind: "partial_order", - unordered_groups: [{ events: ["ready", "duplicate"] }], - }, - }).success, - ).toBe(false); - expect( - processTraceSpecificationSchema.safeParse({ - version: 1, - events: [ - { - ...base.events[0], - cardinality: { kind: "exact", count: 2 }, - }, - ], - language: { - kind: "finite_traces", - variants: [{ id: "once", trace: ["ready"] }], - }, - }).success, - ).toBe(false); - expect( - processTraceSpecificationSchema.safeParse({ - version: 1, - events: [base.events[0]], - language: { - kind: "finite_traces", - variants: [ - { id: "first", trace: ["ready"] }, - { id: "duplicate", trace: ["ready"] }, - ], - }, - }).success, - ).toBe(false); - }); -}); - describe("process trace comparison", () => { it("accepts only explicitly declared concurrent schedules without timestamp causality", () => { const left = capture(values(["terminal", "process", "http", "websocket"])); @@ -277,7 +159,6 @@ describe("process trace comparison", () => { right, partialSpecification(), ); - expect(comparison.verdict).toBe("equivalent"); expect(comparison.left.raw_trace.map(({ event_id }) => event_id)).toEqual([ "ready", @@ -295,7 +176,6 @@ describe("process trace comparison", () => { "unordered:worker:status", ); }); - it("returns the minimal journal slice for a reversed required edge", () => { const baseline = capture( values(["terminal", "process", "http", "websocket"]), @@ -308,7 +188,6 @@ describe("process trace comparison", () => { reversed, partialSpecification(), ); - expect(comparison).toMatchObject({ verdict: "different", diagnostic: { @@ -319,7 +198,6 @@ describe("process trace comparison", () => { }, }); }); - it("enforces explicit negative not-before constraints", () => { const specification: ProcessTraceSpecification = { version: 1, @@ -352,7 +230,6 @@ describe("process trace comparison", () => { const invalid = capture( values(["websocket", "terminal", "process", "http"]), ); - expect(compareProcessTraces(normal, invalid, specification)).toMatchObject({ verdict: "different", diagnostic: { @@ -362,7 +239,6 @@ describe("process trace comparison", () => { }, }); }); - it("supports declared optional events and bounded duplicates", () => { const first = { sequence: 0, at_ms: 1, data: "tick" }; const second = { sequence: 1, at_ms: 2, data: "tick" }; @@ -409,125 +285,10 @@ describe("process trace comparison", () => { ], }, }; - expect(compareProcessTraces(one, two, specification)).toMatchObject({ verdict: "equivalent", left: { matched_variant: "one" }, right: { matched_variant: "two" }, }); }); - - it("enforces exact and range cardinality for one repeated predicate", () => { - const repeated = (count: number) => - capture({ - frames: Array.from({ length: count }, (_, sequence) => ({ - sequence, - at_ms: sequence, - data: "tick", - })), - process_samples: [], - filesystem_checkpoints: [], - protocol_events: [], - shim_events: [], - event_journal: Array.from({ length: count }, (_, index) => ({ - capture_order: index, - collection: "frames" as const, - index, - })), - }); - const event: Omit< - ProcessTraceSpecification["events"][number], - "cardinality" - > = { - id: "tick", - source: "terminal_raw", - exact: { data: "tick" }, - ignore_fields: ["sequence", "at_ms"], - }; - const rangeSpecification: ProcessTraceSpecification = { - version: 1, - events: [{ ...event, cardinality: { kind: "range", min: 2, max: 3 } }], - language: { - kind: "partial_order", - happens_before: [], - not_before: [], - unordered_groups: [], - prefix: [], - suffix: [], - }, - }; - expect( - compareProcessTraces(repeated(2), repeated(3), rangeSpecification), - ).toMatchObject({ verdict: "equivalent" }); - expect( - compareProcessTraces(repeated(1), repeated(2), rangeSpecification), - ).toMatchObject({ - verdict: "different", - diagnostic: { kind: "cardinality", side: "left" }, - }); - expect( - compareProcessTraces(repeated(2), repeated(4), rangeSpecification), - ).toMatchObject({ - verdict: "different", - diagnostic: { kind: "cardinality", side: "right" }, - }); - - const exactSpecification: ProcessTraceSpecification = { - version: 1, - events: [{ ...event, cardinality: { kind: "exact", count: 2 } }], - language: { - kind: "finite_traces", - variants: [{ id: "two", trace: ["tick", "tick"] }], - }, - }; - expect( - compareProcessTraces(repeated(2), repeated(2), exactSpecification), - ).toMatchObject({ verdict: "equivalent" }); - expect( - compareProcessTraces(repeated(3), repeated(2), exactSpecification), - ).toMatchObject({ - verdict: "different", - diagnostic: { kind: "cardinality", side: "left" }, - }); - }); - - it("never proves equivalence from truncated, unknown, or journal-free evidence", () => { - const complete = capture( - values(["terminal", "process", "http", "websocket"]), - ); - const truncated = capture( - values(["terminal", "process", "http", "websocket"]), - { truncated: true }, - ); - expect( - compareProcessTraces(complete, truncated, partialSpecification()).verdict, - ).toBe("unknown"); - - const unknown = capture( - values(["terminal", "process", "http", "websocket"]), - { residualUnknowns: [{ scope: "protocol", reason: "gap" }] }, - ); - expect( - compareProcessTraces(complete, unknown, partialSpecification()).verdict, - ).toBe("unknown"); - - const noJournal = { ...complete, event_journal: [] }; - const comparison = compareProcessTraces( - complete, - noJournal, - partialSpecification(), - ); - expect(comparison).toMatchObject({ - verdict: "unknown", - diagnostic: { kind: "journal", side: "right" }, - }); - - const incompleteJournal = { - ...complete, - event_journal: (complete.event_journal ?? []).slice(1), - }; - expect(() => - compareProcessTraces(complete, incompleteJournal, partialSpecification()), - ).toThrow("Invalid Process Capture v4: event_journal.0.capture_order"); - }); }); diff --git a/tests/composition/process-capture/processTraceComparisonPart2.test.ts b/tests/composition/process-capture/processTraceComparisonPart2.test.ts new file mode 100644 index 000000000..29f1f835e --- /dev/null +++ b/tests/composition/process-capture/processTraceComparisonPart2.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, it } from "vitest"; +import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../../../src/contracts/processCaptureExample.js"; +import { + processCaptureSchema, + type ProcessCapture, +} from "../../../src/domain/processCapture.js"; +import { + compareProcessTraces, + type ProcessTraceSpecification, +} from "../../../src/domain/processTraceComparison.js"; +const emptyCapture = processCaptureSchema.parse(EMPTY_PROCESS_CAPTURE_EXAMPLE); +const capture = ( + values: Pick< + ProcessCapture, + | "frames" + | "process_samples" + | "filesystem_checkpoints" + | "protocol_events" + | "shim_events" + > & { + readonly event_journal: NonNullable; + }, + options: { + readonly truncated?: boolean; + readonly residualUnknowns?: ProcessCapture["residual_unknowns"]; + } = {}, +): ProcessCapture => ({ + ...emptyCapture, + frames: values.frames, + process_samples: values.process_samples, + filesystem_checkpoints: emptyCapture.filesystem_checkpoints, + shim_events: values.shim_events, + protocol_events: values.protocol_events, + event_journal: [ + { + capture_order: 0, + collection: "filesystem_checkpoints", + index: 0, + }, + ...values.event_journal.map((entry) => ({ + ...entry, + capture_order: entry.capture_order + 1, + })), + { + capture_order: values.event_journal.length + 1, + collection: "lifecycle", + index: 0, + }, + { + capture_order: values.event_journal.length + 2, + collection: "lifecycle", + index: 1, + }, + { + capture_order: values.event_journal.length + 3, + collection: "filesystem_checkpoints", + index: 1, + }, + ], + truncated: options.truncated ?? false, + residual_unknowns: options.residualUnknowns ?? [], +}); +const terminal = { sequence: 0, at_ms: 900, data: "Ready" }; +const processStarted = { + at_ms: 1, + pid: 1, + parent_pid: 0, + command: "worker", + process_group_id: 1, + session_id: 1, +}; +const http = { + sequence: 0, + at_ms: 2, + protocol: "http" as const, + direction: "request" as const, + method: "GET", + path: "/status", + data: "", + outcome: "unmatched" as const, +}; +const websocket = { + sequence: 1, + at_ms: 3, + protocol: "websocket" as const, + direction: "received" as const, + method: null, + path: "/ws", + data: "done", + outcome: "matched" as const, +}; +const values = ( + order: readonly ("terminal" | "process" | "http" | "websocket")[], +): Parameters[0] => ({ + frames: [terminal], + process_samples: [processStarted], + filesystem_checkpoints: [], + protocol_events: [http, websocket], + shim_events: [], + event_journal: order.map((event, captureOrder) => ({ + capture_order: captureOrder, + collection: + event === "terminal" + ? "frames" + : event === "process" + ? "process_samples" + : "protocol_events", + index: event === "websocket" ? 1 : 0, + })), +}); +const partialSpecification = (): ProcessTraceSpecification => ({ + version: 1, + events: [ + { + id: "ready", + source: "terminal_raw", + exact: terminal, + cardinality: { kind: "required" }, + }, + { + id: "worker", + source: "process", + exact: processStarted, + cardinality: { kind: "required" }, + }, + { + id: "status", + source: "http", + exact: http, + cardinality: { kind: "required" }, + }, + { + id: "done", + source: "websocket", + exact: websocket, + cardinality: { kind: "required" }, + }, + ], + language: { + kind: "partial_order", + happens_before: [ + { before: "ready", after: "worker" }, + { before: "ready", after: "status" }, + { before: "worker", after: "done" }, + { before: "status", after: "done" }, + ], + not_before: [{ event: "done", anchor: "ready" }], + unordered_groups: [{ events: ["worker", "status"] }], + prefix: ["ready"], + suffix: ["done"], + }, +}); +describe("process trace comparison", () => { + it("enforces exact and range cardinality for one repeated predicate", () => { + const repeated = (count: number) => + capture({ + frames: Array.from({ length: count }, (_, sequence) => ({ + sequence, + at_ms: sequence, + data: "tick", + })), + process_samples: [], + filesystem_checkpoints: [], + protocol_events: [], + shim_events: [], + event_journal: Array.from({ length: count }, (_, index) => ({ + capture_order: index, + collection: "frames" as const, + index, + })), + }); + const event: Omit< + ProcessTraceSpecification["events"][number], + "cardinality" + > = { + id: "tick", + source: "terminal_raw", + exact: { data: "tick" }, + ignore_fields: ["sequence", "at_ms"], + }; + const rangeSpecification: ProcessTraceSpecification = { + version: 1, + events: [{ ...event, cardinality: { kind: "range", min: 2, max: 3 } }], + language: { + kind: "partial_order", + happens_before: [], + not_before: [], + unordered_groups: [], + prefix: [], + suffix: [], + }, + }; + expect( + compareProcessTraces(repeated(2), repeated(3), rangeSpecification), + ).toMatchObject({ verdict: "equivalent" }); + expect( + compareProcessTraces(repeated(1), repeated(2), rangeSpecification), + ).toMatchObject({ + verdict: "different", + diagnostic: { kind: "cardinality", side: "left" }, + }); + expect( + compareProcessTraces(repeated(2), repeated(4), rangeSpecification), + ).toMatchObject({ + verdict: "different", + diagnostic: { kind: "cardinality", side: "right" }, + }); + const exactSpecification: ProcessTraceSpecification = { + version: 1, + events: [{ ...event, cardinality: { kind: "exact", count: 2 } }], + language: { + kind: "finite_traces", + variants: [{ id: "two", trace: ["tick", "tick"] }], + }, + }; + expect( + compareProcessTraces(repeated(2), repeated(2), exactSpecification), + ).toMatchObject({ verdict: "equivalent" }); + expect( + compareProcessTraces(repeated(3), repeated(2), exactSpecification), + ).toMatchObject({ + verdict: "different", + diagnostic: { kind: "cardinality", side: "left" }, + }); + }); + it("never proves equivalence from truncated, unknown, or journal-free evidence", () => { + const complete = capture( + values(["terminal", "process", "http", "websocket"]), + ); + const truncated = capture( + values(["terminal", "process", "http", "websocket"]), + { truncated: true }, + ); + expect( + compareProcessTraces(complete, truncated, partialSpecification()).verdict, + ).toBe("unknown"); + const unknown = capture( + values(["terminal", "process", "http", "websocket"]), + { residualUnknowns: [{ scope: "protocol", reason: "gap" }] }, + ); + expect( + compareProcessTraces(complete, unknown, partialSpecification()).verdict, + ).toBe("unknown"); + const noJournal = { ...complete, event_journal: [] }; + const comparison = compareProcessTraces( + complete, + noJournal, + partialSpecification(), + ); + expect(comparison).toMatchObject({ + verdict: "unknown", + diagnostic: { kind: "journal", side: "right" }, + }); + const incompleteJournal = { + ...complete, + event_journal: (complete.event_journal ?? []).slice(1), + }; + expect(() => + compareProcessTraces(complete, incompleteJournal, partialSpecification()), + ).toThrow("Invalid Process Capture v4: event_journal.0.capture_order"); + }); +}); diff --git a/tests/composition/process-capture/processTraceSpecification.test.ts b/tests/composition/process-capture/processTraceSpecification.test.ts new file mode 100644 index 000000000..61f903dc5 --- /dev/null +++ b/tests/composition/process-capture/processTraceSpecification.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from "vitest"; +import { + processTraceSpecificationSchema, + type ProcessTraceSpecification, +} from "../../../src/domain/processTraceComparison.js"; +const terminal = { sequence: 0, at_ms: 900, data: "Ready" }; +const processStarted = { + at_ms: 1, + pid: 1, + parent_pid: 0, + command: "worker", + process_group_id: 1, + session_id: 1, +}; +const http = { + sequence: 0, + at_ms: 2, + protocol: "http" as const, + direction: "request" as const, + method: "GET", + path: "/status", + data: "", + outcome: "unmatched" as const, +}; +const websocket = { + sequence: 1, + at_ms: 3, + protocol: "websocket" as const, + direction: "received" as const, + method: null, + path: "/ws", + data: "done", + outcome: "matched" as const, +}; +const partialSpecification = (): ProcessTraceSpecification => ({ + version: 1, + events: [ + { + id: "ready", + source: "terminal_raw", + exact: terminal, + cardinality: { kind: "required" }, + }, + { + id: "worker", + source: "process", + exact: processStarted, + cardinality: { kind: "required" }, + }, + { + id: "status", + source: "http", + exact: http, + cardinality: { kind: "required" }, + }, + { + id: "done", + source: "websocket", + exact: websocket, + cardinality: { kind: "required" }, + }, + ], + language: { + kind: "partial_order", + happens_before: [ + { before: "ready", after: "worker" }, + { before: "ready", after: "status" }, + { before: "worker", after: "done" }, + { before: "status", after: "done" }, + ], + not_before: [{ event: "done", anchor: "ready" }], + unordered_groups: [{ events: ["worker", "status"] }], + prefix: ["ready"], + suffix: ["done"], + }, +}); +describe("process trace specification", () => { + it("rejects cycles, implicit concurrency, overlap, and unsatisfiable variants", () => { + const base = partialSpecification(); + expect( + processTraceSpecificationSchema.safeParse({ + ...base, + language: { + kind: "partial_order", + happens_before: [ + { before: "ready", after: "worker" }, + { before: "worker", after: "ready" }, + ], + unordered_groups: [{ events: ["status", "done"] }], + }, + }).success, + ).toBe(false); + expect( + processTraceSpecificationSchema.safeParse({ + version: 1, + events: base.events.slice(0, 3), + language: { + kind: "partial_order", + happens_before: [{ before: "ready", after: "status" }], + not_before: [{ event: "worker", anchor: "ready" }], + unordered_groups: [], + }, + }).success, + ).toBe(false); + expect( + processTraceSpecificationSchema.safeParse({ + version: 1, + events: [ + { + id: "invalid-ignore", + source: "terminal_raw", + exact: "ready", + ignore_fields: ["at_ms"], + }, + ], + language: { + kind: "finite_traces", + variants: [{ id: "one", trace: ["invalid-ignore"] }], + }, + }).success, + ).toBe(false); + expect( + processTraceSpecificationSchema.safeParse({ + version: 1, + events: [ + { + id: "invalid-ignore", + source: "terminal_raw", + exact: terminal, + ignore_fields: ["at_ms"], + }, + ], + language: { + kind: "finite_traces", + variants: [{ id: "one", trace: ["invalid-ignore"] }], + }, + }).success, + ).toBe(false); + expect( + processTraceSpecificationSchema.safeParse({ + version: 1, + events: base.events.slice(0, 2), + language: { kind: "partial_order" }, + }).success, + ).toBe(false); + expect( + processTraceSpecificationSchema.safeParse({ + version: 1, + events: [base.events[0], { ...base.events[0], id: "duplicate" }], + language: { + kind: "partial_order", + unordered_groups: [{ events: ["ready", "duplicate"] }], + }, + }).success, + ).toBe(false); + expect( + processTraceSpecificationSchema.safeParse({ + version: 1, + events: [ + { + ...base.events[0], + cardinality: { kind: "exact", count: 2 }, + }, + ], + language: { + kind: "finite_traces", + variants: [{ id: "once", trace: ["ready"] }], + }, + }).success, + ).toBe(false); + expect( + processTraceSpecificationSchema.safeParse({ + version: 1, + events: [base.events[0]], + language: { + kind: "finite_traces", + variants: [ + { id: "first", trace: ["ready"] }, + { id: "duplicate", trace: ["ready"] }, + ], + }, + }).success, + ).toBe(false); + }); +}); diff --git a/tests/sensitiveTextCapture.test.ts b/tests/composition/process-capture/sensitiveTextCapture.test.ts similarity index 93% rename from tests/sensitiveTextCapture.test.ts rename to tests/composition/process-capture/sensitiveTextCapture.test.ts index 6d00acb11..e1592a1d8 100644 --- a/tests/sensitiveTextCapture.test.ts +++ b/tests/composition/process-capture/sensitiveTextCapture.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { boundedSensitiveText, redactSensitiveText, -} from "../src/browser/SensitiveTextCapture.js"; +} from "../../../src/browser/SensitiveTextCapture.js"; describe("sensitive console text", () => { it("redacts assignments, bearer credentials, and JWT-shaped values", () => { diff --git a/tests/browserObservation.test.ts b/tests/composition/runtime-observation/browserObservation.test.ts similarity index 93% rename from tests/browserObservation.test.ts rename to tests/composition/runtime-observation/browserObservation.test.ts index cf9aa85c8..4b6ba9554 100644 --- a/tests/browserObservation.test.ts +++ b/tests/composition/runtime-observation/browserObservation.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; -import { listBrowserTargets } from "../src/application/BrowserObservationService.js"; -import { createPermissionAuthority } from "../src/application/PermissionAuthority.js"; -import { CdpCaptureCompleteness } from "../src/browser/CdpCaptureCompleteness.js"; -import { CdpCaptureEvents } from "../src/browser/CdpCaptureEvents.js"; -import { ingestElectronScriptEvent } from "../src/browser/CdpElectronScriptEvents.js"; +import { listBrowserTargets } from "../../../src/application/BrowserObservationService.js"; +import { createPermissionAuthority } from "../../../src/application/PermissionAuthority.js"; +import { CdpCaptureCompleteness } from "../../../src/browser/CdpCaptureCompleteness.js"; +import { CdpCaptureEvents } from "../../../src/browser/CdpCaptureEvents.js"; +import { ingestElectronScriptEvent } from "../../../src/browser/CdpElectronScriptEvents.js"; import { browserEndpointSchema, browserOriginSchema, @@ -12,8 +12,8 @@ import { isLiteralLoopbackHostname, listBrowserTargetsInputSchema, sanitizeBrowserUrl, -} from "../src/domain/browserObservation.js"; -import { captureWebScreenshotInputSchema } from "../src/domain/webScreenshot.js"; +} from "../../../src/domain/browserObservation.js"; +import { captureWebScreenshotInputSchema } from "../../../src/domain/webScreenshot.js"; describe("browser observation contracts", () => { it("normalizes exact HTTP origins without accepting broader URL scopes", () => { @@ -155,7 +155,9 @@ describe("browser observation contracts", () => { }, }); }); +}); +describe("browser observation sensitive surfaces and retention", () => { it("requires independent approval for each sensitive capture surface", () => { const base = { cdp_endpoint: "http://127.0.0.1:9222", diff --git a/tests/browserScenario.test.ts b/tests/composition/runtime-observation/browserScenario.test.ts similarity index 98% rename from tests/browserScenario.test.ts rename to tests/composition/runtime-observation/browserScenario.test.ts index 1b12e9a02..fe7f9509b 100644 --- a/tests/browserScenario.test.ts +++ b/tests/composition/runtime-observation/browserScenario.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { BrowserScenarioSecrets } from "../src/browser/BrowserScenarioSecrets.js"; -import { browserScenarioSchema } from "../src/domain/browserScenario.js"; +import { BrowserScenarioSecrets } from "../../../src/browser/BrowserScenarioSecrets.js"; +import { browserScenarioSchema } from "../../../src/domain/browserScenario.js"; const secret = (secret_id: string) => ({ source: "secret", secret_id }); const literal = (value: string) => ({ diff --git a/tests/browserScenarioDiff.test.ts b/tests/composition/runtime-observation/browserScenarioDiff.test.ts similarity index 97% rename from tests/browserScenarioDiff.test.ts rename to tests/composition/runtime-observation/browserScenarioDiff.test.ts index 7d4ef3d34..808481f7f 100644 --- a/tests/browserScenarioDiff.test.ts +++ b/tests/composition/runtime-observation/browserScenarioDiff.test.ts @@ -5,11 +5,11 @@ import { describe, expect, it } from "vitest"; import { compareBrowserScenarios, compareBrowserScenariosInputSchema, -} from "../src/domain/browserScenarioDiff.js"; +} from "../../../src/domain/browserScenarioDiff.js"; import { browserScenarioCaptureSchema, type BrowserScenarioCapture, -} from "../src/domain/browserScenarioCapture.js"; +} from "../../../src/domain/browserScenarioCapture.js"; describe("browser scenario comparison", () => { it("records canonical literal normalization and ignores declared volatile fields", () => { @@ -115,7 +115,9 @@ describe("browser scenario comparison", () => { omitted: 0, }); }); +}); +describe("browser scenario comparison validation", () => { it("reports missing aligned steps without claiming equality", () => { const before = scenarioCapture({}); const after = scenarioCapture({ stepId: "open-profile" }); diff --git a/tests/electronFileScope.test.ts b/tests/composition/runtime-observation/electronFileScope.test.ts similarity index 92% rename from tests/electronFileScope.test.ts rename to tests/composition/runtime-observation/electronFileScope.test.ts index bca210979..bb5c0f991 100644 --- a/tests/electronFileScope.test.ts +++ b/tests/composition/runtime-observation/electronFileScope.test.ts @@ -4,12 +4,12 @@ import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { authorizedElectronFile, canonicalElectronRoots, -} from "../src/browser/ElectronFileScope.js"; +} from "../../../src/browser/ElectronFileScope.js"; describe("Electron file scope", () => { const temporary: string[] = []; diff --git a/tests/composition/runtime-observation/javascriptRuntimeReconciliation.test.ts b/tests/composition/runtime-observation/javascriptRuntimeReconciliation.test.ts new file mode 100644 index 000000000..0e13a0d65 --- /dev/null +++ b/tests/composition/runtime-observation/javascriptRuntimeReconciliation.test.ts @@ -0,0 +1,374 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { afterEach, expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +import { createElectronEvidence } from "../../../src/application/ElectronEvidence.js"; +import { analyzeJavaScriptApplication } from "../../../src/application/JavaScriptApplicationService.js"; +import { reconcileJavaScriptRuntime } from "../../../src/domain/javascriptRuntimeReconciliation.js"; +import { javascriptRuntimeReconciliationResultSchema } from "../../../src/domain/javascriptRuntimeReconciliationSchemas.js"; +import { createWebTextArtifact } from "../../../src/domain/webContentArtifact.js"; +import { permissionAuthorityForRoot } from "../../fixtures/permissionAuthority.js"; + +const SOURCE = `const worker = new Worker("./worker.js");\nexport const observed = worker;\n`; + +const temporary: string[] = []; + +afterEach(async () => { + await Promise.all( + temporary + .splice(0) + .map(async (path) => rm(path, { recursive: true, force: true })), + ); +}); + +it("matches renderer, frame, script bytes, and worker without claiming execution", async () => { + const fixture = await applicationFixture(); + temporary.push(fixture); + const staticEvidence = await analyzeFixture(fixture); + const runtimeEvidence = electronRuntimeEvidence(fixture, SOURCE); + + const result = reconcileJavaScriptRuntime({ + static_layers: [{ role: "application", analysis: staticEvidence }], + runtime_observations: [runtimeEvidence], + }); + + expect(() => + javascriptRuntimeReconciliationResultSchema.parse(result), + ).not.toThrow(); + expect(result.summary).toMatchObject({ + runtime_targets: 1, + runtime_frames: 1, + runtime_scripts: 1, + runtime_workers: 1, + matched: 2, + ambiguous: 2, + unmatched: 0, + }); + expect(result.reconciliations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + entity_kind: "script", + status: "matched", + basis: "content-and-location", + }), + expect.objectContaining({ + entity_kind: "worker", + status: "matched", + basis: "artifact-path", + }), + expect.objectContaining({ + entity_kind: "target", + status: "ambiguous", + reason: "ambiguous-static-candidates", + }), + ]), + ); + expect(result.static_load_states).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "javascript-asset", + status: "loaded", + }), + ]), + ); + expect(result.limitations.join(" ")).toMatch( + /not reported as executed|not code reachability/u, + ); + expect( + result.graph.edges + .filter(({ relation }) => relation === "observed_as") + .every( + ({ evidence }) => + evidence.authority === "cross-layer-reconciliation" && + evidence.state === "inferred", + ), + ).toBe(true); + const worker = result.reconciliations.find( + ({ entity_kind: kind }) => kind === "worker", + ); + const frame = result.reconciliations.find( + ({ entity_kind: kind }) => kind === "frame", + ); + expect(result.graph.edges).toContainEqual( + expect.objectContaining({ + source_node_id: frame?.runtime_node_id, + target_node_id: worker?.runtime_node_id, + relation: "contains", + }), + ); +}); + +it("reports a captured digest disagreement instead of accepting a path match", async () => { + const fixture = await applicationFixture(); + temporary.push(fixture); + const staticEvidence = await analyzeFixture(fixture); + const runtimeEvidence = electronRuntimeEvidence( + fixture, + "export const observed = 'different';\n", + ); + + const result = reconcileJavaScriptRuntime({ + static_layers: [{ role: "application", analysis: staticEvidence }], + runtime_observations: [runtimeEvidence], + }); + const script = result.reconciliations.find( + ({ entity_kind: kind }) => kind === "script", + ); + + expect(script).toMatchObject({ + status: "unmatched", + reason: "captured-content-disagrees-with-static-location", + }); + expect(script?.candidate_static_nodes).toHaveLength(1); +}); + +it("keeps source-map declarations outside primary matching authority", async () => { + const fixture = await applicationFixture(); + temporary.push(fixture); + const staticEvidence = await analyzeFixture(fixture); + const runtimeEvidence = electronRuntimeEvidence(fixture, SOURCE); + const result = reconcileJavaScriptRuntime({ + static_layers: [{ role: "application", analysis: staticEvidence }], + runtime_observations: [runtimeEvidence], + }); + + expect(result.source_map_authority).toMatchObject({ + used_for_primary_matching: false, + static_layers_with_read_approval: 0, + runtime_script_declarations: 0, + }); +}); + +it("imports an operator-provided cache layer through an explicit file mapping", async () => { + const application = await applicationFixture(); + const cache = await createTestTempDirectory("rea-runtime-cache-static-"); + const runtimeCache = await createTestTempDirectory("rea-runtime-cache-live-"); + temporary.push(application, cache, runtimeCache); + const cacheSource = "export const cachedFeature = 'fixture';\n"; + await mkdir(join(cache, "mapped")); + await Promise.all([ + writeFile(join(cache, "mapped", "chunk.js"), cacheSource), + writeFile(join(cache, "outside.js"), "export const outside = true;\n"), + writeFile(join(runtimeCache, "chunk.js"), cacheSource), + writeFile(join(runtimeCache, "index.html"), ""), + ]); + const applicationEvidence = await analyzeFixture(application); + const cacheEvidence = await analyzeFixture(cache); + const runtimeEvidence = electronRuntimeEvidence(runtimeCache, cacheSource, { + scriptFile: "chunk.js", + includeWorker: false, + }); + + const result = reconcileJavaScriptRuntime({ + static_layers: [ + { role: "application", analysis: applicationEvidence }, + { + role: "cache", + analysis: cacheEvidence, + runtime_mappings: [ + { + kind: "file-root", + root: runtimeCache, + artifact_prefix: "mapped", + }, + ], + }, + ], + runtime_observations: [runtimeEvidence], + }); + const script = result.reconciliations.find( + ({ entity_kind: kind }) => kind === "script", + ); + const cacheLayer = result.static_layers.find(({ role }) => role === "cache"); + + expect(script).toMatchObject({ + status: "matched", + basis: "content-and-location", + static_layer_id: cacheLayer?.layer_id, + }); + const outside = result.graph.nodes.find( + (node) => + node.kind === "javascript-asset" && + node.observations.some( + ({ properties }) => properties.path === "outside.js", + ), + ); + expect( + result.static_load_states.find( + ({ static_node_id: nodeId }) => nodeId === outside?.node_id, + ), + ).toMatchObject({ + status: "unknown", + reason: "layer-outside-runtime-scope", + }); +}); + +const applicationFixture = async (): Promise => { + const root = await createTestTempDirectory("rea-runtime-reconciliation-"); + await Promise.all([ + writeFile( + join(root, "package.json"), + JSON.stringify({ + name: "rea-runtime-reconciliation-fixture", + version: "1.0.0", + renderer: "index.html", + }), + ), + writeFile(join(root, "index.html"), ""), + writeFile(join(root, "app.js"), SOURCE), + writeFile(join(root, "worker.js"), "self.onmessage = () => undefined;\n"), + ]); + return root; +}; + +const analyzeFixture = async (root: string) => { + const authority = await permissionAuthorityForRoot( + root, + ["investigation_input"], + ["investigation_input"], + ); + const result = await analyzeJavaScriptApplication(authority, { + input_path: root, + approved: true, + }); + if (!result.ok) throw result.error; + return result.value; +}; + +const electronRuntimeEvidence = ( + root: string, + source: string, + options: { + readonly scriptFile?: string; + readonly includeWorker?: boolean; + readonly targetId?: string; + readonly sourceCaptureApproved?: boolean; + readonly workersUnavailable?: boolean; + } = {}, +) => { + const scriptFile = options.scriptFile ?? "app.js"; + const includeWorker = options.includeWorker ?? true; + const targetId = options.targetId ?? "target-main"; + const sourceCaptureApproved = options.sourceCaptureApproved ?? true; + const input = { + cdp_endpoint: "http://127.0.0.1:9223", + allowed_file_roots: [root], + target_id: targetId, + approved: true as const, + observation_ms: 100, + include_script_sources: sourceCaptureApproved, + source_capture_approved: sourceCaptureApproved, + limits: { + max_frames: 200, + max_dom_nodes: 2_000, + max_scripts: 500, + max_resources: 2_000, + max_workers: 500, + max_script_source_bytes: 1_048_576, + max_total_script_source_bytes: 4_194_304, + }, + }; + return createElectronEvidence( + "inspect_electron_page", + input, + { + schema_version: 1, + browser: { + product: "Electron/fixture", + protocol_version: "1.3", + revision: "fixture", + user_agent: "Electron fixture", + js_version: "13", + }, + target: { + target_id: targetId, + type: "page", + title: "Fixture", + file_path: join(root, "index.html"), + attached: false, + }, + capture_window: { + started_at: "2026-07-15T00:00:00.000Z", + ended_at: "2026-07-15T00:00:00.100Z", + observation_ms: 100, + }, + completeness: options.workersUnavailable + ? { + ...completeCapture(), + status: "attach_limited" as const, + conditions: ["attach_limited" as const], + attach_limited_sections: ["workers" as const], + unavailable_sections: ["workers" as const], + } + : completeCapture(), + frames: [ + { + frame_id: "frame-main", + parent_frame_id: null, + file_path: join(root, "index.html"), + }, + ], + dom: { total_nodes: 0, nodes: [] }, + scripts: { + total: 1, + items: [ + { + script_key: `electron_script_${"1".repeat(64)}`, + frame_id: "frame-main", + file_path: join(root, scriptFile), + cdp_hash: "fixture", + length: Buffer.byteLength(source), + is_module: true, + language: "JavaScript", + source: { + included: true as const, + artifact: createWebTextArtifact(source, "text/javascript"), + }, + }, + ], + }, + resources: [], + workers: includeWorker + ? [ + { + target_id: "worker-main", + type: "worker", + file_path: join(root, "worker.js"), + attached: false, + opener_target_id: targetId, + parent_frame_id: "frame-main", + }, + ] + : [], + limitations: ["Synthetic passive capture fixture."], + }, + { + id: "rea-cdp-electron", + name: "REA Electron file-page CDP observation provider", + version: "1", + }, + ); +}; + +const completeCapture = () => ({ + status: "complete_within_window" as const, + conditions: ["complete_within_window" as const], + policy_filtered_sections: [], + attach_limited_sections: [], + truncated_sections: [], + unavailable_sections: [], + excluded: [], + dropped_events: { + scripts: 0, + network_requests: 0, + console_events: 0, + websocket_connections: 0, + websocket_frames: 0, + webmcp_tools: 0, + timeline_events: 0, + total: 0, + }, +}); diff --git a/tests/managedRuntimeCorrelation.test.ts b/tests/composition/runtime-observation/managedRuntimeCorrelation.test.ts similarity index 90% rename from tests/managedRuntimeCorrelation.test.ts rename to tests/composition/runtime-observation/managedRuntimeCorrelation.test.ts index 319065a14..cd5949444 100644 --- a/tests/managedRuntimeCorrelation.test.ts +++ b/tests/composition/runtime-observation/managedRuntimeCorrelation.test.ts @@ -4,20 +4,20 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { planManagedRuntimeCorrelationEvidence, type ManagedRuntimePolicy, -} from "../src/application/ManagedRuntimeCorrelationService.js"; -import { MANAGED_STATIC_PROVIDER } from "../src/application/InvestigationProviders.js"; -import { createPermissionAuthority } from "../src/application/PermissionAuthority.js"; -import { createEvidence } from "../src/domain/evidence.js"; -import { managedRuntimeCorrelationResultSchema } from "../src/domain/managedRuntimeCorrelation.js"; -import type { BinaryTarget } from "../src/domain/binaryTarget.js"; -import type { PermissionCeiling } from "../src/domain/permissionPolicy.js"; -import { inspectManagedMembersBytes } from "../src/dotnet/ManagedMemberInspector.js"; -import { buildManagedPeFixture } from "./fixtures/managedPe.js"; +} from "../../../src/application/ManagedRuntimeCorrelationService.js"; +import { MANAGED_STATIC_PROVIDER } from "../../../src/application/InvestigationProviders.js"; +import { createPermissionAuthority } from "../../../src/application/PermissionAuthority.js"; +import { createEvidence } from "../../../src/domain/evidence.js"; +import { managedRuntimeCorrelationResultSchema } from "../../../src/domain/managedRuntimeCorrelation.js"; +import type { BinaryTarget } from "../../../src/domain/binaryTarget.js"; +import type { PermissionCeiling } from "../../../src/domain/permissionPolicy.js"; +import { inspectManagedMembersBytes } from "../../../src/dotnet/ManagedMemberInspector.js"; +import { buildManagedPeFixture } from "../../fixtures/managedPe.js"; const memberLimits = { typeOffset: 0, diff --git a/tests/nodeRuntimeCharacterization.test.ts b/tests/composition/runtime-observation/nodeRuntimeCharacterization.test.ts similarity index 94% rename from tests/nodeRuntimeCharacterization.test.ts rename to tests/composition/runtime-observation/nodeRuntimeCharacterization.test.ts index 69975853d..36c7cdc3c 100644 --- a/tests/nodeRuntimeCharacterization.test.ts +++ b/tests/composition/runtime-observation/nodeRuntimeCharacterization.test.ts @@ -4,23 +4,23 @@ import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { executeNodeCharacterization, prepareNodeCharacterization, -} from "../src/application/NodeRuntimeCharacterizationService.js"; +} from "../../../src/application/NodeRuntimeCharacterizationService.js"; import type { JavaScriptReplayHost, JavaScriptReplayPolicy, JavaScriptReplayRunner, -} from "../src/application/JavaScriptReplayPlanning.js"; -import { PermissionAuthority } from "../src/application/PermissionAuthority.js"; -import { createPermissionPolicy } from "../src/domain/permissionPolicy.js"; +} from "../../../src/application/JavaScriptReplayPlanning.js"; +import { PermissionAuthority } from "../../../src/application/PermissionAuthority.js"; +import { createPermissionPolicy } from "../../../src/domain/permissionPolicy.js"; import { nodeCharacterizationPreparationInputSchema, nodeCharacterizationPreparationOutputSchema, -} from "../src/domain/nodeRuntimeCharacterization.js"; +} from "../../../src/domain/nodeRuntimeCharacterization.js"; const roots: string[] = []; const sha256 = (value: Uint8Array): string => diff --git a/tests/pngVisualDiff.test.ts b/tests/composition/runtime-observation/pngVisualDiff.test.ts similarity index 95% rename from tests/pngVisualDiff.test.ts rename to tests/composition/runtime-observation/pngVisualDiff.test.ts index 7ffc0b1c3..764df4391 100644 --- a/tests/pngVisualDiff.test.ts +++ b/tests/composition/runtime-observation/pngVisualDiff.test.ts @@ -2,11 +2,11 @@ import { deflateSync } from "node:zlib"; import { describe, expect, it } from "vitest"; -import { comparePngScreenshots } from "../src/browser/PngVisualDiff.js"; +import { comparePngScreenshots } from "../../../src/browser/PngVisualDiff.js"; import { compareWebScreenshotsInputSchema, createWebScreenshotArtifact, -} from "../src/domain/webScreenshot.js"; +} from "../../../src/domain/webScreenshot.js"; describe("PNG visual diff", () => { it("reports exact changed-pixel and channel metrics", () => { diff --git a/tests/replayMachine.test.ts b/tests/composition/runtime-observation/replayMachine.test.ts similarity index 98% rename from tests/replayMachine.test.ts rename to tests/composition/runtime-observation/replayMachine.test.ts index 55728c82d..47bcec71e 100644 --- a/tests/replayMachine.test.ts +++ b/tests/composition/runtime-observation/replayMachine.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { replayMachineSchema } from "../src/domain/replayMachine.js"; +import { replayMachineSchema } from "../../../src/domain/replayMachine.js"; const loginMachine = () => ({ initial_state: "waiting_for_login", @@ -158,7 +158,9 @@ describe("replay machine", () => { /secret alias/u, ); }); +}); +describe("replay machine validation", () => { it("rejects sensitive captures sourced from persisted action JSON", () => { const candidate = loginMachine(); const capture = candidate.transitions[0]?.captures?.[0]; diff --git a/tests/runtimeCharacterization.test.ts b/tests/composition/runtime-observation/runtimeCharacterization.test.ts similarity index 97% rename from tests/runtimeCharacterization.test.ts rename to tests/composition/runtime-observation/runtimeCharacterization.test.ts index 4d6bf613b..0dec2ea61 100644 --- a/tests/runtimeCharacterization.test.ts +++ b/tests/composition/runtime-observation/runtimeCharacterization.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { createRuntimeCharacterizationPlan, parseRuntimeCharacterizationPlan, -} from "../src/domain/runtimeCharacterization.js"; +} from "../../../src/domain/runtimeCharacterization.js"; const digest = (character: string): string => character.repeat(64); diff --git a/tests/runtimeIdentification.test.ts b/tests/composition/runtime-observation/runtimeIdentification.test.ts similarity index 94% rename from tests/runtimeIdentification.test.ts rename to tests/composition/runtime-observation/runtimeIdentification.test.ts index 63269ad05..a7e96e36c 100644 --- a/tests/runtimeIdentification.test.ts +++ b/tests/composition/runtime-observation/runtimeIdentification.test.ts @@ -9,14 +9,14 @@ import { } from "@zip.js/zip.js"; import { describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { runProviderAnalysis } from "../src/application/DirectAnalysis.js"; -import { parseEvidence } from "../src/domain/evidence.js"; +import { runProviderAnalysis } from "../../../src/application/DirectAnalysis.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; import { identifyRuntimes, runtimeIdentificationResultSchema, -} from "../src/domain/runtimeIdentification.js"; +} from "../../../src/domain/runtimeIdentification.js"; describe("runtime identification", () => { it("identifies APK runtime families and exposes missing semantic providers", async () => { diff --git a/tests/staticRuntimeCorrelation.test.ts b/tests/composition/runtime-observation/staticRuntimeCorrelation.test.ts similarity index 96% rename from tests/staticRuntimeCorrelation.test.ts rename to tests/composition/runtime-observation/staticRuntimeCorrelation.test.ts index a040f291c..ca510fc7f 100644 --- a/tests/staticRuntimeCorrelation.test.ts +++ b/tests/composition/runtime-observation/staticRuntimeCorrelation.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; -import { INVESTIGATION_EXAMPLES } from "../src/contracts/investigationExamples.js"; -import { createEvidence, type Evidence } from "../src/domain/evidence.js"; -import type { JsonValue } from "../src/domain/jsonValue.js"; +import { INVESTIGATION_EXAMPLES } from "../../../src/contracts/investigationExamples.js"; +import { createEvidence, type Evidence } from "../../../src/domain/evidence.js"; +import type { JsonValue } from "../../../src/domain/jsonValue.js"; import { correlateStaticAndRuntime, staticRuntimeCorrelationResultSchema, -} from "../src/domain/staticRuntimeCorrelation.js"; +} from "../../../src/domain/staticRuntimeCorrelation.js"; const staticComparison = INVESTIGATION_EXAMPLES.find_changed_behavior.comparisons[0]; diff --git a/tests/webCaptureDiff.test.ts b/tests/composition/runtime-observation/webCaptureDiff.test.ts similarity index 95% rename from tests/webCaptureDiff.test.ts rename to tests/composition/runtime-observation/webCaptureDiff.test.ts index 37f7c9a48..877907321 100644 --- a/tests/webCaptureDiff.test.ts +++ b/tests/composition/runtime-observation/webCaptureDiff.test.ts @@ -1,26 +1,24 @@ import { afterEach, describe, expect, it } from "vitest"; -import { CdpBrowserProvider } from "../src/browser/CdpBrowserProvider.js"; -import { inspectWebPageInputSchema } from "../src/domain/browserObservation.js"; +import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; +import { inspectWebPageInputSchema } from "../../../src/domain/browserObservation.js"; import { compareWebCaptures, compareWebCapturesInputSchema, webCaptureDiffSchema, -} from "../src/domain/webCaptureDiff.js"; +} from "../../../src/domain/webCaptureDiff.js"; import { startFakeCdpBrowser, type FakeCdpBrowser, -} from "./fixtures/fakeCdpBrowser.js"; +} from "../../fixtures/fakeCdpBrowser.js"; + +const browsers: FakeCdpBrowser[] = []; + +afterEach(async () => { + await Promise.all(browsers.splice(0).map(async (browser) => browser.close())); +}); describe("web capture diff", () => { - const browsers: FakeCdpBrowser[] = []; - - afterEach(async () => { - await Promise.all( - browsers.splice(0).map(async (browser) => browser.close()), - ); - }); - it("reports stable observed changes while preserving unknown dimensions", async () => { const browser = await startFakeCdpBrowser(); browsers.push(browser); @@ -137,7 +135,9 @@ describe("web capture diff", () => { expect(result.dimensions.network.status).toBe("unknown"); expect(result.dimensions.network.total_changes).toBe(0); }); +}); +describe("web capture diff semantics and fingerprints", () => { it("detects accessibility semantics and storage inventory changes", async () => { const browser = await startFakeCdpBrowser({ extraCollections: true }); browsers.push(browser); @@ -211,7 +211,9 @@ describe("web capture diff", () => { changes: [expect.objectContaining({ change: "added" })], }); }); +}); +describe("web capture diff completeness", () => { it("uses complete redacted fingerprints but keeps incomplete evidence unknown", async () => { const browser = await startFakeCdpBrowser({ extraCollections: true }); browsers.push(browser); diff --git a/tests/composition/setup-lifecycle/browserConfig.test.ts b/tests/composition/setup-lifecycle/browserConfig.test.ts new file mode 100644 index 000000000..c500bcdc2 --- /dev/null +++ b/tests/composition/setup-lifecycle/browserConfig.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; + +import { parseConfig } from "../../../src/config.js"; + +describe("browser runtime configuration", () => { + it("builds an exact-origin browser observation ceiling only when enabled", () => { + const result = parseConfig({ + REA_BROWSER_OBSERVE_ENABLED: "true", + REA_BROWSER_CDP_ENDPOINTS_JSON: '["http://127.0.0.1:9222"]', + REA_BROWSER_ALLOWED_ORIGINS_JSON: + '["https://app.example.test", "http://127.0.0.1:3000"]', + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.permissionCeilings).toContainEqual({ + capability: "browser_observe", + roots: [], + executables: [], + environment_names: [], + origins: [ + "http://127.0.0.1:9222", + "http://127.0.0.1:3000", + "https://app.example.test", + ], + network: "external", + mount: false, + }); + expect(result.value.administratorPermissionGrants).toContainEqual( + expect.objectContaining({ + capability: "browser_observe", + lifetime: "administrator", + }), + ); + }); + + it("classifies IPv6 browser origins as loopback network scope", () => { + const result = parseConfig({ + REA_BROWSER_OBSERVE_ENABLED: "true", + REA_BROWSER_CDP_ENDPOINTS_JSON: '["http://127.0.0.1:9222"]', + REA_BROWSER_ALLOWED_ORIGINS_JSON: '["http://[::1]:3000"]', + }); + if (!result.ok) throw result.error; + expect(result.value.permissionCeilings).toContainEqual( + expect.objectContaining({ + capability: "browser_observe", + network: "loopback", + }), + ); + }); + + it("requires an explicit grant for exact-scope browser automation", () => { + const environment = { + REA_BROWSER_SCENARIO_ENABLED: "true", + REA_BROWSER_SCENARIO_EXECUTABLE_ROOTS_JSON: '["/opt/chromium"]', + REA_BROWSER_SCENARIO_CDP_ENDPOINTS_JSON: '["http://127.0.0.1:9222"]', + REA_BROWSER_SCENARIO_ALLOWED_ORIGINS_JSON: '["https://app.example.test"]', + REA_BROWSER_SCENARIO_ALLOWED_ENV_JSON: '["REA_TEST_PASSWORD"]', + }; + const result = parseConfig(environment); + if (!result.ok) throw result.error; + expect(result.value.browserScenarioPolicy).toEqual({ + enabled: true, + executableRoots: ["/opt/chromium"], + cdpEndpoints: ["http://127.0.0.1:9222"], + allowedOrigins: ["https://app.example.test"], + allowedEnvironment: ["REA_TEST_PASSWORD"], + }); + expect(result.value.permissionCeilings).toContainEqual({ + capability: "browser_automate", + roots: [], + executables: ["/opt/chromium"], + environment_names: ["REA_TEST_PASSWORD"], + origins: ["http://127.0.0.1:9222", "https://app.example.test"], + network: "external", + mount: false, + }); + expect(result.value.administratorPermissionGrants).not.toContainEqual( + expect.objectContaining({ capability: "browser_automate" }), + ); + const automatic = parseConfig({ + ...environment, + REA_BROWSER_SCENARIO_AUTO_GRANT: "true", + }); + if (!automatic.ok) throw automatic.error; + expect(automatic.value.administratorPermissionGrants).toContainEqual( + expect.objectContaining({ + capability: "browser_automate", + lifetime: "administrator", + }), + ); + }); + + it.each([ + '["http://localhost:9222"]', + '["http://192.168.1.5:9222"]', + '["https://127.0.0.1:9222"]', + ])("rejects unsafe browser CDP endpoint scopes: %s", (encoded) => { + expect(parseConfig({ REA_BROWSER_CDP_ENDPOINTS_JSON: encoded }).ok).toBe( + false, + ); + }); + + it.each([ + '["https://*.example.test"]', + '["https://app.example.test/private"]', + '["https://user:pass@app.example.test"]', + '["file:///tmp/application.html"]', + ])("rejects non-exact browser origin scopes: %s", (encoded) => { + expect(parseConfig({ REA_BROWSER_ALLOWED_ORIGINS_JSON: encoded }).ok).toBe( + false, + ); + }); +}); diff --git a/tests/clientRegistrationStatus.test.ts b/tests/composition/setup-lifecycle/clientRegistrationStatus.test.ts similarity index 90% rename from tests/clientRegistrationStatus.test.ts rename to tests/composition/setup-lifecycle/clientRegistrationStatus.test.ts index 5d990ce3c..2f1a0ff08 100644 --- a/tests/clientRegistrationStatus.test.ts +++ b/tests/composition/setup-lifecycle/clientRegistrationStatus.test.ts @@ -3,10 +3,10 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { readClientRegistrationStatuses } from "../src/application/ClientRegistrationStatus.js"; -import { PRODUCT_IDENTITY } from "../src/identity.js"; +import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; +import { PRODUCT_IDENTITY } from "../../../src/identity.js"; describe("client registration status", () => { it("distinguishes aligned, stale, missing, and invalid registrations", async () => { diff --git a/tests/config.test.ts b/tests/composition/setup-lifecycle/config.test.ts similarity index 77% rename from tests/config.test.ts rename to tests/composition/setup-lifecycle/config.test.ts index 89a2fd955..2de544426 100644 --- a/tests/config.test.ts +++ b/tests/composition/setup-lifecycle/config.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { parseConfig } from "../src/config.js"; +import { parseConfig } from "../../../src/config.js"; describe("runtime configuration", () => { it("allows target-free startup and defaults to Hopper's documented launcher", () => { @@ -103,7 +103,9 @@ describe("runtime configuration", () => { false, ); }); +}); +describe("runtime permission configuration", () => { it("builds a separate Electron endpoint and file-root ceiling", () => { const result = parseConfig({ REA_ELECTRON_OBSERVE_ENABLED: "true", @@ -212,7 +214,9 @@ describe("runtime configuration", () => { parseConfig({ REA_JAVASCRIPT_REPLAY_NODE_PATH: "relative/node" }).ok, ).toBe(false); }); +}); +describe("runtime target configuration", () => { it("builds a no-network managed runtime ceiling only when enabled", () => { const result = parseConfig({ REA_MANAGED_RUNTIME_ENABLED: "true", @@ -310,7 +314,9 @@ describe("runtime configuration", () => { }, }); }); +}); +describe("runtime collection configuration", () => { it.each(["not-json", "{}", '["ok",1]'])( "rejects invalid loader args: %s", (encoded) => { @@ -399,112 +405,4 @@ describe("runtime configuration", () => { ).toBe(false); }, ); - - it("builds an exact-origin browser observation ceiling only when enabled", () => { - const result = parseConfig({ - REA_BROWSER_OBSERVE_ENABLED: "true", - REA_BROWSER_CDP_ENDPOINTS_JSON: '["http://127.0.0.1:9222"]', - REA_BROWSER_ALLOWED_ORIGINS_JSON: - '["https://app.example.test", "http://127.0.0.1:3000"]', - }); - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.value.permissionCeilings).toContainEqual({ - capability: "browser_observe", - roots: [], - executables: [], - environment_names: [], - origins: [ - "http://127.0.0.1:9222", - "http://127.0.0.1:3000", - "https://app.example.test", - ], - network: "external", - mount: false, - }); - expect(result.value.administratorPermissionGrants).toContainEqual( - expect.objectContaining({ - capability: "browser_observe", - lifetime: "administrator", - }), - ); - }); - - it("classifies IPv6 browser origins as loopback network scope", () => { - const result = parseConfig({ - REA_BROWSER_OBSERVE_ENABLED: "true", - REA_BROWSER_CDP_ENDPOINTS_JSON: '["http://127.0.0.1:9222"]', - REA_BROWSER_ALLOWED_ORIGINS_JSON: '["http://[::1]:3000"]', - }); - if (!result.ok) throw result.error; - expect(result.value.permissionCeilings).toContainEqual( - expect.objectContaining({ - capability: "browser_observe", - network: "loopback", - }), - ); - }); - - it("requires an explicit grant for exact-scope browser automation", () => { - const environment = { - REA_BROWSER_SCENARIO_ENABLED: "true", - REA_BROWSER_SCENARIO_EXECUTABLE_ROOTS_JSON: '["/opt/chromium"]', - REA_BROWSER_SCENARIO_CDP_ENDPOINTS_JSON: '["http://127.0.0.1:9222"]', - REA_BROWSER_SCENARIO_ALLOWED_ORIGINS_JSON: '["https://app.example.test"]', - REA_BROWSER_SCENARIO_ALLOWED_ENV_JSON: '["REA_TEST_PASSWORD"]', - }; - const result = parseConfig(environment); - if (!result.ok) throw result.error; - expect(result.value.browserScenarioPolicy).toEqual({ - enabled: true, - executableRoots: ["/opt/chromium"], - cdpEndpoints: ["http://127.0.0.1:9222"], - allowedOrigins: ["https://app.example.test"], - allowedEnvironment: ["REA_TEST_PASSWORD"], - }); - expect(result.value.permissionCeilings).toContainEqual({ - capability: "browser_automate", - roots: [], - executables: ["/opt/chromium"], - environment_names: ["REA_TEST_PASSWORD"], - origins: ["http://127.0.0.1:9222", "https://app.example.test"], - network: "external", - mount: false, - }); - expect(result.value.administratorPermissionGrants).not.toContainEqual( - expect.objectContaining({ capability: "browser_automate" }), - ); - const automatic = parseConfig({ - ...environment, - REA_BROWSER_SCENARIO_AUTO_GRANT: "true", - }); - if (!automatic.ok) throw automatic.error; - expect(automatic.value.administratorPermissionGrants).toContainEqual( - expect.objectContaining({ - capability: "browser_automate", - lifetime: "administrator", - }), - ); - }); - - it.each([ - '["http://localhost:9222"]', - '["http://192.168.1.5:9222"]', - '["https://127.0.0.1:9222"]', - ])("rejects unsafe browser CDP endpoint scopes: %s", (encoded) => { - expect(parseConfig({ REA_BROWSER_CDP_ENDPOINTS_JSON: encoded }).ok).toBe( - false, - ); - }); - - it.each([ - '["https://*.example.test"]', - '["https://app.example.test/private"]', - '["https://user:pass@app.example.test"]', - '["file:///tmp/application.html"]', - ])("rejects non-exact browser origin scopes: %s", (encoded) => { - expect(parseConfig({ REA_BROWSER_ALLOWED_ORIGINS_JSON: encoded }).ok).toBe( - false, - ); - }); }); diff --git a/tests/directAnalysisPermissions.test.ts b/tests/composition/setup-lifecycle/directAnalysisPermissions.test.ts similarity index 79% rename from tests/directAnalysisPermissions.test.ts rename to tests/composition/setup-lifecycle/directAnalysisPermissions.test.ts index f07647c0c..691a700b0 100644 --- a/tests/directAnalysisPermissions.test.ts +++ b/tests/composition/setup-lifecycle/directAnalysisPermissions.test.ts @@ -3,25 +3,25 @@ import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { writeAnalysisSnapshot } from "../src/application/AnalysisSnapshotFiles.js"; -import { runDirectAnalysis } from "../src/application/DirectAnalysis.js"; -import type { AnalysisSnapshot } from "../src/domain/analysisSnapshot.js"; +import { writeAnalysisSnapshot } from "../../../src/application/AnalysisSnapshotFiles.js"; +import { runDirectAnalysis } from "../../../src/application/DirectAnalysis.js"; +import type { AnalysisSnapshot } from "../../../src/domain/analysisSnapshot.js"; import { snapshotBinding, snapshotTarget, -} from "../src/domain/analysisSnapshot.js"; -import { parseBinaryTarget } from "../src/domain/binaryTarget.js"; -import { createEvidence } from "../src/domain/evidence.js"; -import { createEvidenceBundle } from "../src/domain/evidenceBundle.js"; -import { permissionAuthorityForRoot } from "./fixtures/permissionAuthority.js"; +} from "../../../src/domain/analysisSnapshot.js"; +import { parseBinaryTarget } from "../../../src/domain/binaryTarget.js"; +import { createEvidence } from "../../../src/domain/evidence.js"; +import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; +import { permissionAuthorityForRoot } from "../../fixtures/permissionAuthority.js"; import { REA_WORKFLOW_PROVIDER, workflowAnalysisProfile, -} from "../src/application/InvestigationProviders.js"; -import { HOPPER_PROVIDER_IDENTITY } from "../src/hopper/HopperProvider.js"; -import { resolveHopperAnalysisProfile } from "../src/hopper/HopperAnalysisProfile.js"; +} from "../../../src/application/InvestigationProviders.js"; +import { HOPPER_PROVIDER_IDENTITY } from "../../../src/hopper/HopperProvider.js"; +import { resolveHopperAnalysisProfile } from "../../../src/hopper/HopperAnalysisProfile.js"; let directory: string | undefined; diff --git a/tests/permissionAuthority.test.ts b/tests/composition/setup-lifecycle/permissionAuthority.test.ts similarity index 97% rename from tests/permissionAuthority.test.ts rename to tests/composition/setup-lifecycle/permissionAuthority.test.ts index 9a72b3711..3d19ddcdf 100644 --- a/tests/permissionAuthority.test.ts +++ b/tests/composition/setup-lifecycle/permissionAuthority.test.ts @@ -2,20 +2,20 @@ import { mkdir, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { canonicalizePermissionCeilings, canonicalizePermissionRequest, createPermissionAuthority, type PermissionAuthority, -} from "../src/application/PermissionAuthority.js"; +} from "../../../src/application/PermissionAuthority.js"; import { createPermissionPolicy, evaluatePermission, type PermissionGrant, type PermissionScope, -} from "../src/domain/permissionPolicy.js"; +} from "../../../src/domain/permissionPolicy.js"; describe("permission authority", () => { it("distinguishes an elicitable grant from administrator reconfiguration", async () => { @@ -143,7 +143,9 @@ describe("permission authority", () => { if (result.ok) expect(result.value.roots).toEqual([join(sandbox, "new.json")]); }); +}); +describe("permission authority policy and connection lifecycle", () => { it("commits a configured policy only after every grant validates", async () => { const sandbox = await createTestTempDirectory("rea-permission-reload-"); const oldRoot = join(sandbox, "old"); @@ -252,7 +254,9 @@ describe("permission authority", () => { ok: true, }); }); +}); +describe("permission authority policy reload", () => { it("applies configured policy reloads to existing connections", async () => { const sandbox = await createTestTempDirectory( "rea-permission-live-reload-", diff --git a/tests/permissionPolicy.test.ts b/tests/composition/setup-lifecycle/permissionPolicy.test.ts similarity index 98% rename from tests/permissionPolicy.test.ts rename to tests/composition/setup-lifecycle/permissionPolicy.test.ts index 50c09888b..e340cd3e5 100644 --- a/tests/permissionPolicy.test.ts +++ b/tests/composition/setup-lifecycle/permissionPolicy.test.ts @@ -10,7 +10,7 @@ import { isPathContained, reloadPermissionCeilings, revokePermission, -} from "../src/domain/permissionPolicy.js"; +} from "../../../src/domain/permissionPolicy.js"; describe("permission policy", () => { it("rejects sibling escapes for both POSIX and Windows path separators", () => { @@ -126,7 +126,9 @@ describe("permission policy", () => { missing: { origins: ["https://cdn.example.test"] }, }); }); +}); +describe("permission policy lifecycle", () => { it("consumes once grants and applies revocation and ceiling reload immediately", () => { const ceiling = { capability: "evidence_write" as const, diff --git a/tests/projectPermissionStore.test.ts b/tests/composition/setup-lifecycle/projectPermissionStore.test.ts similarity index 97% rename from tests/projectPermissionStore.test.ts rename to tests/composition/setup-lifecycle/projectPermissionStore.test.ts index 31ad2c69c..39f2e793d 100644 --- a/tests/projectPermissionStore.test.ts +++ b/tests/composition/setup-lifecycle/projectPermissionStore.test.ts @@ -3,13 +3,13 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { readProjectPermissionStore, revokeProjectPermissionGrant, writeProjectPermissionStore, -} from "../src/application/ProjectPermissionStore.js"; +} from "../../../src/application/ProjectPermissionStore.js"; describe("project permission store", () => { it("persists explicit project grants atomically with owner-only permissions", async () => { diff --git a/tests/setupConfig.test.ts b/tests/composition/setup-lifecycle/setupConfig.test.ts similarity index 95% rename from tests/setupConfig.test.ts rename to tests/composition/setup-lifecycle/setupConfig.test.ts index b8cc75a13..9890fa075 100644 --- a/tests/setupConfig.test.ts +++ b/tests/composition/setup-lifecycle/setupConfig.test.ts @@ -2,11 +2,11 @@ import { lstat, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { resolveClientConfigTransactionPath } from "../src/application/ClientConfigPath.js"; -import { configureJsonClient } from "../src/application/Setup.js"; -import { PRODUCT_IDENTITY } from "../src/identity.js"; +import { resolveClientConfigTransactionPath } from "../../../src/application/ClientConfigPath.js"; +import { configureJsonClient } from "../../../src/application/Setup.js"; +import { PRODUCT_IDENTITY } from "../../../src/identity.js"; const pinnedPackage = PRODUCT_IDENTITY.registrationPackageSpecifier; @@ -114,7 +114,9 @@ describe("JSON client configuration transaction", () => { readFile(`${configPath}.rea.backup`, "utf8"), ).rejects.toThrow(); }); +}); +describe("JSON client configuration migration and provider paths", () => { it("migrates an unversioned npx registration and preserves sibling configuration", async () => { directory = await createTestTempDirectory("rea-setup-"); const configPath = join(directory, "mcp.json"); diff --git a/tests/setupSkill.test.ts b/tests/composition/setup-lifecycle/setupSkill.test.ts similarity index 91% rename from tests/setupSkill.test.ts rename to tests/composition/setup-lifecycle/setupSkill.test.ts index 34b065dda..e862c2a9f 100644 --- a/tests/setupSkill.test.ts +++ b/tests/composition/setup-lifecycle/setupSkill.test.ts @@ -1,13 +1,13 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { canonicalSkillNeedsInstall, installCanonicalSkill, -} from "../src/application/Setup.js"; -import { TOOL_CONTRACTS } from "../src/contracts/toolContracts.js"; +} from "../../../src/application/Setup.js"; +import { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js"; const roots: string[] = []; afterEach(async () => { diff --git a/tests/statusProjection.test.ts b/tests/composition/setup-lifecycle/statusProjection.test.ts similarity index 88% rename from tests/statusProjection.test.ts rename to tests/composition/setup-lifecycle/statusProjection.test.ts index 93e6176e9..3012df8a4 100644 --- a/tests/statusProjection.test.ts +++ b/tests/composition/setup-lifecycle/statusProjection.test.ts @@ -3,11 +3,11 @@ import { describe, expect, it } from "vitest"; import { runCapabilityStatus, runProviderStatus, -} from "../src/application/DirectAnalysis.js"; -import { projectDoctorReport } from "../src/application/DoctorProjection.js"; -import { runDoctor, type DoctorHost } from "../src/application/Doctor.js"; -import { CATALOG_IDENTITY } from "../src/catalogIdentity.js"; -import { PRODUCT_IDENTITY } from "../src/identity.js"; +} from "../../../src/application/DirectAnalysis.js"; +import { projectDoctorReport } from "../../../src/application/DoctorProjection.js"; +import { runDoctor, type DoctorHost } from "../../../src/application/Doctor.js"; +import { CATALOG_IDENTITY } from "../../../src/catalogIdentity.js"; +import { PRODUCT_IDENTITY } from "../../../src/identity.js"; const doctorHost = (): DoctorHost => ({ platform: "darwin", diff --git a/tests/conformanceFixtures.test.ts b/tests/conformance/providers/conformanceFixtures.test.ts similarity index 96% rename from tests/conformanceFixtures.test.ts rename to tests/conformance/providers/conformanceFixtures.test.ts index b181f1919..225b9da39 100644 --- a/tests/conformanceFixtures.test.ts +++ b/tests/conformance/providers/conformanceFixtures.test.ts @@ -8,7 +8,7 @@ import { LARGE_FIXTURE_COUNT, sha256, sourceDigest, -} from "../scripts/lib/conformance-fixtures.mjs"; +} from "../../../scripts/lib/conformance-fixtures.mjs"; describe("source-built conformance fixtures", () => { it("grounds the Hopper semantic oracle in the source-owned C fixture", async () => { diff --git a/tests/conformancePackage.test.ts b/tests/conformance/providers/conformancePackage.test.ts similarity index 98% rename from tests/conformancePackage.test.ts rename to tests/conformance/providers/conformancePackage.test.ts index 283f6e806..208c8327b 100644 --- a/tests/conformancePackage.test.ts +++ b/tests/conformance/providers/conformancePackage.test.ts @@ -10,7 +10,7 @@ import { type ReplayPlan, type ShimPlan, type ExpectedEvidence, -} from "../src/domain/conformancePackage.js"; +} from "../../../src/domain/conformancePackage.js"; const validPackage = createConformancePackage({ schema_version: CONFORMANCE_PACKAGE_VERSION, diff --git a/tests/conformanceReplay.test.ts b/tests/conformance/providers/conformanceReplay.test.ts similarity index 97% rename from tests/conformanceReplay.test.ts rename to tests/conformance/providers/conformanceReplay.test.ts index 078997b84..201661f9b 100644 --- a/tests/conformanceReplay.test.ts +++ b/tests/conformance/providers/conformanceReplay.test.ts @@ -4,11 +4,11 @@ import { CONFORMANCE_PACKAGE_VERSION, createConformancePackage, type ConformancePackage, -} from "../src/domain/conformancePackage.js"; +} from "../../../src/domain/conformancePackage.js"; import { replayConformancePackage, type ScenarioReplayResult, -} from "../src/domain/conformanceReplay.js"; +} from "../../../src/domain/conformanceReplay.js"; const validPackage = createConformancePackage({ schema_version: CONFORMANCE_PACKAGE_VERSION, diff --git a/tests/conformanceTrustGate.test.ts b/tests/conformance/providers/conformanceTrustGate.test.ts similarity index 96% rename from tests/conformanceTrustGate.test.ts rename to tests/conformance/providers/conformanceTrustGate.test.ts index 0e03a9f07..b9254d0ba 100644 --- a/tests/conformanceTrustGate.test.ts +++ b/tests/conformance/providers/conformanceTrustGate.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; -import type { VerifierContract } from "../src/domain/conformancePackage.js"; +import type { VerifierContract } from "../../../src/domain/conformancePackage.js"; import { compareDimension, evaluateTrustGate, isSemanticDimension, isVolatileDimension, -} from "../src/domain/conformanceTrustGate.js"; +} from "../../../src/domain/conformanceTrustGate.js"; const contract: VerifierContract = { scenario_id: "s1", diff --git a/tests/coverageMonotonicity.test.ts b/tests/conformance/providers/coverageMonotonicity.test.ts similarity index 94% rename from tests/coverageMonotonicity.test.ts rename to tests/conformance/providers/coverageMonotonicity.test.ts index 203020b41..7d712ccd4 100644 --- a/tests/coverageMonotonicity.test.ts +++ b/tests/conformance/providers/coverageMonotonicity.test.ts @@ -3,20 +3,20 @@ import { createHash } from "node:crypto"; import { fc, it } from "@fast-check/vitest"; import { describe, expect } from "vitest"; -import type { BinaryTarget } from "../src/domain/binaryTarget.js"; +import type { BinaryTarget } from "../../../src/domain/binaryTarget.js"; import { assessManagedGraphOmissions, managedGraphEvidenceCoverage, totalManagedGraphOmitted, type ManagedGraphProjectionLimits, -} from "../src/domain/managedApplicationGraphCoverage.js"; +} from "../../../src/domain/managedApplicationGraphCoverage.js"; import { assessKnownPageCoverage, type KnownCollectionPage, -} from "../src/domain/knownPageCoverage.js"; -import { inspectManagedMembersBytes } from "../src/dotnet/ManagedMemberInspector.js"; -import { inspectManagedNativeBoundariesBytes } from "../src/dotnet/ManagedNativeBoundaryInspector.js"; -import { buildManagedPeFixture } from "./fixtures/managedPe.js"; +} from "../../../src/domain/knownPageCoverage.js"; +import { inspectManagedMembersBytes } from "../../../src/dotnet/ManagedMemberInspector.js"; +import { inspectManagedNativeBoundariesBytes } from "../../../src/dotnet/ManagedNativeBoundaryInspector.js"; +import { buildManagedPeFixture } from "../../fixtures/managedPe.js"; const MEMBER_LIMITS = { typeOffset: 0, diff --git a/tests/ghidraWindowsFixture.test.ts b/tests/conformance/providers/ghidra/ghidraWindowsFixture.test.ts similarity index 92% rename from tests/ghidraWindowsFixture.test.ts rename to tests/conformance/providers/ghidra/ghidraWindowsFixture.test.ts index 4e27080a9..8d8573025 100644 --- a/tests/ghidraWindowsFixture.test.ts +++ b/tests/conformance/providers/ghidra/ghidraWindowsFixture.test.ts @@ -7,10 +7,10 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { parseExecutableHeader } from "../src/domain/binaryTarget.js"; +import { parseExecutableHeader } from "../../../../src/domain/binaryTarget.js"; const exec = promisify(execFile); -const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const root = resolve(dirname(fileURLToPath(import.meta.url)), "../../../.."); const generator = resolve(root, "scripts", "create-ghidra-windows-fixture.mjs"); const fixture = resolve(root, "build", "fixtures", "rea-ghidra-windows.exe"); const expectedSha256 = diff --git a/tests/realGhidraWindowsWorkflow.test.ts b/tests/conformance/providers/ghidra/realGhidraWindowsWorkflow.test.ts similarity index 90% rename from tests/realGhidraWindowsWorkflow.test.ts rename to tests/conformance/providers/ghidra/realGhidraWindowsWorkflow.test.ts index ec1574598..1740e512d 100644 --- a/tests/realGhidraWindowsWorkflow.test.ts +++ b/tests/conformance/providers/ghidra/realGhidraWindowsWorkflow.test.ts @@ -5,7 +5,10 @@ import { describe, expect, it } from "vitest"; describe("real Windows Ghidra workflow trust boundary", () => { it("runs only default-branch code on the fixed self-hosted environment", async () => { const workflow = await readFile( - new URL("../.github/workflows/real-ghidra-windows.yml", import.meta.url), + new URL( + "../../../../.github/workflows/real-ghidra-windows.yml", + import.meta.url, + ), "utf8", ); diff --git a/tests/windowsCapabilities.test.ts b/tests/conformance/providers/ghidra/windowsCapabilities.test.ts similarity index 97% rename from tests/windowsCapabilities.test.ts rename to tests/conformance/providers/ghidra/windowsCapabilities.test.ts index bbeea41a4..032b93398 100644 --- a/tests/windowsCapabilities.test.ts +++ b/tests/conformance/providers/ghidra/windowsCapabilities.test.ts @@ -4,7 +4,7 @@ import { probeWindowsCapabilities, systemNoFollowOpenCapability, type WindowsCapabilityDependencies, -} from "../src/application/WindowsCapabilities.js"; +} from "../../../../src/application/WindowsCapabilities.js"; const dependencies = ( overrides: Partial = {}, diff --git a/tests/realHopperAssertions.test.ts b/tests/conformance/providers/hopper/realHopperAssertions.test.ts similarity index 98% rename from tests/realHopperAssertions.test.ts rename to tests/conformance/providers/hopper/realHopperAssertions.test.ts index f50a185f5..42d902d4c 100644 --- a/tests/realHopperAssertions.test.ts +++ b/tests/conformance/providers/hopper/realHopperAssertions.test.ts @@ -7,7 +7,7 @@ import { requireFunctionDossierOracle, requireFunctionDossier, requirePseudocode, -} from "../src/application/RealHopperAssertions.js"; +} from "../../../../src/application/RealHopperAssertions.js"; describe("real Hopper semantic assertions", () => { it("rejects two target paths containing the same binary", () => { @@ -87,7 +87,9 @@ describe("real Hopper semantic assertions", () => { }; expect(requireFunctionDossier(scanLimited, "0x1000")).toEqual(scanLimited); }); +}); +describe("real Hopper fixture assertions", () => { it("requires fixture-specific positive dossier evidence", () => { const entry = fixtureDossier({ address: "0x1000", diff --git a/tests/realHopperFixture.test.ts b/tests/conformance/providers/hopper/realHopperFixture.test.ts similarity index 96% rename from tests/realHopperFixture.test.ts rename to tests/conformance/providers/hopper/realHopperFixture.test.ts index 2c864c4a6..56a0ec6d1 100644 --- a/tests/realHopperFixture.test.ts +++ b/tests/conformance/providers/hopper/realHopperFixture.test.ts @@ -3,9 +3,9 @@ import { readFile, rm, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; -import { loadRealHopperFixtureTargets } from "../scripts/lib/real-hopper-fixture.mjs"; +import { loadRealHopperFixtureTargets } from "../../../../scripts/lib/real-hopper-fixture.mjs"; let root: string | undefined; afterEach(async () => { diff --git a/tests/realHopperPagination.test.ts b/tests/conformance/providers/hopper/realHopperPagination.test.ts similarity index 95% rename from tests/realHopperPagination.test.ts rename to tests/conformance/providers/hopper/realHopperPagination.test.ts index 834a8e9a8..625609d5e 100644 --- a/tests/realHopperPagination.test.ts +++ b/tests/conformance/providers/hopper/realHopperPagination.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { verifyLargeFixturePagination } from "../scripts/lib/real-hopper-pagination.mjs"; +import { verifyLargeFixturePagination } from "../../../../scripts/lib/real-hopper-pagination.mjs"; const normalize = (value: unknown): unknown => value; diff --git a/tests/realHopperSemantic.test.ts b/tests/conformance/providers/hopper/realHopperSemantic.test.ts similarity index 97% rename from tests/realHopperSemantic.test.ts rename to tests/conformance/providers/hopper/realHopperSemantic.test.ts index b97981734..394ef03ce 100644 --- a/tests/realHopperSemantic.test.ts +++ b/tests/conformance/providers/hopper/realHopperSemantic.test.ts @@ -4,7 +4,7 @@ import { analyzeFixtureProcedure, requireCurrentDocument, resolveFixtureProcedure, -} from "../scripts/lib/real-hopper-semantic.mjs"; +} from "../../../../scripts/lib/real-hopper-semantic.mjs"; const normalize = (value: unknown): unknown => value; diff --git a/tests/managedConformanceFixture.test.ts b/tests/conformance/providers/managed/managedConformanceFixture.test.ts similarity index 78% rename from tests/managedConformanceFixture.test.ts rename to tests/conformance/providers/managed/managedConformanceFixture.test.ts index 68f9d158f..059f3d49d 100644 --- a/tests/managedConformanceFixture.test.ts +++ b/tests/conformance/providers/managed/managedConformanceFixture.test.ts @@ -3,10 +3,10 @@ import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; -import { buildManagedPeFixture } from "../scripts/lib/managed-pe-fixture.mjs"; -import { parseBinaryTarget } from "../src/domain/binaryTarget.js"; +import { buildManagedPeFixture } from "../../../../scripts/lib/managed-pe-fixture.mjs"; +import { parseBinaryTarget } from "../../../../src/domain/binaryTarget.js"; const workspaces: string[] = []; diff --git a/tests/managedNativeVerification.test.ts b/tests/conformance/providers/managed/managedNativeVerification.test.ts similarity index 93% rename from tests/managedNativeVerification.test.ts rename to tests/conformance/providers/managed/managedNativeVerification.test.ts index b10082b0e..3b64be66c 100644 --- a/tests/managedNativeVerification.test.ts +++ b/tests/conformance/providers/managed/managedNativeVerification.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it } from "vitest"; -import { verifyManagedNativeBoundariesEvidence } from "../src/application/ManagedNativeVerificationService.js"; -import { MANAGED_NATIVE_VERIFICATION_EXAMPLE } from "../src/contracts/managedWorkflowExamples.js"; -import { createEvidence } from "../src/domain/evidence.js"; +import { verifyManagedNativeBoundariesEvidence } from "../../../../src/application/ManagedNativeVerificationService.js"; +import { MANAGED_NATIVE_VERIFICATION_EXAMPLE } from "../../../../src/contracts/managedWorkflowExamples.js"; +import { createEvidence } from "../../../../src/domain/evidence.js"; import { managedNativeVerificationInputSchema, verifyManagedNativeBoundaries, -} from "../src/domain/managedNativeVerification.js"; -import { inspectMachoSchema } from "../src/domain/nativeInspection.js"; +} from "../../../../src/domain/managedNativeVerification.js"; +import { inspectMachoSchema } from "../../../../src/domain/nativeInspection.js"; const exampleInput = () => managedNativeVerificationInputSchema.parse( diff --git a/tests/readmeConsistency.test.ts b/tests/conformance/readmeConsistency.test.ts similarity index 80% rename from tests/readmeConsistency.test.ts rename to tests/conformance/readmeConsistency.test.ts index 83b881b84..395bf0ef3 100644 --- a/tests/readmeConsistency.test.ts +++ b/tests/conformance/readmeConsistency.test.ts @@ -6,17 +6,17 @@ import { ENHANCED_TOOL_CONTRACTS, OFFICIAL_TOOL_CONTRACTS, SESSION_TOOL_CONTRACTS, -} from "../src/contracts/toolContracts.js"; -import { NATIVE_TOOL_CONTRACTS } from "../src/contracts/nativeToolContracts.js"; -import { ARTIFACT_TOOL_CONTRACTS } from "../src/contracts/artifactToolContracts.js"; -import { MANAGED_TOOL_CONTRACTS } from "../src/contracts/managedToolContracts.js"; -import { MANAGED_WORKFLOW_TOOL_CONTRACTS } from "../src/contracts/managedWorkflowToolContracts.js"; -import { BROWSER_PROVIDER_TOOL_CONTRACTS } from "../src/contracts/browserProviderToolContracts.js"; -import { ELECTRON_TOOL_CONTRACTS } from "../src/contracts/electronToolContracts.js"; -import { JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS } from "../src/contracts/javascriptRuntimeObservationToolContracts.js"; -import { APPLICATION_TOOL_CONTRACTS } from "../src/contracts/applicationToolContracts.js"; -import { SUPPORTED_CLIENT_DEFINITIONS } from "../src/application/SupportedClients.js"; -import { PRODUCT_IDENTITY } from "../src/identity.js"; +} from "../../src/contracts/toolContracts.js"; +import { NATIVE_TOOL_CONTRACTS } from "../../src/contracts/nativeToolContracts.js"; +import { ARTIFACT_TOOL_CONTRACTS } from "../../src/contracts/artifactToolContracts.js"; +import { MANAGED_TOOL_CONTRACTS } from "../../src/contracts/managedToolContracts.js"; +import { MANAGED_WORKFLOW_TOOL_CONTRACTS } from "../../src/contracts/managedWorkflowToolContracts.js"; +import { BROWSER_PROVIDER_TOOL_CONTRACTS } from "../../src/contracts/browserProviderToolContracts.js"; +import { ELECTRON_TOOL_CONTRACTS } from "../../src/contracts/electronToolContracts.js"; +import { JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS } from "../../src/contracts/javascriptRuntimeObservationToolContracts.js"; +import { APPLICATION_TOOL_CONTRACTS } from "../../src/contracts/applicationToolContracts.js"; +import { SUPPORTED_CLIENT_DEFINITIONS } from "../../src/application/SupportedClients.js"; +import { PRODUCT_IDENTITY } from "../../src/identity.js"; const readmes = [ "README.md", diff --git a/tests/releaseConfiguration.test.ts b/tests/conformance/releaseConfiguration.test.ts similarity index 100% rename from tests/releaseConfiguration.test.ts rename to tests/conformance/releaseConfiguration.test.ts diff --git a/tests/scanTodos.test.ts b/tests/conformance/scanTodos.test.ts similarity index 94% rename from tests/scanTodos.test.ts rename to tests/conformance/scanTodos.test.ts index 2ab1da7d7..2085dd3ba 100644 --- a/tests/scanTodos.test.ts +++ b/tests/conformance/scanTodos.test.ts @@ -5,7 +5,7 @@ import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; const execute = promisify(execFile); diff --git a/tests/dispatcher.test.ts b/tests/dispatcher.test.ts deleted file mode 100644 index 5afe2869a..000000000 --- a/tests/dispatcher.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { execFile } from "node:child_process"; -import { copyFile, mkdir, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { promisify } from "node:util"; - -import { afterEach, describe, expect, it } from "vitest"; - -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; - -const execFileAsync = promisify(execFile); -let fixtureRoot: string | undefined; - -afterEach(async () => { - if (fixtureRoot !== undefined) { - await rm(fixtureRoot, { recursive: true, force: true }); - fixtureRoot = undefined; - } -}); - -describe("executable dispatcher", () => { - it("reserves production MCP routing for bare mode arguments", async () => { - fixtureRoot = await createTestTempDirectory("rea-dispatcher-"); - const scripts = join(fixtureRoot, "scripts"); - const dist = join(fixtureRoot, "dist"); - await Promise.all([mkdir(scripts), mkdir(dist)]); - await Promise.all([ - copyFile("scripts/rea.mjs", join(scripts, "rea.mjs")), - copyFile( - "scripts/package-runner-bootstrap.mjs", - join(scripts, "package-runner-bootstrap.mjs"), - ), - writeFile( - join(fixtureRoot, "package.json"), - JSON.stringify({ type: "module", version: "1.0.0" }), - ), - writeFile( - join(dist, "main.js"), - 'export const runEntrypoint = () => process.stdout.write(JSON.stringify({ adapter: "mcp" }));\n', - ), - writeFile( - join(dist, "cli.js"), - 'export const createCli = () => ({ serve: (args) => process.stdout.write(JSON.stringify({ adapter: "cli", args })) });\n', - ), - writeFile( - join(dist, "cliOutput.js"), - "export const sanitizeCliOutput = (output) => output; export const validateCliOutputArguments = () => ({ ok: true }); export const renderCliOutputArgumentError = () => '';\n", - ), - writeFile( - join(dist, "mcpDoctor.js"), - 'export const runProductionMcpDoctorCli = (args) => Promise.resolve({ output: JSON.stringify({ adapter: "mcp-doctor", args }), exitCode: 0 });\n', - ), - ]); - - const invoke = async (args: readonly string[]): Promise => { - const { stdout } = await execFileAsync(process.execPath, [ - join(scripts, "rea.mjs"), - ...args, - ]); - return JSON.parse(stdout) as unknown; - }; - - await expect(invoke(["mcp"])).resolves.toEqual({ adapter: "mcp" }); - await expect(invoke(["--mcp"])).resolves.toEqual({ adapter: "mcp" }); - await expect(invoke(["mcp", "doctor", "--json"])).resolves.toEqual({ - adapter: "mcp-doctor", - args: ["--json"], - }); - await expect(invoke(["mcp", "add"])).resolves.toEqual({ - adapter: "cli", - args: ["mcp", "add"], - }); - await expect(invoke(["--mcp", "extra"])).resolves.toEqual({ - adapter: "cli", - args: ["--mcp", "extra"], - }); - }); - - it("explains how to restore a missing compiled runtime", async () => { - fixtureRoot = await createTestTempDirectory("rea-dispatcher-"); - const scripts = join(fixtureRoot, "scripts"); - await mkdir(scripts); - await Promise.all([ - copyFile("scripts/rea.mjs", join(scripts, "rea.mjs")), - copyFile( - "scripts/package-runner-bootstrap.mjs", - join(scripts, "package-runner-bootstrap.mjs"), - ), - writeFile( - join(fixtureRoot, "package.json"), - JSON.stringify({ type: "module", version: "1.0.0" }), - ), - ]); - - for (const args of [["mcp"], ["mcp", "doctor"], ["--help"]]) { - const execution = execFileAsync(process.execPath, [ - join(scripts, "rea.mjs"), - ...args, - ]); - await expect(execution).rejects.toMatchObject({ - code: 1, - stderr: expect.stringContaining( - "REA's compiled runtime is missing. Run `npm ci` in", - ), - }); - await expect(execution).rejects.toMatchObject({ - stderr: expect.not.stringContaining("ERR_MODULE_NOT_FOUND"), - }); - } - }); -}); diff --git a/tests/electronApplicationAnalysis.test.ts b/tests/electronApplicationAnalysis.test.ts deleted file mode 100644 index 6b8b8b6f7..000000000 --- a/tests/electronApplicationAnalysis.test.ts +++ /dev/null @@ -1,326 +0,0 @@ -import { join } from "node:path"; - -import { describe, expect, it } from "vitest"; - -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; - -import { analyzeJavaScriptApplication } from "../src/application/JavaScriptApplicationService.js"; -import { PermissionAuthority } from "../src/application/PermissionAuthority.js"; -import { reconstructJavaScriptArtifact } from "../src/application/JavaScriptArtifactReconstruction.js"; -import { parseEvidence } from "../src/domain/evidence.js"; -import { parseJavaScriptApplicationGraph } from "../src/domain/javascriptApplicationGraph.js"; -import { analyzeJavaScriptApplicationInputSchema } from "../src/domain/javascriptApplicationAnalysis.js"; -import { createPermissionPolicy } from "../src/domain/permissionPolicy.js"; -import { writeElectronBoundaryFixture } from "./fixtures/electronBoundaryApplication.js"; - -describe("static Electron application analysis", () => { - it("requires explicit approval, an absolute path, and graph-safe combined bounds", () => { - expect( - analyzeJavaScriptApplicationInputSchema.safeParse({ - input_path: "relative/app.asar", - approved: true, - }).success, - ).toBe(false); - expect( - analyzeJavaScriptApplicationInputSchema.safeParse({ - input_path: "/tmp/app.asar", - }).success, - ).toBe(false); - expect( - analyzeJavaScriptApplicationInputSchema.safeParse({ - input_path: "/tmp/app.asar", - approved: true, - limits: { - max_entries: 100_000, - max_findings: 200_000, - }, - }).success, - ).toBe(false); - expect( - analyzeJavaScriptApplicationInputSchema.parse({ - input_path: "/tmp/app.asar", - approved: true, - }), - ).toMatchObject({ - format: "auto", - source_map_read_approved: false, - limits: { max_findings: 8_000 }, - }); - }); - - it("maps windows, preload, contextBridge, IPC, validations, utility, and native boundaries without execution", async () => { - const root = await fixtureDirectory(); - Reflect.deleteProperty(globalThis, "__rea_electron_fixture_executed"); - - const first = await reconstructJavaScriptArtifact({ input_path: root }); - const second = await reconstructJavaScriptArtifact({ input_path: root }); - const graph = parseJavaScriptApplicationGraph(first.graph); - - expect( - Reflect.get(globalThis, "__rea_electron_fixture_executed"), - ).toBeUndefined(); - expect(first.graph).toEqual(second.graph); - expect(first.electron_summary).toMatchObject({ - browser_windows: 3, - explicit_web_preferences: 8, - preload_entrypoints: 1, - context_bridge_apis: 2, - exposed_api_members: 5, - ipc: { - literal_channels: 5, - dynamic_channel_operations: 2, - paired_renderer_transmissions: 4, - ambiguous_renderer_transmissions: 1, - unpaired_literal_renderer_transmissions: 1, - }, - sender_validation_observations: 2, - utility_processes: 1, - resolved_utility_entrypoints: 1, - native_addon_bindings: 4, - resolved_native_addon_bindings: 4, - }); - expect(graph.nodes.map(({ kind }) => kind)).toEqual( - expect.arrayContaining([ - "browser-window", - "electron-preload", - "context-bridge-api", - "ipc-channel", - "ipc-handler", - "electron-utility", - "native-addon", - "native-export", - ]), - ); - const roles = graph.nodes.filter(({ kind }) => - ["electron-main", "electron-preload", "electron-renderer"].includes(kind), - ); - expect(roles.length).toBeGreaterThanOrEqual(3); - for (const role of roles) { - for (const observation of role.observations) - expect(observation.properties).toMatchObject({ - declared_path: expect.any(String), - resolution_context: expect.stringMatching( - /^(package-entrypoint|filesystem-expression|module-specifier|html-reference)$/u, - ), - resolution_status: expect.stringMatching( - /^(resolved|not-found|unavailable|external|rejected)$/u, - ), - limitations: expect.any(Array), - }); - if ( - role.observations.some( - ({ properties }) => properties.resolution_status === "resolved", - ) - ) - expect( - graph.edges.some( - (edge) => - edge.source_node_id === role.node_id && - edge.relation === "maps_to", - ), - ).toBe(true); - } - const dirnamePreload = roles.find( - ({ kind, observations }) => - kind === "electron-preload" && - observations.some( - ({ properties }) => - properties.declared_path === "preload.js" && - properties.resolution_context === "filesystem-expression", - ), - ); - expect(dirnamePreload?.observations[0]?.properties).toMatchObject({ - declared_path: "preload.js", - resolution_context: "filesystem-expression", - resolved_path: "preload.js", - resolution_status: "resolved", - limitations: [], - }); - expect(graph.nodes).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - kind: "browser-window", - observations: expect.arrayContaining([ - expect.objectContaining({ - properties: expect.objectContaining({ - preload_path: "preload.js", - absence_means_default: false, - web_preferences: expect.arrayContaining([ - expect.objectContaining({ - name: "nodeIntegration", - value: expect.objectContaining({ - status: "literal", - value: true, - }), - }), - ]), - }), - }), - ]), - }), - expect.objectContaining({ - kind: "browser-window", - observations: expect.arrayContaining([ - expect.objectContaining({ - properties: expect.objectContaining({ - web_preferences: expect.arrayContaining([ - expect.objectContaining({ - name: "nodeIntegration", - value: expect.objectContaining({ - status: "literal", - value: false, - }), - }), - expect.objectContaining({ - name: "contextIsolation", - value: expect.objectContaining({ - status: "literal", - value: true, - }), - }), - ]), - }), - }), - ]), - }), - expect.objectContaining({ - kind: "context-bridge-api", - observations: expect.arrayContaining([ - expect.objectContaining({ - properties: expect.objectContaining({ - api_key: "reaApi", - members: expect.arrayContaining([ - "nested", - "nested.write", - "read", - ]), - }), - }), - ]), - }), - expect.objectContaining({ - kind: "ipc-handler", - observations: expect.arrayContaining([ - expect.objectContaining({ - properties: expect.objectContaining({ - fact_kind: "sender-validation-candidate", - enforcement: "unknown", - }), - }), - ]), - }), - expect.objectContaining({ - kind: "native-export", - observations: expect.arrayContaining([ - expect.objectContaining({ - properties: expect.objectContaining({ - native_export_verification: "not-performed", - }), - }), - ]), - }), - ]), - ); - const pairingEdges = graph.edges.filter( - ({ properties }) => - properties.pairing_basis === "unique-exact-literal-channel", - ); - expect(pairingEdges).toHaveLength(4); - expect( - pairingEdges.some( - ({ properties }) => properties.channel === "rea:ambiguous", - ), - ).toBe(false); - expect(graph.limitations.join(" ")).toMatch(/dynamic IPC|ambiguous/iu); - }); - - it("returns Evidence v2 only after exact investigation-input authorization", async () => { - const root = await fixtureDirectory(); - const authority = permissionAuthority(root); - - const result = await analyzeJavaScriptApplication(authority, { - input_path: root, - approved: true, - }); - - if (!result.ok) throw result.error; - const evidence = parseEvidence(result.value); - expect(evidence).toMatchObject({ - operation: "analyze_javascript_application", - predicate_type: "rea.javascript-application-analysis/v2", - provider: { id: "rea-javascript-application" }, - authority: "shipped-artifact", - confidence: "derived", - subject: { local_path: root, format: "directory" }, - normalized_result: { - schema_version: 2, - input_path: root, - summary: { browser_windows: 3 }, - }, - }); - - const denied = await analyzeJavaScriptApplication( - permissionAuthority(root), - { - input_path: join(root, ".."), - approved: true, - }, - ); - expect(denied).toMatchObject({ - ok: false, - error: { _tag: "PermissionRequiredError" }, - }); - }); - - it("returns a tagged cancellation without executing application code", async () => { - const root = await fixtureDirectory(); - const controller = new AbortController(); - controller.abort(); - Reflect.deleteProperty(globalThis, "__rea_electron_fixture_executed"); - - const result = await analyzeJavaScriptApplication( - permissionAuthority(root), - { input_path: root, approved: true }, - { signal: controller.signal }, - ); - - expect(result).toMatchObject({ - ok: false, - error: { _tag: "ArtifactOperationError", reason: "cancelled" }, - }); - expect( - Reflect.get(globalThis, "__rea_electron_fixture_executed"), - ).toBeUndefined(); - }); -}); - -const fixtureDirectory = async (): Promise => { - const root = await createTestTempDirectory("rea-electron-boundaries-"); - await writeElectronBoundaryFixture(root); - return root; -}; - -const permissionAuthority = (root: string): PermissionAuthority => - new PermissionAuthority( - createPermissionPolicy( - [scope(root)], - [ - { - ...scope(root), - grant_id: "test:investigation-input", - lifetime: "session", - operation_identity: null, - expires_at: null, - }, - ], - ), - ); - -const scope = (root: string) => ({ - capability: "investigation_input" as const, - roots: [root], - executables: [], - environment_names: [], - network: "none" as const, - mount: false, -}); diff --git a/tests/electronMcp.test.ts b/tests/electronMcp.test.ts deleted file mode 100644 index 10ce374cc..000000000 --- a/tests/electronMcp.test.ts +++ /dev/null @@ -1,358 +0,0 @@ -import { mkdir, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { pathToFileURL } from "node:url"; - -import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; -import { afterEach, describe, expect, it } from "vitest"; -import { z } from "zod"; - -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; - -import { BinarySession } from "../src/application/BinarySession.js"; -import { loadConfiguredPermissionAuthority } from "../src/application/PermissionConfiguration.js"; -import { CdpElectronProvider } from "../src/browser/CdpElectronProvider.js"; -import { parseConfig } from "../src/config.js"; -import { createServer } from "../src/server/createServer.js"; -import { observed } from "./fixtures/analysisExecution.js"; -import { - startFakeCdpBrowser, - type FakeCdpBrowser, -} from "./fixtures/fakeCdpBrowser.js"; -import { writeElectronBoundaryFixture } from "./fixtures/electronBoundaryApplication.js"; - -describe("Electron MCP tools", () => { - const browsers: FakeCdpBrowser[] = []; - const resources: Array<{ close(): Promise }> = []; - const temporary: string[] = []; - - afterEach(async () => { - await Promise.all(resources.splice(0).map(async (item) => item.close())); - await Promise.all( - browsers.splice(0).map(async (browser) => browser.close()), - ); - await Promise.all( - temporary - .splice(0) - .map(async (path) => rm(path, { recursive: true, force: true })), - ); - }); - - it("exposes root-confined Electron discovery and inspection as Evidence v2", async () => { - const root = await createTestTempDirectory("rea-electron-mcp-"); - temporary.push(root); - await writeFile(join(root, "index.html"), ""); - await writeFile( - join(root, "app.js"), - "export const observed = 'source-secret';", - ); - await writeFile( - join(root, "package.json"), - JSON.stringify({ name: "runtime-fixture", renderer: "index.html" }), - ); - await writeFile(join(root, "worker.js"), "self.onmessage = () => {};\n"); - const browser = await startFakeCdpBrowser({ - electronFileUrl: pathToFileURL(join(root, "index.html")).href, - }); - browsers.push(browser); - const config = parseConfig({ - REA_ELECTRON_OBSERVE_ENABLED: "true", - REA_ELECTRON_CDP_ENDPOINTS_JSON: JSON.stringify([browser.endpoint]), - REA_ELECTRON_FILE_ROOTS_JSON: JSON.stringify([root]), - REA_INVESTIGATION_INPUT_ROOTS_JSON: JSON.stringify([root]), - }); - if (!config.ok) throw config.error; - const authority = await loadConfiguredPermissionAuthority(config.value); - if (!authority.ok) throw authority.error; - const session = new BinarySession(() => ({ - execute: () => Promise.resolve(observed(null)), - close: () => Promise.resolve(), - })); - const server = createServer(session, session, { - electronObservation: new CdpElectronProvider(), - permissionAuthority: authority.value, - availabilityPolicy: () => ({ - processCaptureEnabled: false, - evidenceFileRoots: 0, - investigationInputRoots: 1, - electronObservationEnabled: true, - }), - }); - const client = new Client({ name: "electron-mcp-test", version: "1" }); - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - resources.push(client, server, session); - await server.connect(serverTransport); - await client.connect(clientTransport); - - const listed = await client.callTool({ - name: "list_electron_targets", - arguments: { - cdp_endpoint: browser.endpoint, - allowed_file_roots: [root], - approved: true, - }, - }); - expect(listed.isError).not.toBe(true); - expect(listed.structuredContent).toMatchObject({ - result: { - targets: { items: [{ target_id: "electron-page" }] }, - }, - }); - const inspected = await client.callTool({ - name: "inspect_electron_page", - arguments: { - cdp_endpoint: browser.endpoint, - allowed_file_roots: [root], - target_id: "electron-page", - approved: true, - observation_ms: 0, - include_script_sources: true, - source_capture_approved: true, - }, - }); - const analyzed = await client.callTool({ - name: "analyze_javascript_application", - arguments: { input_path: root, approved: true, detail: "full" }, - }); - expect(analyzed.isError).not.toBe(true); - expect(analyzed.structuredContent).toMatchObject({ - result: { graph: { nodes: expect.any(Array), edges: expect.any(Array) } }, - }); - const analysisText = analyzed.content.find(({ type }) => type === "text"); - expect(analysisText).toBeDefined(); - if (analysisText?.type !== "text") - throw new TypeError("Missing JavaScript analysis text projection"); - expect(analysisText.text).not.toContain('"nodes":['); - const reconciled = await client.callTool({ - name: "reconcile_javascript_runtime", - arguments: { - static_layers: [ - { - role: "application", - analysis: evidenceFor(session, analyzed.structuredContent), - }, - ], - runtime_observations: [ - evidenceFor(session, inspected.structuredContent), - ], - }, - }); - expect(reconciled.isError).not.toBe(true); - expect(reconciled.structuredContent).toMatchObject({ - result: { - summary: { runtime_scripts: 1, matched: expect.any(Number) }, - source_map_authority: { used_for_primary_matching: false }, - }, - }); - expect(inspected.isError).not.toBe(true); - expect(inspected.structuredContent).toMatchObject({ - result: { - target: { file_path: expect.stringMatching(/\/index\.html$/u) }, - scripts: { - items: [ - expect.objectContaining({ - frame_id: "frame-main", - file_path: expect.stringMatching(/\/app\.js$/u), - }), - ], - }, - workers: [ - expect.objectContaining({ - target_id: "electron-worker", - opener_target_id: "electron-page", - file_path: expect.stringMatching(/\/worker\.js$/u), - }), - ], - }, - }); - const outside = join(root, "..", `outside-${Date.now().toString(16)}`); - await mkdir(outside); - temporary.push(outside); - const commandsBeforeDenial = browser.commands.length; - const denied = await client.callTool({ - name: "list_electron_targets", - arguments: { - cdp_endpoint: browser.endpoint, - allowed_file_roots: [outside], - approved: true, - }, - }); - expect(denied.isError).toBe(true); - expect(denied.structuredContent).toMatchObject({ - error: { - code: "permission_required", - details: { missing: { roots: [expect.stringMatching(/outside-/u)] } }, - }, - }); - expect(browser.commands).toHaveLength(commandsBeforeDenial); - }); - - it("exposes the target-free static JavaScript application workflow", async () => { - const root = await createTestTempDirectory("rea-electron-static-mcp-"); - temporary.push(root); - await writeElectronBoundaryFixture(root); - const config = parseConfig({ - REA_INVESTIGATION_INPUT_ROOTS_JSON: JSON.stringify([root]), - }); - if (!config.ok) throw config.error; - const authority = await loadConfiguredPermissionAuthority(config.value); - if (!authority.ok) throw authority.error; - const session = new BinarySession(() => ({ - execute: () => Promise.resolve(observed(null)), - close: () => Promise.resolve(), - })); - const server = createServer(session, session, { - permissionAuthority: authority.value, - availabilityPolicy: () => ({ - processCaptureEnabled: false, - evidenceFileRoots: 0, - investigationInputRoots: 1, - }), - }); - const client = new Client({ - name: "electron-static-mcp-test", - version: "1", - }); - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - resources.push(client, server, session); - await server.connect(serverTransport); - await client.connect(clientTransport); - - const analyzed = await client.callTool({ - name: "analyze_javascript_application", - arguments: { input_path: root, approved: true }, - }); - - expect(analyzed.isError).not.toBe(true); - const projected = z - .object({ - evidence_id: z.string(), - result: z.object({ - schema_version: z.literal(2), - input_path: z.string(), - unknowns: z.array(z.string()), - summary: z.object({ - browser_windows: z.number(), - context_bridge_apis: z.number(), - ipc: z.object({ paired_renderer_transmissions: z.number() }), - }), - graph: z.object({ - graph_id: z.string(), - node_count: z.number(), - edge_count: z.number(), - top_findings: z.array(z.unknown()), - pages: z.object({ nodes: z.string(), edges: z.string() }), - }), - semantic_graph: z.object({ - graph_id: z.string(), - nodes: z.number(), - relations: z.number(), - unknown_frontiers: z.number(), - query_tool: z.literal("trace_javascript_semantics"), - }), - }), - }) - .parse(analyzed.structuredContent); - expect(projected.result).toMatchObject({ - input_path: expect.stringMatching(/rea-electron-static-mcp-/u), - summary: { - browser_windows: 3, - context_bridge_apis: 2, - ipc: { paired_renderer_transmissions: 4 }, - }, - }); - expect(projected.result.graph.node_count).toBeGreaterThan(0); - expect(projected.result.semantic_graph.nodes).toBeGreaterThan(0); - expect(JSON.stringify(analyzed.structuredContent)).not.toContain( - '"nodes":[', - ); - - const nodePage = await client.readResource({ - uri: projected.result.graph.pages.nodes, - }); - const nodeContent = nodePage.contents[0]; - if (nodeContent === undefined || !("text" in nodeContent)) - throw new TypeError("Missing JavaScript application graph node page"); - expect(JSON.parse(nodeContent.text)).toMatchObject({ - evidence_id: projected.evidence_id, - graph_id: projected.result.graph.graph_id, - collection: "nodes", - items: expect.any(Array), - offset: 0, - limit: 100, - }); - }); - - it("reports an unconfigured investigation ceiling before static analysis", async () => { - const root = await createTestTempDirectory("rea-electron-denied-mcp-"); - temporary.push(root); - await writeElectronBoundaryFixture(root); - const config = parseConfig({}); - if (!config.ok) throw config.error; - const authority = await loadConfiguredPermissionAuthority(config.value); - if (!authority.ok) throw authority.error; - const session = new BinarySession(() => ({ - execute: () => Promise.resolve(observed(null)), - close: () => Promise.resolve(), - })); - const server = createServer(session, session, { - permissionAuthority: authority.value, - }); - const client = new Client({ - name: "electron-denied-mcp-test", - version: "1", - }); - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - resources.push(client, server, session); - await server.connect(serverTransport); - await client.connect(clientTransport); - - const status = await client.callTool({ - name: "binary_session", - arguments: { - detail: "capabilities", - capability_family: "electron-provider", - limit: 100, - }, - }); - expect(status.structuredContent).toMatchObject({ - result: { - capabilities: { - items: expect.arrayContaining([ - expect.objectContaining({ - name: "analyze_javascript_application", - available: false, - reason: "policy_disabled", - }), - expect.objectContaining({ - name: "reconcile_javascript_runtime", - available: true, - reason: "available", - }), - ]), - }, - }, - }); - - expect( - (await client.listTools()).tools.some( - ({ name }) => name === "analyze_javascript_application", - ), - ).toBe(false); - }); - - const evidenceFor = (session: BinarySession, value: unknown) => { - if ( - typeof value !== "object" || - value === null || - !("evidence_id" in value) || - typeof value.evidence_id !== "string" - ) - throw new TypeError("Missing compact Evidence ID"); - const evidence = session.evidenceById(value.evidence_id); - if (evidence === undefined) throw new TypeError("Missing session Evidence"); - return evidence; - }; -}); diff --git a/tests/enhancedTools.test.ts b/tests/enhancedTools.test.ts deleted file mode 100644 index e2caef1b4..000000000 --- a/tests/enhancedTools.test.ts +++ /dev/null @@ -1,865 +0,0 @@ -import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; -import type { CallToolResult } from "@modelcontextprotocol/server"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -import type { AnalysisOperationPort } from "../src/application/AnalysisProvider.js"; -import { EnhancedTools } from "../src/application/EnhancedTools.js"; -import { - ENHANCED_TOOL_CONTRACTS, - SESSION_TOOL_CONTRACTS, - TOOL_CONTRACTS, -} from "../src/contracts/toolContracts.js"; -import { enhancedInputSchemas } from "../src/contracts/enhancedInputs.js"; -import { MANAGED_WORKFLOW_TOOL_CONTRACTS } from "../src/contracts/managedWorkflowToolContracts.js"; -import { AnalysisOutputError } from "../src/domain/errors.js"; -import { jsonValueSchema, type JsonValue } from "../src/domain/jsonValue.js"; -import { err } from "../src/domain/result.js"; -import { createServer } from "../src/server/createServer.js"; -import { observed as ok } from "./fixtures/analysisExecution.js"; - -const PROCEDURES = { - "0x1": "_TtC7Fixture5Class", - "0x2": "_TtV7Fixture6Struct", - "0x3": "_TtP7Fixture8Protocol", - "0x4": "_TtO7Fixture4Enum", - "0x5": "_TtE7Fixture9Extension", - "0x6": "prefix_TtOther", -}; - -const page = (values: Readonly>) => ({ - items: Object.entries(values).map(([address, value]) => ({ address, value })), - offset: 0, - limit: 100, - total: Object.keys(values).length, - next_offset: null, - has_more: false, -}); - -const fixturePort = (): AnalysisOperationPort => ({ - execute: (name, arguments_) => { - switch (name) { - case "list_procedures": - return Promise.resolve(ok(page(PROCEDURES))); - case "list_names": - return Promise.resolve( - ok( - page({ - "0x10": "_OBJC_CLASS_$_Fixture", - "0x11": "_OBJC_CLASS_$_Fixture", - "0x12": "_OBJC_PROTOCOL_$_FixtureDelegate", - "0x13": "entry", - }), - ), - ); - case "procedure_pseudo_code": { - const procedure = arguments_.procedure; - return Promise.resolve( - ok(typeof procedure === "string" ? `pseudo:${procedure}` : "invalid"), - ); - } - case "procedure_callees": { - const procedure = arguments_.procedure; - return Promise.resolve( - ok( - procedure === "0x1" - ? ["0x2", "0x3"] - : procedure === "0x2" - ? ["0x1"] - : [], - ), - ); - } - case "procedure_callers": - return Promise.resolve(ok(["0x9"])); - case "address_name": - return Promise.resolve(ok(arguments_.address ?? null)); - case "xrefs": - return Promise.resolve(ok(["0x20", "0x21"])); - case "resolve_containing_procedure": - return Promise.resolve( - ok({ - query_address: - typeof arguments_.address === "string" - ? arguments_.address - : "0x0", - found: false, - procedure: null, - reason: "not_in_procedure", - }), - ); - case "list_segments": - return Promise.resolve( - ok([{ name: "__TEXT", start: "0x1000", end: "0x2000" }]), - ); - case "list_documents": - return Promise.resolve(ok(["fixture"])); - case "list_strings": - return Promise.resolve(ok(page({ "0x30": "hello" }))); - case "analyze_function": - return Promise.resolve( - ok({ - procedure: { - address: "0x1", - name: "entry", - signature: null, - locals: [], - }, - pseudocode: { - text: "return 0;", - total_chars: 9, - returned_chars: 9, - truncated: false, - next_offset: null, - }, - assembly: { - items: [], - total: 0, - returned: 0, - truncated: false, - next_offset: null, - }, - comments: emptyBounded(), - callers: emptyBounded(), - callees: emptyBounded(), - incoming_references: emptyBounded(), - outgoing_references: emptyBounded(), - referenced_strings: emptyBounded(), - referenced_names: emptyBounded(), - basic_blocks: emptyBounded(), - instruction_scan: { scanned: 0, truncated: false }, - }), - ); - default: - return Promise.resolve(ok(null)); - } - }, -}); - -const emptyBounded = () => ({ - items: [], - total: 0, - returned: 0, - truncated: false, - next_offset: null, -}); - -const resources: Array<{ close(): Promise }> = []; - -afterEach(async () => { - vi.restoreAllMocks(); - await Promise.all( - resources.splice(0).map(async (resource) => resource.close()), - ); -}); - -const connect = async (analysis: AnalysisOperationPort = fixturePort()) => { - const server = createServer(analysis); - const client = new Client({ name: "enhanced-test", version: "1.0.0" }); - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - resources.push(client, server); - await server.connect(serverTransport); - await client.connect(clientTransport); - return client; -}; - -const jsonResult = (result: CallToolResult): JsonValue => { - if (result.structuredContent === undefined) - throw new Error("Tool result omitted structured content"); - const structured = jsonValueSchema.safeParse(result.structuredContent); - if (!structured.success) - throw new Error("Tool structured result was not JSON"); - if ( - typeof structured.data === "object" && - structured.data !== null && - !Array.isArray(structured.data) && - "normalized_result" in structured.data - ) { - return structured.data.normalized_result ?? null; - } - if ( - typeof structured.data === "object" && - structured.data !== null && - !Array.isArray(structured.data) && - "evidence_id" in structured.data && - "result" in structured.data - ) - return structured.data.result ?? null; - const text = result.content.find((item) => item.type === "text"); - if (text?.type !== "text") - throw new Error("Tool result omitted text content"); - const decoded: unknown = JSON.parse(text.text); - const parsed = jsonValueSchema.safeParse(decoded); - if (!parsed.success) throw new Error("Tool result was not JSON"); - return parsed.data; -}; - -describe("enhanced MCP tools", () => { - it("lists the complete target-open analysis surface", async () => { - const client = await connect(); - const listed = await client.listTools(); - expect(listed.tools).toHaveLength( - TOOL_CONTRACTS.length - - SESSION_TOOL_CONTRACTS.length - - MANAGED_WORKFLOW_TOOL_CONTRACTS.length, - ); - expect( - listed.tools - .map(({ name }) => name) - .filter((name) => - ENHANCED_TOOL_CONTRACTS.some((tool) => tool.name === name), - ) - .sort(), - ).toEqual(ENHANCED_TOOL_CONTRACTS.map(({ name }) => name).sort()); - }); - - it("executes all thirteen tools through production registration", async () => { - const client = await connect(); - const calls = [ - ["swift_classes", { pattern: "Fixture" }], - ["get_objc_classes", { pattern: "Fixture" }], - ["get_objc_protocols", {}], - ["batch_decompile", { addresses: ["0x1", "0x2"] }], - ["get_call_graph", { address: "0x1", direction: "forward", depth: 2 }], - ["analyze_swift_types", {}], - ["find_xrefs_to_name", { name: "entry" }], - ["binary_overview", {}], - ["analyze_function", { procedure: "0x1" }], - ["inspect_native_api", { procedure: "0x1" }], - ["trace_feature", { query: "hello", max_operations: 10 }], - ["find_code_for_string", { query: "hello", max_operations: 10 }], - ["trace_call_path", { start: "0x1", goal: "0x2", max_operations: 10 }], - ] as const; - const results = await Promise.all( - calls.map(async ([name, arguments_]) => - jsonResult(await client.callTool({ name, arguments: arguments_ })), - ), - ); - expect(results[0]).toMatchObject({ count: 1 }); - expect(results[1]).toMatchObject({ count: 1 }); - expect(results[2]).toMatchObject({ count: 1 }); - expect(results[3]).toEqual({ - items: [ - { address: "0x1", status: "ok", pseudocode: "pseudo:0x1" }, - { address: "0x2", status: "ok", pseudocode: "pseudo:0x2" }, - ], - total: 2, - succeeded: 2, - failed: 0, - }); - expect(results[4]).toEqual({ - "0": [{ address: "0x1", status: "ok", calls: ["0x2", "0x3"] }], - "1": [ - { address: "0x2", status: "ok", calls: ["0x1"] }, - { address: "0x3", status: "ok", calls: [] }, - ], - }); - expect(results[5]).toMatchObject({ total: 6 }); - expect(results[6]).toEqual({ - status: "resolved", - name: "entry", - address: "0x13", - xrefs: ["0x20", "0x21"], - }); - expect(results[7]).toMatchObject({ - document: "fixture", - segment_count: 1, - procedure_count: 6, - string_count: 1, - }); - expect(results[8]).toMatchObject({ - procedure: { address: "0x1", name: "entry" }, - pseudocode: { text: "return 0;" }, - }); - expect(results[9]).toMatchObject({ - procedure: { address: "0x1", name: "entry" }, - boundary: { available: false }, - unsupported_branches: [ - "structured-boundary-types", - "jump-table-data-mapping", - ], - residual_unknowns: expect.arrayContaining([ - expect.stringContaining("boundary types"), - ]), - substeps: [ - { operation: "analyze_function", status: "completed" }, - { operation: "project_native_api_boundary", status: "unsupported" }, - { operation: "preserve_residual_unknowns", status: "completed" }, - ], - }); - expect(results[10]).toMatchObject({ - query: "hello", - search_mode: "literal", - truncated: false, - references: [ - { target_address: "0x30", source_address: "0x20" }, - { target_address: "0x30", source_address: "0x21" }, - ], - }); - expect(results[11]).toMatchObject({ - query: "hello", - matches: [{ type: "string", address: "0x30", value: "hello" }], - truncated: false, - }); - expect(results[12]).toMatchObject({ - start: "0x1", - goal: "0x2", - direction: "forward", - goal_status: "reached", - traversal_path: ["0x1", "0x2"], - nodes: [ - { address: "0x1", depth: 0 }, - { address: "0x2", depth: 1 }, - ], - truncated: false, - }); - }); - - it("returns Evidence IDs and explicit call-path frontiers at limits", async () => { - const client = await connect(); - const result = await client.callTool({ - name: "trace_call_path", - arguments: { - start: "0x1", - goal: "0x3", - max_nodes: 1, - max_operations: 10, - }, - }); - - expect(result.isError).not.toBe(true); - expect(result.structuredContent).toMatchObject({ - evidence_id: expect.stringMatching(/^ev_[a-f0-9]{64}$/u), - result: { - goal_status: "not_reached", - nodes: [{ address: "0x1", depth: 0 }], - frontier: ["0x2"], - truncated: true, - residual_unknowns: expect.arrayContaining([ - expect.stringContaining("reached the node limit"), - expect.stringContaining("reached the edge limit"), - ]), - limits: { - max_nodes: 1, - nodes_visited: 1, - operations_used: 1, - }, - }, - }); - }); - - it("bounds batch concurrency at the parsed maximum of 20", async () => { - let active = 0; - let maximum = 0; - const analysis: AnalysisOperationPort = { - execute: async (name) => { - if (name !== "procedure_pseudo_code") return ok(null); - active += 1; - maximum = Math.max(maximum, active); - await new Promise((resolve) => setTimeout(resolve, 2)); - active -= 1; - return ok("pseudo"); - }, - }; - const client = await connect(analysis); - const result = await client.callTool({ - name: "batch_decompile", - arguments: { - addresses: Array.from( - { length: 20 }, - (_, index) => `0x${String(index)}`, - ), - }, - }); - expect(result.isError).not.toBe(true); - expect(maximum).toBe(20); - }); - - it("returns ordered typed batch failures and zero counts for empty input", async () => { - const tools = new EnhancedTools({ - execute: (_name, arguments_) => - arguments_.procedure === "0x2" - ? Promise.resolve(err(new AnalysisOutputError("decompile", "failed"))) - : Promise.resolve(ok("pseudo")), - }); - - const result = await tools.execute("batch_decompile", { - addresses: ["0x1", "0x2"], - }); - const empty = await tools.execute("batch_decompile", { addresses: [] }); - - expect(result).toEqual({ - ok: true, - value: { - items: [ - { address: "0x1", status: "ok", pseudocode: "pseudo" }, - { - address: "0x2", - status: "error", - error: { - code: "unreadable_output", - category: "execution_failure", - message: - "Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.", - retryable: false, - remediation: { - action: - "Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.", - restart_required: false, - }, - }, - }, - ], - total: 2, - succeeded: 1, - failed: 1, - }, - }); - expect(empty).toEqual({ - ok: true, - value: { items: [], total: 0, succeeded: 0, failed: 0 }, - }); - }); - - it("returns typed graph failures and stable unresolved-name results", async () => { - const tools = new EnhancedTools({ - execute: (name) => - name === "procedure_callees" - ? Promise.resolve(err(new AnalysisOutputError(name, "failed"))) - : Promise.resolve(ok(page({}))), - }); - - const graph = await tools.execute("get_call_graph", { - address: "0x1", - direction: "forward", - depth: 1, - }); - const unresolved = await tools.execute("find_xrefs_to_name", { - name: "missing", - }); - - expect(graph).toMatchObject({ - ok: true, - value: { - "0": [ - { - address: "0x1", - status: "error", - error: { - category: "execution_failure", - message: - "Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.", - }, - }, - ], - }, - }); - expect(unresolved).toEqual({ - ok: true, - value: { - status: "unresolved", - name: "missing", - reason: "name_not_found", - }, - }); - }); - - it("follows procedure pagination for whole-binary workflows", async () => { - const offsets: number[] = []; - const client = await connect({ - execute: (_name, arguments_) => { - const offset = arguments_.offset; - offsets.push(typeof offset === "number" ? offset : 0); - return Promise.resolve( - ok({ - items: [ - { - address: offset === 500 ? "0x2" : "0x1", - value: offset === 500 ? "_TtC4Last" : "_TtC5First", - }, - ], - offset: typeof offset === "number" ? offset : 0, - limit: 500, - total: 2, - next_offset: offset === 500 ? null : 500, - has_more: offset !== 500, - }), - ); - }, - }); - const result = jsonResult( - await client.callTool({ name: "swift_classes", arguments: {} }), - ); - expect(offsets).toEqual([0, 500]); - expect(result).toMatchObject({ count: 2 }); - }); - - it("does not start another page after cancellation", async () => { - const controller = new AbortController(); - let calls = 0; - const analysis: AnalysisOperationPort = { - execute: () => { - calls += 1; - controller.abort(); - return Promise.resolve( - ok({ - items: [{ address: "0x1", value: "_TtC5First" }], - offset: 0, - limit: 500, - total: 2, - next_offset: 500, - has_more: true, - }), - ); - }, - }; - - const result = await new EnhancedTools(analysis).execute( - "swift_classes", - {}, - controller.signal, - ); - - expect(calls).toBe(1); - expect(result.ok).toBe(false); - if (result.ok) throw new Error("expected cancellation"); - expect(result.error._tag).toBe("AnalysisCancelledError"); - }); - - it("follows name pagination for exhaustive Objective-C discovery", async () => { - const offsets: number[] = []; - const client = await connect({ - execute: (name, arguments_) => { - expect(name).toBe("list_names"); - const offset = - typeof arguments_.offset === "number" ? arguments_.offset : 0; - offsets.push(offset); - return Promise.resolve( - ok({ - items: [ - { - address: offset === 500 ? "0x2" : "0x1", - value: - offset === 500 ? "_OBJC_CLASS_$_Last" : "_OBJC_CLASS_$_First", - }, - ], - offset, - limit: 500, - total: 2, - next_offset: offset === 500 ? null : 500, - has_more: offset !== 500, - }), - ); - }, - }); - const result = jsonResult( - await client.callTool({ name: "get_objc_classes", arguments: {} }), - ); - expect(offsets).toEqual([0, 500]); - expect(result).toMatchObject({ count: 2 }); - }); - - it("honors overview detail and limit while reporting exhaustive totals", async () => { - const procedureOffsets: number[] = []; - const client = await connect({ - execute: (name, arguments_) => { - switch (name) { - case "list_segments": - return Promise.resolve( - ok([ - { name: "__TEXT", start: "0x1000", end: "0x1800" }, - { name: "__DATA", start: "0x1800", end: "0x2000" }, - ]), - ); - case "list_documents": - return Promise.resolve(ok(["fixture"])); - case "list_strings": - return Promise.resolve( - ok({ - items: [{ address: "0x30", value: "first page only" }], - offset: 0, - limit: 100, - total: 700, - next_offset: 100, - has_more: true, - }), - ); - case "list_procedures": { - const offset = - typeof arguments_.offset === "number" ? arguments_.offset : 0; - procedureOffsets.push(offset); - return Promise.resolve( - ok({ - items: [ - { - address: offset === 500 ? "0x2" : "0x1", - value: offset === 500 ? "last" : "first", - }, - ], - offset, - limit: 500, - total: 2, - next_offset: offset === 500 ? null : 500, - has_more: offset !== 500, - }), - ); - } - default: - return Promise.resolve(ok(null)); - } - }, - }); - const result = jsonResult( - await client.callTool({ - name: "binary_overview", - arguments: { detail: "detailed", limit: 1 }, - }), - ); - expect(procedureOffsets).toEqual([0]); - expect(result).toEqual({ - document: "fixture", - detail: "detailed", - segments: [ - { name: "__TEXT", start: "0x1000", end: "0x1800", length: 2048 }, - ], - segment_count: 2, - procedure_count: 2, - string_count: 700, - }); - }); - - it("rejects non-advancing pagination metadata", async () => { - const client = await connect({ - execute: () => - Promise.resolve( - ok({ - items: [{ address: "0x1", value: "_TtC5First" }], - offset: 0, - limit: 500, - total: 2, - next_offset: 0, - has_more: true, - }), - ), - }); - const result = await client.callTool({ - name: "swift_classes", - arguments: {}, - }); - expect(result.isError).toBe(true); - const text = result.content.find((item) => item.type === "text"); - expect(text?.type === "text" ? text.text : "").toBe( - JSON.stringify(result.structuredContent), - ); - }); - - it("stops trace_feature at its explicit operation budget", async () => { - let operations = 0; - const client = await connect({ - execute: () => { - operations += 1; - return Promise.resolve( - ok({ - items: [{ address: "0x1", value: "needle" }], - offset: 0, - limit: 500, - total: 2, - next_offset: 1, - has_more: true, - }), - ); - }, - }); - const result = jsonResult( - await client.callTool({ - name: "trace_feature", - arguments: { query: "needle", max_operations: 1 }, - }), - ); - expect(operations).toBe(1); - expect(result).toMatchObject({ - operations_used: 1, - operation_budget: 1, - truncated: true, - }); - expect(JSON.stringify(result)).toContain("operation budget"); - }); - - it("returns correction-only structured validation issues", async () => { - const client = await connect(); - const traceParser = vi.spyOn( - enhancedInputSchemas.trace_feature, - "safeParse", - ); - await client.callTool({ - name: "trace_feature", - arguments: { query: "needle", max_operations: 1 }, - }); - expect(traceParser).toHaveBeenCalledTimes(1); - traceParser.mockClear(); - const misspelled = await client.callTool({ - name: "trace_feature", - arguments: { query: "needle", max_operatons: 1 }, - }); - expect(misspelled).toMatchObject({ - isError: true, - structuredContent: { - error: { - code: "invalid_request", - retryable: true, - details: { - issues: [{ path: ["max_operatons"], reason: "unknown_argument" }], - }, - }, - }, - }); - expect(traceParser).not.toHaveBeenCalled(); - const nestedMisspelled = await client.callTool({ - name: "analyze_function", - arguments: { - procedure: "0x1", - collection_offset: { commentz: 1 }, - }, - }); - expect(nestedMisspelled).toMatchObject({ - isError: true, - structuredContent: { - error: { - details: { - issues: [ - { - path: ["collection_offset", "commentz"], - reason: "unknown_argument", - }, - ], - }, - }, - }, - }); - const bounded = await client.callTool({ - name: "trace_feature", - arguments: { query: "needle", max_operations: 101 }, - }); - expect(bounded.structuredContent).toMatchObject({ - error: { - details: { - issues: [ - { - path: ["max_operations"], - reason: "out_of_range", - maximum: 100, - }, - ], - }, - }, - }); - expect(bounded.content[0]).toEqual({ - type: "text", - text: JSON.stringify(bounded.structuredContent), - }); - }); - - it("returns a typed tool error for malformed Hopper boundary values", async () => { - const client = await connect({ - execute: () => Promise.resolve(ok(["not", "a", "procedure", "map"])), - }); - const result = await client.callTool({ - name: "swift_classes", - arguments: {}, - }); - expect(result.isError).toBe(true); - const text = result.content.find((item) => item.type === "text"); - expect(text?.type === "text" ? text.text : "").toBe( - JSON.stringify(result.structuredContent), - ); - }); - - it("rejects incomplete function dossiers at the application boundary", async () => { - const client = await connect({ - execute: () => - Promise.resolve( - ok({ - procedure: { address: "0x1", name: "entry" }, - pseudocode: { text: "plausible but incomplete" }, - }), - ), - }); - const result = await client.callTool({ - name: "analyze_function", - arguments: { procedure: "0x1" }, - }); - expect(result.isError).toBe(true); - const text = result.content.find((item) => item.type === "text"); - expect(text?.type === "text" ? text.text : "").toBe( - JSON.stringify(result.structuredContent), - ); - }); - - it("rejects deceptive function dossier collection metadata", async () => { - const malformedPort = fixturePort(); - const client = await connect({ - execute: async (name, arguments_, options) => { - const result = await malformedPort.execute(name, arguments_, options); - if (!result.ok || name !== "analyze_function") return result; - const dossier = result.value.result; - if ( - typeof dossier !== "object" || - dossier === null || - Array.isArray(dossier) - ) - return result; - return ok({ - ...dossier, - comments: { - items: [], - total: 0, - returned: 1, - truncated: false, - next_offset: null, - }, - }); - }, - }); - const result = await client.callTool({ - name: "analyze_function", - arguments: { procedure: "0x1" }, - }); - expect(result.isError).toBe(true); - const text = result.content.find((item) => item.type === "text"); - expect(text?.type === "text" ? text.text : "").toBe( - JSON.stringify(result.structuredContent), - ); - }); - - it("accepts a final dossier page whose total exceeds returned", async () => { - const malformedPort = fixturePort(); - const client = await connect({ - execute: async (name, arguments_, options) => { - const result = await malformedPort.execute(name, arguments_, options); - if (!result.ok || name !== "analyze_function") return result; - const dossier = result.value.result; - if ( - typeof dossier !== "object" || - dossier === null || - Array.isArray(dossier) - ) - return result; - return ok({ - ...dossier, - callers: { - items: [], - total: 1, - returned: 0, - truncated: false, - next_offset: null, - }, - }); - }, - }); - const result = await client.callTool({ - name: "analyze_function", - arguments: { procedure: "0x1" }, - }); - expect(result.isError).not.toBe(true); - }); -}); diff --git a/tests/codexAgentEval.test.ts b/tests/evaluation/model-evals/codexAgentEval.test.ts similarity index 97% rename from tests/codexAgentEval.test.ts rename to tests/evaluation/model-evals/codexAgentEval.test.ts index 4ddd0e59a..5f52663be 100644 --- a/tests/codexAgentEval.test.ts +++ b/tests/evaluation/model-evals/codexAgentEval.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { evaluateCodexEvents } from "../src/evaluation/CodexAgentEval.js"; +import { evaluateCodexEvents } from "../../../src/evaluation/CodexAgentEval.js"; describe("Codex agent release evaluation", () => { it("measures natural routing, repeated calls, tokens, and epistemic completion", () => { @@ -100,7 +100,9 @@ describe("Codex agent release evaluation", () => { .repeatedCallCount, ).toBe(1); }); +}); +describe("Codex agent completion evaluation", () => { it("requires scenario-specific answer facts when configured", () => { const event = (text: string) => [ { @@ -160,7 +162,9 @@ describe("Codex agent release evaluation", () => { expect(metrics.reaCalls[0]?.error).toBe(true); }); +}); +describe("Codex agent workflow evaluation", () => { it("requires an ordered tool subsequence and rejects input validation failures", () => { const evidenceId = `ev_${"b".repeat(64)}`; const call = ( diff --git a/tests/evidenceResources.test.ts b/tests/evidenceResources.test.ts deleted file mode 100644 index 66c8c0ab9..000000000 --- a/tests/evidenceResources.test.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; -import { describe, expect, it } from "vitest"; -import { z } from "zod"; - -import { BinarySession } from "../src/application/BinarySession.js"; -import { createEvidence } from "../src/domain/evidence.js"; -import { observed } from "./fixtures/analysisExecution.js"; -import { createServer } from "../src/server/createServer.js"; -import { createEvidenceBundle } from "../src/domain/evidenceBundle.js"; -import { createInvestigationWorkspace } from "../src/domain/investigationWorkspace.js"; - -const provider = { id: "fixture", name: "Fixture", version: "1" }; - -describe("evidence MCP resources", () => { - it("publishes mutable snapshot changes for every Evidence mutation", () => { - const session = new BinarySession(() => ({ - health: () => Promise.resolve(), - execute: () => Promise.resolve(observed(null)), - close: () => Promise.resolve(), - })); - let updates = 0; - session.onAnalysisSnapshotChanged(() => { - updates += 1; - }); - const first = createEvidence(undefined, provider, { - operation: "notification_probe", - parameters: {}, - result: 1, - }); - expect(session.recordEvidence(first)).toEqual({ ok: true, value: "added" }); - expect(session.recordEvidence(first)).toEqual({ - ok: true, - value: "duplicate", - }); - const imported = createEvidence(undefined, provider, { - operation: "import_notification_probe", - parameters: {}, - result: 2, - }); - expect( - session.importEvidenceBundle(createEvidenceBundle([imported])), - ).toEqual({ ok: true, value: 1 }); - expect( - session.recordUnknown({ - approved: true, - question: "Does mutation publish a snapshot update?", - severity: "low", - domain: "notifications", - supporting_evidence_ids: [first.evidence_id], - contradicting_evidence_ids: [], - required_authority: "controlled-replay", - required_confidence: "observed", - required_environment: null, - recommended_probes: [], - relationships: [], - }).ok, - ).toBe(true); - expect(updates).toBe(3); - }); - - it("retains bundles without eviction and rejects a seventeenth digest", () => { - const session = new BinarySession(() => ({ - health: () => Promise.resolve(), - execute: () => Promise.resolve(observed(null)), - close: () => Promise.resolve(), - })); - const first = session.snapshotEvidenceBundle(); - expect(first.ok).toBe(true); - for (let index = 0; index < 15; index += 1) { - session.recordEvidence( - createEvidence(undefined, provider, { - operation: `retention_probe_${String(index)}`, - parameters: {}, - result: index, - }), - ); - expect(session.snapshotEvidenceBundle().ok).toBe(true); - } - session.recordEvidence( - createEvidence(undefined, provider, { - operation: "retention_probe_overflow", - parameters: {}, - result: true, - }), - ); - expect(session.snapshotEvidenceBundle()).toMatchObject({ - ok: false, - error: { _tag: "EvidenceLimitError", limit: "records", maximum: 16 }, - }); - if (first.ok) - expect(session.retainedEvidenceBundle(first.value.bundleDigest)).toEqual( - expect.any(String), - ); - }); - - it("lists and reads only evidence owned by the current session", async () => { - const session = new BinarySession(() => ({ - health: () => Promise.resolve(), - execute: () => Promise.resolve(observed(null)), - close: () => Promise.resolve(), - })); - const evidence = createEvidence(undefined, provider, { - operation: "resource_probe", - parameters: {}, - result: { observed: true }, - }); - session.recordEvidence(evidence); - const capture = createEvidence(undefined, provider, { - operation: "capture_process_scenario", - parameters: {}, - result: { - frames: [{ sequence: 0, at_ms: 0, data: "ready" }], - protocol_events: [], - replay_transitions: [], - }, - }); - session.recordEvidence(capture); - const workspace = createInvestigationWorkspace( - "resource test", - createEvidenceBundle([]), - [], - ); - session.retainInvestigationWorkspace(workspace); - const unknown = session.recordUnknown({ - approved: true, - question: "Was every branch observed?", - severity: "medium", - domain: "control-flow", - supporting_evidence_ids: [evidence.evidence_id], - contradicting_evidence_ids: [], - required_authority: "controlled-replay", - required_confidence: "observed", - required_environment: null, - recommended_probes: [], - relationships: [], - }); - expect(unknown.ok).toBe(true); - const server = createServer(session, session); - const client = new Client({ name: "evidence-resource-test", version: "1" }); - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - try { - await server.connect(serverTransport); - await client.connect(clientTransport); - - const templates = await client.listResourceTemplates(); - expect(templates.resourceTemplates).toContainEqual( - expect.objectContaining({ uriTemplate: "rea://evidence/{evidenceId}" }), - ); - expect( - templates.resourceTemplates.map(({ uriTemplate }) => uriTemplate), - ).toEqual( - expect.arrayContaining([ - "rea://artifact/{manifestId}/{collection}", - "rea://function/{targetSha256}/{address}", - "rea://evidence-bundle/{bundleDigest}", - "rea://workspace/{workspaceId}/revision/{revision}", - ]), - ); - expect(templates.resourceTemplates).toContainEqual( - expect.objectContaining({ - uriTemplate: "rea://evidence/{evidenceId}/section/{section}", - }), - ); - const listed = await client.listResources(); - expect(listed.resources).toContainEqual( - expect.objectContaining({ - uri: "rea://snapshot/current", - }), - ); - expect(listed.resources).not.toContainEqual( - expect.objectContaining({ - uri: `rea://evidence/${evidence.evidence_id}`, - }), - ); - const read = await client.readResource({ - uri: `rea://evidence/${evidence.evidence_id}`, - }); - expect(read.contents).toEqual([ - expect.objectContaining({ - uri: `rea://evidence/${evidence.evidence_id}`, - mimeType: "application/json", - text: expect.stringContaining(evidence.evidence_id), - }), - ]); - const firstSnapshot = await client.callTool({ - name: "snapshot_evidence_bundle", - arguments: {}, - }); - const secondSnapshot = await client.callTool({ - name: "snapshot_evidence_bundle", - arguments: {}, - }); - expect(secondSnapshot.structuredContent).toEqual( - firstSnapshot.structuredContent, - ); - expect(firstSnapshot.content).toContainEqual( - expect.objectContaining({ - type: "text", - text: expect.stringContaining("Copy the opaque URI exactly"), - }), - ); - const bundleUri = z - .object({ result: z.object({ bundle_uri: z.string() }) }) - .parse(firstSnapshot.structuredContent).result.bundle_uri; - const retainedBundle = await client.readResource({ uri: bundleUri }); - expect(retainedBundle.contents[0]).toEqual( - expect.objectContaining({ - uri: bundleUri, - text: expect.stringContaining(evidence.evidence_id), - }), - ); - const terminal = await client.readResource({ - uri: `rea://evidence/${capture.evidence_id}/section/terminal`, - }); - const workspaceResource = await client.readResource({ - uri: `rea://workspace/${workspace.workspace_id}/revision/${String(workspace.revision)}`, - }); - expect(workspaceResource.contents[0]).toEqual( - expect.objectContaining({ - text: expect.stringContaining(workspace.revision_digest), - }), - ); - expect(terminal.contents[0]).toEqual( - expect.objectContaining({ text: expect.stringContaining("ready") }), - ); - if (unknown.ok) { - const unknownResource = await client.readResource({ - uri: `rea://unknown/${unknown.value.unknown_id}`, - }); - expect(unknownResource.contents[0]).toEqual( - expect.objectContaining({ - text: expect.stringContaining(unknown.value.revision_digest), - }), - ); - } - await expect( - client.readResource({ uri: `rea://evidence/ev_${"0".repeat(64)}` }), - ).rejects.toThrow(/not found/iu); - } finally { - await Promise.allSettled([ - client.close(), - server.close(), - session.close(), - ]); - } - }); -}); diff --git a/tests/fixtures/fakeCdpBrowser.ts b/tests/fixtures/fakeCdpBrowser.ts index 66738fefb..ea8e8afd0 100644 --- a/tests/fixtures/fakeCdpBrowser.ts +++ b/tests/fixtures/fakeCdpBrowser.ts @@ -2,73 +2,20 @@ import { createServer, type IncomingMessage } from "node:http"; import { WebSocket, WebSocketServer } from "ws"; -interface FakeCdpCommand { - readonly id: number; - readonly method: string; - readonly params: Readonly>; - readonly sessionId?: string; -} - -export interface FakeCdpBrowser { - readonly endpoint: string; - readonly browserWebSocketUrl: string; - readonly allowedOrigin: string; - readonly commands: readonly FakeCdpCommand[]; - readonly httpRequests: readonly { - readonly url: string; - readonly authorization: string | undefined; - readonly cookie: string | undefined; - readonly referer: string | undefined; - }[]; - close(): Promise; -} - -interface FakeOptions { - readonly malformedDiscovery?: boolean; - readonly oversizedDiscovery?: boolean; - readonly invalidBrowserWebSocket?: boolean; - readonly pageScopedVersionWebSocket?: boolean; - readonly omitTargetWebSocket?: boolean; - readonly additionalPageWithWebSocket?: boolean; - readonly additionalPageWithoutWebSocket?: boolean; - readonly invalidAttachedSession?: boolean; - readonly malformedMessageOnMethod?: string; - readonly malformedEventOnMethod?: string; - readonly malformedEventShapeOnMethod?: string; - readonly closeOnMethod?: string; - readonly hangOnMethod?: string; - readonly unsupportedMethods?: readonly string[]; - readonly transitionalFrameReads?: number; - readonly attachedFrameUrl?: string; - readonly frameUrlAfterFirstRead?: string; - readonly navigateDuringObservationUrl?: string; - readonly navigateDuringCaptureUrl?: string; - readonly navigateDuringScreenshotUrl?: string; - readonly extraCollections?: boolean; - readonly foreignSessionEvents?: boolean; - readonly redirectToDisallowedOrigin?: boolean; - readonly unrelatedWorker?: boolean; - readonly binaryWebSocketEvent?: boolean; - readonly invalidBinaryWebSocketEvent?: boolean; - readonly sourceMapBody?: string; - readonly sessionTimeline?: - | "same_origin" - | "outside_policy" - | "target_detached"; - readonly closeAfterMethod?: string; - readonly sensitiveShapes?: boolean; - readonly invalidResponseBodyBase64?: boolean; - readonly webMcpTools?: boolean; - readonly webMcpChildLeavesScope?: boolean; - readonly electronFileUrl?: string; - readonly duplicateElectronInventory?: boolean; - readonly urlShapedAllowedTitle?: - | boolean - | "host-path" - | "root-relative" - | "prefixed"; -} +import { emitEvents } from "./fakeCdpBrowserEvents.js"; +import { resultFor, versionTargetId } from "./fakeCdpBrowserResults.js"; +import { parseCommand, targets } from "./fakeCdpBrowserTargets.js"; +import type { + FakeCdpBrowser, + FakeCdpCommand, + FakeOptions, +} from "./fakeCdpBrowserTypes.js"; +export type { + FakeCdpBrowser, + FakeCdpCommand, + FakeOptions, +} from "./fakeCdpBrowserTypes.js"; /** Start a real HTTP/WebSocket fake at the same seams as a user-owned browser. */ export const startFakeCdpBrowser = async ( options: FakeOptions = {}, @@ -200,10 +147,7 @@ export const startFakeCdpBrowser = async ( http.once("error", reject); http.listen(0, "127.0.0.1", () => resolve()); }); - const address = http.address(); - if (address === null || typeof address === "string") - throw new Error("Fake CDP server did not bind a TCP address"); - port = address.port; + port = boundPort(http.address()); const endpoint = `http://127.0.0.1:${String(port)}`; return { endpoint, @@ -223,940 +167,10 @@ export const startFakeCdpBrowser = async ( }; }; -const targets = ( - port: number, - options: FakeOptions = {}, -): readonly Record[] => [ - { - id: "allowed-page", - type: "page", - title: allowedPageTitle(port, options.urlShapedAllowedTitle), - url: `http://127.0.0.1:${String(port)}/app?token=page-secret#fragment`, - attached: false, - ...(options.omitTargetWebSocket === true - ? {} - : { - webSocketDebuggerUrl: `ws://localhost:${String(port)}/devtools/page/allowed-page`, - }), - }, - { - id: "disallowed-page", - type: "page", - title: "Must not leak", - url: "https://private.example.test/app?token=forbidden", - attached: false, - }, - ...(options.additionalPageWithWebSocket === true - ? [ - { - id: "allowed-page-with-socket", - type: "page", - title: "Second inspectable application page", - url: `http://127.0.0.1:${String(port)}/second`, - attached: false, - webSocketDebuggerUrl: `ws://localhost:${String(port)}/devtools/page/allowed-page-with-socket`, - }, - ] - : []), - ...(options.additionalPageWithoutWebSocket === true - ? [ - { - id: "allowed-page-without-socket", - type: "page", - title: "Unavailable application page", - url: `http://127.0.0.1:${String(port)}/unavailable`, - attached: false, - }, - ] - : []), - { - id: "unsupported-page", - type: "page", - title: "Internal page", - url: "chrome://settings/", - attached: false, - }, - { - id: "worker-1", - type: "service_worker", - title: "Worker", - url: `http://127.0.0.1:${String(port)}/worker.js?secret=worker`, - attached: false, - openerId: "allowed-page", - }, - ...(options.extraCollections === true - ? [ - { - id: "worker-2", - type: "shared_worker", - title: "Second worker", - url: `http://127.0.0.1:${String(port)}/worker-2.js`, - attached: false, - openerId: "allowed-page", - }, - ] - : []), - ...(options.unrelatedWorker === true - ? [ - { - id: "worker-other-page", - type: "dedicated_worker", - title: "Other page worker", - url: `http://127.0.0.1:${String(port)}/other-page-worker.js`, - attached: false, - openerId: "other-page", - }, - ] - : []), - ...(options.electronFileUrl === undefined - ? [] - : [ - { - id: "electron-page", - type: "page", - title: "Electron application", - url: options.electronFileUrl, - attached: false, - ...(options.omitTargetWebSocket === true - ? {} - : { - webSocketDebuggerUrl: `ws://localhost:${String(port)}/devtools/page/electron-page`, - }), - }, - { - id: "electron-worker", - type: "worker", - title: "Electron worker", - url: new URL("worker.js", options.electronFileUrl).href, - attached: false, - openerId: "electron-page", - }, - ]), -]; - -const allowedPageTitle = ( - port: number, - style: FakeOptions["urlShapedAllowedTitle"], -): string => { - const suffix = "/app?startup=title-secret#fragment"; - const host = `127.0.0.1:${String(port)}`; - if (style === true) return `http://${host}${suffix}`; - if (style === "host-path") return `${host}${suffix}`; - if (style === "root-relative") return suffix; - if (style === "prefixed") return `Loading ${host}${suffix}`; - return "Inspectable application"; +const boundPort = ( + address: string | null | { readonly port: number }, +): number => { + if (address === null || typeof address === "string") + throw new Error("Fake CDP server did not bind a TCP address"); + return address.port; }; - -const parseCommand = (text: string): FakeCdpCommand => { - const value: unknown = JSON.parse(text); - if (typeof value !== "object" || value === null) - throw new TypeError("Expected CDP command object"); - if (!("id" in value) || typeof value.id !== "number") - throw new TypeError("Expected CDP command id"); - if (!("method" in value) || typeof value.method !== "string") - throw new TypeError("Expected CDP method"); - const params = - "params" in value && isRecord(value.params) ? value.params : {}; - return { - id: value.id, - method: value.method, - params, - ...("sessionId" in value && typeof value.sessionId === "string" - ? { sessionId: value.sessionId } - : {}), - }; -}; - -const isRecord = (value: unknown): value is Readonly> => - typeof value === "object" && value !== null && !Array.isArray(value); - -const resultFor = ( - command: FakeCdpCommand, - port: number, - options: FakeOptions, - frameTreeReads: number, -): Readonly> => { - switch (command.method) { - case "Target.attachToTarget": - return attachToTargetResult(options); - case "Page.getFrameTree": - return frameTreeResult(port, options, frameTreeReads); - case "Page.getResourceTree": - return resourceTreeResult(port, options); - case "DOMSnapshot.captureSnapshot": - return domSnapshotResult(port, options); - case "Accessibility.getFullAXTree": - return accessibilityTreeResult(options); - case "Debugger.getScriptSource": - return scriptSourceResult(); - case "Network.getResponseBody": - return responseBodyResult(options); - case "Page.captureScreenshot": - return screenshotResult(); - case "Target.getTargets": - return { targetInfos: targets(port, options).map(endpointTargetToInfo) }; - case "Storage.getUsageAndQuota": - return storageUsageResult(); - case "DOMStorage.getDOMStorageItems": - return domStorageResult(options); - case "Network.getCookies": - return cookieStorageResult(); - case "IndexedDB.requestDatabaseNames": - return indexedDbResult(options); - case "IndexedDB.requestDatabase": - return indexedDbDatabaseResult(command); - case "IndexedDB.requestData": - return indexedDbDataResult(); - case "CacheStorage.requestCacheNames": - return cacheStorageResult(options); - case "CacheStorage.requestEntries": - return cacheEntriesResult(port); - case "CacheStorage.requestCachedResponse": - return cachedResponseResult(); - default: - return {}; - } -}; - -const attachToTargetResult = (options: FakeOptions) => ({ - sessionId: options.invalidAttachedSession ? "" : "session-1", -}); - -const frameTreeResult = ( - port: number, - options: FakeOptions, - frameTreeReads: number, -) => - frameTree( - port, - frameTreeReads > 1 && options.frameUrlAfterFirstRead !== undefined - ? options.frameUrlAfterFirstRead - : frameTreeReads <= (options.transitionalFrameReads ?? 0) - ? ":" - : (options.electronFileUrl ?? options.attachedFrameUrl), - options.extraCollections === true, - ); - -const resourceTreeResult = (port: number, options: FakeOptions) => - resourceTree( - port, - options.extraCollections === true, - options.electronFileUrl, - options.duplicateElectronInventory === true, - ); - -const domSnapshotResult = (port: number, options: FakeOptions) => - domSnapshot(port, options.electronFileUrl, options.extraCollections === true); - -const accessibilityTreeResult = (options: FakeOptions) => - accessibilityTree(options.extraCollections === true); - -const scriptSourceResult = () => ({ - scriptSource: "export const observed = 'source-secret';", -}); - -const responseBodyResult = (options: FakeOptions) => - options.invalidResponseBodyBase64 === true - ? { body: "%%%not-base64%%%", base64Encoded: true } - : { - body: JSON.stringify({ - result: { ok: true, token: "response-body-secret" }, - items: [{ id: 1 }, { id: 2 }], - }), - base64Encoded: false, - }; - -const screenshotResult = () => ({ - data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PzWvWQAAAABJRU5ErkJggg==", -}); - -const storageUsageResult = () => ({ - usage: 42, - quota: 1_024, - usageBreakdown: [], -}); - -const domStorageResult = (options: FakeOptions) => ({ - entries: - options.extraCollections === true - ? [ - ["public-key", "storage-secret"], - ["second-key", "second-secret"], - ] - : [["public-key", "storage-secret"]], -}); - -const cookieStorageResult = () => ({ - cookies: [ - { - name: "session", - value: "cookie-secret", - domain: "127.0.0.1", - path: "/", - secure: false, - httpOnly: true, - }, - ], -}); - -const indexedDbResult = (options: FakeOptions) => ({ - databaseNames: - options.extraCollections === true ? ["app-db", "second-db"] : ["app-db"], -}); - -const indexedDbDatabaseResult = (command: FakeCdpCommand) => ({ - databaseWithObjectStores: { - name: - typeof command.params.databaseName === "string" - ? command.params.databaseName - : "app-db", - version: 1, - objectStores: [ - { - name: "records", - keyPath: { type: "string", string: "id" }, - autoIncrement: false, - indexes: [], - }, - ], - }, -}); - -const indexedDbDataResult = () => ({ - objectStoreDataEntries: [ - { - key: { type: "string", value: "row-1" }, - primaryKey: { type: "string", value: "row-1" }, - value: { type: "string", value: "indexed-db-secret" }, - }, - ], - hasMore: false, -}); - -const cacheStorageResult = (options: FakeOptions) => ({ - caches: [ - { cacheName: "assets-v1", cacheId: "secret-id" }, - ...(options.extraCollections === true - ? [{ cacheName: "assets-v2", cacheId: "second-secret-id" }] - : []), - ], -}); - -const cacheEntriesResult = (port: number) => ({ - cacheDataEntries: [ - { - requestURL: `http://127.0.0.1:${String(port)}/cached`, - requestMethod: "GET", - requestHeaders: [], - responseTime: 1, - responseStatus: 200, - responseStatusText: "OK", - responseType: "basic", - responseHeaders: [], - }, - ], - returnCount: 1, -}); - -const cachedResponseResult = () => ({ - response: { body: Buffer.from("cache-body-secret").toString("base64") }, -}); - -const versionTargetId = (options: FakeOptions): string => - options.electronFileUrl === undefined ? "allowed-page" : "electron-page"; - -const endpointTargetToInfo = ( - target: Readonly>, -): Readonly> => ({ - targetId: target.id, - type: target.type, - title: target.title, - url: target.url, - attached: target.attached, - ...(target.openerId === undefined ? {} : { openerId: target.openerId }), - ...(target.parentFrameId === undefined - ? {} - : { parentFrameId: target.parentFrameId }), -}); - -const frameTree = ( - port: number, - overrideUrl?: string, - extraCollections = false, -): Readonly> => ({ - frameTree: { - frame: { - id: "frame-main", - loaderId: "loader-main", - url: - overrideUrl ?? - `http://127.0.0.1:${String(port)}/app?token=frame-secret`, - }, - childFrames: [ - ...(extraCollections - ? [ - { - frame: { - id: "frame-child", - parentId: "frame-main", - loaderId: "loader-child", - url: `http://127.0.0.1:${String(port)}/child`, - }, - }, - ] - : []), - { - frame: { - id: "frame-private", - parentId: "frame-main", - loaderId: "loader-private", - url: "https://private.example.test/frame?secret=forbidden", - }, - }, - ], - }, -}); - -const resourceTree = ( - port: number, - extraCollections = false, - electronFileUrl?: string, - duplicateElectronInventory = false, -): Readonly> => ({ - frameTree: { - frame: { - id: "frame-main", - loaderId: "loader-main", - url: electronFileUrl ?? `http://127.0.0.1:${String(port)}/app`, - }, - resources: [ - { - url: - electronFileUrl === undefined - ? `http://127.0.0.1:${String(port)}/app.js?token=script-secret` - : new URL("app.js", electronFileUrl).href, - type: "Script", - mimeType: "text/javascript", - contentSize: 128, - }, - ...(duplicateElectronInventory - ? [ - { - url: - electronFileUrl === undefined - ? `http://127.0.0.1:${String(port)}/app.js?token=script-secret` - : new URL("app.js", electronFileUrl).href, - type: "Script", - mimeType: "text/javascript", - contentSize: 128, - }, - ] - : []), - ...(extraCollections - ? [ - { - url: `http://127.0.0.1:${String(port)}/app.css`, - type: "Stylesheet", - mimeType: "text/css", - contentSize: 64, - }, - ] - : []), - { - url: "https://private.example.test/private.js?secret=forbidden", - type: "Script", - mimeType: "text/javascript", - }, - ], - }, -}); - -const domSnapshot = ( - port: number, - electronFileUrl?: string, - extraCollections = false, -): Readonly> => { - const secondDocumentUrl = - electronFileUrl !== undefined && extraCollections - ? new URL("child.html", electronFileUrl).href - : "https://private.example.test/frame"; - const strings = [ - electronFileUrl ?? `http://127.0.0.1:${String(port)}/app`, - "#document", - "", - "LINK", - "token", - "dom-secret", - secondDocumentUrl, - "PRIVATE-TEXT", - "href", - "/agent?token=dom-url-secret", - "rel", - "mcp", - "DIV", - "child text", - ]; - return { - strings, - documents: [ - { - documentURL: 0, - nodes: { - nodeType: [9, 1], - nodeName: [1, 3], - nodeValue: [2, 2], - parentIndex: [-1, 0], - attributes: [[], [4, 5, 8, 9, 10, 11]], - }, - }, - { - documentURL: 6, - nodes: - electronFileUrl !== undefined && extraCollections - ? { - nodeType: [9, 1], - nodeName: [1, 12], - nodeValue: [2, 13], - parentIndex: [-1, 0], - attributes: [[], []], - } - : { - nodeType: [3], - nodeName: [1], - nodeValue: [7], - parentIndex: [-1], - attributes: [[]], - }, - }, - ], - }; -}; - -const accessibilityTree = ( - extraCollections = false, -): Readonly> => ({ - nodes: [ - { - nodeId: "ax-1", - ignored: false, - role: { type: "role", value: "button" }, - name: { type: "computedString", value: "Submit report" }, - description: { type: "computedString", value: "Sends the form" }, - childIds: extraCollections ? ["ax-2"] : [], - properties: [ - { - name: "disabled", - value: { type: "boolean", value: false }, - }, - { - name: "expanded", - value: { type: "booleanOrUndefined", value: false }, - }, - ], - }, - ...(extraCollections - ? [ - { - nodeId: "ax-2", - parentId: "ax-1", - ignored: false, - role: { type: "role", value: "link" }, - name: { type: "computedString", value: "Second action" }, - }, - ] - : []), - ], -}); - -const emitEvents = ( - socket: WebSocket, - command: FakeCdpCommand, - port: number, - options: FakeOptions, -): void => { - emitDebuggerEvents(socket, command, port, options); - if (command.method === "Network.enable") { - if (options.navigateDuringObservationUrl !== undefined) - event(socket, "Page.frameNavigated", command.sessionId, { - frame: { - id: "frame-main", - url: options.navigateDuringObservationUrl, - }, - }); - const url = `http://127.0.0.1:${String(port)}/api?token=network-secret`; - event(socket, "Network.requestWillBeSent", command.sessionId, { - requestId: "request-1", - type: "Fetch", - request: { - url, - method: "POST", - headers: { - Authorization: "Bearer request-secret", - "Content-Type": "application/json; charset=utf-8", - }, - postData: JSON.stringify({ - operation: "lookup", - token: "request-body-secret", - filters: { active: true }, - }), - }, - initiator: { - type: "script", - stack: { - callFrames: [ - { - url: `http://127.0.0.1:${String(port)}/app.js?caller=caller-secret`, - lineNumber: 3, - columnNumber: 5, - }, - ], - }, - }, - }); - if (options.redirectToDisallowedOrigin === true) - event(socket, "Network.requestWillBeSent", command.sessionId, { - requestId: "request-1", - type: "Fetch", - request: { - url: "https://private.example.test/redirected", - method: "GET", - }, - }); - event(socket, "Network.responseReceived", command.sessionId, { - requestId: "request-1", - response: { - url: - options.redirectToDisallowedOrigin === true - ? "https://private.example.test/redirected" - : url, - status: 200, - mimeType: "application/json", - headers: { - "Set-Cookie": "response-secret", - "Content-Length": "321", - "Content-Encoding": "br", - "Content-Security-Policy": - "default-src 'self' https:; script-src 'nonce-csp-secret' 'sha256-hash-secret' https://private.example.test https://127.0.0.1", - Link: `; rel="mcp service-desc"; type="application/json", ; rel="mcp"`, - "Cross-Origin-Opener-Policy": "same-origin", - "Cross-Origin-Embedder-Policy": "require-corp", - "Permissions-Policy": "camera=(), geolocation=(self)", - "X-Model-Context": "header-secret", - }, - }, - }); - event(socket, "Network.loadingFinished", command.sessionId, { - requestId: "request-1", - encodedDataLength: 321, - }); - event(socket, "Network.webSocketCreated", command.sessionId, { - requestId: "websocket-1", - url: `ws://127.0.0.1:${String(port)}/live?token=websocket-url-secret`, - }); - event(socket, "Network.webSocketFrameSent", command.sessionId, { - requestId: "websocket-1", - response: { - opcode: 1, - payloadData: - options.sensitiveShapes === true - ? JSON.stringify({ - event: "updated", - token: "websocket-secret", - payload: { count: 2 }, - }) - : "websocket-secret", - }, - }); - if (options.binaryWebSocketEvent === true) - event(socket, "Network.webSocketFrameReceived", command.sessionId, { - requestId: "websocket-1", - response: { - opcode: 2, - payloadData: - options.invalidBinaryWebSocketEvent === true ? "%%%" : "AQID", - }, - }); - } - emitRuntimeEvents(socket, command, port, options); - if (command.method === "WebMCP.enable" && options.webMcpTools === true) { - event(socket, "WebMCP.toolsAdded", command.sessionId, { - tools: [ - { - name: "search_orders", - description: "Search orders; authorization=Bearer tool-secret", - frameId: "frame-main", - backendNodeId: 42, - inputSchema: { - type: "object", - properties: { - orderId: { type: "string", example: "schema-secret" }, - includeItems: { type: "boolean", default: true }, - }, - required: ["orderId"], - }, - annotations: { - readOnly: true, - untrustedContent: true, - autosubmit: false, - }, - stackTrace: { - callFrames: [ - { - url: `http://127.0.0.1:${String(port)}/app.js?token=tool-source-secret`, - lineNumber: 12, - columnNumber: 4, - }, - ], - }, - }, - { - name: "private_tool", - description: "private-tool-secret", - frameId: "frame-private", - }, - ...(options.extraCollections === true - ? [ - { - name: "update_order", - description: "Update an order", - frameId: "frame-main", - inputSchema: { - type: "object", - properties: { orderId: { type: "string" } }, - }, - }, - ] - : []), - ...(options.webMcpChildLeavesScope === true - ? [ - { - name: "child_tool", - description: "Must be removed after child navigation", - frameId: "frame-child", - }, - ] - : []), - ], - }); - if (options.webMcpChildLeavesScope === true) { - event(socket, "Page.frameNavigated", command.sessionId, { - frame: { - id: "frame-child", - parentId: "frame-main", - url: "https://private.example.test/escaped", - }, - }); - event(socket, "WebMCP.toolsAdded", command.sessionId, { - tools: [ - { - name: "escaped_child_tool", - description: "cross-origin-child-secret", - frameId: "frame-child", - }, - ], - }); - } - } - emitCaptureNavigation(socket, command, options); - if ( - command.method === "Page.setLifecycleEventsEnabled" && - options.sessionTimeline !== undefined - ) { - const allowed = `http://127.0.0.1:${String(port)}/reloaded?token=session-secret`; - event(socket, "Page.frameRequestedNavigation", command.sessionId, { - frameId: "frame-main", - url: allowed, - reason: "reload", - timestamp: 10, - }); - event(socket, "Page.frameNavigated", command.sessionId, { - frame: { - id: "frame-main", - loaderId: "loader-reload", - url: allowed, - transitionType: "reload", - }, - timestamp: 11, - }); - event(socket, "Page.navigatedWithinDocument", command.sessionId, { - frameId: "frame-main", - url: `${allowed}#spa-secret`, - navigationType: "historyApi", - timestamp: 12, - }); - const redirectUrl = - options.sessionTimeline === "outside_policy" - ? "https://private.example.test/outside?token=redirect-secret" - : `http://127.0.0.1:${String(port)}/redirected?token=redirect-secret`; - event(socket, "Network.requestWillBeSent", command.sessionId, { - requestId: "document-request", - frameId: "frame-main", - loaderId: "loader-redirect", - request: { url: redirectUrl, method: "GET" }, - redirectResponse: { status: 302 }, - timestamp: 13, - }); - event(socket, "Network.loadingFailed", command.sessionId, { - requestId: "failed-request", - frameId: "frame-main", - loaderId: "loader-reload", - errorText: "net::ERR_CONNECTION_REFUSED", - timestamp: 14, - }); - event(socket, "Page.lifecycleEvent", command.sessionId, { - frameId: "frame-main", - loaderId: "loader-reload", - name: "networkIdle", - timestamp: 15, - }); - if (options.sessionTimeline === "target_detached") - event(socket, "Target.detachedFromTarget", undefined, { - sessionId: command.sessionId, - targetId: "allowed-page", - }); - } -}; - -const emitDebuggerEvents = ( - socket: WebSocket, - command: FakeCdpCommand, - port: number, - options: FakeOptions, -): void => { - if (command.method !== "Debugger.enable") return; - if (options.foreignSessionEvents === true) - event(socket, "Debugger.scriptParsed", "foreign-session", { - scriptId: "script-foreign", - url: `http://127.0.0.1:${String(port)}/foreign.js`, - hash: "foreign-hash", - length: 20, - isModule: false, - }); - event(socket, "Debugger.scriptParsed", command.sessionId, { - scriptId: "script-allowed", - url: - options.electronFileUrl === undefined - ? `http://127.0.0.1:${String(port)}/app.js?token=script-secret` - : new URL("app.js", options.electronFileUrl).href, - hash: "cdp-hash", - length: 40, - isModule: true, - executionContextId: 1, - scriptLanguage: "JavaScript", - sourceMapURL: "/app.js.map?token=map-secret", - }); - if ( - options.electronFileUrl !== undefined && - options.duplicateElectronInventory === true - ) - event(socket, "Debugger.scriptParsed", command.sessionId, { - scriptId: "script-allowed-duplicate", - url: new URL("app.js", options.electronFileUrl).href, - hash: "cdp-hash", - length: 40, - isModule: true, - executionContextId: 1, - scriptLanguage: "JavaScript", - }); - event(socket, "Debugger.scriptParsed", command.sessionId, { - scriptId: "script-private", - url: "https://private.example.test/private.js?secret=forbidden", - hash: "private-hash", - length: 100, - isModule: false, - }); - event(socket, "Debugger.scriptParsed", command.sessionId, { - scriptId: "script-inline", - url: "", - hash: "inline-secret", - length: 100, - isModule: false, - }); -}; - -const emitRuntimeEvents = ( - socket: WebSocket, - command: FakeCdpCommand, - port: number, - options: FakeOptions, -): void => { - if (command.method !== "Runtime.enable") return; - event(socket, "Runtime.executionContextCreated", command.sessionId, { - context: { - id: 1, - origin: - options.electronFileUrl === undefined - ? `http://127.0.0.1:${String(port)}` - : "file://", - auxData: { frameId: "frame-main", isDefault: true }, - }, - }); - event(socket, "Runtime.consoleAPICalled", command.sessionId, { - type: "log", - timestamp: 123, - args: [ - { - type: "string", - value: - options.sensitiveShapes === true - ? "authorization=Bearer console-secret" - : "console-secret", - }, - ...(options.sensitiveShapes === true - ? [ - { type: "number", value: 42 }, - { - type: "object", - objectId: "must-not-be-expanded", - description: "object-secret", - }, - ] - : []), - ], - stackTrace: { - callFrames: [ - { - url: `http://127.0.0.1:${String(port)}/app.js?secret=console-url`, - lineNumber: 7, - columnNumber: 9, - }, - ], - }, - }); - event(socket, "Runtime.consoleAPICalled", command.sessionId, { - type: "unknown-origin-console-secret", - timestamp: 124, - args: [{ type: "string", value: "unknown-console-value-secret" }], - }); -}; - -const emitCaptureNavigation = ( - socket: WebSocket, - command: FakeCdpCommand, - options: FakeOptions, -): void => { - const navigationUrl = - command.method === "DOMSnapshot.captureSnapshot" - ? options.navigateDuringCaptureUrl - : command.method === "Page.captureScreenshot" - ? options.navigateDuringScreenshotUrl - : undefined; - if (navigationUrl !== undefined) - event(socket, "Page.frameNavigated", command.sessionId, { - frame: { id: "frame-main", url: navigationUrl }, - }); -}; - -const event = ( - socket: WebSocket, - method: string, - sessionId: string | undefined, - params: Readonly>, -): void => - socket.send( - JSON.stringify({ - method, - params, - ...(sessionId === undefined ? {} : { sessionId }), - }), - ); diff --git a/tests/fixtures/fakeCdpBrowserEvents.ts b/tests/fixtures/fakeCdpBrowserEvents.ts new file mode 100644 index 000000000..be0895fa1 --- /dev/null +++ b/tests/fixtures/fakeCdpBrowserEvents.ts @@ -0,0 +1,404 @@ +import { type WebSocket } from "ws"; + +import type { FakeCdpCommand, FakeOptions } from "./fakeCdpBrowserTypes.js"; + +export const emitEvents = ( + socket: WebSocket, + command: FakeCdpCommand, + port: number, + options: FakeOptions, +): void => { + emitDebuggerEvents(socket, command, port, options); + emitNetworkEvents(socket, command, port, options); + emitRuntimeEvents(socket, command, port, options); + if (command.method === "WebMCP.enable" && options.webMcpTools === true) { + event(socket, "WebMCP.toolsAdded", command.sessionId, { + tools: [ + { + name: "search_orders", + description: "Search orders; authorization=Bearer tool-secret", + frameId: "frame-main", + backendNodeId: 42, + inputSchema: { + type: "object", + properties: { + orderId: { type: "string", example: "schema-secret" }, + includeItems: { type: "boolean", default: true }, + }, + required: ["orderId"], + }, + annotations: { + readOnly: true, + untrustedContent: true, + autosubmit: false, + }, + stackTrace: { + callFrames: [ + { + url: `http://127.0.0.1:${String(port)}/app.js?token=tool-source-secret`, + lineNumber: 12, + columnNumber: 4, + }, + ], + }, + }, + { + name: "private_tool", + description: "private-tool-secret", + frameId: "frame-private", + }, + ...(options.extraCollections === true + ? [ + { + name: "update_order", + description: "Update an order", + frameId: "frame-main", + inputSchema: { + type: "object", + properties: { orderId: { type: "string" } }, + }, + }, + ] + : []), + ...(options.webMcpChildLeavesScope === true + ? [ + { + name: "child_tool", + description: "Must be removed after child navigation", + frameId: "frame-child", + }, + ] + : []), + ], + }); + if (options.webMcpChildLeavesScope === true) { + event(socket, "Page.frameNavigated", command.sessionId, { + frame: { + id: "frame-child", + parentId: "frame-main", + url: "https://private.example.test/escaped", + }, + }); + event(socket, "WebMCP.toolsAdded", command.sessionId, { + tools: [ + { + name: "escaped_child_tool", + description: "cross-origin-child-secret", + frameId: "frame-child", + }, + ], + }); + } + } + emitCaptureNavigation(socket, command, options); + if ( + command.method === "Page.setLifecycleEventsEnabled" && + options.sessionTimeline !== undefined + ) { + const allowed = `http://127.0.0.1:${String(port)}/reloaded?token=session-secret`; + event(socket, "Page.frameRequestedNavigation", command.sessionId, { + frameId: "frame-main", + url: allowed, + reason: "reload", + timestamp: 10, + }); + event(socket, "Page.frameNavigated", command.sessionId, { + frame: { + id: "frame-main", + loaderId: "loader-reload", + url: allowed, + transitionType: "reload", + }, + timestamp: 11, + }); + event(socket, "Page.navigatedWithinDocument", command.sessionId, { + frameId: "frame-main", + url: `${allowed}#spa-secret`, + navigationType: "historyApi", + timestamp: 12, + }); + const redirectUrl = + options.sessionTimeline === "outside_policy" + ? "https://private.example.test/outside?token=redirect-secret" + : `http://127.0.0.1:${String(port)}/redirected?token=redirect-secret`; + event(socket, "Network.requestWillBeSent", command.sessionId, { + requestId: "document-request", + frameId: "frame-main", + loaderId: "loader-redirect", + request: { url: redirectUrl, method: "GET" }, + redirectResponse: { status: 302 }, + timestamp: 13, + }); + event(socket, "Network.loadingFailed", command.sessionId, { + requestId: "failed-request", + frameId: "frame-main", + loaderId: "loader-reload", + errorText: "net::ERR_CONNECTION_REFUSED", + timestamp: 14, + }); + event(socket, "Page.lifecycleEvent", command.sessionId, { + frameId: "frame-main", + loaderId: "loader-reload", + name: "networkIdle", + timestamp: 15, + }); + if (options.sessionTimeline === "target_detached") + event(socket, "Target.detachedFromTarget", undefined, { + sessionId: command.sessionId, + targetId: "allowed-page", + }); + } +}; + +const emitNetworkEvents = ( + socket: WebSocket, + command: FakeCdpCommand, + port: number, + options: FakeOptions, +): void => { + if (command.method !== "Network.enable") return; + if (options.navigateDuringObservationUrl !== undefined) + event(socket, "Page.frameNavigated", command.sessionId, { + frame: { + id: "frame-main", + url: options.navigateDuringObservationUrl, + }, + }); + const url = `http://127.0.0.1:${String(port)}/api?token=network-secret`; + event(socket, "Network.requestWillBeSent", command.sessionId, { + requestId: "request-1", + type: "Fetch", + request: { + url, + method: "POST", + headers: { + Authorization: "Bearer request-secret", + "Content-Type": "application/json; charset=utf-8", + }, + postData: JSON.stringify({ + operation: "lookup", + token: "request-body-secret", + filters: { active: true }, + }), + }, + initiator: { + type: "script", + stack: { + callFrames: [ + { + url: `http://127.0.0.1:${String(port)}/app.js?caller=caller-secret`, + lineNumber: 3, + columnNumber: 5, + }, + ], + }, + }, + }); + if (options.redirectToDisallowedOrigin === true) + event(socket, "Network.requestWillBeSent", command.sessionId, { + requestId: "request-1", + type: "Fetch", + request: { + url: "https://private.example.test/redirected", + method: "GET", + }, + }); + event(socket, "Network.responseReceived", command.sessionId, { + requestId: "request-1", + response: { + url: + options.redirectToDisallowedOrigin === true + ? "https://private.example.test/redirected" + : url, + status: 200, + mimeType: "application/json", + headers: { + "Set-Cookie": "response-secret", + "Content-Length": "321", + "Content-Encoding": "br", + "Content-Security-Policy": + "default-src 'self' https:; script-src 'nonce-csp-secret' 'sha256-hash-secret' https://private.example.test https://127.0.0.1", + Link: `; rel="mcp service-desc"; type="application/json", ; rel="mcp"`, + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "require-corp", + "Permissions-Policy": "camera=(), geolocation=(self)", + "X-Model-Context": "header-secret", + }, + }, + }); + event(socket, "Network.loadingFinished", command.sessionId, { + requestId: "request-1", + encodedDataLength: 321, + }); + event(socket, "Network.webSocketCreated", command.sessionId, { + requestId: "websocket-1", + url: `ws://127.0.0.1:${String(port)}/live?token=websocket-url-secret`, + }); + event(socket, "Network.webSocketFrameSent", command.sessionId, { + requestId: "websocket-1", + response: { + opcode: 1, + payloadData: + options.sensitiveShapes === true + ? JSON.stringify({ + event: "updated", + token: "websocket-secret", + payload: { count: 2 }, + }) + : "websocket-secret", + }, + }); + if (options.binaryWebSocketEvent === true) + event(socket, "Network.webSocketFrameReceived", command.sessionId, { + requestId: "websocket-1", + response: { + opcode: 2, + payloadData: + options.invalidBinaryWebSocketEvent === true ? "%%%" : "AQID", + }, + }); +}; + +const emitDebuggerEvents = ( + socket: WebSocket, + command: FakeCdpCommand, + port: number, + options: FakeOptions, +): void => { + if (command.method !== "Debugger.enable") return; + if (options.foreignSessionEvents === true) + event(socket, "Debugger.scriptParsed", "foreign-session", { + scriptId: "script-foreign", + url: `http://127.0.0.1:${String(port)}/foreign.js`, + hash: "foreign-hash", + length: 20, + isModule: false, + }); + event(socket, "Debugger.scriptParsed", command.sessionId, { + scriptId: "script-allowed", + url: + options.electronFileUrl === undefined + ? `http://127.0.0.1:${String(port)}/app.js?token=script-secret` + : new URL("app.js", options.electronFileUrl).href, + hash: "cdp-hash", + length: 40, + isModule: true, + executionContextId: 1, + scriptLanguage: "JavaScript", + sourceMapURL: "/app.js.map?token=map-secret", + }); + if ( + options.electronFileUrl !== undefined && + options.duplicateElectronInventory === true + ) + event(socket, "Debugger.scriptParsed", command.sessionId, { + scriptId: "script-allowed-duplicate", + url: new URL("app.js", options.electronFileUrl).href, + hash: "cdp-hash", + length: 40, + isModule: true, + executionContextId: 1, + scriptLanguage: "JavaScript", + }); + event(socket, "Debugger.scriptParsed", command.sessionId, { + scriptId: "script-private", + url: "https://private.example.test/private.js?secret=forbidden", + hash: "private-hash", + length: 100, + isModule: false, + }); + event(socket, "Debugger.scriptParsed", command.sessionId, { + scriptId: "script-inline", + url: "", + hash: "inline-secret", + length: 100, + isModule: false, + }); +}; + +const emitRuntimeEvents = ( + socket: WebSocket, + command: FakeCdpCommand, + port: number, + options: FakeOptions, +): void => { + if (command.method !== "Runtime.enable") return; + event(socket, "Runtime.executionContextCreated", command.sessionId, { + context: { + id: 1, + origin: + options.electronFileUrl === undefined + ? `http://127.0.0.1:${String(port)}` + : "file://", + auxData: { frameId: "frame-main", isDefault: true }, + }, + }); + event(socket, "Runtime.consoleAPICalled", command.sessionId, { + type: "log", + timestamp: 123, + args: [ + { + type: "string", + value: + options.sensitiveShapes === true + ? "authorization=Bearer console-secret" + : "console-secret", + }, + ...(options.sensitiveShapes === true + ? [ + { type: "number", value: 42 }, + { + type: "object", + objectId: "must-not-be-expanded", + description: "object-secret", + }, + ] + : []), + ], + stackTrace: { + callFrames: [ + { + url: `http://127.0.0.1:${String(port)}/app.js?secret=console-url`, + lineNumber: 7, + columnNumber: 9, + }, + ], + }, + }); + event(socket, "Runtime.consoleAPICalled", command.sessionId, { + type: "unknown-origin-console-secret", + timestamp: 124, + args: [{ type: "string", value: "unknown-console-value-secret" }], + }); +}; + +const emitCaptureNavigation = ( + socket: WebSocket, + command: FakeCdpCommand, + options: FakeOptions, +): void => { + const navigationUrl = + command.method === "DOMSnapshot.captureSnapshot" + ? options.navigateDuringCaptureUrl + : command.method === "Page.captureScreenshot" + ? options.navigateDuringScreenshotUrl + : undefined; + if (navigationUrl !== undefined) + event(socket, "Page.frameNavigated", command.sessionId, { + frame: { id: "frame-main", url: navigationUrl }, + }); +}; + +const event = ( + socket: WebSocket, + method: string, + sessionId: string | undefined, + params: Readonly>, +): void => + socket.send( + JSON.stringify({ + method, + params, + ...(sessionId === undefined ? {} : { sessionId }), + }), + ); diff --git a/tests/fixtures/fakeCdpBrowserResults.ts b/tests/fixtures/fakeCdpBrowserResults.ts new file mode 100644 index 000000000..0cc9ca8b5 --- /dev/null +++ b/tests/fixtures/fakeCdpBrowserResults.ts @@ -0,0 +1,400 @@ +import type { FakeCdpCommand, FakeOptions } from "./fakeCdpBrowserTypes.js"; +import { targets } from "./fakeCdpBrowserTargets.js"; + +export const resultFor = ( + command: FakeCdpCommand, + port: number, + options: FakeOptions, + frameTreeReads: number, +): Readonly> => { + switch (command.method) { + case "Target.attachToTarget": + return attachToTargetResult(options); + case "Page.getFrameTree": + return frameTreeResult(port, options, frameTreeReads); + case "Page.getResourceTree": + return resourceTreeResult(port, options); + case "DOMSnapshot.captureSnapshot": + return domSnapshotResult(port, options); + case "Accessibility.getFullAXTree": + return accessibilityTreeResult(options); + case "Debugger.getScriptSource": + return scriptSourceResult(); + case "Network.getResponseBody": + return responseBodyResult(options); + case "Page.captureScreenshot": + return screenshotResult(); + case "Target.getTargets": + return { targetInfos: targets(port, options).map(endpointTargetToInfo) }; + case "Storage.getUsageAndQuota": + return storageUsageResult(); + case "DOMStorage.getDOMStorageItems": + return domStorageResult(options); + case "Network.getCookies": + return cookieStorageResult(); + case "IndexedDB.requestDatabaseNames": + return indexedDbResult(options); + case "IndexedDB.requestDatabase": + return indexedDbDatabaseResult(command); + case "IndexedDB.requestData": + return indexedDbDataResult(); + case "CacheStorage.requestCacheNames": + return cacheStorageResult(options); + case "CacheStorage.requestEntries": + return cacheEntriesResult(port); + case "CacheStorage.requestCachedResponse": + return cachedResponseResult(); + default: + return {}; + } +}; + +const attachToTargetResult = (options: FakeOptions) => ({ + sessionId: options.invalidAttachedSession ? "" : "session-1", +}); + +const frameTreeResult = ( + port: number, + options: FakeOptions, + frameTreeReads: number, +) => + frameTree( + port, + frameTreeReads > 1 && options.frameUrlAfterFirstRead !== undefined + ? options.frameUrlAfterFirstRead + : frameTreeReads <= (options.transitionalFrameReads ?? 0) + ? ":" + : (options.electronFileUrl ?? options.attachedFrameUrl), + options.extraCollections === true, + ); + +const resourceTreeResult = (port: number, options: FakeOptions) => + resourceTree( + port, + options.extraCollections === true, + options.electronFileUrl, + options.duplicateElectronInventory === true, + ); + +const domSnapshotResult = (port: number, options: FakeOptions) => + domSnapshot(port, options.electronFileUrl, options.extraCollections === true); + +const accessibilityTreeResult = (options: FakeOptions) => + accessibilityTree(options.extraCollections === true); + +const scriptSourceResult = () => ({ + scriptSource: "export const observed = 'source-secret';", +}); + +const responseBodyResult = (options: FakeOptions) => + options.invalidResponseBodyBase64 === true + ? { body: "%%%not-base64%%%", base64Encoded: true } + : { + body: JSON.stringify({ + result: { ok: true, token: "response-body-secret" }, + items: [{ id: 1 }, { id: 2 }], + }), + base64Encoded: false, + }; + +const screenshotResult = () => ({ + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PzWvWQAAAABJRU5ErkJggg==", +}); + +const storageUsageResult = () => ({ + usage: 42, + quota: 1_024, + usageBreakdown: [], +}); + +const domStorageResult = (options: FakeOptions) => ({ + entries: + options.extraCollections === true + ? [ + ["public-key", "storage-secret"], + ["second-key", "second-secret"], + ] + : [["public-key", "storage-secret"]], +}); + +const cookieStorageResult = () => ({ + cookies: [ + { + name: "session", + value: "cookie-secret", + domain: "127.0.0.1", + path: "/", + secure: false, + httpOnly: true, + }, + ], +}); + +const indexedDbResult = (options: FakeOptions) => ({ + databaseNames: + options.extraCollections === true ? ["app-db", "second-db"] : ["app-db"], +}); + +const indexedDbDatabaseResult = (command: FakeCdpCommand) => ({ + databaseWithObjectStores: { + name: + typeof command.params.databaseName === "string" + ? command.params.databaseName + : "app-db", + version: 1, + objectStores: [ + { + name: "records", + keyPath: { type: "string", string: "id" }, + autoIncrement: false, + indexes: [], + }, + ], + }, +}); + +const indexedDbDataResult = () => ({ + objectStoreDataEntries: [ + { + key: { type: "string", value: "row-1" }, + primaryKey: { type: "string", value: "row-1" }, + value: { type: "string", value: "indexed-db-secret" }, + }, + ], + hasMore: false, +}); + +const cacheStorageResult = (options: FakeOptions) => ({ + caches: [ + { cacheName: "assets-v1", cacheId: "secret-id" }, + ...(options.extraCollections === true + ? [{ cacheName: "assets-v2", cacheId: "second-secret-id" }] + : []), + ], +}); + +const cacheEntriesResult = (port: number) => ({ + cacheDataEntries: [ + { + requestURL: `http://127.0.0.1:${String(port)}/cached`, + requestMethod: "GET", + requestHeaders: [], + responseTime: 1, + responseStatus: 200, + responseStatusText: "OK", + responseType: "basic", + responseHeaders: [], + }, + ], + returnCount: 1, +}); + +const cachedResponseResult = () => ({ + response: { body: Buffer.from("cache-body-secret").toString("base64") }, +}); + +export const versionTargetId = (options: FakeOptions): string => + options.electronFileUrl === undefined ? "allowed-page" : "electron-page"; + +const endpointTargetToInfo = ( + target: Readonly>, +): Readonly> => ({ + targetId: target.id, + type: target.type, + title: target.title, + url: target.url, + attached: target.attached, + ...(target.openerId === undefined ? {} : { openerId: target.openerId }), + ...(target.parentFrameId === undefined + ? {} + : { parentFrameId: target.parentFrameId }), +}); + +const frameTree = ( + port: number, + overrideUrl?: string, + extraCollections = false, +): Readonly> => ({ + frameTree: { + frame: { + id: "frame-main", + loaderId: "loader-main", + url: + overrideUrl ?? + `http://127.0.0.1:${String(port)}/app?token=frame-secret`, + }, + childFrames: [ + ...(extraCollections + ? [ + { + frame: { + id: "frame-child", + parentId: "frame-main", + loaderId: "loader-child", + url: `http://127.0.0.1:${String(port)}/child`, + }, + }, + ] + : []), + { + frame: { + id: "frame-private", + parentId: "frame-main", + loaderId: "loader-private", + url: "https://private.example.test/frame?secret=forbidden", + }, + }, + ], + }, +}); + +const resourceTree = ( + port: number, + extraCollections = false, + electronFileUrl?: string, + duplicateElectronInventory = false, +): Readonly> => ({ + frameTree: { + frame: { + id: "frame-main", + loaderId: "loader-main", + url: electronFileUrl ?? `http://127.0.0.1:${String(port)}/app`, + }, + resources: [ + { + url: + electronFileUrl === undefined + ? `http://127.0.0.1:${String(port)}/app.js?token=script-secret` + : new URL("app.js", electronFileUrl).href, + type: "Script", + mimeType: "text/javascript", + contentSize: 128, + }, + ...(duplicateElectronInventory + ? [ + { + url: + electronFileUrl === undefined + ? `http://127.0.0.1:${String(port)}/app.js?token=script-secret` + : new URL("app.js", electronFileUrl).href, + type: "Script", + mimeType: "text/javascript", + contentSize: 128, + }, + ] + : []), + ...(extraCollections + ? [ + { + url: `http://127.0.0.1:${String(port)}/app.css`, + type: "Stylesheet", + mimeType: "text/css", + contentSize: 64, + }, + ] + : []), + { + url: "https://private.example.test/private.js?secret=forbidden", + type: "Script", + mimeType: "text/javascript", + }, + ], + }, +}); + +const domSnapshot = ( + port: number, + electronFileUrl?: string, + extraCollections = false, +): Readonly> => { + const secondDocumentUrl = + electronFileUrl !== undefined && extraCollections + ? new URL("child.html", electronFileUrl).href + : "https://private.example.test/frame"; + const strings = [ + electronFileUrl ?? `http://127.0.0.1:${String(port)}/app`, + "#document", + "", + "LINK", + "token", + "dom-secret", + secondDocumentUrl, + "PRIVATE-TEXT", + "href", + "/agent?token=dom-url-secret", + "rel", + "mcp", + "DIV", + "child text", + ]; + return { + strings, + documents: [ + { + documentURL: 0, + nodes: { + nodeType: [9, 1], + nodeName: [1, 3], + nodeValue: [2, 2], + parentIndex: [-1, 0], + attributes: [[], [4, 5, 8, 9, 10, 11]], + }, + }, + { + documentURL: 6, + nodes: + electronFileUrl !== undefined && extraCollections + ? { + nodeType: [9, 1], + nodeName: [1, 12], + nodeValue: [2, 13], + parentIndex: [-1, 0], + attributes: [[], []], + } + : { + nodeType: [3], + nodeName: [1], + nodeValue: [7], + parentIndex: [-1], + attributes: [[]], + }, + }, + ], + }; +}; + +const accessibilityTree = ( + extraCollections = false, +): Readonly> => ({ + nodes: [ + { + nodeId: "ax-1", + ignored: false, + role: { type: "role", value: "button" }, + name: { type: "computedString", value: "Submit report" }, + description: { type: "computedString", value: "Sends the form" }, + childIds: extraCollections ? ["ax-2"] : [], + properties: [ + { + name: "disabled", + value: { type: "boolean", value: false }, + }, + { + name: "expanded", + value: { type: "booleanOrUndefined", value: false }, + }, + ], + }, + ...(extraCollections + ? [ + { + nodeId: "ax-2", + parentId: "ax-1", + ignored: false, + role: { type: "role", value: "link" }, + name: { type: "computedString", value: "Second action" }, + }, + ] + : []), + ], +}); diff --git a/tests/fixtures/fakeCdpBrowserTargets.ts b/tests/fixtures/fakeCdpBrowserTargets.ts new file mode 100644 index 000000000..f4cedb4de --- /dev/null +++ b/tests/fixtures/fakeCdpBrowserTargets.ts @@ -0,0 +1,148 @@ +import type { FakeCdpCommand, FakeOptions } from "./fakeCdpBrowserTypes.js"; + +export const targets = ( + port: number, + options: FakeOptions = {}, +): readonly Record[] => [ + { + id: "allowed-page", + type: "page", + title: allowedPageTitle(port, options.urlShapedAllowedTitle), + url: `http://127.0.0.1:${String(port)}/app?token=page-secret#fragment`, + attached: false, + ...(options.omitTargetWebSocket === true + ? {} + : { + webSocketDebuggerUrl: `ws://localhost:${String(port)}/devtools/page/allowed-page`, + }), + }, + { + id: "disallowed-page", + type: "page", + title: "Must not leak", + url: "https://private.example.test/app?token=forbidden", + attached: false, + }, + ...(options.additionalPageWithWebSocket === true + ? [ + { + id: "allowed-page-with-socket", + type: "page", + title: "Second inspectable application page", + url: `http://127.0.0.1:${String(port)}/second`, + attached: false, + webSocketDebuggerUrl: `ws://localhost:${String(port)}/devtools/page/allowed-page-with-socket`, + }, + ] + : []), + ...(options.additionalPageWithoutWebSocket === true + ? [ + { + id: "allowed-page-without-socket", + type: "page", + title: "Unavailable application page", + url: `http://127.0.0.1:${String(port)}/unavailable`, + attached: false, + }, + ] + : []), + { + id: "unsupported-page", + type: "page", + title: "Internal page", + url: "chrome://settings/", + attached: false, + }, + { + id: "worker-1", + type: "service_worker", + title: "Worker", + url: `http://127.0.0.1:${String(port)}/worker.js?secret=worker`, + attached: false, + openerId: "allowed-page", + }, + ...(options.extraCollections === true + ? [ + { + id: "worker-2", + type: "shared_worker", + title: "Second worker", + url: `http://127.0.0.1:${String(port)}/worker-2.js`, + attached: false, + openerId: "allowed-page", + }, + ] + : []), + ...(options.unrelatedWorker === true + ? [ + { + id: "worker-other-page", + type: "dedicated_worker", + title: "Other page worker", + url: `http://127.0.0.1:${String(port)}/other-page-worker.js`, + attached: false, + openerId: "other-page", + }, + ] + : []), + ...(options.electronFileUrl === undefined + ? [] + : [ + { + id: "electron-page", + type: "page", + title: "Electron application", + url: options.electronFileUrl, + attached: false, + ...(options.omitTargetWebSocket === true + ? {} + : { + webSocketDebuggerUrl: `ws://localhost:${String(port)}/devtools/page/electron-page`, + }), + }, + { + id: "electron-worker", + type: "worker", + title: "Electron worker", + url: new URL("worker.js", options.electronFileUrl).href, + attached: false, + openerId: "electron-page", + }, + ]), +]; + +const allowedPageTitle = ( + port: number, + style: FakeOptions["urlShapedAllowedTitle"], +): string => { + const suffix = "/app?startup=title-secret#fragment"; + const host = `127.0.0.1:${String(port)}`; + if (style === true) return `http://${host}${suffix}`; + if (style === "host-path") return `${host}${suffix}`; + if (style === "root-relative") return suffix; + if (style === "prefixed") return `Loading ${host}${suffix}`; + return "Inspectable application"; +}; + +export const parseCommand = (text: string): FakeCdpCommand => { + const value: unknown = JSON.parse(text); + if (typeof value !== "object" || value === null) + throw new TypeError("Expected CDP command object"); + if (!("id" in value) || typeof value.id !== "number") + throw new TypeError("Expected CDP command id"); + if (!("method" in value) || typeof value.method !== "string") + throw new TypeError("Expected CDP method"); + const params = + "params" in value && isRecord(value.params) ? value.params : {}; + return { + id: value.id, + method: value.method, + params, + ...("sessionId" in value && typeof value.sessionId === "string" + ? { sessionId: value.sessionId } + : {}), + }; +}; + +const isRecord = (value: unknown): value is Readonly> => + typeof value === "object" && value !== null && !Array.isArray(value); diff --git a/tests/fixtures/fakeCdpBrowserTypes.ts b/tests/fixtures/fakeCdpBrowserTypes.ts new file mode 100644 index 000000000..0924b4073 --- /dev/null +++ b/tests/fixtures/fakeCdpBrowserTypes.ts @@ -0,0 +1,66 @@ +export interface FakeCdpCommand { + readonly id: number; + readonly method: string; + readonly params: Readonly>; + readonly sessionId?: string; +} + +export interface FakeCdpBrowser { + readonly endpoint: string; + readonly browserWebSocketUrl: string; + readonly allowedOrigin: string; + readonly commands: readonly FakeCdpCommand[]; + readonly httpRequests: readonly { + readonly url: string; + readonly authorization: string | undefined; + readonly cookie: string | undefined; + readonly referer: string | undefined; + }[]; + close(): Promise; +} + +export interface FakeOptions { + readonly malformedDiscovery?: boolean; + readonly oversizedDiscovery?: boolean; + readonly invalidBrowserWebSocket?: boolean; + readonly pageScopedVersionWebSocket?: boolean; + readonly omitTargetWebSocket?: boolean; + readonly additionalPageWithWebSocket?: boolean; + readonly additionalPageWithoutWebSocket?: boolean; + readonly invalidAttachedSession?: boolean; + readonly malformedMessageOnMethod?: string; + readonly malformedEventOnMethod?: string; + readonly malformedEventShapeOnMethod?: string; + readonly closeOnMethod?: string; + readonly hangOnMethod?: string; + readonly unsupportedMethods?: readonly string[]; + readonly transitionalFrameReads?: number; + readonly attachedFrameUrl?: string; + readonly frameUrlAfterFirstRead?: string; + readonly navigateDuringObservationUrl?: string; + readonly navigateDuringCaptureUrl?: string; + readonly navigateDuringScreenshotUrl?: string; + readonly extraCollections?: boolean; + readonly foreignSessionEvents?: boolean; + readonly redirectToDisallowedOrigin?: boolean; + readonly unrelatedWorker?: boolean; + readonly binaryWebSocketEvent?: boolean; + readonly invalidBinaryWebSocketEvent?: boolean; + readonly sourceMapBody?: string; + readonly sessionTimeline?: + | "same_origin" + | "outside_policy" + | "target_detached"; + readonly closeAfterMethod?: string; + readonly sensitiveShapes?: boolean; + readonly invalidResponseBodyBase64?: boolean; + readonly webMcpTools?: boolean; + readonly webMcpChildLeavesScope?: boolean; + readonly electronFileUrl?: string; + readonly duplicateElectronInventory?: boolean; + readonly urlShapedAllowedTitle?: + | boolean + | "host-path" + | "root-relative" + | "prefixed"; +} diff --git a/tests/fixtures/javascriptApplicationGraph.ts b/tests/fixtures/javascriptApplicationGraph.ts index c22f2163f..517885156 100644 --- a/tests/fixtures/javascriptApplicationGraph.ts +++ b/tests/fixtures/javascriptApplicationGraph.ts @@ -96,305 +96,304 @@ export const runtimeEvidence = ( evidence_ids: [], }); +type FixtureNode = ReturnType; +type NodeProperties = FixtureNode["observations"][number]["properties"]; + +type FixtureNodeArguments = [ + kind: FixtureNode["kind"], + identity: FixtureNode["identity"], + label: string, + properties: NodeProperties, + evidence: ApplicationGraphEvidence, +]; + +const fixtureNode = ( + ...[kind, identity, label, properties, evidence]: FixtureNodeArguments +): FixtureNode => + createJavaScriptApplicationNode({ + kind, + identity, + observations: [{ label, properties, evidence }], + }); + +const buildArtifactNodes = (): FixtureNode[] => [ + fixtureNode( + "package", + { + strategy: "content-digest", + stability: "global-exact", + sha256: APPLICATION_GRAPH_DIGESTS.package, + }, + "Synthetic desktop package", + { format: "directory" }, + artifactEvidence(APPLICATION_GRAPH_DIGESTS.package, "package.json"), + ), + fixtureNode( + "artifact", + { + strategy: "content-digest", + stability: "global-exact", + sha256: APPLICATION_GRAPH_DIGESTS.asar, + }, + "resources/app.asar", + { format: "asar" }, + artifactEvidence(APPLICATION_GRAPH_DIGESTS.asar, "resources/app.asar"), + ), + fixtureNode( + "electron-preload", + { + strategy: "canonical-path", + stability: "artifact-version", + artifact_sha256: APPLICATION_GRAPH_DIGESTS.asar, + path: "dist/preload.js", + }, + "desktop preload", + { sandboxed: true }, + artifactEvidence( + APPLICATION_GRAPH_DIGESTS.asar, + "dist/preload.js", + "ast-static-analysis", + ), + ), +]; + +const buildInteractionNodes = (): FixtureNode[] => [ + fixtureNode( + "context-bridge-api", + { + strategy: "artifact-local-key", + stability: "artifact-version", + artifact_sha256: APPLICATION_GRAPH_DIGESTS.asar, + namespace: "contextBridge", + key: "desktopApi", + }, + "desktopApi", + { methods: ["openProject"] }, + artifactEvidence( + APPLICATION_GRAPH_DIGESTS.asar, + "dist/preload.js", + "ast-static-analysis", + ), + ), + fixtureNode( + "ipc-channel", + { + strategy: "artifact-local-key", + stability: "artifact-version", + artifact_sha256: APPLICATION_GRAPH_DIGESTS.asar, + namespace: "electron-ipc", + key: "project:open", + }, + "project:open", + { mode: "invoke" }, + artifactEvidence( + APPLICATION_GRAPH_DIGESTS.asar, + "dist/preload.js", + "ast-static-analysis", + ), + ), + fixtureNode( + "ipc-handler", + { + strategy: "artifact-local-key", + stability: "artifact-version", + artifact_sha256: APPLICATION_GRAPH_DIGESTS.asar, + namespace: "ipcMain.handle", + key: "project:open", + }, + "project open handler", + { process: "main" }, + artifactEvidence( + APPLICATION_GRAPH_DIGESTS.asar, + "dist/main.js", + "ast-static-analysis", + ), + ), +]; + +const buildNativeRuntimeNodes = (): FixtureNode[] => [ + fixtureNode( + "native-addon", + { + strategy: "content-digest", + stability: "global-exact", + sha256: APPLICATION_GRAPH_DIGESTS.nativeAddon, + }, + "synthetic.node", + { abi: "napi" }, + artifactEvidence( + APPLICATION_GRAPH_DIGESTS.nativeAddon, + "native/synthetic.node", + "native-analysis-provider", + ), + ), + fixtureNode( + "native-export", + { + strategy: "artifact-local-key", + stability: "artifact-version", + artifact_sha256: APPLICATION_GRAPH_DIGESTS.nativeAddon, + namespace: "napi-export", + key: "openProject", + }, + "openProject", + { calling_convention: "napi" }, + artifactEvidence( + APPLICATION_GRAPH_DIGESTS.nativeAddon, + "native/synthetic.node", + "native-analysis-provider", + ), + ), + fixtureNode( + "runtime-script-instance", + { + strategy: "runtime-instance", + stability: "capture-only", + capture_sha256: APPLICATION_GRAPH_DIGESTS.capture, + runtime_key: "script-preload", + }, + "preload runtime script", + { url: "file:///synthetic/dist/preload.js" }, + runtimeEvidence("script-preload"), + ), +]; + +const observedEdge = ( + sourceNodeId: string, + targetNodeId: string, + relation: "contains" | "exposes" | "handles", + evidence: ApplicationGraphEvidence, +) => + createJavaScriptApplicationEdge({ + source_node_id: sourceNodeId, + target_node_id: targetNodeId, + relation, + properties: {}, + evidence, + }); + +const inferredEdge = ( + sourceNodeId: string, + targetNodeId: string, + relation: "invokes" | "calls" | "loads", + path: string, +) => + createJavaScriptApplicationEdge({ + source_node_id: sourceNodeId, + target_node_id: targetNodeId, + relation, + properties: {}, + evidence: inferredArtifactEvidence(APPLICATION_GRAPH_DIGESTS.asar, path), + }); + +const buildEdges = (nodes: FixtureNode[]) => { + const [packageNode, asarNode, preloadNode, bridgeNode, channelNode] = nodes; + const [handlerNode, addonNode, exportNode, runtimeNode] = nodes.slice(5); + if ( + packageNode === undefined || + asarNode === undefined || + preloadNode === undefined || + bridgeNode === undefined || + channelNode === undefined || + handlerNode === undefined || + addonNode === undefined || + exportNode === undefined || + runtimeNode === undefined + ) { + throw new Error( + "Synthetic application graph fixture nodes are incomplete.", + ); + } + return [ + observedEdge( + packageNode.node_id, + asarNode.node_id, + "contains", + artifactEvidence(APPLICATION_GRAPH_DIGESTS.package, "resources/app.asar"), + ), + observedEdge( + asarNode.node_id, + preloadNode.node_id, + "contains", + artifactEvidence(APPLICATION_GRAPH_DIGESTS.asar, "dist/preload.js"), + ), + observedEdge( + preloadNode.node_id, + bridgeNode.node_id, + "exposes", + artifactEvidence( + APPLICATION_GRAPH_DIGESTS.asar, + "dist/preload.js", + "ast-static-analysis", + ), + ), + inferredEdge( + bridgeNode.node_id, + channelNode.node_id, + "invokes", + "dist/preload.js", + ), + observedEdge( + channelNode.node_id, + handlerNode.node_id, + "handles", + artifactEvidence( + APPLICATION_GRAPH_DIGESTS.asar, + "dist/main.js", + "ast-static-analysis", + ), + ), + inferredEdge( + handlerNode.node_id, + addonNode.node_id, + "loads", + "dist/main.js", + ), + inferredEdge( + handlerNode.node_id, + exportNode.node_id, + "calls", + "dist/main.js", + ), + observedEdge( + addonNode.node_id, + exportNode.node_id, + "contains", + artifactEvidence( + APPLICATION_GRAPH_DIGESTS.nativeAddon, + "native/synthetic.node", + "native-analysis-provider", + ), + ), + createJavaScriptApplicationEdge({ + source_node_id: preloadNode.node_id, + target_node_id: runtimeNode.node_id, + relation: "observed_as", + properties: {}, + evidence: runtimeEvidence("script-preload"), + }), + ]; +}; + /** Build a synthetic cross-layer Electron graph without proprietary artifacts. */ export const buildSyntheticJavaScriptApplicationGraph = (): JavaScriptApplicationGraph => { - const packageNode = createJavaScriptApplicationNode({ - kind: "package", - identity: { - strategy: "content-digest", - stability: "global-exact", - sha256: APPLICATION_GRAPH_DIGESTS.package, - }, - observations: [ - { - label: "Synthetic desktop package", - properties: { format: "directory" }, - evidence: artifactEvidence( - APPLICATION_GRAPH_DIGESTS.package, - "package.json", - ), - }, - ], - }); - const asarNode = createJavaScriptApplicationNode({ - kind: "artifact", - identity: { - strategy: "content-digest", - stability: "global-exact", - sha256: APPLICATION_GRAPH_DIGESTS.asar, - }, - observations: [ - { - label: "resources/app.asar", - properties: { format: "asar" }, - evidence: artifactEvidence( - APPLICATION_GRAPH_DIGESTS.asar, - "resources/app.asar", - ), - }, - ], - }); - const preloadNode = createJavaScriptApplicationNode({ - kind: "electron-preload", - identity: { - strategy: "canonical-path", - stability: "artifact-version", - artifact_sha256: APPLICATION_GRAPH_DIGESTS.asar, - path: "dist/preload.js", - }, - observations: [ - { - label: "desktop preload", - properties: { sandboxed: true }, - evidence: artifactEvidence( - APPLICATION_GRAPH_DIGESTS.asar, - "dist/preload.js", - "ast-static-analysis", - ), - }, - ], - }); - const bridgeNode = createJavaScriptApplicationNode({ - kind: "context-bridge-api", - identity: { - strategy: "artifact-local-key", - stability: "artifact-version", - artifact_sha256: APPLICATION_GRAPH_DIGESTS.asar, - namespace: "contextBridge", - key: "desktopApi", - }, - observations: [ - { - label: "desktopApi", - properties: { methods: ["openProject"] }, - evidence: artifactEvidence( - APPLICATION_GRAPH_DIGESTS.asar, - "dist/preload.js", - "ast-static-analysis", - ), - }, - ], - }); - const channelNode = createJavaScriptApplicationNode({ - kind: "ipc-channel", - identity: { - strategy: "artifact-local-key", - stability: "artifact-version", - artifact_sha256: APPLICATION_GRAPH_DIGESTS.asar, - namespace: "electron-ipc", - key: "project:open", - }, - observations: [ - { - label: "project:open", - properties: { mode: "invoke" }, - evidence: artifactEvidence( - APPLICATION_GRAPH_DIGESTS.asar, - "dist/preload.js", - "ast-static-analysis", - ), - }, - ], - }); - const handlerNode = createJavaScriptApplicationNode({ - kind: "ipc-handler", - identity: { - strategy: "artifact-local-key", - stability: "artifact-version", - artifact_sha256: APPLICATION_GRAPH_DIGESTS.asar, - namespace: "ipcMain.handle", - key: "project:open", - }, - observations: [ - { - label: "project open handler", - properties: { process: "main" }, - evidence: artifactEvidence( - APPLICATION_GRAPH_DIGESTS.asar, - "dist/main.js", - "ast-static-analysis", - ), - }, - ], - }); - const addonNode = createJavaScriptApplicationNode({ - kind: "native-addon", - identity: { - strategy: "content-digest", - stability: "global-exact", - sha256: APPLICATION_GRAPH_DIGESTS.nativeAddon, - }, - observations: [ - { - label: "synthetic.node", - properties: { abi: "napi" }, - evidence: artifactEvidence( - APPLICATION_GRAPH_DIGESTS.nativeAddon, - "native/synthetic.node", - "native-analysis-provider", - ), - }, - ], - }); - const exportNode = createJavaScriptApplicationNode({ - kind: "native-export", - identity: { - strategy: "artifact-local-key", - stability: "artifact-version", - artifact_sha256: APPLICATION_GRAPH_DIGESTS.nativeAddon, - namespace: "napi-export", - key: "openProject", - }, - observations: [ - { - label: "openProject", - properties: { calling_convention: "napi" }, - evidence: artifactEvidence( - APPLICATION_GRAPH_DIGESTS.nativeAddon, - "native/synthetic.node", - "native-analysis-provider", - ), - }, - ], - }); - const runtimeNode = createJavaScriptApplicationNode({ - kind: "runtime-script-instance", - identity: { - strategy: "runtime-instance", - stability: "capture-only", - capture_sha256: APPLICATION_GRAPH_DIGESTS.capture, - runtime_key: "script-preload", - }, - observations: [ - { - label: "preload runtime script", - properties: { url: "file:///synthetic/dist/preload.js" }, - evidence: runtimeEvidence("script-preload"), - }, - ], - }); - - const observedEdge = ( - sourceNodeId: string, - targetNodeId: string, - relation: "contains" | "exposes" | "handles", - evidence: ApplicationGraphEvidence, - ) => - createJavaScriptApplicationEdge({ - source_node_id: sourceNodeId, - target_node_id: targetNodeId, - relation, - properties: {}, - evidence, - }); - const inferredEdge = ( - sourceNodeId: string, - targetNodeId: string, - relation: "invokes" | "calls" | "loads", - path: string, - ) => - createJavaScriptApplicationEdge({ - source_node_id: sourceNodeId, - target_node_id: targetNodeId, - relation, - properties: {}, - evidence: inferredArtifactEvidence( - APPLICATION_GRAPH_DIGESTS.asar, - path, - ), - }); - const nodes = [ - packageNode, - asarNode, - preloadNode, - bridgeNode, - channelNode, - handlerNode, - addonNode, - exportNode, - runtimeNode, - ]; - const edges = [ - observedEdge( - packageNode.node_id, - asarNode.node_id, - "contains", - artifactEvidence( - APPLICATION_GRAPH_DIGESTS.package, - "resources/app.asar", - ), - ), - observedEdge( - asarNode.node_id, - preloadNode.node_id, - "contains", - artifactEvidence(APPLICATION_GRAPH_DIGESTS.asar, "dist/preload.js"), - ), - observedEdge( - preloadNode.node_id, - bridgeNode.node_id, - "exposes", - artifactEvidence( - APPLICATION_GRAPH_DIGESTS.asar, - "dist/preload.js", - "ast-static-analysis", - ), - ), - inferredEdge( - bridgeNode.node_id, - channelNode.node_id, - "invokes", - "dist/preload.js", - ), - observedEdge( - channelNode.node_id, - handlerNode.node_id, - "handles", - artifactEvidence( - APPLICATION_GRAPH_DIGESTS.asar, - "dist/main.js", - "ast-static-analysis", - ), - ), - inferredEdge( - handlerNode.node_id, - addonNode.node_id, - "loads", - "dist/main.js", - ), - inferredEdge( - handlerNode.node_id, - exportNode.node_id, - "calls", - "dist/main.js", - ), - observedEdge( - addonNode.node_id, - exportNode.node_id, - "contains", - artifactEvidence( - APPLICATION_GRAPH_DIGESTS.nativeAddon, - "native/synthetic.node", - "native-analysis-provider", - ), - ), - createJavaScriptApplicationEdge({ - source_node_id: preloadNode.node_id, - target_node_id: runtimeNode.node_id, - relation: "observed_as", - properties: {}, - evidence: runtimeEvidence("script-preload"), - }), + ...buildArtifactNodes(), + ...buildInteractionNodes(), + ...buildNativeRuntimeNodes(), ]; + const packageNode = nodes[0]; + if (packageNode === undefined) { + throw new Error("Synthetic application graph fixture has no root node."); + } return createJavaScriptApplicationGraph({ schema: "JavaScriptApplicationGraph", schema_version: 1, root_node_ids: [packageNode.node_id], nodes, - edges, + edges: buildEdges(nodes), coverage: completeCoverage, limitations: [ "Synthetic fixture demonstrates identity and authority boundaries only.", diff --git a/tests/fixtures/temporaryDirectory.ts b/tests/fixtures/temporaryDirectory.ts index 402912060..218c39fed 100644 --- a/tests/fixtures/temporaryDirectory.ts +++ b/tests/fixtures/temporaryDirectory.ts @@ -1,9 +1,13 @@ -import { mkdtemp, realpath, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { rm } from "node:fs/promises"; import { join } from "node:path"; import { onTestFinished } from "vitest"; +import { + createTestWorkspace, + removeTestWorkspace, +} from "../support/workspace/workspaceFixture.js"; + const TEMPORARY_PREFIX = /^[A-Za-z0-9][A-Za-z0-9._-]*-$/u; /** Creates a canonical temporary directory owned by the current Vitest case. */ @@ -16,18 +20,15 @@ export const createTestTempDirectory = async ( ); } - const canonicalTemporaryRoot = await realpath(tmpdir()); - const created = await mkdtemp(join(canonicalTemporaryRoot, prefix)); - const canonicalDirectory = await realpath(created); - + const workspace = await createTestWorkspace(prefix); + const canonicalDirectory = workspace.root; + // Preserve the legacy helper's empty-root contract; callers own all content. + await Promise.all([ + rm(join(canonicalDirectory, "home"), { recursive: true, force: true }), + rm(join(canonicalDirectory, "xdg"), { recursive: true, force: true }), + ]); onTestFinished(async () => { - await rm(canonicalDirectory, { - recursive: true, - force: true, - maxRetries: 3, - retryDelay: 25, - }); + await removeTestWorkspace(canonicalDirectory); }); - return canonicalDirectory; }; diff --git a/tests/ghidraMcp.test.ts b/tests/ghidraMcp.test.ts deleted file mode 100644 index b3df582c3..000000000 --- a/tests/ghidraMcp.test.ts +++ /dev/null @@ -1,595 +0,0 @@ -import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; -import { describe, expect, it } from "vitest"; - -import { BinarySession } from "../src/application/BinarySession.js"; -import { EnhancedTools } from "../src/application/EnhancedTools.js"; -import { AnalysisProviderRegistry } from "../src/application/AnalysisProviderRegistry.js"; -import { SessionProviderRouter } from "../src/application/SessionProviderRouter.js"; -import { parseConfig } from "../src/config.js"; -import type { JsonValue } from "../src/domain/jsonValue.js"; -import { ok } from "../src/domain/result.js"; -import type { GhidraInstallationHost } from "../src/ghidra/GhidraInstallation.js"; -import type { GhidraOperation } from "../src/ghidra/GhidraClient.js"; -import { - GhidraProvider, - type GhidraProviderClientFactory, -} from "../src/ghidra/GhidraProvider.js"; -import { GHIDRA_SESSION_CAPABILITIES } from "../src/ghidra/GhidraSessionValues.js"; -import { silentLogger } from "../src/logger.js"; -import { createServer } from "../src/server/createServer.js"; -import { - ghidraBounded, - ghidraFunctionClassification, - ghidraFunctionDossier, - ghidraFunctionIdentity, - ghidraReferenceEdge, -} from "./fixtures/ghidraFunction.js"; - -const INSTALL = "/opt/ghidra_12.1.2_PUBLIC"; - -describe("Ghidra MCP and shared CLI composition", () => { - it("preserves provider evidence, composed parity, and capability routing", async () => { - const harness = await connectGhidraMcp("ghidra-parity"); - const { calls, mcp, session } = harness; - try { - const listed = sessionEvidence( - session, - ( - await mcp.callTool({ - name: "list_procedures", - arguments: {}, - }) - ).structuredContent, - ); - expect(listed).toMatchObject({ - operation: "list_procedures", - provider: { id: "ghidra", name: "Ghidra", version: "12.1.2" }, - analysis_profile: { - provider: { id: "ghidra", version: "12.1.2" }, - parameters: { - import_mode: "ephemeral-read-only", - analyzer_preset: "ghidra-default", - }, - }, - normalized_result: { - items: [ - { - address: "0x401000", - value: "fixture_main", - procedure: { - external: false, - thunk: false, - thunk_target: null, - }, - }, - ], - total: 1, - }, - raw_result: { - items: [{ value_truncated: false }], - }, - }); - - const mcpOverview = sessionEvidence( - session, - ( - await mcp.callTool({ - name: "binary_overview", - arguments: { detail: "detailed", limit: 5 }, - }) - ).structuredContent, - ); - const directOverview = await new EnhancedTools(session).execute( - "binary_overview", - { detail: "detailed", limit: 5 }, - ); - expect(directOverview.ok).toBe(true); - if (!directOverview.ok) return; - expect(mcpOverview.normalized_result).toEqual(directOverview.value); - expect(mcpOverview).toMatchObject({ - provider: { id: "rea-workflow" }, - confidence: "derived", - normalized_result: { - document: "fixture", - segment_count: 1, - procedure_count: 1, - string_count: 2, - segments: [{ name: ".text", length: 256 }], - }, - }); - - const pseudocode = sessionEvidence( - session, - ( - await mcp.callTool({ - name: "procedure_pseudo_code", - arguments: { procedure: "fixture_main" }, - }) - ).structuredContent, - ); - expect(pseudocode).toMatchObject({ - operation: "procedure_pseudo_code", - provider: { id: "ghidra", version: "12.1.2" }, - normalized_result: expect.stringContaining("return 42"), - limitations: expect.arrayContaining([ - expect.stringContaining("not text-equivalent to Hopper"), - expect.stringContaining("30-second native deadline"), - ]), - }); - - const analyzed = sessionEvidence( - session, - ( - await mcp.callTool({ - name: "analyze_function", - arguments: { procedure: "fixture_main", include_assembly: true }, - }) - ).structuredContent, - ); - expect(analyzed).toMatchObject({ - operation: "analyze_function", - provider: { id: "rea-workflow", version: "1" }, - normalized_result: { - procedure: { - address: "0x401000", - name: "fixture_main", - classification: { - external: false, - thunk: false, - provenance: "ghidra-function-manager", - }, - }, - pseudocode: { truncated: false }, - outgoing_references: { - items: [ - { - kind: { - available: true, - provenance: "ghidra-reference-manager", - }, - }, - ], - }, - limitations: expect.arrayContaining([ - expect.stringContaining("indirect flows without target addresses"), - expect.stringContaining( - "not original source or Hopper-equivalent text", - ), - ]), - }, - limitations: [ - "Derived by an REA workflow from one or more provider observations.", - ], - }); - const directAnalyzed = await new EnhancedTools(session).execute( - "analyze_function", - { procedure: "fixture_main", include_assembly: true }, - ); - expect(directAnalyzed.ok).toBe(true); - if (!directAnalyzed.ok) return; - expect(analyzed.normalized_result).toEqual(directAnalyzed.value); - - const batch = await mcp.callTool({ - name: "batch_decompile", - arguments: { addresses: ["fixture_main"] }, - }); - expect(batch.isError).not.toBe(true); - expect(batch.structuredContent).toMatchObject({ - result: { - succeeded: 1, - failed: 0, - items: [{ status: "ok" }], - }, - }); - - const availableNames = (await mcp.listTools()).tools.map( - ({ name }) => name, - ); - expect(availableNames).not.toContain("set_comment"); - expect(calls).not.toContain("set_comment"); - - const status = await mcp.callTool({ - name: "binary_session", - arguments: { detail: "full" }, - }); - expect(status.structuredContent).toMatchObject({ - result: { - open: true, - analysis_provider_binding: { - provider: { id: "ghidra", version: "12.1.2" }, - }, - tool_availability: expect.arrayContaining([ - expect.objectContaining({ - name: "binary_overview", - available: true, - }), - expect.objectContaining({ name: "swift_classes", available: true }), - expect.objectContaining({ - name: "get_objc_classes", - available: true, - }), - expect.objectContaining({ - name: "batch_decompile", - available: true, - }), - expect.objectContaining({ - name: "trace_feature", - available: true, - }), - expect.objectContaining({ - name: "get_call_graph", - available: true, - }), - expect.objectContaining({ - name: "find_xrefs_to_name", - available: true, - }), - expect.objectContaining({ - name: "set_comment", - available: false, - }), - ]), - }, - }); - } finally { - await harness.close(); - } - }, 10_000); - - it("records approved native API truncation once and links its evidence", async () => { - const harness = await connectGhidraMcp("ghidra-native-api"); - const { mcp, session } = harness; - try { - const unapprovedInspection = sessionEvidence( - session, - ( - await mcp.callTool({ - name: "inspect_native_api", - arguments: { procedure: "fixture_truncated" }, - }) - ).structuredContent, - ); - expect(unapprovedInspection.normalized_result).toMatchObject({ - boundary: { - available: true, - jump_tables: [{ mappings_truncated: true }], - }, - residual_unknowns: [ - expect.stringContaining("additional data sources or targets"), - ], - }); - expect( - ( - await mcp.callTool({ - name: "list_unknowns", - arguments: {}, - }) - ).structuredContent, - ).toMatchObject({ result: { items: [] } }); - - const approvedInspection = sessionEvidence( - session, - ( - await mcp.callTool({ - name: "inspect_native_api", - arguments: { - procedure: "fixture_truncated", - unknown_registry_approved: true, - }, - }) - ).structuredContent, - ); - await mcp.callTool({ - name: "inspect_native_api", - arguments: { - procedure: "fixture_truncated", - unknown_registry_approved: true, - }, - }); - expect( - ( - await mcp.callTool({ - name: "list_unknowns", - arguments: {}, - }) - ).structuredContent, - ).toMatchObject({ - result: { - items: [ - { - unknown: { - status: "open", - domain: "native-api", - question: expect.stringContaining( - "additional data sources or targets", - ), - supporting_evidence_ids: [approvedInspection.evidence_id], - recommended_probes: [ - { - operation: "inspect_native_api", - rationale: expect.stringContaining("ABI probe"), - }, - ], - }, - }, - ], - }, - }); - } finally { - await harness.close(); - } - }, 10_000); -}); - -const connectGhidraMcp = async (name: string) => { - const calls: GhidraOperation[] = []; - const factory: GhidraProviderClientFactory = (options) => ({ - start: () => - Promise.resolve( - ok(sessionInfo(options.profileDigest, options.targetSha256)), - ), - callTool: (operation, input) => { - calls.push(operation); - return Promise.resolve(ok(resultFor(operation, input))); - }, - close: () => Promise.resolve(), - }); - const config = parseConfig({ GHIDRA_INSTALL_DIR: INSTALL }); - if (!config.ok) throw config.error; - const provider = new GhidraProvider( - config.value, - silentLogger, - installationHost(), - factory, - ); - const session = new BinarySession( - SessionProviderRouter.selectable( - new AnalysisProviderRegistry([provider]), - [], - ), - ); - const server = createServer(session, session, { logger: silentLogger }); - const mcp = new Client({ name, version: "1.0.0" }); - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - const close = async () => { - await Promise.allSettled([mcp.close(), server.close()]); - await session.close(); - }; - try { - await server.connect(serverTransport); - await mcp.connect(clientTransport); - const opened = await mcp.callTool({ - name: "open_binary", - arguments: { path: process.execPath }, - }); - if (opened.isError === true) - throw new Error("The Ghidra MCP harness could not open its target"); - } catch (cause: unknown) { - await close(); - throw cause; - } - return { - calls, - mcp, - session, - close, - }; -}; - -const sessionEvidence = (session: BinarySession, value: unknown) => { - if ( - typeof value !== "object" || - value === null || - !("evidence_id" in value) || - typeof value.evidence_id !== "string" - ) - throw new TypeError( - `Missing compact Evidence ID: ${JSON.stringify(value)}`, - ); - const evidence = session.evidenceById(value.evidence_id); - if (evidence === undefined) throw new TypeError("Missing session Evidence"); - return evidence; -}; - -const installationHost = (): GhidraInstallationHost => ({ - platform: "linux", - architecture: "x64", - readText: () => "application.version=12.1.2\n", - executable: () => true, - probeJava: () => ({ - version: "21.0.11", - major: 21, - home: "/usr/lib/jvm/jdk-21", - bits: 64, - jdk: true, - }), -}); - -const sessionInfo = (profileDigest: string, targetSha256: string) => ({ - name: "REA Ghidra bridge" as const, - bridge_version: 6 as const, - run_id: "11111111-1111-4111-8111-111111111111", - profile_digest: profileDigest, - provider: { id: "ghidra" as const, version: "12.1.2" }, - read_only: true as const, - analysis_complete: true, - analysis_timed_out: false, - capabilities: [...GHIDRA_SESSION_CAPABILITIES], - target: { - name: "fixture", - language_id: "x86:LE:64:default", - compiler_spec_id: "gcc", - image_base: "0x400000", - default_address_space: "ram", - sha256: targetSha256, - }, -}); - -type GhidraResultBuilder = ( - input: Readonly>, - limit: number, -) => JsonValue; - -const resultBuilders = new Map([ - ["list_documents", () => ["fixture"]], - [ - "list_procedures", - (_input, limit) => - page( - [ - { - address: "0x401000", - value: "fixture_main", - value_truncated: false, - procedure: { - external: false, - thunk: false, - thunk_target: null, - }, - }, - ], - limit, - ), - ], - [ - "list_strings", - (_input, limit) => - page( - [ - stringItem("0x402000", "inventory fixture"), - stringItem("0x402020", "external fixture"), - ], - limit, - ), - ], - [ - "list_segments", - () => [ - { - name: ".text", - start: "0x401000", - end: "0x401100", - readable: true, - writable: false, - executable: true, - permissions: { available: true, source: "ghidra-memory-block" }, - provenance: "ghidra-memory-block", - address_space: "ram", - image_base: "0x400000", - initialized: true, - overlay: false, - sections: [], - }, - ], - ], - [ - "list_names", - (_input, limit) => - page( - [ - { - address: "0x401000", - value: "fixture_main", - value_truncated: false, - symbol: { - primary: true, - dynamic: false, - external: false, - type: "function", - source: "user_defined", - }, - }, - ], - limit, - ), - ], - ["search_procedures", (_input, limit) => page([], limit)], - ["search_strings", (_input, limit) => page([], limit)], - ["address_name", () => "fixture_main"], - ["procedure_address", () => "0x401000"], - [ - "resolve_containing_procedure", - () => ({ - query_address: "0x401001", - found: true, - procedure: ghidraFunctionIdentity(), - }), - ], - ["procedure_assembly", () => "0x401000: CALL 0x401020\n0x401005: RET"], - ["procedure_callees", () => []], - ["procedure_callers", () => []], - [ - "procedure_info", - () => ({ - name: "fixture_main", - entrypoint: "0x401000", - basicblock_count: 1, - length: 6, - signature: "int fixture_main(void)", - locals: [], - classification: ghidraFunctionClassification(), - }), - ], - ["procedure_pseudo_code", () => "int fixture_main(void) { return 42; }"], - [ - "read_function_instructions", - () => ({ - procedure: ghidraFunctionIdentity(), - instructions: ghidraBounded(["0x401000: CALL 0x401020", "0x401005: RET"]), - instructions_scanned: 2, - instruction_scan_truncated: false, - limitations: ["Ghidra-specific instruction text."], - }), - ], - [ - "procedure_references", - (input, _limit) => ({ - procedure: ghidraFunctionIdentity(), - direction: input.direction ?? "outgoing", - references: ghidraBounded([ghidraReferenceEdge()]), - instructions_scanned: 2, - instruction_scan_truncated: false, - }), - ], - ["xrefs", () => ["0x401001"]], - [ - "analyze_function", - (input, _limit) => - ghidraFunctionDossier( - input.include_assembly === true, - input.procedure === "fixture_truncated", - ), - ], -]); - -const resultFor = ( - operation: GhidraOperation, - input: Readonly>, -): JsonValue => { - const limit = typeof input.limit === "number" ? input.limit : 100; - const builder = resultBuilders.get(operation); - if (builder === undefined) - throw new TypeError(`Unexpected Ghidra operation: ${operation}`); - return builder(input, limit); -}; - -const page = (items: readonly JsonValue[], limit: number): JsonValue => ({ - items: items.slice(0, limit), - offset: 0, - limit, - total: items.length, - next_offset: items.length > limit ? limit : null, - has_more: items.length > limit, -}); - -const stringItem = (address: string, value: string): JsonValue => ({ - address, - value, - value_truncated: false, - string: { - encoding: "UTF-8", - termination: "present_or_not_required", - byte_length: value.length + 1, - }, -}); diff --git a/tests/hopperClient.test.ts b/tests/hopperClient.test.ts deleted file mode 100644 index 376f68044..000000000 --- a/tests/hopperClient.test.ts +++ /dev/null @@ -1,763 +0,0 @@ -import { spawn, type ChildProcess } from "node:child_process"; -import { getEventListeners } from "node:events"; -import { access } from "node:fs/promises"; -import { fileURLToPath } from "node:url"; - -import { afterEach, describe, expect, it } from "vitest"; - -import { OFFICIAL_TOOL_CONTRACTS } from "../src/contracts/toolContracts.js"; -import type { HopperStartupDiagnostic } from "../src/domain/hopperStartupFailure.js"; -import { projectAnalysisError } from "../src/domain/errors.js"; -import { ok } from "../src/domain/result.js"; -import type { - BridgeLauncher, - BridgeSession, -} from "../src/hopper/BridgeLauncher.js"; -import { HopperClient } from "../src/hopper/HopperClient.js"; -import type { HopperDiagnostic } from "../src/hopper/HopperDiagnostics.js"; -import { LINUX_PRIVATE_DISPLAY_DIAGNOSTIC_PREFIX } from "../src/hopper/LinuxPrivateDisplayDiagnostic.js"; -import { providerCleanupFailure } from "../src/hopper/HopperDiagnostics.js"; -import { cleanupOwnedProcessGroup } from "../src/process/ProcessOwnership.js"; -import { spawnOwnedProviderProcess } from "../src/process/ProviderProcess.js"; - -const fixturePath = fileURLToPath( - new URL("./fixtures/fakeHopper.mjs", import.meta.url), -); - -class FixtureLauncher implements BridgeLauncher { - socketPaths: string[] = []; - directories: string[] = []; - runIds: string[] = []; - processes: ChildProcess[] = []; - - constructor(readonly tokenOverride?: string) {} - - launch(session: BridgeSession) { - this.socketPaths.push(session.socketPath); - this.directories.push(session.directory); - this.runIds.push(session.runId); - const child = spawn( - process.execPath, - [ - fixturePath, - session.socketPath, - this.tokenOverride ?? session.token, - session.runId, - ], - { - stdio: ["ignore", "ignore", "pipe"], - }, - ); - this.processes.push(child); - return Promise.resolve( - ok({ - process: child, - ownsProcessLifetime: true, - }), - ); - } -} - -class SilentLauncher implements BridgeLauncher { - launch() { - return Promise.resolve( - ok({ - process: spawn( - process.execPath, - ["-e", "setInterval(() => {}, 1000)"], - { - stdio: ["ignore", "ignore", "pipe"], - }, - ), - ownsProcessLifetime: true, - }), - ); - } -} - -class OwnedFixtureLauncher implements BridgeLauncher { - ownership: - | { - readonly runId: string; - readonly leaderPid: number; - readonly processGroupId: number; - } - | undefined; - - async launch(session: BridgeSession) { - const started = await spawnOwnedProviderProcess({ - command: process.execPath, - arguments: [ - fixturePath, - session.socketPath, - session.token, - session.runId, - "cleanup-required", - ], - runId: session.runId, - expectedCommand: null, - }); - this.ownership = started.ownership; - return ok({ - process: started.process, - ownsProcessLifetime: true, - ownership: started.ownership, - shutdownByCleanup: true, - cleanup: () => cleanupOwnedProcessGroup(started.ownership), - }); - } -} - -class UnconfirmedFixtureLauncher implements BridgeLauncher { - launch(session: BridgeSession) { - return Promise.resolve( - ok({ - process: spawn( - process.execPath, - [ - fixturePath, - session.socketPath, - session.token, - session.runId, - "unconfirmed", - ], - { stdio: ["ignore", "ignore", "pipe"] }, - ), - ownsProcessLifetime: false, - }), - ); - } -} - -class CancelThenFixtureLauncher implements BridgeLauncher { - #launches = 0; - - launch(session: BridgeSession) { - this.#launches += 1; - return this.#launches === 1 - ? new SilentLauncher().launch() - : new FixtureLauncher().launch(session); - } -} - -class ExitingLauncher implements BridgeLauncher { - constructor(readonly code: number) {} - - launch() { - return Promise.resolve( - ok({ - process: spawn(process.execPath, ["-e", `process.exit(${this.code})`], { - stdio: ["ignore", "ignore", "pipe"], - }), - ownsProcessLifetime: true, - }), - ); - } -} - -class DiagnosticExitingLauncher implements BridgeLauncher { - launch() { - const diagnostic: HopperStartupDiagnostic = { - schema_version: 1, - component: "hopper_private_display", - operation: "launch", - status: "error", - failure_code: "x11_socket_directory_unusable", - reason: "socket_directory_read_only", - socket_directory: "/tmp/.X11-unix", - socket_directory_mode: "0777", - mount_read_only: true, - effective_socket_directory_mode: "0777", - effective_mount_read_only: true, - wsl: true, - strategy: "direct", - fallback_reason: null, - xvfb_stderr_bytes: 100, - xvfb_stderr_truncated: false, - }; - const line = `${LINUX_PRIVATE_DISPLAY_DIAGNOSTIC_PREFIX}${JSON.stringify(diagnostic)}\n`; - return Promise.resolve( - ok({ - process: spawn( - process.execPath, - [ - "-e", - `process.stderr.write(${JSON.stringify(line)}, () => process.exit(80))`, - ], - { stdio: ["ignore", "ignore", "pipe"] }, - ), - ownsProcessLifetime: true, - }), - ); - } -} - -class LateSilentLauncher implements BridgeLauncher { - readonly directories: string[] = []; - readonly processes: ChildProcess[] = []; - - async launch(session: BridgeSession) { - this.directories.push(session.directory); - const launched = await new SilentLauncher().launch(); - if (launched.ok) this.processes.push(launched.value.process); - await new Promise((resolve) => setTimeout(resolve, 50)); - return launched; - } -} - -const clients: HopperClient[] = []; -const startClient = async () => { - const client = new HopperClient({ - launcher: new FixtureLauncher(), - requestTimeoutMs: 100, - startupTimeoutMs: 1_000, - }); - clients.push(client); - await expect(client.start()).resolves.toEqual({ - ok: true, - value: { name: "REA Hopper bridge", version: "1.0.0" }, - }); - return client; -}; - -afterEach(async () => { - await Promise.all(clients.splice(0).map((client) => client.close())); -}); - -describe("HopperClient", () => { - it("sanitizes unexpected owned-cleanup exceptions", () => { - expect( - providerCleanupFailure( - new Error("secret-run-token 22222222-2222-4222-8222-222222222222"), - ), - ).toEqual({ - cleaned: false, - reason: "owned provider cleanup failed", - }); - }); - - it("uses the caller-assigned provider run identity", async () => { - const launcher = new FixtureLauncher(); - const client = new HopperClient({ - launcher, - runId: "11111111-1111-4111-8111-111111111111", - startupTimeoutMs: 1_000, - }); - clients.push(client); - - expect((await client.start()).ok).toBe(true); - expect(launcher.runIds).toEqual(["11111111-1111-4111-8111-111111111111"]); - }); - - it("keeps the native socket path below macOS sockaddr_un limits", async () => { - const launcher = new FixtureLauncher(); - const client = new HopperClient({ launcher, startupTimeoutMs: 1_000 }); - clients.push(client); - const started = await client.start(); - expect(started.ok).toBe(true); - expect(Buffer.byteLength(launcher.socketPaths[0] ?? "")).toBeLessThan(104); - }); - - it("serializes concurrent calls until the active Hopper reply arrives", async () => { - const client = await startClient(); - const slow = client.callTool("echo", { label: "slow", delay: 30 }); - const fast = client.callTool("echo", { label: "fast", delay: 1 }); - let fastSettled = false; - void fast.then(() => { - fastSettled = true; - }); - await new Promise((resolve) => setTimeout(resolve, 10)); - expect(fastSettled).toBe(false); - await expect(Promise.all([slow, fast])).resolves.toEqual([ - { ok: true, value: { label: "slow", delay: 30 } }, - { ok: true, value: { label: "fast", delay: 1 } }, - ]); - }); - - it("routes every established operation through the authenticated bridge", async () => { - const client = await startClient(); - const results = await Promise.all( - OFFICIAL_TOOL_CONTRACTS.map(({ name }) => client.callTool(name, {})), - ); - expect(results).toHaveLength(36); - expect(results.every((result) => result.ok)).toBe(true); - }); - - it("rejects a bridge session with the wrong capability token", async () => { - const client = new HopperClient({ - launcher: new FixtureLauncher("wrong-token"), - startupTimeoutMs: 1_000, - }); - clients.push(client); - const result = await client.start(); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.error._tag).toBe("HopperRemoteError"); - }); - - it.each([ - ["malformed", "HopperProtocolError"], - ["wrong_id", "HopperProtocolError"], - ["wrong_event_id", "HopperProtocolError"], - ["malformed_event", "HopperProtocolError"], - ["remote_error", "HopperRemoteError"], - ["hang", "HopperTimeoutError"], - ["exit", "HopperProcessError"], - ])("projects %s as %s", async (method, expectedTag) => { - const client = await startClient(); - const result = await client.callTool(method); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.error._tag).toBe(expectedTag); - }); - - it("preserves a sanitized bridge exception diagnostic", async () => { - const diagnostics: HopperDiagnostic[] = []; - const client = new HopperClient({ - launcher: new FixtureLauncher(), - requestTimeoutMs: 100, - startupTimeoutMs: 1_000, - onDiagnostic: (event) => diagnostics.push(event), - }); - clients.push(client); - await expect(client.start()).resolves.toMatchObject({ ok: true }); - const result = await client.callTool("remote_error"); - expect(result.ok).toBe(false); - if (!result.ok) - expect(result.error).toMatchObject({ - _tag: "HopperRemoteError", - diagnosticType: "bridge_exception", - }); - expect(diagnostics).toContainEqual({ - type: "bridge-diagnostic", - request_id: 2, - code: -32001, - category: "bridge_exception", - message: "safe fake failure", - }); - }); - - it("forwards correlated Python progress before the terminal response", async () => { - const client = await startClient(); - const updates: Array<{ - readonly phase: string; - readonly completed: number; - readonly terminal?: boolean; - }> = []; - const result = await client.callTool( - "echo", - { value: "progress" }, - { - progress: { - report: (update) => { - updates.push(update); - return Promise.resolve(); - }, - }, - }, - ); - - expect(result).toEqual({ ok: true, value: { value: "progress" } }); - expect(updates).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - phase: "hopper_bridge", - completed: 0, - }), - expect.objectContaining({ - phase: "hopper_bridge", - completed: 1, - terminal: true, - }), - ]), - ); - }); - - it("isolates synchronous progress observer failures from bridge responses", async () => { - const client = await startClient(); - await expect( - client.callTool( - "echo", - { value: "alive" }, - { - progress: { - report: () => { - throw new Error("observer failed"); - }, - }, - }, - ), - ).resolves.toEqual({ ok: true, value: { value: "alive" } }); - }); - - it("projects a missing bridge API as typed capability unavailability", async () => { - const client = await startClient(); - const result = await client.callTool("capability_unavailable"); - expect(result.ok).toBe(false); - if (!result.ok) - expect(result.error).toMatchObject({ - _tag: "AnalysisCapabilityUnavailableError", - providerId: "hopper", - operation: "capability_unavailable", - reason: "fixture API is unavailable", - }); - }); - - it("cancels and ignores late responses without corrupting the session", async () => { - const client = await startClient(); - const controller = new AbortController(); - const pending = client.callTool("hang", {}, { signal: controller.signal }); - controller.abort(); - const cancelled = await pending; - expect(cancelled.ok).toBe(false); - if (!cancelled.ok) - expect(cancelled.error._tag).toBe("HopperCancelledError"); - - const timedOut = await client.callTool( - "echo", - { delay: 30 }, - { timeoutMs: 5 }, - ); - expect(timedOut.ok).toBe(false); - await new Promise((resolve) => setTimeout(resolve, 40)); - await expect(client.callTool("echo", { value: "alive" })).resolves.toEqual({ - ok: true, - value: { value: "alive" }, - }); - }); - - it("retains timed-out bridge activity until the late reply releases the queue", async () => { - const client = await startClient(); - const progress: string[] = []; - const result = await client.callTool( - "echo", - { delay: 60 }, - { - timeoutMs: 5, - progress: { - report: (update) => { - progress.push(update.message); - return Promise.resolve(); - }, - }, - }, - ); - - expect(result).toMatchObject({ - ok: false, - error: { _tag: "HopperTimeoutError" }, - }); - expect(client.requestActivity()).toMatchObject({ - operation: "echo", - callerState: "timed_out", - timeoutMs: 5, - }); - expect(progress).toEqual( - expect.arrayContaining([ - expect.stringContaining("started on Hopper's serial bridge"), - ]), - ); - await new Promise((resolve) => setTimeout(resolve, 70)); - expect(client.requestActivity()).toBeNull(); - await expect(client.callTool("echo", { alive: true })).resolves.toEqual({ - ok: true, - value: { alive: true }, - }); - }); - - it("cancels bridge startup without waiting for the startup timeout", async () => { - const client = new HopperClient({ - launcher: new CancelThenFixtureLauncher(), - startupTimeoutMs: 10_000, - }); - clients.push(client); - const controller = new AbortController(); - const startedAt = Date.now(); - const pending = client.callTool("echo", {}, { signal: controller.signal }); - setTimeout(() => { - controller.abort(); - }, 10); - const result = await pending; - expect(result.ok).toBe(false); - if (!result.ok) expect(result.error._tag).toBe("HopperCancelledError"); - expect(Date.now() - startedAt).toBeLessThan(500); - await expect(client.start()).resolves.toEqual({ - ok: true, - value: { name: "REA Hopper bridge", version: "1.0.0" }, - }); - await expect( - client.callTool("echo", { value: "retried" }), - ).resolves.toEqual({ - ok: true, - value: { value: "retried" }, - }); - }); - - it("applies one deadline to launcher, socket, and health startup phases", async () => { - const launcher = new LateSilentLauncher(); - const client = new HopperClient({ launcher, startupTimeoutMs: 20 }); - clients.push(client); - - const result = await client.start(); - - expect(result).toMatchObject({ - ok: false, - error: { _tag: "HopperTimeoutError", timeoutMs: 20 }, - }); - expect(launcher.directories).toHaveLength(1); - await expect(access(launcher.directories[0] ?? "")).rejects.toMatchObject({ - code: "ENOENT", - }); - const process = launcher.processes[0]; - expect( - process !== undefined && - (process.exitCode !== null || process.signalCode !== null), - ).toBe(true); - }); - - it.each([70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80])( - "reports Linux adapter exit %i during bridge startup", - async (exitCode) => { - const client = new HopperClient({ - launcher: new ExitingLauncher(exitCode), - startupTimeoutMs: 10_000, - }); - clients.push(client); - const result = await client.start(); - expect(result.ok).toBe(false); - if (!result.ok) - expect(result.error).toMatchObject({ - _tag: "HopperProcessError", - exitCode, - }); - }, - ); - - it("projects actionable CI remediation for Hopper UI, license, and lifecycle failures", async () => { - const client = new HopperClient({ - launcher: new ExitingLauncher(75), - startupTimeoutMs: 10_000, - }); - clients.push(client); - const result = await client.start(); - expect(result.ok).toBe(false); - if (result.ok) return; - - const projected = projectAnalysisError(result.error); - expect(projected.message).toContain( - "rea doctor --provider hopper --detail full --json", - ); - expect(projected.message).toContain("UI or license prompt"); - expect(projected.message).toContain("close stale Hopper sessions"); - expect(projected.details).toMatchObject({ - failure_code: "hopper_exited_during_startup", - exit_code: 75, - }); - }); - - it("preserves the bounded private-display diagnostic from an adapter exit", async () => { - const client = new HopperClient({ - launcher: new DiagnosticExitingLauncher(), - startupTimeoutMs: 10_000, - }); - clients.push(client); - const result = await client.start(); - expect(result).toMatchObject({ - ok: false, - error: { - _tag: "HopperProcessError", - exitCode: 80, - failureCode: "x11_socket_directory_unusable", - diagnostic: { - socket_directory: "/tmp/.X11-unix", - socket_directory_mode: "0777", - mount_read_only: true, - wsl: true, - strategy: "direct", - }, - }, - }); - }); - - it("allows a short-lived launcher to hand off bridge startup", async () => { - const client = new HopperClient({ - launcher: new ExitingLauncher(0), - startupTimeoutMs: 100, - }); - clients.push(client); - const result = await client.start(); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.error._tag).toBe("HopperTimeoutError"); - }); - - it("ignores more than 1,024 late responses without corrupting the session", async () => { - const client = await startClient(); - const timedOut = await Promise.all( - Array.from({ length: 1_025 }, (_, index) => - client.callTool("echo", { index, delay: 150 }, { timeoutMs: 5 }), - ), - ); - expect(timedOut.every((result) => !result.ok)).toBe(true); - await new Promise((resolve) => setTimeout(resolve, 200)); - await expect(client.callTool("echo", { value: "alive" })).resolves.toEqual({ - ok: true, - value: { value: "alive" }, - }); - }); - - it("makes concurrent close callers await the same shutdown", async () => { - const client = await startClient(); - let firstSettled = false; - const first = client.close().then(() => { - firstSettled = true; - }); - await client.close(); - expect(firstSettled).toBe(true); - await first; - }); - - it("reports unconfirmed unowned document cleanup as a typed close failure", async () => { - const client = new HopperClient({ - launcher: new UnconfirmedFixtureLauncher(), - startupTimeoutMs: 1_000, - }); - clients.push(client); - expect((await client.start()).ok).toBe(true); - - const closed = await client.closeWithOutcome(); - - expect(closed).toMatchObject({ - ok: false, - error: { - _tag: "ProviderAdapterError", - cleanupIncomplete: true, - cleanupResources: ["hopper-document"], - }, - }); - if (!closed.ok) - expect(projectAnalysisError(closed.error)).toMatchObject({ - code: "cleanup_incomplete", - details: { - provider_id: "hopper", - operation: "close_binary", - cleanup: "incomplete", - resources: ["hopper-document"], - }, - }); - }); - - it("kills only its owned Hopper group and emits sanitized shutdown coordinates", async () => { - const unrelated = spawn( - process.execPath, - ["-e", "process.title = 'Hopper'; setInterval(() => undefined, 1000)"], - { detached: true, stdio: "ignore" }, - ); - await new Promise((resolve, reject) => { - unrelated.once("spawn", resolve); - unrelated.once("error", reject); - }); - const launcher = new OwnedFixtureLauncher(); - const diagnostics: unknown[] = []; - const runId = "22222222-2222-4222-8222-222222222222"; - const client = new HopperClient({ - launcher, - runId, - startupTimeoutMs: 1_000, - onDiagnostic: (event) => diagnostics.push(event), - }); - clients.push(client); - - try { - await expect(client.start()).resolves.toMatchObject({ ok: true }); - await client.close(); - - const unrelatedPid = unrelated.pid; - if (unrelatedPid === undefined) - throw new Error("Unrelated Hopper fixture has no PID"); - expect(() => process.kill(unrelatedPid, 0)).not.toThrow(); - expect(diagnostics).toContainEqual({ - type: "owned-shutdown", - status: "verified-cleanup", - launcher_pid: launcher.ownership?.leaderPid, - process_group_id: launcher.ownership?.processGroupId, - cleanup_signaled: true, - reason: null, - }); - expect(JSON.stringify(diagnostics)).not.toContain(runId); - } finally { - await stopUnrelatedFixture(unrelated); - } - }); - - it("makes sequential double-close leave no runtime or process listeners", async () => { - const launcher = new FixtureLauncher(); - const client = new HopperClient({ launcher, startupTimeoutMs: 1_000 }); - clients.push(client); - await expect(client.start()).resolves.toMatchObject({ ok: true }); - - await client.close(); - await client.close(); - - const directory = launcher.directories[0] ?? ""; - const child = launcher.processes[0]; - await expect(access(directory)).rejects.toMatchObject({ code: "ENOENT" }); - expect(child).toBeDefined(); - if (child === undefined) - throw new Error("Fixture process was not captured"); - expect(child.exitCode !== null || child.signalCode !== null).toBe(true); - expect(getEventListeners(child, "exit")).toHaveLength(0); - expect(getEventListeners(child, "close")).toHaveLength(0); - expect(getEventListeners(child, "error")).toHaveLength(0); - expect(child.stderr).not.toBeNull(); - if (child.stderr === null) throw new Error("Fixture stderr is unavailable"); - expect(getEventListeners(child.stderr, "data")).toHaveLength(0); - }); - - it("does not finish startup after an immediate close", async () => { - const client = new HopperClient({ - launcher: new FixtureLauncher(), - startupTimeoutMs: 1_000, - }); - clients.push(client); - - const starting = client.start(); - await client.close(); - - const result = await starting; - expect(result.ok).toBe(false); - if (!result.ok) expect(result.error._tag).toBe("HopperCancelledError"); - }); - - it("starts a fresh session after close completes", async () => { - const client = await startClient(); - await client.close(); - - await expect(client.start()).resolves.toEqual({ - ok: true, - value: { name: "REA Hopper bridge", version: "1.0.0" }, - }); - }); - - it("serializes a restart requested while close is in progress", async () => { - const client = await startClient(); - const closing = client.close(); - const restarting = client.start(); - - await closing; - await expect(restarting).resolves.toEqual({ - ok: true, - value: { name: "REA Hopper bridge", version: "1.0.0" }, - }); - await expect(client.callTool("echo", { value: "fresh" })).resolves.toEqual({ - ok: true, - value: { value: "fresh" }, - }); - }); -}); - -const stopUnrelatedFixture = async (child: ChildProcess): Promise => { - if (child.exitCode !== null || child.signalCode !== null) return; - const exited = new Promise((resolve) => { - child.once("exit", () => resolve()); - }); - child.kill("SIGKILL"); - await exited; -}; diff --git a/tests/integration/serverIntegration.test.ts b/tests/integration/serverIntegration.test.ts deleted file mode 100644 index 3015b2d75..000000000 --- a/tests/integration/serverIntegration.test.ts +++ /dev/null @@ -1,603 +0,0 @@ -import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; -import type { CallToolResult } from "@modelcontextprotocol/server"; -import { afterEach, describe, expect, it } from "vitest"; -import { z } from "zod"; - -import { BinarySession } from "../../src/application/BinarySession.js"; -import type { - AnalysisClient, - AnalysisOperationPort, - AnalysisProvider, - CapabilityDescriptor, -} from "../../src/application/AnalysisProvider.js"; -import { - AnalysisCapabilityUnavailableError, - HopperRemoteError, -} from "../../src/domain/errors.js"; -import { err } from "../../src/domain/result.js"; -import { observed as ok } from "../fixtures/analysisExecution.js"; -import { createServer } from "../../src/server/createServer.js"; -import { createEvidence } from "../../src/domain/evidence.js"; -import { processCaptureSchema } from "../../src/domain/processCapture.js"; -import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../../src/contracts/processCaptureExample.js"; -import { jsonValueSchema } from "../../src/domain/jsonValue.js"; -import { PROCESS_PROVIDER } from "../../src/server/sessionToolPolicies.js"; -import { - SESSION_TOOL_CONTRACTS, - TOOL_CONTRACTS, -} from "../../src/contracts/toolContracts.js"; -import { MANAGED_WORKFLOW_TOOL_CONTRACTS } from "../../src/contracts/managedWorkflowToolContracts.js"; - -const resources: Array<{ close(): Promise }> = []; - -afterEach(async () => { - await Promise.all( - resources.splice(0).map(async (resource) => resource.close()), - ); -}); - -const connect = async (analysis: AnalysisOperationPort) => { - const server = createServer(analysis); - const client = new Client({ - name: "integration-test", - version: "1.0.0", - }); - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - resources.push(client, server); - await server.connect(serverTransport); - await client.connect(clientTransport); - return client; -}; - -const providerWithCapabilities = ( - operations: readonly CapabilityDescriptor["operation"][], -): AnalysisProvider => { - const identity = { id: "fixture", name: "Fixture", version: "1" }; - const capabilities: readonly CapabilityDescriptor[] = operations.map( - (operation) => ({ - provider: identity, - operation, - inputContractVersion: 1, - outputContractVersion: 1, - available: true, - reason: null, - pagination: "none", - exhaustive: true, - effects: { - mutatesArtifact: false, - launchesProcess: false, - mayShowUi: false, - mayAccessNetwork: false, - mayWriteFilesystem: false, - changesPermissions: false, - requiresRoot: false, - }, - limits: { - maxResults: null, - maxPayloadBytes: null, - timeoutMs: null, - }, - limitations: [], - }), - ); - return { - identity: () => identity, - capabilities: () => capabilities, - createClient: () => ({ - execute: () => Promise.resolve(ok(null)), - close: () => Promise.resolve(), - }), - }; -}; - -const text = (result: CallToolResult): string => { - const content = result.content.find((item) => item.type === "text"); - if (content?.type !== "text") throw new Error("missing text result"); - return content.text; -}; - -const structured = (result: CallToolResult): Record => { - if ( - typeof result.structuredContent !== "object" || - result.structuredContent === null - ) - throw new Error("missing structured result"); - return Object.fromEntries(Object.entries(result.structuredContent)); -}; - -const objectEntries = (value: unknown): readonly [string, unknown][] => - typeof value === "object" && value !== null && !Array.isArray(value) - ? Object.entries(value) - : []; - -const referencedSchema = (schema: unknown, root: unknown): unknown => { - const object = Object.fromEntries(objectEntries(schema)); - const reference = object.$ref; - if (typeof reference !== "string" || !reference.startsWith("#/$defs/")) - return schema; - return Object.fromEntries(objectEntries(root)).$defs instanceof Object - ? Object.fromEntries( - objectEntries(Object.fromEntries(objectEntries(root)).$defs), - )[reference.slice(8)] - : schema; -}; - -const assertDescribedStrictObjects = ( - schema: unknown, - root = schema, - path = "$", -): void => { - for (const [key, value] of objectEntries(schema)) { - if (key === "examples") continue; - if (key === "properties") { - const properties = objectEntries(value); - for (const [property, propertySchema] of properties) { - const resolved = referencedSchema(propertySchema, root); - const propertyObject = Object.fromEntries( - objectEntries(propertySchema), - ); - expect( - propertyObject.description ?? - Object.fromEntries(objectEntries(resolved)).description, - `${path}.properties.${property}`, - ).toEqual(expect.any(String)); - assertDescribedStrictObjects( - propertySchema, - root, - `${path}.properties.${property}`, - ); - } - expect( - Object.fromEntries(objectEntries(schema)).additionalProperties, - ).toBe(false); - continue; - } - assertDescribedStrictObjects(value, root, `${path}.${key}`); - } - if (Array.isArray(schema)) - for (const [index, value] of schema.entries()) - assertDescribedStrictObjects(value, root, `${path}[${String(index)}]`); -}; - -const assertDescribedRootObject = (schema: unknown, path: string): void => { - const object = Object.fromEntries(objectEntries(schema)); - expect(object.additionalProperties, path).toBe(false); - for (const [property, propertySchema] of objectEntries(object.properties)) - expect( - Object.fromEntries(objectEntries(propertySchema)).description, - `${path}.properties.${property}`, - ).toEqual(expect.any(String)); -}; - -describe("full MCP integration with multi-tool sequences", () => { - it("executes a realistic workflow: list methods, decompile selected, get xrefs", async () => { - const client = await connect({ - execute: (name, args) => { - switch (name) { - case "list_procedures": - return Promise.resolve( - ok({ - items: [ - { address: "0x1000", value: "main" }, - { address: "0x2000", value: "helper" }, - ], - offset: 0, - limit: 100, - total: 2, - next_offset: null, - has_more: false, - }), - ); - case "procedure_pseudo_code": - return Promise.resolve( - ok(`pseudo for ${(args as { procedure: string }).procedure}`), - ); - case "xrefs": - return Promise.resolve(ok(["0x1000"])); - default: - return Promise.resolve(ok(null)); - } - }, - }); - - const listResult = await client.callTool({ - name: "list_procedures", - arguments: {}, - }); - expect(listResult.isError).not.toBe(true); - expect(structured(listResult)).toMatchObject({ - result: { - items: [ - { address: "0x1000", value: "main" }, - { address: "0x2000", value: "helper" }, - ], - total: 2, - has_more: false, - }, - }); - - const decompileResult = await client.callTool({ - name: "procedure_pseudo_code", - arguments: { procedure: "0x1000" }, - }); - expect(structured(decompileResult)).toMatchObject({ - result: "pseudo for 0x1000", - }); - - const xrefResult = await client.callTool({ - name: "xrefs", - arguments: {}, - }); - expect(structured(xrefResult)).toMatchObject({ - result: ["0x1000"], - }); - }); - - it("advertises the complete currently available inventory with a session", async () => { - const session = new BinarySession( - (_path) => - ({ - execute: () => Promise.resolve(ok(null)), - close: () => Promise.resolve(), - }) satisfies AnalysisClient, - ); - const server = createServer( - { execute: () => Promise.resolve(ok(null)) }, - session, - ); - const client = new Client({ - name: "integration-test", - version: "1.0.0", - }); - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - resources.push(client, server); - await server.connect(serverTransport); - await client.connect(clientTransport); - - const listed = await client.listTools(); - const names = listed.tools.map((t) => t.name); - expect(names).toContain("open_binary"); - expect(names).toContain("close_binary"); - expect(names).toContain("binary_session"); - expect(names).not.toContain("binary_overview"); - expect(names).not.toContain("batch_decompile"); - const status = structured( - await client.callTool({ - name: "binary_session", - arguments: { detail: "full" }, - }), - ); - const available = new Set( - z - .object({ - result: z.object({ - tool_availability: z.array( - z.object({ name: z.string(), available: z.boolean() }), - ), - }), - }) - .parse(status) - .result.tool_availability.filter((item) => item.available) - .map(({ name }) => name), - ); - expect(new Set(names)).toEqual(available); - - const contracts = new Map( - TOOL_CONTRACTS.map((contract) => [contract.name, contract]), - ); - for (const tool of listed.tools) { - const contract = contracts.get(tool.name); - expect(contract, tool.name).toBeDefined(); - expect(tool.title, tool.name).toBe(contract?.title); - expect(tool.description, tool.name).toBe(contract?.description); - expect(tool.annotations, tool.name).toEqual(contract?.annotations); - expect(tool.outputSchema, tool.name).toBeDefined(); - assertDescribedStrictObjects( - tool.inputSchema, - tool.inputSchema, - tool.name, - ); - assertDescribedRootObject(tool.outputSchema, `${tool.name}.output`); - } - }, 10_000); - - it("records approved trace truncation as a deduplicated residual unknown", async () => { - const analysis: AnalysisOperationPort = { - execute: () => - Promise.resolve( - ok({ - items: [], - offset: 0, - limit: 500, - total: 0, - next_offset: null, - has_more: false, - }), - ), - }; - const session = new BinarySession( - providerWithCapabilities([ - "list_strings", - "list_procedures", - "xrefs", - "resolve_containing_procedure", - ]), - ); - const server = createServer(analysis, session); - const client = new Client({ name: "unknown-workflow", version: "1.0.0" }); - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - resources.push(client, server); - await server.connect(serverTransport); - await client.connect(clientTransport); - await client.callTool({ - name: "open_binary", - arguments: { path: process.execPath }, - }); - - for (let attempt = 0; attempt < 2; attempt += 1) { - const traced = await client.callTool({ - name: "trace_feature", - arguments: { - query: "license", - max_operations: 1, - unknown_registry_approved: true, - }, - }); - expect(traced.isError).not.toBe(true); - } - const listed = structured( - await client.callTool({ name: "list_unknowns", arguments: {} }), - ); - expect(listed).toMatchObject({ - result: { - items: [ - { - unknown: { - status: "open", - domain: "control-flow", - question: "Investigation reached the operation budget.", - }, - }, - ], - }, - }); - }); - - it("records approved typed capability unavailability without forwarding the flag", async () => { - const received: Array>> = []; - const analysis: AnalysisOperationPort = { - execute: (name, arguments_) => { - received.push(arguments_); - return Promise.resolve( - err( - new AnalysisCapabilityUnavailableError( - "partial", - name, - "Decompiler is not installed.", - ), - ), - ); - }, - }; - const session = new BinarySession( - providerWithCapabilities(["procedure_pseudo_code"]), - ); - const server = createServer(analysis, session); - const client = new Client({ name: "unavailable-unknown", version: "1" }); - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - resources.push(client, server); - await server.connect(serverTransport); - await client.connect(clientTransport); - await client.callTool({ - name: "open_binary", - arguments: { path: process.execPath }, - }); - - const unavailable = await client.callTool({ - name: "procedure_pseudo_code", - arguments: { - procedure: "main", - unknown_registry_approved: true, - }, - }); - expect(unavailable.isError).toBe(true); - expect(structured(unavailable)).toMatchObject({ - error: { - category: "unsupported_provider", - }, - }); - expect(received[0]).not.toHaveProperty("unknown_registry_approved"); - expect( - structured( - await client.callTool({ name: "list_unknowns", arguments: {} }), - ), - ).toMatchObject({ - result: { - items: [ - { - unknown: { - domain: "analysis-capability", - question: - "The requested analysis is unavailable for the current target.", - recommended_probes: [ - { - rationale: - "Choose another analysis or target that can answer this question.", - }, - ], - }, - }, - ], - }, - }); - }); - - it("records approved capture disagreement as a contradicted unknown", async () => { - const session = new BinarySession( - () => - ({ - execute: () => Promise.resolve(ok(null)), - close: () => Promise.resolve(), - }) satisfies AnalysisClient, - ); - const left = processCaptureSchema.parse(EMPTY_PROCESS_CAPTURE_EXAMPLE); - const right = processCaptureSchema.parse({ - ...EMPTY_PROCESS_CAPTURE_EXAMPLE, - interaction_events: [ - { - sequence: 0, - scheduled_at_ms: 0, - dispatched_at_ms: 0, - type: "input", - data: "fixture", - outcome: "dispatched", - }, - ], - shim_events: [ - { - sequence: 0, - at_ms: 0, - command: "fixture", - route_index: null, - arguments: [], - working_directory: "/tmp", - outcome: "unmatched", - }, - ], - }); - const captureEvidence = (capture: typeof left) => - createEvidence(undefined, PROCESS_PROVIDER, { - predicateType: "rea.process-capture/v4", - operation: "capture_process_scenario", - parameters: {}, - result: jsonValueSchema.parse(capture), - confidence: "observed", - authority: "controlled-replay", - }); - const leftEvidence = captureEvidence(left); - const rightEvidence = captureEvidence(right); - expect(session.recordEvidence(leftEvidence).ok).toBe(true); - expect(session.recordEvidence(rightEvidence).ok).toBe(true); - const server = createServer( - { execute: () => Promise.resolve(ok(null)) }, - session, - ); - const client = new Client({ name: "comparison-unknown", version: "1" }); - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - resources.push(client, server); - await server.connect(serverTransport); - await client.connect(clientTransport); - - const mismatched = await client.callTool({ - name: "compare_process_captures", - arguments: { - left_evidence_id: leftEvidence.evidence_id, - left: right, - right_evidence_id: rightEvidence.evidence_id, - right, - }, - }); - expect(mismatched.isError).toBe(true); - - const compared = await client.callTool({ - name: "compare_process_captures", - arguments: { - left_evidence_id: leftEvidence.evidence_id, - right_evidence_id: rightEvidence.evidence_id, - unknown_registry_approved: true, - }, - }); - expect(compared.isError).not.toBe(true); - expect( - structured( - await client.callTool({ name: "list_unknowns", arguments: {} }), - ), - ).toMatchObject({ - result: { - items: [ - { - unknown: { - status: "contradicted", - domain: "process-comparison", - question: "Process captures disagree across: interaction, shim", - contradicting_evidence_ids: [rightEvidence.evidence_id], - }, - }, - ], - }, - }); - }); - - it("preserves the target-open tool inventory without a session", async () => { - const client = await connect({ - execute: () => Promise.resolve(ok(null)), - }); - const listed = await client.listTools(); - expect(listed.tools).toHaveLength( - TOOL_CONTRACTS.length - - SESSION_TOOL_CONTRACTS.length - - MANAGED_WORKFLOW_TOOL_CONTRACTS.length, - ); - const names = listed.tools.map((t) => t.name); - expect(names).toContain("binary_overview"); - expect(names).toContain("batch_decompile"); - expect(names).not.toContain("open_binary"); - }); - - it("projects remote failures without provider or bridge details", async () => { - const client = await connect({ - execute: () => - Promise.resolve(err(new HopperRemoteError(-32000, "bridge timeout"))), - }); - - const result = await client.callTool({ - name: "list_documents", - arguments: {}, - }); - expect(result.isError).toBe(true); - expect(structured(result)).toMatchObject({ - error: { - category: "execution_failure", - }, - }); - expect(text(result)).toBe(JSON.stringify(result.structuredContent)); - }); - - it("handles concurrent tool calls without corruption", async () => { - const invocations: string[] = []; - const client = await connect({ - execute: (name) => { - invocations.push(name); - return Promise.resolve( - ok( - ["list_procedures", "list_strings"].includes(name) - ? { - items: [], - offset: 0, - limit: 100, - total: 0, - next_offset: null, - has_more: false, - } - : [], - ), - ); - }, - }); - - const results = await Promise.all([ - client.callTool({ name: "list_procedures", arguments: {} }), - client.callTool({ name: "list_segments", arguments: {} }), - client.callTool({ name: "list_strings", arguments: {} }), - ]); - - expect(results.every((r) => !r.isError)).toBe(true); - expect(invocations).toHaveLength(3); - expect(new Set(invocations).size).toBe(3); - }); -}); diff --git a/tests/investigationMcp.test.ts b/tests/investigationMcp.test.ts deleted file mode 100644 index f9b4e29cb..000000000 --- a/tests/investigationMcp.test.ts +++ /dev/null @@ -1,684 +0,0 @@ -import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; -import { mkdir, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { z } from "zod"; - -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; - -import { BinarySession } from "../src/application/BinarySession.js"; -import { FUNCTION_COMPARISON_EXAMPLE } from "../src/contracts/functionComparisonExample.js"; -import { INVESTIGATION_EXAMPLES } from "../src/contracts/investigationExamples.js"; -import { - FUNCTION_COMPARISON_EVIDENCE, - PROCESS_CAPTURE_RECONSTRUCTION, - PROCESS_CAPTURE_REFERENCE, - PROCESS_COMPARISON_EVIDENCE, -} from "../src/contracts/investigationExamples.js"; -import { changedBehaviorResultSchema } from "../src/domain/changedBehavior.js"; -import { createEvidence } from "../src/domain/evidence.js"; -import { jsonObjectSchema, jsonValueSchema } from "../src/domain/jsonValue.js"; -import { createServer } from "../src/server/createServer.js"; -import { observed } from "./fixtures/analysisExecution.js"; -import { readInvestigationWorkspace } from "../src/application/InvestigationWorkspaceStore.js"; -import type { EvidenceFilePolicy } from "../src/domain/evidenceBundle.js"; -import type { PermissionAuthority } from "../src/application/PermissionAuthority.js"; -import { permissionAuthorityForRoot } from "./fixtures/permissionAuthority.js"; - -describe("investigation MCP workflows", () => { - it("never retains derived evidence after request cancellation", async () => { - const { session, server, client } = await connected(); - const inputs = [ - { - name: "find_changed_behavior", - arguments: INVESTIGATION_EXAMPLES.find_changed_behavior, - }, - { - name: "build_call_path", - arguments: INVESTIGATION_EXAMPLES.build_call_path, - }, - { - name: "correlate_static_and_runtime", - arguments: INVESTIGATION_EXAMPLES.correlate_static_and_runtime, - }, - ] as const; - for (const evidence of [ - FUNCTION_COMPARISON_EXAMPLE.left, - FUNCTION_COMPARISON_EXAMPLE.right, - FUNCTION_COMPARISON_EVIDENCE, - PROCESS_CAPTURE_REFERENCE, - PROCESS_CAPTURE_RECONSTRUCTION, - PROCESS_COMPARISON_EVIDENCE, - ]) - expect(session.recordEvidence(evidence).ok).toBe(true); - const initialCount = session.exportEvidenceBundle().records.length; - try { - for (const input of inputs) { - const controller = new AbortController(); - const request = client.callTool(input, { - signal: controller.signal, - onprogress: () => controller.abort(), - }); - await expect(request).rejects.toThrow(/abort/iu); - } - await expect - .poll(() => session.exportEvidenceBundle().records.length) - .toBe(initialCount); - } finally { - await close(session, server, client); - } - }); - - it("runs and reuses a persistent cross-version workspace", async () => { - const directory = await createTestTempDirectory("rea-investigation-mcp-"); - const left = join(directory, "left"); - const right = join(directory, "right"); - const workspace = join(directory, "workspace.json"); - await Promise.all([mkdir(left), mkdir(right)]); - await Promise.all([ - writeFile(join(left, "feature.txt"), "old\n"), - writeFile(join(right, "feature.txt"), "new\n"), - ]); - const filePolicy = evidencePolicy(directory); - const { session, server, client } = await connected(filePolicy); - try { - const arguments_ = { - investigation_run: { - approved: true, - workspace_path: workspace, - workspace_name: "mcp-release-diff", - left_path: left, - right_path: right, - options: { page_size: 500, change_limit: 100 }, - }, - }; - const first = await client.callTool({ - name: "find_changed_behavior", - arguments: arguments_, - }); - expect(first.isError).not.toBe(true); - const firstEvidence = sessionEvidence(session, first.structuredContent); - expect(firstEvidence.normalized_result).toMatchObject({ - behavior_status: "unknown", - summary: { static_candidates: 2 }, - investigation_run: { inventory_evidence_count: 2 }, - }); - expect(session.hasEvidence(firstEvidence.evidence_id)).toBe(true); - const loaded = await readInvestigationWorkspace(workspace, filePolicy); - expect(loaded).toMatchObject({ - ok: true, - value: { revision: 3, runs: [{ status: "complete" }] }, - }); - expect(first.content).toContainEqual( - expect.objectContaining({ - type: "resource_link", - uri: expect.stringMatching( - /^rea:\/\/workspace\/ws_[a-f0-9]{64}\/revision\/3$/u, - ), - }), - ); - const retainedWorkspace = session.investigationWorkspaces()[0]; - expect(retainedWorkspace).toBeDefined(); - if (retainedWorkspace !== undefined) { - const resource = await client.readResource({ - uri: `rea://workspace/${retainedWorkspace.workspace_id}/revision/${String(retainedWorkspace.revision)}`, - }); - expect(resource.contents[0]).toEqual( - expect.objectContaining({ - text: expect.stringContaining(retainedWorkspace.revision_digest), - }), - ); - } - - const second = await client.callTool({ - name: "find_changed_behavior", - arguments: arguments_, - }); - expect(second.isError, JSON.stringify(second)).not.toBe(true); - const secondEvidence = sessionEvidence(session, second.structuredContent); - expect(secondEvidence.evidence_id).toBe(firstEvidence.evidence_id); - expect( - await readInvestigationWorkspace(workspace, filePolicy), - ).toMatchObject({ ok: true, value: { revision: 3 } }); - } finally { - await close(session, server, client); - await rm(directory, { recursive: true, force: true }); - } - }); - - it("replays a complete workspace without write authority", async () => { - const directory = await createTestTempDirectory("rea-investigation-read-"); - const left = join(directory, "left"); - const right = join(directory, "right"); - const workspace = join(directory, "workspace.json"); - await Promise.all([mkdir(left), mkdir(right)]); - await Promise.all([ - writeFile(join(left, "feature.txt"), "old\n"), - writeFile(join(right, "feature.txt"), "new\n"), - ]); - const filePolicy = evidencePolicy(directory); - const arguments_ = { - investigation_run: { - approved: true, - workspace_path: workspace, - workspace_name: "read-only-replay", - left_path: left, - right_path: right, - options: { page_size: 500, change_limit: 100 }, - }, - }; - const writer = await investigationAuthority(directory, true); - const initial = await connected(filePolicy, [directory], writer); - try { - const created = await initial.client.callTool({ - name: "find_changed_behavior", - arguments: arguments_, - }); - expect(created.isError, JSON.stringify(created)).not.toBe(true); - } finally { - await close(initial.session, initial.server, initial.client); - } - - const reader = await investigationAuthority(directory, false); - const replay = await connected(filePolicy, [directory], reader); - try { - const cached = await replay.client.callTool({ - name: "find_changed_behavior", - arguments: arguments_, - }); - expect(cached.isError, JSON.stringify(cached)).not.toBe(true); - expect( - await readInvestigationWorkspace(workspace, filePolicy), - ).toMatchObject({ ok: true, value: { revision: 3 } }); - - await writeFile(join(right, "feature.txt"), "changed again\n"); - const denied = await replay.client.callTool({ - name: "find_changed_behavior", - arguments: arguments_, - }); - expect(denied).toMatchObject({ - isError: true, - structuredContent: { - error: { - code: "permission_required", - details: { capability: "investigation_workspace_write" }, - }, - }, - }); - } finally { - await close(replay.session, replay.server, replay.client); - await rm(directory, { recursive: true, force: true }); - } - }); - - it("defers input permission for an explicit replay with deleted inputs", async () => { - const directory = await createTestTempDirectory( - "rea-investigation-replay-", - ); - const left = join(directory, "left"); - const right = join(directory, "right"); - const workspace = join(directory, "workspace.json"); - await Promise.all([mkdir(left), mkdir(right)]); - await Promise.all([ - writeFile(join(left, "feature.txt"), "old\n"), - writeFile(join(right, "feature.txt"), "new\n"), - ]); - const filePolicy = evidencePolicy(directory); - const investigationRun = { - approved: true, - workspace_path: workspace, - workspace_name: "selected-replay", - left_path: left, - right_path: right, - options: { page_size: 500, change_limit: 100 }, - }; - const writer = await investigationAuthority(directory, true); - const initial = await connected(filePolicy, [directory], writer); - let runId: string | undefined; - try { - const created = await initial.client.callTool({ - name: "find_changed_behavior", - arguments: { investigation_run: investigationRun }, - }); - expect(created.isError, JSON.stringify(created)).not.toBe(true); - const evidence = sessionEvidence( - initial.session, - created.structuredContent, - ); - const result = changedBehaviorResultSchema.parse( - evidence.normalized_result, - ); - runId = result.investigation_run?.run_id; - } finally { - await close(initial.session, initial.server, initial.client); - } - if (runId === undefined) throw new Error("missing completed run ID"); - - await Promise.all([ - rm(left, { recursive: true, force: true }), - rm(right, { recursive: true, force: true }), - ]); - const workspaceOnly = await permissionAuthorityForRoot( - directory, - [ - "investigation_workspace_read", - "investigation_workspace_write", - "investigation_input", - ], - ["investigation_workspace_read"], - ); - const replay = await connected(filePolicy, [], workspaceOnly); - try { - const cached = await replay.client.callTool({ - name: "find_changed_behavior", - arguments: { - investigation_run: { - ...investigationRun, - replay_run_id: runId, - }, - }, - }); - expect(cached.isError, JSON.stringify(cached)).not.toBe(true); - expect( - await readInvestigationWorkspace(workspace, filePolicy), - ).toMatchObject({ ok: true, value: { revision: 3 } }); - } finally { - await close(replay.session, replay.server, replay.client); - await rm(directory, { recursive: true, force: true }); - } - }); - - it("refuses automatic artifact reads outside operator-approved roots", async () => { - const directory = await createTestTempDirectory("rea-investigation-mcp-"); - const approvedInputs = join(directory, "approved-inputs"); - const outsideInputs = join(directory, "outside-inputs"); - const evidenceRoot = join(directory, "evidence"); - await Promise.all([ - mkdir(approvedInputs), - mkdir(outsideInputs), - mkdir(evidenceRoot), - ]); - const left = join(outsideInputs, "left"); - const right = join(outsideInputs, "right"); - await Promise.all([mkdir(left), mkdir(right)]); - const { session, server, client } = await connected( - evidencePolicy(evidenceRoot), - [approvedInputs], - ); - try { - const response = await client.callTool({ - name: "find_changed_behavior", - arguments: { - investigation_run: { - approved: true, - workspace_path: join(evidenceRoot, "workspace.json"), - left_path: left, - right_path: right, - }, - }, - }); - expect(response.isError).toBe(true); - expect(response.content[0]).toEqual({ - type: "text", - text: JSON.stringify(response.structuredContent), - }); - } finally { - await close(session, server, client); - await rm(directory, { recursive: true, force: true }); - } - }); - - it("aggregates comparison Evidence and records an approved runtime gap", async () => { - const { session, server, client } = await connected(); - const comparison = - INVESTIGATION_EXAMPLES.find_changed_behavior.comparisons[0]; - if (comparison === undefined) throw new Error("missing comparison fixture"); - for (const evidence of [ - FUNCTION_COMPARISON_EXAMPLE.left, - FUNCTION_COMPARISON_EXAMPLE.right, - comparison, - ]) - expect(session.recordEvidence(evidence).ok).toBe(true); - try { - const response = await client.callTool({ - name: "find_changed_behavior", - arguments: { - comparisons: [comparison], - unknown_registry_approved: true, - }, - }); - expect(response.isError, JSON.stringify(response)).not.toBe(true); - const evidence = sessionEvidence(session, response.structuredContent); - expect(evidence).toMatchObject({ - provider: { id: "rea-changed-behavior" }, - normalized_result: { behavior_status: "unknown" }, - }); - expect( - session.listUnknowns({ domain: "changed-behavior" }), - ).toMatchObject([ - { - question: - "Did both versions behave the same under a complete controlled replay?", - }, - ]); - } finally { - await close(session, server, client); - } - }); - - it("builds a zero-hop exact-address path with dossier citations", async () => { - const { session, server, client } = await connected(); - expect(session.recordEvidence(FUNCTION_COMPARISON_EXAMPLE.left).ok).toBe( - true, - ); - try { - const response = await client.callTool({ - name: "build_call_path", - arguments: INVESTIGATION_EXAMPLES.build_call_path, - }); - expect(response.isError).not.toBe(true); - const evidence = sessionEvidence(session, response.structuredContent); - expect(evidence).toMatchObject({ - provider: { id: "rea-call-path" }, - normalized_result: { - status: "found", - shortest_hops: 0, - paths: { total: 1 }, - }, - evidence_links: [FUNCTION_COMPARISON_EXAMPLE.left.evidence_id], - }); - } finally { - await close(session, server, client); - } - }); - - it("records a safe unresolved call-path question", async () => { - const { session, server, client } = await connected(); - const base = FUNCTION_COMPARISON_EXAMPLE.left; - if (base.subject === null) throw new Error("missing dossier subject"); - const dossier = createEvidence( - { - path: base.subject.local_path, - sha256: base.subject.digest.sha256, - format: base.subject.format, - ...(base.subject.architecture === null - ? {} - : { architecture: base.subject.architecture }), - }, - base.provider, - { - predicateType: base.predicate_type, - operation: base.operation, - parameters: base.parameters, - result: jsonValueSchema.parse({ - ...jsonObjectSchema.parse(base.normalized_result), - callees: { - items: [{ address: "0x2000", name: "next" }], - total: 1, - returned: 1, - truncated: false, - next_offset: null, - }, - }), - rawResult: base.raw_result, - confidence: base.confidence, - authority: base.authority, - limitations: base.limitations, - locations: base.locations, - evidenceLinks: base.evidence_links, - }, - ); - expect(session.recordEvidence(dossier).ok).toBe(true); - try { - const response = await client.callTool({ - name: "build_call_path", - arguments: { - ...INVESTIGATION_EXAMPLES.build_call_path, - functions: [dossier], - start: { address: "0x1000" }, - goal: { address: "0x3000" }, - unknown_registry_approved: true, - }, - }); - expect(response.isError, JSON.stringify(response)).not.toBe(true); - expect(session.listUnknowns({ domain: "call-path" })).toMatchObject([ - { - question: - "Can the requested call path be established from complete analysis?", - }, - ]); - } finally { - await close(session, server, client); - } - }); - - it("records an explicit non-causal static/runtime hypothesis", async () => { - const { session, server, client } = await connected(); - for (const evidence of [ - FUNCTION_COMPARISON_EXAMPLE.left, - FUNCTION_COMPARISON_EXAMPLE.right, - FUNCTION_COMPARISON_EVIDENCE, - PROCESS_CAPTURE_REFERENCE, - PROCESS_CAPTURE_RECONSTRUCTION, - PROCESS_COMPARISON_EVIDENCE, - ]) - expect(session.recordEvidence(evidence).ok).toBe(true); - try { - const response = await client.callTool({ - name: "correlate_static_and_runtime", - arguments: INVESTIGATION_EXAMPLES.correlate_static_and_runtime, - }); - expect(response.isError).not.toBe(true); - const evidence = sessionEvidence(session, response.structuredContent); - expect(evidence).toMatchObject({ - provider: { id: "rea-static-runtime-correlation" }, - confidence: "inferred", - normalized_result: { - status: "correlated", - summary: { hypotheses: 1 }, - }, - }); - } finally { - await close(session, server, client); - } - }); - - it("records a safe unresolved static/runtime question", async () => { - const { session, server, client } = await connected(); - for (const evidence of [ - FUNCTION_COMPARISON_EXAMPLE.left, - FUNCTION_COMPARISON_EXAMPLE.right, - FUNCTION_COMPARISON_EVIDENCE, - PROCESS_CAPTURE_REFERENCE, - PROCESS_CAPTURE_RECONSTRUCTION, - PROCESS_COMPARISON_EVIDENCE, - ]) - expect(session.recordEvidence(evidence).ok).toBe(true); - const mapping = - INVESTIGATION_EXAMPLES.correlate_static_and_runtime.mappings[0]; - if (mapping === undefined) throw new Error("missing correlation fixture"); - try { - const response = await client.callTool({ - name: "correlate_static_and_runtime", - arguments: { - ...INVESTIGATION_EXAMPLES.correlate_static_and_runtime, - mappings: [ - { - ...mapping, - static: { - ...mapping.static, - selector: { kind: "function", dimension: "assembly" }, - }, - }, - ], - unknown_registry_approved: true, - }, - }); - expect(response.isError).not.toBe(true); - expect( - session.listUnknowns({ domain: "static-runtime-correlation" }), - ).toMatchObject([ - { - question: - "Does runtime behavior match the available static analysis?", - }, - ]); - } finally { - await close(session, server, client); - } - }); - - it("passes only the finite declared reconstruction specification", async () => { - const { session, server, client } = await connected(); - for (const evidence of [ - PROCESS_CAPTURE_REFERENCE, - PROCESS_CAPTURE_RECONSTRUCTION, - PROCESS_COMPARISON_EVIDENCE, - ]) - expect(session.recordEvidence(evidence).ok).toBe(true); - try { - const response = await client.callTool({ - name: "verify_reconstruction", - arguments: { - ...INVESTIGATION_EXAMPLES.verify_reconstruction, - unknown_registry_approved: true, - }, - }); - expect(response.isError).not.toBe(true); - const evidence = sessionEvidence(session, response.structuredContent); - expect(evidence).toMatchObject({ - provider: { id: "rea-reconstruction-verifier" }, - normalized_result: { status: "pass", summary: { passed: 1 } }, - }); - expect(evidence.limitations).toContain( - "Pass means every declared claim passed; it does not establish global implementation equivalence.", - ); - } finally { - await close(session, server, client); - } - }); - - it("cannot omit a session-owned active unknown from reconstruction input", async () => { - const { session, server, client } = await connected(); - for (const evidence of [ - PROCESS_CAPTURE_REFERENCE, - PROCESS_CAPTURE_RECONSTRUCTION, - PROCESS_COMPARISON_EVIDENCE, - ]) - expect(session.recordEvidence(evidence).ok).toBe(true); - expect( - session.recordUnknown({ - approved: true, - question: "Was terminal equivalence reproduced independently?", - severity: "high", - domain: "reconstruction-verification", - supporting_evidence_ids: [PROCESS_COMPARISON_EVIDENCE.evidence_id], - contradicting_evidence_ids: [], - required_authority: "controlled-replay", - required_confidence: "observed", - required_environment: null, - recommended_probes: [ - { - operation: "capture_process_scenario", - rationale: "Repeat both sides under one controlled environment.", - }, - ], - relationships: [], - }).ok, - ).toBe(true); - try { - const response = await client.callTool({ - name: "verify_reconstruction", - arguments: { - ...INVESTIGATION_EXAMPLES.verify_reconstruction, - unknown_registry_approved: true, - }, - }); - expect(response.isError).not.toBe(true); - const evidence = sessionEvidence(session, response.structuredContent); - expect(evidence.normalized_result).toMatchObject({ - status: "unknown", - summary: { unknown: 1 }, - }); - expect( - session.listUnknowns({ domain: "reconstruction-verification" }), - ).toContainEqual( - expect.objectContaining({ - question: "Does the reconstruction satisfy every declared claim?", - }), - ); - } finally { - await close(session, server, client); - } - }); -}); - -const connected = async ( - evidenceFilePolicy?: EvidenceFilePolicy, - investigationInputRoots: readonly string[] = evidenceFilePolicy?.roots ?? [], - permissionAuthority?: PermissionAuthority, -) => { - const session = new BinarySession(() => ({ - health: () => Promise.resolve(), - execute: () => Promise.resolve(observed(null)), - close: () => Promise.resolve(), - })); - const server = createServer(session, session, { - ...(evidenceFilePolicy === undefined - ? {} - : { - evidenceFilePolicy, - investigationInputRoots, - }), - ...(permissionAuthority === undefined ? {} : { permissionAuthority }), - }); - const client = new Client({ name: "investigation-test", version: "1" }); - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - await server.connect(serverTransport); - await client.connect(clientTransport); - return { session, server, client }; -}; - -const evidencePolicy = (root: string): EvidenceFilePolicy => ({ - roots: [root], - maxBytes: 64 * 1024 * 1024, - maxDepth: 64, - maxStringLength: 1024 * 1024, - maxNodes: 1_000_000, -}); - -const sessionEvidence = (session: BinarySession, value: unknown) => { - const parsed = z - .object({ evidence_id: z.string().regex(/^ev_[a-f0-9]{64}$/u) }) - .parse(value); - const evidence = session.evidenceById(parsed.evidence_id); - if (evidence === undefined) throw new TypeError("Missing session Evidence"); - return evidence; -}; - -const investigationAuthority = async ( - root: string, - includeWrite: boolean, -): Promise => - permissionAuthorityForRoot( - root, - [ - "investigation_workspace_read", - "investigation_workspace_write", - "investigation_input", - ], - [ - "investigation_workspace_read", - "investigation_input", - ...(includeWrite ? (["investigation_workspace_write"] as const) : []), - ], - ); - -const close = async ( - session: BinarySession, - server: Awaited>, - client: Client, -) => { - await Promise.allSettled([client.close(), server.close(), session.close()]); -}; diff --git a/tests/javascriptApplicationGraph.test.ts b/tests/javascriptApplicationGraph.test.ts deleted file mode 100644 index 0ca48e3e1..000000000 --- a/tests/javascriptApplicationGraph.test.ts +++ /dev/null @@ -1,836 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - computeJavaScriptApplicationGraphSha256, - createJavaScriptApplicationEdge, - createJavaScriptApplicationGraph, - createJavaScriptApplicationNode, - javascriptApplicationGraphSchema, - parseJavaScriptApplicationGraph, - serializeJavaScriptApplicationGraph, - type ApplicationGraphEvidence, - type ApplicationNode, - type JavaScriptApplicationGraph, -} from "../src/domain/javascriptApplicationGraph.js"; -import { - JAVASCRIPT_APPLICATION_NODE_KINDS, - JAVASCRIPT_APPLICATION_RELATIONS, -} from "../src/domain/javascriptApplicationGraphSchemas.js"; -import { - APPLICATION_GRAPH_DIGESTS, - artifactEvidence, - buildSyntheticJavaScriptApplicationGraph, - inferredArtifactEvidence, - runtimeEvidence, -} from "./fixtures/javascriptApplicationGraph.js"; - -const completeCoverage = { - status: "complete" as const, - truncated: false, - omitted_count: 0, - limits: [], -}; - -const unknownEvidence = ( - limitations: string[] = ["The extractor could not classify this fact."], -): ApplicationGraphEvidence => ({ - authority: "unknown", - state: "unknown", - confidence: "unknown", - artifact: { - available: false, - reason: "unknown", - detail: "Artifact provenance is unknown.", - }, - location: { - available: false, - reason: "unknown", - detail: "Source location is unknown.", - }, - extractor: { - name: "test", - version: "1", - operation: "unknown", - executable_sha256: null, - }, - coverage: { - status: "unknown", - truncated: false, - omitted_count: null, - limits: [], - }, - limitations, - evidence_ids: [], -}); - -const nodeByLabel = ( - graph: JavaScriptApplicationGraph, - label: string, -): ApplicationNode => { - const node = graph.nodes.find(({ observations }) => - observations.some((observation) => observation.label === label), - ); - if (node === undefined) throw new TypeError(`Missing fixture node: ${label}`); - return node; -}; - -const firstOf = (values: readonly Value[], label: string): Value => { - const value = values[0]; - if (value === undefined) throw new TypeError(`Missing fixture ${label}`); - return value; -}; - -const graphForNode = (node: ApplicationNode): JavaScriptApplicationGraph => - createJavaScriptApplicationGraph({ - schema: "JavaScriptApplicationGraph", - schema_version: 1, - root_node_ids: [node.node_id], - nodes: [node], - edges: [], - coverage: completeCoverage, - limitations: [], - }); - -const contentNode = ( - evidence: ApplicationGraphEvidence, - properties: Record = {}, -): ApplicationNode => - createJavaScriptApplicationNode({ - kind: "javascript-module", - identity: { - strategy: "content-digest", - stability: "global-exact", - sha256: APPLICATION_GRAPH_DIGESTS.asar, - }, - observations: [{ label: "module", properties, evidence }], - }); - -describe("JavaScript Application Graph", () => { - it("defines the complete provider-neutral v1 node and relation vocabulary", () => { - expect(JAVASCRIPT_APPLICATION_NODE_KINDS).toEqual([ - "package", - "installer", - "artifact", - "asar-entry", - "electron-main", - "electron-preload", - "electron-renderer", - "electron-utility", - "javascript-asset", - "javascript-chunk", - "javascript-module", - "source-map", - "source-module", - "browser-window", - "frame", - "target", - "context-bridge-api", - "ipc-channel", - "ipc-handler", - "worker", - "service-worker", - "endpoint", - "storage", - "native-addon", - "native-export", - "managed-assembly", - "managed-module", - "managed-type", - "managed-method", - "managed-field", - "managed-pinvoke-import", - "managed-native-implementation", - "runtime-script-instance", - "unknown", - ]); - expect(JAVASCRIPT_APPLICATION_RELATIONS).toEqual([ - "contains", - "loads", - "imports", - "maps_to", - "exposes", - "sends", - "invokes", - "handles", - "calls", - "persists_to", - "observed_as", - "changed_from", - ]); - }); - - it("round-trips one canonical, versioned, byte-stable graph", () => { - const graph = buildSyntheticJavaScriptApplicationGraph(); - const serialized = serializeJavaScriptApplicationGraph(graph); - const decoded: unknown = JSON.parse(serialized); - - expect(parseJavaScriptApplicationGraph(decoded)).toEqual(graph); - expect(javascriptApplicationGraphSchema.parse(decoded)).toEqual(graph); - expect(serializeJavaScriptApplicationGraph(decoded)).toBe(serialized); - expect(computeJavaScriptApplicationGraphSha256(decoded)).toMatch( - /^[a-f0-9]{64}$/u, - ); - - const { graph_id: _graphId, ...semantic } = graph; - expect( - createJavaScriptApplicationGraph({ - ...semantic, - nodes: semantic.nodes.toReversed(), - edges: semantic.edges.toReversed(), - }), - ).toEqual(graph); - }); - - it("represents the synthetic ASAR to preload to IPC to native chain", () => { - const graph = buildSyntheticJavaScriptApplicationGraph(); - const labels = [ - "resources/app.asar", - "desktop preload", - "desktopApi", - "project:open", - "project open handler", - "synthetic.node", - "openProject", - ]; - const chain = labels.map((label) => nodeByLabel(graph, label)); - const expectedRelations = [ - "contains", - "exposes", - "invokes", - "handles", - "loads", - "contains", - ]; - - for (let index = 0; index < expectedRelations.length; index += 1) - expect(graph.edges).toContainEqual( - expect.objectContaining({ - source_node_id: chain[index]?.node_id, - target_node_id: chain[index + 1]?.node_id, - relation: expectedRelations[index], - }), - ); - expect(chain.at(-2)?.kind).toBe("native-addon"); - expect(chain.at(-1)?.kind).toBe("native-export"); - }); - - it("keeps static observations, static inferences, runtime facts, and native facts distinct", () => { - const graph = buildSyntheticJavaScriptApplicationGraph(); - const preload = nodeByLabel(graph, "desktop preload"); - const runtime = nodeByLabel(graph, "preload runtime script"); - const nativeExport = nodeByLabel(graph, "openProject"); - const invoke = graph.edges.find(({ relation }) => relation === "invokes"); - const observedAs = graph.edges.find( - ({ relation }) => relation === "observed_as", - ); - - expect(preload.observations[0]?.evidence).toMatchObject({ - authority: "ast-static-analysis", - state: "observed", - }); - expect(invoke?.evidence).toMatchObject({ - authority: "static-relationship-inference", - state: "inferred", - }); - expect(runtime.observations[0]?.evidence).toMatchObject({ - authority: "passive-cdp-runtime", - state: "observed", - }); - expect(observedAs?.evidence.authority).toBe("passive-cdp-runtime"); - expect(observedAs?.identifier_strategy).toEqual({ - strategy: "semantic-content-sha256", - stability: "relationship-exact", - }); - expect(runtime.observations[0]?.identifier_strategy).toEqual({ - strategy: "semantic-content-sha256", - stability: "observation-exact", - }); - expect(nativeExport.observations[0]?.evidence.authority).toBe( - "native-analysis-provider", - ); - expect(preload.node_id).not.toBe(runtime.node_id); - }); - - it("retains explicit unknown observations without using them as identity proof", () => { - const node = createJavaScriptApplicationNode({ - kind: "javascript-module", - identity: { - strategy: "content-digest", - stability: "global-exact", - sha256: APPLICATION_GRAPH_DIGESTS.asar, - }, - observations: [ - { - label: "observed module", - properties: {}, - evidence: artifactEvidence( - APPLICATION_GRAPH_DIGESTS.asar, - "dist/module.js", - ), - }, - { - label: null, - properties: { question: "runtime correspondence" }, - evidence: unknownEvidence(), - }, - ], - }); - - const graph = graphForNode(node); - expect( - graph.nodes[0]?.observations.find( - ({ evidence }) => evidence.state === "unknown", - )?.evidence, - ).toMatchObject({ authority: "unknown", confidence: "unknown" }); - }); - - it("separates stable entity identity from changing observations", () => { - const identity = { - strategy: "canonical-path" as const, - stability: "artifact-version" as const, - artifact_sha256: APPLICATION_GRAPH_DIGESTS.asar, - path: "dist/chunk.js", - }; - const first = createJavaScriptApplicationNode({ - kind: "javascript-chunk", - identity, - observations: [ - { - label: "first label", - properties: { modules: 10 }, - evidence: artifactEvidence( - APPLICATION_GRAPH_DIGESTS.asar, - "dist/chunk.js", - "ast-static-analysis", - ), - }, - ], - }); - const second = createJavaScriptApplicationNode({ - kind: "javascript-chunk", - identity, - observations: [ - { - label: "renamed chunk", - properties: { modules: 11 }, - evidence: artifactEvidence( - APPLICATION_GRAPH_DIGESTS.asar, - "dist/chunk.js", - "ast-static-analysis", - ), - }, - ], - }); - - expect(first.node_id).toBe(second.node_id); - expect(first.observations[0]?.observation_id).not.toBe( - second.observations[0]?.observation_id, - ); - }); - - it("normalizes structural identity and evidence set ordering", () => { - const evidence = inferredArtifactEvidence( - APPLICATION_GRAPH_DIGESTS.asar, - "dist/module.js", - ); - evidence.limitations = ["z limitation", "a limitation", "a limitation"]; - evidence.evidence_ids = [ - `ev_${"b".repeat(64)}`, - `ev_${"a".repeat(64)}`, - `ev_${"a".repeat(64)}`, - ]; - evidence.coverage.limits = [ - { name: "modules", value: 100, unit: "items" }, - { name: "modules", value: 100, unit: "items" }, - ]; - const node = createJavaScriptApplicationNode({ - kind: "javascript-module", - identity: { - strategy: "structural-fingerprint", - stability: "cross-version-inference", - algorithm: "synthetic-v1", - fingerprint_sha256: "5".repeat(64), - basis: ["syntax-tree", "imports", "syntax-tree"], - }, - observations: [{ label: "module", properties: {}, evidence }], - }); - - expect(node.identity).toMatchObject({ basis: ["imports", "syntax-tree"] }); - expect(node.observations[0]?.evidence.limitations).toEqual([ - "a limitation", - "z limitation", - ]); - expect(node.observations[0]?.evidence.evidence_ids).toEqual([ - `ev_${"a".repeat(64)}`, - `ev_${"b".repeat(64)}`, - ]); - expect(node.observations[0]?.evidence.coverage.limits).toEqual([ - { name: "modules", value: 100, unit: "items" }, - ]); - expect(graphForNode(node).nodes).toEqual([node]); - }); - - it("scopes runtime identities to a capture and artifact paths to a version", () => { - const runtime = nodeByLabel( - buildSyntheticJavaScriptApplicationGraph(), - "preload runtime script", - ); - const differentCapture = createJavaScriptApplicationNode({ - kind: "runtime-script-instance", - identity: { - strategy: "runtime-instance", - stability: "capture-only", - capture_sha256: "6".repeat(64), - runtime_key: "script-preload", - }, - observations: [ - { - label: "other capture", - properties: {}, - evidence: { - ...runtimeEvidence("script-preload"), - location: { - available: true, - value: { - kind: "runtime", - capture_sha256: "6".repeat(64), - target_key: "target-main", - frame_key: "frame-main", - script_key: "script-preload", - }, - }, - }, - }, - ], - }); - expect(differentCapture.node_id).not.toBe(runtime.node_id); - - const firstArtifact = createJavaScriptApplicationNode({ - kind: "javascript-asset", - identity: { - strategy: "canonical-path", - stability: "artifact-version", - artifact_sha256: APPLICATION_GRAPH_DIGESTS.asar, - path: "dist/app.js", - }, - observations: [ - { - label: null, - properties: {}, - evidence: artifactEvidence( - APPLICATION_GRAPH_DIGESTS.asar, - "dist/app.js", - ), - }, - ], - }); - const secondArtifact = createJavaScriptApplicationNode({ - kind: "javascript-asset", - identity: { - strategy: "canonical-path", - stability: "artifact-version", - artifact_sha256: "7".repeat(64), - path: "dist/app.js", - }, - observations: [ - { - label: null, - properties: {}, - evidence: artifactEvidence("7".repeat(64), "dist/app.js"), - }, - ], - }); - expect(firstArtifact.node_id).not.toBe(secondArtifact.node_id); - }); - - it("admits source-map and observation-scoped identities only with supporting observations", () => { - const sourceModule = createJavaScriptApplicationNode({ - kind: "source-module", - identity: { - strategy: "source-map-original", - stability: "source-map-exact", - source_map_sha256: APPLICATION_GRAPH_DIGESTS.asar, - original_source: "webpack:///src/editor.ts", - source_sha256: null, - }, - observations: [ - { - label: "src/editor.ts", - properties: { recovered_from: "source-map" }, - evidence: artifactEvidence( - APPLICATION_GRAPH_DIGESTS.asar, - "dist/app.js.map", - "ast-static-analysis", - ), - }, - ], - }); - const observationScoped = createJavaScriptApplicationNode({ - kind: "unknown", - identity: { - strategy: "observation-fingerprint", - stability: "observation-only", - observation_sha256: "8".repeat(64), - scope: "synthetic static inventory", - }, - observations: [ - { - label: "unclassified entity", - properties: {}, - evidence: artifactEvidence( - APPLICATION_GRAPH_DIGESTS.asar, - "dist/app.js", - ), - }, - ], - }); - - expect(graphForNode(sourceModule).nodes).toEqual([sourceModule]); - expect(graphForNode(observationScoped).nodes).toEqual([observationScoped]); - }); - - it("rejects unsupported versions, unknown fields, and stale commitments", () => { - const graph = buildSyntheticJavaScriptApplicationGraph(); - expect(() => - parseJavaScriptApplicationGraph({ ...graph, schema_version: 2 }), - ).toThrow(/Unsupported JavaScript Application Graph schema version: 2/u); - expect(() => - parseJavaScriptApplicationGraph({ ...graph, surprise: true }), - ).toThrow(); - - const staleGraph = structuredClone(graph); - staleGraph.graph_id = `jag_${"f".repeat(64)}`; - expect(() => parseJavaScriptApplicationGraph(staleGraph)).toThrow( - /Graph identifier/u, - ); - - const staleNode = structuredClone(graph); - firstOf(staleNode.nodes, "node").node_id = `jag_node_${"f".repeat(64)}`; - expect(() => parseJavaScriptApplicationGraph(staleNode)).toThrow( - /Node identifier/u, - ); - - const staleObservation = structuredClone(graph); - firstOf( - firstOf(staleObservation.nodes, "node").observations, - "observation", - ).observation_id = `jag_observation_${"f".repeat(64)}`; - expect(() => parseJavaScriptApplicationGraph(staleObservation)).toThrow( - /Observation identifier/u, - ); - - const staleEdge = structuredClone(graph); - firstOf(staleEdge.edges, "edge").edge_id = `jag_edge_${"f".repeat(64)}`; - expect(() => parseJavaScriptApplicationGraph(staleEdge)).toThrow( - /Edge identifier/u, - ); - }); - - it("rejects duplicate nodes, missing roots, dangling edges, and self edges", () => { - const graph = buildSyntheticJavaScriptApplicationGraph(); - const { graph_id: _graphId, ...semantic } = graph; - const source = firstOf(semantic.nodes, "node"); - expect(() => - createJavaScriptApplicationGraph({ - ...semantic, - nodes: [...semantic.nodes, source], - }), - ).toThrow(/Nodes must be sorted and unique/u); - expect(() => - createJavaScriptApplicationGraph({ - ...semantic, - root_node_ids: [`jag_node_${"9".repeat(64)}`], - }), - ).toThrow(/Root identifier/u); - - const missingId = `jag_node_${"9".repeat(64)}`; - const dangling = createJavaScriptApplicationEdge({ - source_node_id: source.node_id, - target_node_id: missingId, - relation: "loads", - properties: {}, - evidence: inferredArtifactEvidence( - APPLICATION_GRAPH_DIGESTS.asar, - "dist/main.js", - ), - }); - expect(() => - createJavaScriptApplicationGraph({ - ...semantic, - edges: [...semantic.edges, dangling], - }), - ).toThrow(/endpoints must name graph nodes/u); - - const selfEdge = createJavaScriptApplicationEdge({ - source_node_id: source.node_id, - target_node_id: source.node_id, - relation: "imports", - properties: {}, - evidence: inferredArtifactEvidence( - APPLICATION_GRAPH_DIGESTS.asar, - "dist/main.js", - ), - }); - expect(() => - createJavaScriptApplicationGraph({ - ...semantic, - edges: [...semantic.edges, selfEdge], - }), - ).toThrow(/self-referential/u); - }); - - it("allows changed_from only between entities of the same kind", () => { - const graph = buildSyntheticJavaScriptApplicationGraph(); - const preload = nodeByLabel(graph, "desktop preload"); - const runtime = nodeByLabel(graph, "preload runtime script"); - const earlierPreload = createJavaScriptApplicationNode({ - kind: "electron-preload", - identity: { - strategy: "canonical-path", - stability: "artifact-version", - artifact_sha256: "7".repeat(64), - path: "dist/preload.js", - }, - observations: [ - { - label: "earlier preload", - properties: {}, - evidence: artifactEvidence("7".repeat(64), "dist/preload.js"), - }, - ], - }); - const changedFrom = createJavaScriptApplicationEdge({ - source_node_id: preload.node_id, - target_node_id: earlierPreload.node_id, - relation: "changed_from", - properties: {}, - evidence: inferredArtifactEvidence( - APPLICATION_GRAPH_DIGESTS.asar, - "dist/preload.js", - ), - }); - expect( - createJavaScriptApplicationGraph({ - schema: "JavaScriptApplicationGraph", - schema_version: 1, - root_node_ids: [preload.node_id], - nodes: [preload, earlierPreload], - edges: [changedFrom], - coverage: completeCoverage, - limitations: [], - }).edges, - ).toEqual([changedFrom]); - - const invalid = createJavaScriptApplicationEdge({ - source_node_id: preload.node_id, - target_node_id: runtime.node_id, - relation: "changed_from", - properties: {}, - evidence: inferredArtifactEvidence( - APPLICATION_GRAPH_DIGESTS.asar, - "dist/preload.js", - ), - }); - expect(() => - createJavaScriptApplicationGraph({ - schema: "JavaScriptApplicationGraph", - schema_version: 1, - root_node_ids: [preload.node_id], - nodes: [preload, runtime], - edges: [invalid], - coverage: completeCoverage, - limitations: [], - }), - ).toThrow(/changed_from endpoints must have the same node kind/u); - }); - - it("requires every identity strategy to have compatible supporting evidence", () => { - const mismatchedPath = createJavaScriptApplicationNode({ - kind: "javascript-asset", - identity: { - strategy: "canonical-path", - stability: "artifact-version", - artifact_sha256: APPLICATION_GRAPH_DIGESTS.asar, - path: "dist/expected.js", - }, - observations: [ - { - label: null, - properties: {}, - evidence: artifactEvidence( - APPLICATION_GRAPH_DIGESTS.asar, - "dist/other.js", - ), - }, - ], - }); - expect(() => graphForNode(mismatchedPath)).toThrow( - /identity is not supported/u, - ); - - const runtimeWithStaticEvidence = createJavaScriptApplicationNode({ - kind: "runtime-script-instance", - identity: { - strategy: "runtime-instance", - stability: "capture-only", - capture_sha256: APPLICATION_GRAPH_DIGESTS.capture, - runtime_key: "script-preload", - }, - observations: [ - { - label: null, - properties: {}, - evidence: artifactEvidence( - APPLICATION_GRAPH_DIGESTS.asar, - "dist/preload.js", - "ast-static-analysis", - ), - }, - ], - }); - expect(() => graphForNode(runtimeWithStaticEvidence)).toThrow( - /identity is not supported/u, - ); - - const mismatchedDigest = contentNode( - artifactEvidence("7".repeat(64), "dist/module.js"), - ); - expect(() => graphForNode(mismatchedDigest)).toThrow( - /identity is not supported/u, - ); - }); - - it("prevents static authorities from claiming runtime locations", () => { - const evidence = artifactEvidence( - APPLICATION_GRAPH_DIGESTS.asar, - "dist/module.js", - "ast-static-analysis", - ); - evidence.location = runtimeEvidence("script-module").location; - expect(() => graphForNode(contentNode(evidence))).toThrow( - /cannot claim a runtime location/u, - ); - }); - - it("enforces epistemic truthfulness and content-addressed artifacts", () => { - const inferredAsObserved = inferredArtifactEvidence( - APPLICATION_GRAPH_DIGESTS.asar, - "dist/module.js", - ); - inferredAsObserved.state = "observed"; - inferredAsObserved.confidence = "exact"; - expect(() => contentNode(inferredAsObserved)).toThrow( - /must remain inferred/u, - ); - - const inferenceWithoutLimit = inferredArtifactEvidence( - APPLICATION_GRAPH_DIGESTS.asar, - "dist/module.js", - ); - inferenceWithoutLimit.limitations = []; - expect(() => contentNode(inferenceWithoutLimit)).toThrow( - /Inferred facts require an explicit limitation/u, - ); - - const unknownWithoutLimit = unknownEvidence([]); - expect(() => contentNode(unknownWithoutLimit)).toThrow( - /require unknown confidence and an explicit limitation/u, - ); - - const missingArtifact = artifactEvidence( - APPLICATION_GRAPH_DIGESTS.asar, - "dist/module.js", - ); - missingArtifact.artifact = { - available: false, - reason: "not-observed", - detail: "Bytes were not observed.", - }; - expect(() => contentNode(missingArtifact)).toThrow( - /require a content digest/u, - ); - - const mismatchedArtifact = artifactEvidence( - APPLICATION_GRAPH_DIGESTS.asar, - "dist/module.js", - ); - if (mismatchedArtifact.artifact.available) - mismatchedArtifact.artifact.artifact_id = `art_${"8".repeat(64)}`; - expect(() => contentNode(mismatchedArtifact)).toThrow( - /same SHA-256 digest/u, - ); - - const unlocatedObservation = artifactEvidence( - APPLICATION_GRAPH_DIGESTS.asar, - "dist/module.js", - ); - unlocatedObservation.location = { - available: false, - reason: "not-observed", - detail: "The exact location was not recorded.", - }; - expect(() => contentNode(unlocatedObservation)).toThrow( - /Observed facts require an actionable location/u, - ); - }); - - it("rejects false truncation claims and unbounded properties", () => { - const truncated = artifactEvidence( - APPLICATION_GRAPH_DIGESTS.asar, - "dist/module.js", - ); - truncated.coverage = { - status: "partial", - truncated: true, - omitted_count: 0, - limits: [], - }; - expect(() => contentNode(truncated)).toThrow( - /must be partial, name a limit, and not claim zero omissions/u, - ); - - const nonComplete = artifactEvidence( - APPLICATION_GRAPH_DIGESTS.asar, - "dist/module.js", - ); - nonComplete.coverage = { - status: "partial", - truncated: false, - omitted_count: null, - limits: [], - }; - expect(() => contentNode(nonComplete)).toThrow( - /Non-complete coverage requires an explicit limitation/u, - ); - - const validNode = contentNode( - artifactEvidence(APPLICATION_GRAPH_DIGESTS.asar, "dist/module.js"), - ); - expect(() => - createJavaScriptApplicationGraph({ - schema: "JavaScriptApplicationGraph", - schema_version: 1, - root_node_ids: [validNode.node_id], - nodes: [validNode], - edges: [], - coverage: { - status: "partial", - truncated: false, - omitted_count: null, - limits: [], - }, - limitations: [], - }), - ).toThrow(/Non-complete graph coverage requires an explicit limitation/u); - - const properties = Object.fromEntries( - Array.from({ length: 65 }, (_, index) => [`key-${String(index)}`, index]), - ); - expect(() => - contentNode( - artifactEvidence(APPLICATION_GRAPH_DIGESTS.asar, "dist/module.js"), - properties, - ), - ).toThrow(/exceed 64 keys/u); - }); -}); diff --git a/tests/javascriptArtifactReconstruction.test.ts b/tests/javascriptArtifactReconstruction.test.ts deleted file mode 100644 index 777154702..000000000 --- a/tests/javascriptArtifactReconstruction.test.ts +++ /dev/null @@ -1,672 +0,0 @@ -import { Readable } from "node:stream"; -import { mkdir, rm, symlink, writeFile } from "node:fs/promises"; -import { join } from "node:path"; - -import { createPackageWithOptions } from "@electron/asar"; -import { describe, expect, it } from "vitest"; - -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; - -import { readJavaScriptArtifactFiles } from "../src/application/JavaScriptArtifactFiles.js"; -import { reconstructJavaScriptArtifact } from "../src/application/JavaScriptArtifactReconstruction.js"; -import { - artifactLimitsForReconstruction, - javascriptArtifactReconstructionInputSchema, -} from "../src/application/JavaScriptArtifactReconstructionInput.js"; -import { scanArtifactInventory } from "../src/application/ArtifactInventory.js"; -import { - type ArtifactEntry, - type ArtifactReader, -} from "../src/artifacts/ArtifactReader.js"; -import { parseJavaScriptApplicationGraph } from "../src/domain/javascriptApplicationGraph.js"; -import { javaScriptApplicationAnalysisResultV2Schema } from "../src/domain/javascriptApplicationAnalysis.js"; -import { createJavaScriptSemanticGraph } from "../src/domain/javascriptSemanticGraph.js"; -import { parseJavaScriptSemanticGraph } from "../src/domain/javascriptSemanticGraphSerialization.js"; -import { writeJavaScriptArtifactFixture } from "./fixtures/javascriptArtifactApplication.js"; - -describe("JavaScript artifact reconstruction", () => { - it("reconstructs package, Electron roles, Webpack/Rspack modules, and cross-layer facts without execution", async () => { - const root = await fixtureDirectory(); - Reflect.deleteProperty(globalThis, "__rea_bundle_executed"); - - const result = await reconstructJavaScriptArtifact({ - input_path: root, - source_map_read_approved: true, - }); - const graph = parseJavaScriptApplicationGraph(result.graph); - const semanticGraph = parseJavaScriptSemanticGraph(result.semantic_graph); - - expect(Reflect.get(globalThis, "__rea_bundle_executed")).toBeUndefined(); - expect(result.input_path).toBe(root); - expect(result.statistics).toMatchObject({ - modules: 4, - parse_failures: 0, - omitted_text_files: 0, - policy_filtered_text_files: 0, - }); - expect(graph.nodes.map(({ kind }) => kind)).toEqual( - expect.arrayContaining([ - "package", - "artifact", - "electron-main", - "electron-preload", - "electron-renderer", - "javascript-asset", - "javascript-chunk", - "javascript-module", - "worker", - "service-worker", - "endpoint", - "storage", - "source-map", - "source-module", - "native-addon", - ]), - ); - expect( - graph.nodes.filter(({ kind }) => kind === "javascript-chunk"), - ).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - observations: expect.arrayContaining([ - expect.objectContaining({ - properties: expect.objectContaining({ bundler: "webpack" }), - }), - ]), - }), - expect.objectContaining({ - observations: expect.arrayContaining([ - expect.objectContaining({ - properties: expect.objectContaining({ bundler: "rspack" }), - }), - ]), - }), - ]), - ); - expect( - graph.nodes.filter(({ kind }) => kind === "javascript-module"), - ).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - observations: expect.arrayContaining([ - expect.objectContaining({ - properties: expect.objectContaining({ - module_key: "1", - structural_fingerprint_algorithm: "babel-ast-v1", - }), - }), - ]), - }), - ]), - ); - expect(graph.edges.map(({ relation }) => relation)).toEqual( - expect.arrayContaining([ - "contains", - "loads", - "imports", - "maps_to", - "exposes", - "calls", - "persists_to", - ]), - ); - expect(semanticGraph.application_graph_id).toBe(graph.graph_id); - const { graph_id: _semanticGraphId, ...semanticGraphInput } = semanticGraph; - const mismatchedSemanticGraph = createJavaScriptSemanticGraph({ - ...semanticGraphInput, - application_graph_id: `jag_${"f".repeat(64)}`, - }); - const { electron_summary: summary, ...analysisResult } = result; - expect(() => - javaScriptApplicationAnalysisResultV2Schema.parse({ - ...analysisResult, - schema_version: 2, - summary, - semantic_graph: mismatchedSemanticGraph, - }), - ).toThrow(/must commit the containing application graph/u); - expect( - semanticGraph.nodes.some( - ({ application_node_ids: identifiers }) => identifiers.length > 0, - ), - ).toBe(true); - expect(semanticGraph.relations.length).toBeGreaterThan(0); - const mainRole = graph.nodes.find( - (node) => - node.kind === "electron-main" && - node.observations.some( - ({ properties }) => properties.declared_path === "main.js", - ), - ); - const preloadRole = graph.nodes.find( - (node) => - node.kind === "electron-preload" && - node.observations.some( - ({ properties }) => properties.declared_path === "preload.js", - ), - ); - expect(mainRole?.observations[0]?.properties).toMatchObject({ - declared_path: "main.js", - resolution_context: "package-entrypoint", - resolved_path: "main.js", - resolution_status: "resolved", - limitations: [], - }); - expect(preloadRole?.observations[0]?.properties).toMatchObject({ - declared_path: "preload.js", - resolution_context: "filesystem-expression", - resolved_path: "preload.js", - resolution_status: "resolved", - limitations: [], - }); - const mainAsset = graph.nodes.find( - (node) => - node.kind === "javascript-asset" && - node.observations.some( - ({ evidence }) => - evidence.location.available && - evidence.location.value.kind === "artifact-path" && - evidence.location.value.path === "main.js", - ), - ); - if (mainAsset === undefined) - throw new Error("Expected main JavaScript asset"); - const mainSemanticNodes = semanticGraph.nodes.filter( - ({ identity }) => identity.module_path === "main.js", - ); - const linkedMainSemanticNodes = mainSemanticNodes.filter( - ({ application_node_ids: identifiers }) => - identifiers.includes(mainAsset.node_id), - ); - expect(linkedMainSemanticNodes.length).toBeGreaterThan(0); - expect(linkedMainSemanticNodes.length).toBeLessThan( - mainSemanticNodes.length, - ); - for (const role of [mainRole, preloadRole]) { - expect(role).toBeDefined(); - expect( - graph.edges.some( - (edge) => - edge.source_node_id === role?.node_id && - edge.relation === "maps_to", - ), - ).toBe(true); - } - const endpointJson = JSON.stringify( - graph.nodes.filter(({ kind }) => kind === "endpoint"), - ); - expect(endpointJson).toContain("token=%5BREDACTED%5D"); - expect(endpointJson).not.toContain("fixture-secret"); - }); - - it("produces deterministic ASAR graphs with unpacked native linkage and complete paths/digests", async () => { - const root = await createTestTempDirectory("rea-javascript-asar-"); - const source = join(root, "source"); - await mkdir(source); - await writeJavaScriptArtifactFixture(source); - const archive = join(root, "app.asar"); - await createPackageWithOptions(source, archive, { unpack: "**/*.node" }); - - const first = await reconstructJavaScriptArtifact({ - input_path: archive, - format: "asar", - source_map_read_approved: true, - }); - const second = await reconstructJavaScriptArtifact({ - input_path: archive, - format: "asar", - source_map_read_approved: true, - }); - - expect(first.graph).toEqual(second.graph); - expect(first.inventory_graph_sha256).toBe(second.inventory_graph_sha256); - expect(first.graph.graph_id).toBe(second.graph.graph_id); - expect(first.graph.nodes).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - kind: "asar-entry", - observations: expect.arrayContaining([ - expect.objectContaining({ - properties: expect.objectContaining({ - entry_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u), - inventory_artifact_id: - expect.stringMatching(/^art_[a-f0-9]{64}$/u), - }), - }), - ]), - }), - expect.objectContaining({ - kind: "native-addon", - observations: expect.arrayContaining([ - expect.objectContaining({ - properties: expect.objectContaining({ - path: "native/addon.node", - unpacked: true, - }), - }), - ]), - }), - ]), - ); - expect(first.graph.edges).toContainEqual( - expect.objectContaining({ - relation: "loads", - properties: expect.objectContaining({ - resolved_path: "native/addon.node", - }), - }), - ); - }); - - it("keeps ASAR analysis usable when unpacked native companion bytes are absent", async () => { - const root = await createTestTempDirectory("rea-javascript-asar-missing-"); - const source = join(root, "source"); - await mkdir(source); - await writeJavaScriptArtifactFixture(source); - const archive = join(root, "app.asar"); - await createPackageWithOptions(source, archive, { unpack: "**/*.node" }); - await rm(join(`${archive}.unpacked`, "native", "addon.node")); - - const snapshot = await scanArtifactInventory(archive, { - maxEntries: 8_000, - maxTotalBytes: 512 * 1024 * 1024, - maxEntryBytes: 128 * 1024 * 1024, - maxCompressionRatio: 1_000, - maxDepth: 64, - maxPathBytes: 4_096, - }); - const missingNative = snapshot.occurrences.find( - ({ logical_path }) => logical_path === "native/addon.node", - ); - - expect(missingNative).toMatchObject({ - artifact_id: null, - hash_status: "unavailable", - limitations: expect.arrayContaining([ - "ASAR unpacked companion bytes were unavailable; no content hash or child artifact was produced.", - ]), - }); - - const result = await reconstructJavaScriptArtifact({ - input_path: archive, - format: "asar", - source_map_read_approved: true, - }); - - expect(result.statistics.parsed_javascript_files).toBeGreaterThan(0); - expect(result.graph.nodes).toEqual( - expect.arrayContaining([ - expect.objectContaining({ kind: "javascript-asset" }), - ]), - ); - }); - - it("recurses into filesystem-backed ASAR containers without losing container-relative paths", async () => { - const root = await createTestTempDirectory("rea-javascript-nested-asar-"); - const source = await fixtureDirectory(); - const outer = join(root, "outer"); - const resources = join(outer, "resources"); - await mkdir(resources, { recursive: true }); - await createPackageWithOptions(source, join(resources, "app.asar"), { - unpack: "**/*.node", - }); - - const result = await reconstructJavaScriptArtifact({ - input_path: outer, - source_map_read_approved: true, - }); - - expect(result.statistics.nested_asar_containers).toBe(1); - expect(result.statistics.modules).toBe(4); - expect(result.graph.nodes).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - kind: "artifact", - observations: expect.arrayContaining([ - expect.objectContaining({ - label: "resources/app.asar", - properties: expect.objectContaining({ format: "asar" }), - }), - ]), - }), - expect.objectContaining({ - kind: "javascript-asset", - observations: expect.arrayContaining([ - expect.objectContaining({ - properties: expect.objectContaining({ - path: "resources/app.asar/main.js", - }), - }), - ]), - }), - ]), - ); - }); - - it("discovers source maps without reading them until separately approved", async () => { - const root = await fixtureDirectory(); - const unapproved = await reconstructJavaScriptArtifact({ - input_path: root, - source_map_read_approved: false, - }); - const approved = await reconstructJavaScriptArtifact({ - input_path: root, - source_map_read_approved: true, - }); - - expect( - unapproved.graph.nodes.some(({ kind }) => kind === "source-map"), - ).toBe(true); - expect( - unapproved.graph.nodes.some(({ kind }) => kind === "source-module"), - ).toBe(false); - expect(unapproved.graph.coverage).toMatchObject({ - status: "partial", - truncated: false, - omitted_count: null, - }); - expect(unapproved.statistics.policy_filtered_text_files).toBe(1); - expect(unapproved.graph.nodes).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - kind: "unknown", - observations: expect.arrayContaining([ - expect.objectContaining({ - properties: expect.objectContaining({ - operation: "parse-local-source-map", - }), - evidence: expect.objectContaining({ state: "unavailable" }), - }), - ]), - }), - ]), - ); - expect( - approved.graph.nodes.some(({ kind }) => kind === "source-module"), - ).toBe(true); - }); - - it("reports text and AST limits as truncation instead of absence", async () => { - const root = await fixtureDirectory(); - const result = await reconstructJavaScriptArtifact({ - input_path: root, - source_map_read_approved: true, - limits: { - max_text_file_bytes: 128, - max_total_text_bytes: 1_024, - max_ast_nodes: 50, - }, - }); - - expect(result.statistics.limit_omitted_text_files).toBeGreaterThan(0); - expect(result.graph.coverage).toMatchObject({ - status: "partial", - truncated: true, - }); - expect(result.graph.coverage.omitted_count).toBeGreaterThan(0); - expect(result.graph.nodes.some(({ kind }) => kind === "unknown")).toBe( - true, - ); - expect(result.graph.limitations.join(" ")).toMatch(/incomplete|bound/iu); - }); - - it("uses an unknown omission count when AST traversal reaches its bound", async () => { - const root = await createTestTempDirectory("rea-javascript-ast-limit-"); - await writeFile( - join(root, "large.js"), - [ - 'import "./unobserved.js";', - ...Array.from( - { length: 100 }, - (_, index) => - `export const value${String(index)} = ${String(index)};`, - ), - ].join("\n"), - ); - - const result = await reconstructJavaScriptArtifact({ - input_path: root, - limits: { max_ast_nodes: 50 }, - }); - - expect(result.statistics.limit_omitted_text_files).toBe(0); - expect(result.statistics.truncated_scopes).toBe(1); - expect(result.graph.coverage).toMatchObject({ - status: "partial", - truncated: true, - omitted_count: null, - }); - const derivedImport = result.graph.edges.find( - ({ relation, properties }) => - relation === "imports" && properties.specifier === "./unobserved.js", - ); - expect(derivedImport?.evidence.coverage).toEqual({ - status: "partial", - truncated: true, - omitted_count: null, - limits: expect.arrayContaining([ - { name: "max-ast-nodes", value: 50, unit: "items" }, - ]), - }); - }); - - it("does not follow symlinks or read oversized JavaScript text", async () => { - const root = await fixtureDirectory(); - const outside = await createTestTempDirectory("rea-javascript-outside-"); - const outsideFile = join(outside, "secret.js"); - await writeFile( - outsideFile, - 'fetch("https://outside-secret.invalid/credential");', - ); - await symlink(outsideFile, join(root, "escape.js")); - await writeFile( - join(root, "oversized.js"), - `const oversizedSecret = "${"private-marker-".repeat(256)}";`, - ); - - const result = await reconstructJavaScriptArtifact({ - input_path: root, - limits: { max_text_file_bytes: 512 }, - }); - const encoded = JSON.stringify(result); - - expect(result.statistics.limit_omitted_text_files).toBeGreaterThan(0); - expect(result.graph.coverage).toMatchObject({ - status: "partial", - truncated: true, - }); - expect(encoded).not.toContain(outsideFile); - expect(encoded).not.toContain("outside-secret.invalid"); - expect(encoded).not.toContain("private-marker-private-marker"); - }); - - it("keeps malformed JavaScript, package metadata, and source maps as explicit unknowns", async () => { - const root = await fixtureDirectory(); - await Promise.all([ - writeFile(join(root, "package.json"), "{"), - writeFile(join(root, "broken.js"), "function broken( {"), - writeFile(join(root, "renderer", "renderer.js.map"), "{"), - ]); - - const result = await reconstructJavaScriptArtifact({ - input_path: root, - source_map_read_approved: true, - }); - const unknownOperations = result.graph.nodes - .filter(({ kind }) => kind === "unknown") - .flatMap(({ observations }) => - observations.map(({ properties }) => properties.operation), - ); - - expect(result.statistics.parse_failures).toBeGreaterThan(0); - expect(result.graph.coverage).toMatchObject({ - status: "partial", - truncated: false, - omitted_count: null, - }); - expect(unknownOperations).toEqual( - expect.arrayContaining([ - "parse-javascript", - "parse-package-json", - "parse-local-source-map", - ]), - ); - }); - - it("applies one source-map source budget across every local map", async () => { - const root = await createTestTempDirectory("rea-javascript-source-maps-"); - await Promise.all([ - writeFile( - join(root, "a.js.map"), - JSON.stringify({ - version: 3, - sources: ["a-one.ts", "a-two.ts"], - sourcesContent: ["one", "two"], - names: [], - mappings: "", - }), - ), - writeFile( - join(root, "b.js.map"), - JSON.stringify({ - version: 3, - sources: ["b-one.ts", "b-two.ts"], - sourcesContent: ["three", "four"], - names: [], - mappings: "", - }), - ), - ]); - - const result = await reconstructJavaScriptArtifact({ - input_path: root, - source_map_read_approved: true, - limits: { max_source_map_sources: 3 }, - }); - - expect( - result.graph.nodes.filter(({ kind }) => kind === "source-module"), - ).toHaveLength(3); - expect(result.graph.coverage).toMatchObject({ - status: "partial", - truncated: true, - omitted_count: null, - }); - }); - - it("bounds repeated content observations while retaining every containment path", async () => { - const root = await createTestTempDirectory("rea-javascript-observations-"); - await Promise.all( - Array.from({ length: 70 }, (_, index) => - writeFile( - join(root, `duplicate-${String(index).padStart(2, "0")}.js`), - "export const same = 1;\n", - ), - ), - ); - - const result = await reconstructJavaScriptArtifact({ input_path: root }); - const duplicateNode = result.graph.nodes.find( - ({ kind, observations }) => - kind === "javascript-asset" && - observations.some(({ properties }) => - String(properties.path).startsWith("duplicate-"), - ), - ); - const duplicateEdges = result.graph.edges.filter( - ({ relation, properties }) => - relation === "contains" && - String(properties.path).startsWith("duplicate-"), - ); - - expect(duplicateNode?.observations).toHaveLength(64); - expect(duplicateEdges).toHaveLength(70); - expect(result.graph.coverage).toMatchObject({ - status: "partial", - truncated: true, - omitted_count: 6, - }); - }, 15_000); - - it("rejects traversal from a production reader seam and malformed ASAR containers", async () => { - const root = await fixtureDirectory(); - const input = javascriptArtifactReconstructionInputSchema.parse({ - input_path: root, - }); - const snapshot = await scanArtifactInventory( - root, - artifactLimitsForReconstruction(input), - ); - await expect( - readJavaScriptArtifactFiles(new TraversalReader(), snapshot, input), - ).rejects.toMatchObject({ reason: "path" }); - - const malformed = join(root, "malformed.asar"); - await writeFile(malformed, "not an asar"); - await expect( - reconstructJavaScriptArtifact({ input_path: malformed }), - ).rejects.toMatchObject({ - name: "ArtifactReaderFailure", - reason: "format", - }); - }); - - it("preserves cancellation and explicit format diagnostics", async () => { - const root = await fixtureDirectory(); - const controller = new AbortController(); - controller.abort(); - await expect( - reconstructJavaScriptArtifact({ input_path: root }, controller.signal), - ).rejects.toMatchObject({ reason: "cancelled" }); - await expect( - reconstructJavaScriptArtifact({ input_path: root, format: "asar" }), - ).rejects.toMatchObject({ - reason: "format", - message: expect.stringContaining(root), - }); - expect(() => - javascriptArtifactReconstructionInputSchema.parse({ - input_path: root, - limits: { max_entries: 100_000 }, - }), - ).toThrow(/graph contract/iu); - }); -}); - -class TraversalReader implements ArtifactReader { - readonly format = "directory" as const; - - async *entries(): AsyncIterable { - yield { - path: "../escape.js", - kind: "file", - declaredSize: 1, - compressedSize: null, - executable: false, - encrypted: false, - byteOffset: null, - declaredSha256: null, - unpacked: false, - limitations: [], - adapterKey: "/tmp/escape.js", - }; - } - - open(): Promise { - return Promise.resolve(Readable.from("x")); - } - - close(): Promise { - return Promise.resolve(); - } - - provenance(): readonly [] { - return []; - } -} - -const fixtureDirectory = async (): Promise => { - const root = await createTestTempDirectory("rea-javascript-artifact-"); - await writeJavaScriptArtifactFixture(root); - return root; -}; diff --git a/tests/javascriptRuntimeReconciliation.test.ts b/tests/javascriptRuntimeReconciliation.test.ts deleted file mode 100644 index b2ec3bffd..000000000 --- a/tests/javascriptRuntimeReconciliation.test.ts +++ /dev/null @@ -1,543 +0,0 @@ -import { execFile } from "node:child_process"; -import { mkdir, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { promisify } from "node:util"; - -import { afterEach, describe, expect, it } from "vitest"; - -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; - -import { createElectronEvidence } from "../src/application/ElectronEvidence.js"; -import { analyzeJavaScriptApplication } from "../src/application/JavaScriptApplicationService.js"; -import { reconcileJavaScriptRuntime } from "../src/domain/javascriptRuntimeReconciliation.js"; -import { javascriptRuntimeReconciliationResultSchema } from "../src/domain/javascriptRuntimeReconciliationSchemas.js"; -import { createWebTextArtifact } from "../src/domain/webContentArtifact.js"; -import { permissionAuthorityForRoot } from "./fixtures/permissionAuthority.js"; - -const SOURCE = `const worker = new Worker("./worker.js");\nexport const observed = worker;\n`; -const execute = promisify(execFile); - -describe("JavaScript static/passive-runtime reconciliation", () => { - const temporary: string[] = []; - - afterEach(async () => { - await Promise.all( - temporary - .splice(0) - .map(async (path) => rm(path, { recursive: true, force: true })), - ); - }); - - it("matches renderer, frame, script bytes, and worker without claiming execution", async () => { - const fixture = await applicationFixture(); - temporary.push(fixture); - const staticEvidence = await analyzeFixture(fixture); - const runtimeEvidence = electronRuntimeEvidence(fixture, SOURCE); - - const result = reconcileJavaScriptRuntime({ - static_layers: [{ role: "application", analysis: staticEvidence }], - runtime_observations: [runtimeEvidence], - }); - - expect(() => - javascriptRuntimeReconciliationResultSchema.parse(result), - ).not.toThrow(); - expect(result.summary).toMatchObject({ - runtime_targets: 1, - runtime_frames: 1, - runtime_scripts: 1, - runtime_workers: 1, - matched: 2, - ambiguous: 2, - unmatched: 0, - }); - expect(result.reconciliations).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - entity_kind: "script", - status: "matched", - basis: "content-and-location", - }), - expect.objectContaining({ - entity_kind: "worker", - status: "matched", - basis: "artifact-path", - }), - expect.objectContaining({ - entity_kind: "target", - status: "ambiguous", - reason: "ambiguous-static-candidates", - }), - ]), - ); - expect(result.static_load_states).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - kind: "javascript-asset", - status: "loaded", - }), - ]), - ); - expect(result.limitations.join(" ")).toMatch( - /not reported as executed|not code reachability/u, - ); - expect( - result.graph.edges - .filter(({ relation }) => relation === "observed_as") - .every( - ({ evidence }) => - evidence.authority === "cross-layer-reconciliation" && - evidence.state === "inferred", - ), - ).toBe(true); - const worker = result.reconciliations.find( - ({ entity_kind: kind }) => kind === "worker", - ); - const frame = result.reconciliations.find( - ({ entity_kind: kind }) => kind === "frame", - ); - expect(result.graph.edges).toContainEqual( - expect.objectContaining({ - source_node_id: frame?.runtime_node_id, - target_node_id: worker?.runtime_node_id, - relation: "contains", - }), - ); - }); - - it("reports a captured digest disagreement instead of accepting a path match", async () => { - const fixture = await applicationFixture(); - temporary.push(fixture); - const staticEvidence = await analyzeFixture(fixture); - const runtimeEvidence = electronRuntimeEvidence( - fixture, - "export const observed = 'different';\n", - ); - - const result = reconcileJavaScriptRuntime({ - static_layers: [{ role: "application", analysis: staticEvidence }], - runtime_observations: [runtimeEvidence], - }); - const script = result.reconciliations.find( - ({ entity_kind: kind }) => kind === "script", - ); - - expect(script).toMatchObject({ - status: "unmatched", - reason: "captured-content-disagrees-with-static-location", - }); - expect(script?.candidate_static_nodes).toHaveLength(1); - }); - - it("keeps source-map declarations outside primary matching authority", async () => { - const fixture = await applicationFixture(); - temporary.push(fixture); - const staticEvidence = await analyzeFixture(fixture); - const runtimeEvidence = electronRuntimeEvidence(fixture, SOURCE); - const result = reconcileJavaScriptRuntime({ - static_layers: [{ role: "application", analysis: staticEvidence }], - runtime_observations: [runtimeEvidence], - }); - - expect(result.source_map_authority).toMatchObject({ - used_for_primary_matching: false, - static_layers_with_read_approval: 0, - runtime_script_declarations: 0, - }); - }); - - it("imports an operator-provided cache layer through an explicit file mapping", async () => { - const application = await applicationFixture(); - const cache = await createTestTempDirectory("rea-runtime-cache-static-"); - const runtimeCache = await createTestTempDirectory( - "rea-runtime-cache-live-", - ); - temporary.push(application, cache, runtimeCache); - const cacheSource = "export const cachedFeature = 'fixture';\n"; - await mkdir(join(cache, "mapped")); - await Promise.all([ - writeFile(join(cache, "mapped", "chunk.js"), cacheSource), - writeFile(join(cache, "outside.js"), "export const outside = true;\n"), - writeFile(join(runtimeCache, "chunk.js"), cacheSource), - writeFile(join(runtimeCache, "index.html"), ""), - ]); - const applicationEvidence = await analyzeFixture(application); - const cacheEvidence = await analyzeFixture(cache); - const runtimeEvidence = electronRuntimeEvidence(runtimeCache, cacheSource, { - scriptFile: "chunk.js", - includeWorker: false, - }); - - const result = reconcileJavaScriptRuntime({ - static_layers: [ - { role: "application", analysis: applicationEvidence }, - { - role: "cache", - analysis: cacheEvidence, - runtime_mappings: [ - { - kind: "file-root", - root: runtimeCache, - artifact_prefix: "mapped", - }, - ], - }, - ], - runtime_observations: [runtimeEvidence], - }); - const script = result.reconciliations.find( - ({ entity_kind: kind }) => kind === "script", - ); - const cacheLayer = result.static_layers.find( - ({ role }) => role === "cache", - ); - - expect(script).toMatchObject({ - status: "matched", - basis: "content-and-location", - static_layer_id: cacheLayer?.layer_id, - }); - const outside = result.graph.nodes.find( - (node) => - node.kind === "javascript-asset" && - node.observations.some( - ({ properties }) => properties.path === "outside.js", - ), - ); - expect( - result.static_load_states.find( - ({ static_node_id: nodeId }) => nodeId === outside?.node_id, - ), - ).toMatchObject({ - status: "unknown", - reason: "layer-outside-runtime-scope", - }); - }); - - it("keeps byte-identical cross-layer candidates explicitly ambiguous", async () => { - const application = await applicationFixture(); - const assets = await createTestTempDirectory("rea-runtime-assets-static-"); - const runtime = await createTestTempDirectory("rea-runtime-assets-live-"); - temporary.push(application, assets, runtime); - await Promise.all([ - writeFile(join(assets, "app.js"), SOURCE), - writeFile(join(runtime, "app.js"), SOURCE), - writeFile(join(runtime, "index.html"), ""), - ]); - const result = reconcileJavaScriptRuntime({ - static_layers: [ - { - role: "application", - analysis: await analyzeFixture(application), - }, - { role: "assets", analysis: await analyzeFixture(assets) }, - ], - runtime_observations: [ - electronRuntimeEvidence(runtime, SOURCE, { includeWorker: false }), - ], - }); - const script = result.reconciliations.find( - ({ entity_kind: kind }) => kind === "script", - ); - - expect(script).toMatchObject({ - status: "ambiguous", - reason: "ambiguous-static-candidates", - candidate_static_count: 2, - }); - expect(script?.candidate_static_nodes).toHaveLength(2); - expect( - new Set( - script?.candidate_static_nodes.map( - ({ static_layer_id: layerId }) => layerId, - ), - ).size, - ).toBe(2); - }); - - it("retains one target per capture and keeps load-state absence unknown when output is bounded", async () => { - const fixture = await applicationFixture(); - temporary.push(fixture); - const staticEvidence = await analyzeFixture(fixture); - const runtimeObservations = [ - electronRuntimeEvidence(fixture, SOURCE, { - includeWorker: false, - targetId: "target-one", - }), - electronRuntimeEvidence(fixture, SOURCE, { - includeWorker: false, - targetId: "target-two", - }), - ]; - - const result = reconcileJavaScriptRuntime({ - static_layers: [{ role: "application", analysis: staticEvidence }], - runtime_observations: runtimeObservations, - limits: { - max_runtime_entities: 2, - max_reconciliation_items: 2, - max_static_load_states: 20_000, - }, - }); - - expect(result.runtime_captures).toHaveLength(2); - expect( - new Set(result.runtime_captures.map(({ target_node_id: id }) => id)).size, - ).toBe(2); - expect(result.summary).toMatchObject({ - runtime_targets: 2, - runtime_frames: 0, - runtime_scripts: 0, - runtime_workers: 0, - static_not_observed: 0, - }); - expect(result.coverage).toMatchObject({ - status: "truncated", - truncated: true, - omitted_runtime_entities: 4, - }); - expect( - result.static_load_states.every(({ status }) => status === "unknown"), - ).toBe(true); - }); - - it("rejects runtime source bytes whose Evidence omits source-capture approval", async () => { - const fixture = await applicationFixture(); - temporary.push(fixture); - const staticEvidence = await analyzeFixture(fixture); - const contradictoryRuntime = electronRuntimeEvidence(fixture, SOURCE, { - includeWorker: false, - sourceCaptureApproved: false, - }); - - expect(() => - reconcileJavaScriptRuntime({ - static_layers: [{ role: "application", analysis: staticEvidence }], - runtime_observations: [contradictoryRuntime], - }), - ).toThrow(/source-capture approval/u); - }); - - it("keeps graph omission counts unknown when a runtime section is unavailable", async () => { - const fixture = await applicationFixture(); - temporary.push(fixture); - const result = reconcileJavaScriptRuntime({ - static_layers: [ - { role: "application", analysis: await analyzeFixture(fixture) }, - ], - runtime_observations: [ - electronRuntimeEvidence(fixture, SOURCE, { - includeWorker: false, - workersUnavailable: true, - }), - ], - }); - - expect(result.coverage).toMatchObject({ - status: "partial", - truncated: false, - }); - expect(result.graph.coverage).toMatchObject({ - status: "partial", - truncated: false, - omitted_count: null, - }); - }); - - it("runs the local verifier from operator-provided paths without emitting source", async () => { - const fixture = await applicationFixture(); - const evidenceRoot = await createTestTempDirectory("rea-runtime-evidence-"); - const evidencePath = join(evidenceRoot, "runtime-evidence.json"); - temporary.push(fixture, evidenceRoot); - await writeFile( - evidencePath, - JSON.stringify(electronRuntimeEvidence(fixture, SOURCE)), - ); - - const { stdout } = await execute( - process.execPath, - [ - "scripts/verify-local-javascript-runtime.mjs", - "--application", - fixture, - "--runtime-evidence", - evidencePath, - ], - { cwd: process.cwd(), maxBuffer: 16 * 1_024 * 1_024 }, - ); - const output: unknown = JSON.parse(stdout); - - expect(output).toMatchObject({ - verified: true, - summary: { runtime_scripts: 1 }, - }); - expect(stdout).not.toContain(SOURCE.trim()); - }); -}); - -const applicationFixture = async (): Promise => { - const root = await createTestTempDirectory("rea-runtime-reconciliation-"); - await Promise.all([ - writeFile( - join(root, "package.json"), - JSON.stringify({ - name: "rea-runtime-reconciliation-fixture", - version: "1.0.0", - renderer: "index.html", - }), - ), - writeFile(join(root, "index.html"), ""), - writeFile(join(root, "app.js"), SOURCE), - writeFile(join(root, "worker.js"), "self.onmessage = () => undefined;\n"), - ]); - return root; -}; - -const analyzeFixture = async (root: string) => { - const authority = await permissionAuthorityForRoot( - root, - ["investigation_input"], - ["investigation_input"], - ); - const result = await analyzeJavaScriptApplication(authority, { - input_path: root, - approved: true, - }); - if (!result.ok) throw result.error; - return result.value; -}; - -const electronRuntimeEvidence = ( - root: string, - source: string, - options: { - readonly scriptFile?: string; - readonly includeWorker?: boolean; - readonly targetId?: string; - readonly sourceCaptureApproved?: boolean; - readonly workersUnavailable?: boolean; - } = {}, -) => { - const scriptFile = options.scriptFile ?? "app.js"; - const includeWorker = options.includeWorker ?? true; - const targetId = options.targetId ?? "target-main"; - const sourceCaptureApproved = options.sourceCaptureApproved ?? true; - const input = { - cdp_endpoint: "http://127.0.0.1:9223", - allowed_file_roots: [root], - target_id: targetId, - approved: true as const, - observation_ms: 100, - include_script_sources: sourceCaptureApproved, - source_capture_approved: sourceCaptureApproved, - limits: { - max_frames: 200, - max_dom_nodes: 2_000, - max_scripts: 500, - max_resources: 2_000, - max_workers: 500, - max_script_source_bytes: 1_048_576, - max_total_script_source_bytes: 4_194_304, - }, - }; - return createElectronEvidence( - "inspect_electron_page", - input, - { - schema_version: 1, - browser: { - product: "Electron/fixture", - protocol_version: "1.3", - revision: "fixture", - user_agent: "Electron fixture", - js_version: "13", - }, - target: { - target_id: targetId, - type: "page", - title: "Fixture", - file_path: join(root, "index.html"), - attached: false, - }, - capture_window: { - started_at: "2026-07-15T00:00:00.000Z", - ended_at: "2026-07-15T00:00:00.100Z", - observation_ms: 100, - }, - completeness: options.workersUnavailable - ? { - ...completeCapture(), - status: "attach_limited" as const, - conditions: ["attach_limited" as const], - attach_limited_sections: ["workers" as const], - unavailable_sections: ["workers" as const], - } - : completeCapture(), - frames: [ - { - frame_id: "frame-main", - parent_frame_id: null, - file_path: join(root, "index.html"), - }, - ], - dom: { total_nodes: 0, nodes: [] }, - scripts: { - total: 1, - items: [ - { - script_key: `electron_script_${"1".repeat(64)}`, - frame_id: "frame-main", - file_path: join(root, scriptFile), - cdp_hash: "fixture", - length: Buffer.byteLength(source), - is_module: true, - language: "JavaScript", - source: { - included: true as const, - artifact: createWebTextArtifact(source, "text/javascript"), - }, - }, - ], - }, - resources: [], - workers: includeWorker - ? [ - { - target_id: "worker-main", - type: "worker", - file_path: join(root, "worker.js"), - attached: false, - opener_target_id: targetId, - parent_frame_id: "frame-main", - }, - ] - : [], - limitations: ["Synthetic passive capture fixture."], - }, - { - id: "rea-cdp-electron", - name: "REA Electron file-page CDP observation provider", - version: "1", - }, - ); -}; - -const completeCapture = () => ({ - status: "complete_within_window" as const, - conditions: ["complete_within_window" as const], - policy_filtered_sections: [], - attach_limited_sections: [], - truncated_sections: [], - unavailable_sections: [], - excluded: [], - dropped_events: { - scripts: 0, - network_requests: 0, - console_events: 0, - websocket_connections: 0, - websocket_frames: 0, - webmcp_tools: 0, - timeline_events: 0, - total: 0, - }, -}); diff --git a/tests/javascriptSemanticAnalysis.test.ts b/tests/javascriptSemanticAnalysis.test.ts deleted file mode 100644 index 746a54c22..000000000 --- a/tests/javascriptSemanticAnalysis.test.ts +++ /dev/null @@ -1,1143 +0,0 @@ -import { fc, it } from "@fast-check/vitest"; -import { describe, expect } from "vitest"; - -import { analyzeJavaScriptSemantics } from "../src/domain/javascriptSemanticAnalysis.js"; -import { - semanticBinding, - semanticReferenceAt, - type JavaScriptSemanticBinding, - type JavaScriptSemanticIr, -} from "../src/domain/javascriptSemanticIr.js"; - -describe("JavaScript semantic analysis", () => { - it("resolves imports, require destructuring, aliases, assignments, and shadowing", () => { - const ir = analyzeJavaScriptSemantics(` - import { ipcRenderer as ir } from "electron"; - const { ipcMain: bus } = require("electron"); - const forwarded = bus; - let assigned; - assigned = ir; - ir.invoke("outside"); - bus.handle("main", handler); - function local(ipcRenderer) { - const bus = require("./local-bus.js"); - ipcRenderer.send("shadowed"); - return bus; - } - `); - - expect(ir.coverage).toEqual({ - status: "complete", - omittedCount: 0, - limitsReached: [], - }); - expect(origin(topLevelBinding(ir, "ir"))).toEqual({ - specifier: "electron", - importedPath: ["ipcRenderer"], - }); - expect(origin(topLevelBinding(ir, "bus"))).toEqual({ - specifier: "electron", - importedPath: ["ipcMain"], - }); - expect(origin(topLevelBinding(ir, "forwarded"))).toEqual({ - specifier: "electron", - importedPath: ["ipcMain"], - }); - expect(origin(topLevelBinding(ir, "assigned"))).toEqual({ - specifier: "electron", - importedPath: ["ipcRenderer"], - }); - expect(ir.moduleLinks).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - kind: "require", - specifier: "electron", - importedName: "ipcMain", - localName: "bus", - }), - ]), - ); - - const innerBus = bindingsNamed(ir, "bus").find( - ({ scopeId }) => scopeId !== programScope(ir).scopeId, - ); - expect(innerBus).toBeDefined(); - if (innerBus === undefined) return; - expect(origin(innerBus)).toEqual({ - specifier: "./local-bus.js", - importedPath: [], - }); - const shadow = onlyBinding(ir, "ipcRenderer"); - expect(shadow.provenance).toEqual({ - status: "local", - origins: [], - reason: null, - }); - const shadowReference = ir.references.find( - ({ name, bindingId, role }) => - name === "ipcRenderer" && - bindingId === shadow.bindingId && - role === "read", - ); - expect(shadowReference?.resolution).toBe("resolved"); - expect(shadowReference?.bindingId).not.toBe( - topLevelBinding(ir, "ir").bindingId, - ); - if (shadowReference === undefined || shadowReference.bindingId === null) - throw new Error("Missing shadow reference"); - expect(semanticBinding(ir, shadowReference.bindingId)).toEqual(shadow); - expect( - semanticReferenceAt( - ir, - shadowReference.location.start.line, - shadowReference.location.start.column, - ), - ).toEqual(shadowReference); - }); - - it("propagates literal, template, object, conditional, and destructured values", () => { - const ir = analyzeJavaScriptSemantics(` - const prefix = "rea"; - const suffix = "open"; - const channel = \`${"${prefix}"}:${"${suffix}"}\`; - const options = { - channel, - mode: enabled ? "read" : "write", - }; - const selected = options.channel; - const { mode } = options; - const [first] = ["zero", "one"]; - `); - - expect(topLevelBinding(ir, "channel").value).toEqual({ - status: "literal", - value: "rea:open", - }); - expect(topLevelBinding(ir, "selected").value).toEqual({ - status: "literal", - value: "rea:open", - }); - expect(topLevelBinding(ir, "mode").value).toEqual({ - status: "union", - values: ["read", "write"], - }); - expect(topLevelBinding(ir, "first").value).toEqual({ - status: "literal", - value: "zero", - }); - }); - - it("retains ESM, re-export, require, and CommonJS export relationships", () => { - const ir = analyzeJavaScriptSemantics(` - export { ipcRenderer as bridge } from "electron"; - export * from "./wrapper.js"; - export const localValue = 1; - const addon = require("./native.node"); - module.exports.addon = addon; - export default function namedDefault() {} - `); - - expect(ir.moduleLinks).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - kind: "re-export", - specifier: "electron", - importedName: "ipcRenderer", - exportedName: "bridge", - }), - expect.objectContaining({ - kind: "re-export", - specifier: "./wrapper.js", - importedName: "*", - exportedName: "*", - }), - expect.objectContaining({ - kind: "export", - localName: "localValue", - exportedName: "localValue", - }), - expect.objectContaining({ - kind: "require", - specifier: "./native.node", - localName: "addon", - }), - expect.objectContaining({ - kind: "commonjs-export", - localName: "addon", - exportedName: "addon", - }), - expect.objectContaining({ - kind: "export", - localName: "namedDefault", - exportedName: "default", - }), - ]), - ); - }); - - it("keeps function, class, and method identities separate from bindings", () => { - const ir = analyzeJavaScriptSemantics(` - class Service { - run() {} - get value() { return 1; } - #privateMethod() {} - } - const arrow = () => 1; - const object = { method() {} }; - `); - - expect(ir.callables).toEqual( - expect.arrayContaining([ - expect.objectContaining({ kind: "class", name: "Service" }), - expect.objectContaining({ kind: "method", name: "run" }), - expect.objectContaining({ kind: "method", name: "value" }), - expect.objectContaining({ kind: "method", name: "#privateMethod" }), - expect.objectContaining({ kind: "function", name: "arrow" }), - expect.objectContaining({ kind: "method", name: "method" }), - ]), - ); - expect(bindingsNamed(ir, "run")).toEqual([]); - expect(bindingsNamed(ir, "method")).toEqual([]); - }); - - it("links exact local calls to parameters, returns, and closure captures", () => { - const ir = analyzeJavaScriptSemantics(` - const prefix = "rea"; - function render(value, { mode }) { - return prefix + value + mode; - } - const alias = render; - const result = alias(input, { mode: "fast" }); - `); - - expect(ir.schemaVersion).toBe(4); - const render = onlyCallable(ir, "render"); - const call = ir.callSites.find( - ({ calleeCallableIds }) => calleeCallableIds[0] === render.callableId, - ); - expect(call).toMatchObject({ - kind: "call", - callerCallableId: null, - resolution: "exact", - calleeCallableIds: [render.callableId], - arguments: [ - { index: 0, spread: false }, - { index: 1, spread: false }, - ], - }); - if (call === undefined) throw new Error("Missing render call"); - expect( - ir.argumentFlows - .filter(({ callSiteId }) => callSiteId === call.callSiteId) - .map(({ argumentIndex, parameterBindingId }) => ({ - argumentIndex, - parameter: semanticBinding(ir, parameterBindingId)?.name, - })), - ).toEqual([ - { argumentIndex: 0, parameter: "value" }, - { argumentIndex: 1, parameter: "mode" }, - ]); - expect(ir.callReturnFlows).toEqual([ - expect.objectContaining({ - callSiteId: call.callSiteId, - callableId: render.callableId, - returnSiteId: render.returnSites[0]?.returnSiteId, - }), - ]); - expect(ir.closureCaptures).toEqual([ - expect.objectContaining({ - callableId: render.callableId, - bindingId: topLevelBinding(ir, "prefix").bindingId, - }), - ]); - expect(ir.frontiers).toEqual([]); - }); - - it("recovers explicit Promise ownership, chains, aggregation, and awaits", () => { - const ir = analyzeJavaScriptSemantics(` - async function run(value) { - const base = Promise.resolve(value); - const chained = base.then(work).finally(cleanup); - await chained; - Promise.resolve(value).then(work); - return Promise.all([base, Promise.resolve(value)]); - } - const settled = Promise.allSettled([Promise.resolve(1)]); - `); - - expect( - ir.promiseOperations.map( - ({ kind, method, ownership, sourceResolution }) => ({ - kind, - method, - ownership, - sourceResolution, - }), - ), - ).toEqual( - expect.arrayContaining([ - { - kind: "static", - method: "resolve", - ownership: "assigned", - sourceResolution: "complete", - }, - { - kind: "chain", - method: "then", - ownership: "chained", - sourceResolution: "complete", - }, - { - kind: "chain", - method: "finally", - ownership: "assigned", - sourceResolution: "complete", - }, - { - kind: "awaited-expression", - method: "await", - ownership: "awaited", - sourceResolution: "complete", - }, - { - kind: "chain", - method: "then", - ownership: "detached", - sourceResolution: "complete", - }, - { - kind: "aggregate", - method: "all", - ownership: "returned", - sourceResolution: "complete", - }, - { - kind: "aggregate", - method: "allSettled", - ownership: "assigned", - sourceResolution: "complete", - }, - ]), - ); - const aggregate = ir.promiseOperations.find( - ({ method }) => method === "all", - ); - expect(aggregate?.sourcePromiseIds).toHaveLength(2); - expect(aggregate?.returnSiteId).not.toBeNull(); - }); - - it("does not treat a shadowed Promise binding as the intrinsic", () => { - const ir = analyzeJavaScriptSemantics(` - function run(Promise) { - return Promise.resolve(1).then(work); - } - `); - - expect(ir.promiseOperations).toEqual([ - expect.objectContaining({ - kind: "chain", - method: "then", - ownership: "returned", - sourcePromiseIds: [], - sourceResolution: "unresolved", - }), - ]); - }); - - it("bounds Promise recovery with an explicit frontier", () => { - const ir = analyzeJavaScriptSemantics( - ` - Promise.resolve(1); - Promise.resolve(2); - Promise.resolve(3); - `, - { maxPromiseOperations: 1 }, - ); - - expect(ir.promiseOperations).toHaveLength(1); - expect(ir.coverage).toMatchObject({ - status: "truncated", - limitsReached: ["maxPromiseOperations"], - }); - }); - - it("fingerprints formatting and local-name changes identically", () => { - const left = analyzeJavaScriptSemantics(` - function calculate(value) { - const result = value + 1; - return result; - } - `); - const right = analyzeJavaScriptSemantics( - "function renamed(input){const output=input+1;return output}", - ); - const changed = analyzeJavaScriptSemantics( - "function renamed(input){const output=input+2;return output}", - ); - - expect(left.functionFingerprints).toHaveLength(1); - expect(right.functionFingerprints).toHaveLength(1); - expect(left.functionFingerprints[0]?.components).toEqual( - right.functionFingerprints[0]?.components, - ); - expect( - changed.functionFingerprints[0]?.components.literalSetSha256, - ).not.toBe(left.functionFingerprints[0]?.components.literalSetSha256); - }); - - it("retains ambiguous local callees and explicit dynamic frontiers", () => { - const ir = analyzeJavaScriptSemantics(` - function left(value) { return value; } - function right(value) { return value; } - const selected = chooseLeft ? left : right; - selected(input); - receiver[key](input); - external(input); - `); - - const selected = ir.callSites.find( - ({ resolution }) => resolution === "ambiguous", - ); - expect(selected?.calleeCallableIds).toEqual( - [ - onlyCallable(ir, "left").callableId, - onlyCallable(ir, "right").callableId, - ].sort(), - ); - expect( - ir.argumentFlows.filter( - ({ callSiteId }) => callSiteId === selected?.callSiteId, - ), - ).toHaveLength(2); - expect(ir.frontiers).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - kind: "dynamic-call", - reason: expect.stringMatching(/ambiguous/iu), - }), - expect.objectContaining({ - kind: "dynamic-property", - reason: expect.stringMatching(/computed member/iu), - }), - expect.objectContaining({ - kind: "dynamic-call", - reason: "Unresolved call target external.", - }), - ]), - ); - }); - - it("recovers EventEmitter candidates and imported timer handle cancellation", () => { - const ir = analyzeJavaScriptSemantics(` - import { - setTimeout as later, - clearTimeout as cancel, - } from "node:timers"; - function run(bus, dynamicName) { - const handle = later(work, 25); - bus.on("ready", handler); - bus.once(dynamicName, handler); - bus.emit("ready"); - bus.off("ready", handler); - cancel(handle); - } - `); - - expect( - ir.eventOperations.map(({ kind, method, eventName, resolution }) => ({ - kind, - method, - eventName, - resolution, - })), - ).toEqual([ - { - kind: "register", - method: "on", - eventName: "ready", - resolution: "complete", - }, - { - kind: "register", - method: "once", - eventName: null, - resolution: "unresolved", - }, - { - kind: "dispatch", - method: "emit", - eventName: "ready", - resolution: "complete", - }, - { - kind: "remove", - method: "off", - eventName: "ready", - resolution: "complete", - }, - ]); - expect(ir.timerOperations).toEqual([ - expect.objectContaining({ - kind: "schedule", - method: "setTimeout", - delayMilliseconds: 25, - resolution: "complete", - }), - expect.objectContaining({ - kind: "cancel", - method: "clearTimeout", - delayMilliseconds: null, - resolution: "complete", - linkedTimerId: ir.timerOperations[0]?.timerId, - }), - ]); - expect(ir.functionFingerprints[0]?.components.effects).toEqual([ - "event", - "timer", - ]); - }); - - it("does not treat shadowed timer globals as Node timers", () => { - const ir = analyzeJavaScriptSemantics(` - function run(setTimeout, clearTimeout) { - const handle = setTimeout(work, 1); - clearTimeout(handle); - } - `); - - expect(ir.timerOperations).toEqual([]); - }); - - it("recovers child-process argv, env, stdio, listeners, and signals", () => { - const ir = analyzeJavaScriptSemantics(` - import { spawn as launch } from "node:child_process"; - function run() { - const child = launch( - "/bin/tool", - ["--mode", "fast"], - { env: { MODE: "test" }, stdio: ["ignore", "pipe", "pipe"] }, - ); - child.on("exit", onExit); - child.once("error", onError); - child.kill("SIGINT"); - } - `); - - expect(ir.childProcessSpawns).toEqual([ - expect.objectContaining({ - method: "spawn", - command: "/bin/tool", - argvCount: 2, - environmentSupplied: true, - stdioMode: "array", - resolution: "complete", - }), - ]); - expect( - ir.childProcessInteractions.map( - ({ kind, eventName, signalName, resolution }) => ({ - kind, - eventName, - signalName, - resolution, - }), - ), - ).toEqual([ - { - kind: "listener", - eventName: "exit", - signalName: null, - resolution: "complete", - }, - { - kind: "listener", - eventName: "error", - signalName: null, - resolution: "complete", - }, - { - kind: "signal", - eventName: null, - signalName: "SIGINT", - resolution: "complete", - }, - ]); - expect(ir.functionFingerprints[0]?.components.effects).toContain( - "child-process", - ); - }); - - it("recovers configuration, requests, response consumers, and boundaries", () => { - const ir = analyzeJavaScriptSemantics(` - import { readFileSync } from "node:fs"; - import * as http from "node:http"; - async function run(schema) { - const endpoint = process.env.API_URL ?? "https://fallback.test"; - const mode = process.argv[2]; - const raw = readFileSync("./config.json", "utf8"); - const parsed = JSON.parse(raw); - const port = Number(process.env.PORT); - const validated = schema.parse(parsed); - const response = await fetch(endpoint, { - method: "POST", - body: validated, - }); - const body = await response.json(); - http.request("https://audit.test", { body }); - return { mode, port, body }; - } - `); - - expect( - ir.configurationOperations.map(({ kind, key }) => ({ kind, key })), - ).toEqual( - expect.arrayContaining([ - { kind: "environment", key: "API_URL" }, - { kind: "default", key: "API_URL" }, - { kind: "argv", key: "2" }, - { kind: "file", key: "./config.json" }, - { kind: "environment", key: "PORT" }, - ]), - ); - expect( - ir.requestOperations.map(({ kind, method, endpoint, resolution }) => ({ - kind, - method, - endpoint, - resolution, - })), - ).toEqual( - expect.arrayContaining([ - { - kind: "request", - method: "fetch", - endpoint: null, - resolution: "partial", - }, - { - kind: "response-consumer", - method: "json", - endpoint: null, - resolution: "complete", - }, - { - kind: "request", - method: "request", - endpoint: "https://audit.test", - resolution: "complete", - }, - ]), - ); - expect( - ir.boundaryOperations.map(({ kind, method, resolution }) => ({ - kind, - method, - resolution, - })), - ).toEqual( - expect.arrayContaining([ - { kind: "parse", method: "JSON.parse", resolution: "complete" }, - { kind: "coerce", method: "Number", resolution: "complete" }, - { kind: "parse", method: "parse", resolution: "partial" }, - ]), - ); - expect(ir.functionFingerprints[0]?.components.effects).toContain("network"); - }); - - it("recovers built-in resource acquisition and exact local release", () => { - const ir = analyzeJavaScriptSemantics(` - import { open } from "node:fs/promises"; - import { connect as dial } from "node:net"; - async function run(path) { - const file = await open(path); - const socket = dial({ port: 9000 }); - await file.close(); - socket.destroy(); - } - `); - - expect( - ir.resourceOperations.map(({ kind, method, resolution }) => ({ - kind, - method, - resolution, - })), - ).toEqual([ - { kind: "acquire", method: "open", resolution: "complete" }, - { kind: "acquire", method: "connect", resolution: "complete" }, - { kind: "release", method: "close", resolution: "complete" }, - { kind: "release", method: "destroy", resolution: "complete" }, - ]); - expect(ir.functionFingerprints[0]?.components.effects).toContain( - "resource", - ); - }); - - it("bounds every added semantic effect family independently", () => { - const ir = analyzeJavaScriptSemantics( - ` - import { spawn } from "node:child_process"; - import { open } from "node:fs/promises"; - Promise.resolve(1); - bus.on("ready", handler); - setTimeout(work, 1); - spawn("/bin/tool"); - const endpoint = process.env.API_URL; - fetch("https://example.test"); - JSON.parse(raw); - open(path); - `, - { - maxPromiseOperations: 0, - maxEventOperations: 0, - maxTimerOperations: 0, - maxChildProcessOperations: 0, - maxConfigurationOperations: 0, - maxRequestOperations: 0, - maxBoundaryOperations: 0, - maxResourceOperations: 0, - maxObjectOperations: 0, - }, - ); - - expect(ir.coverage.limitsReached).toEqual( - expect.arrayContaining([ - "maxBoundaryOperations", - "maxChildProcessOperations", - "maxConfigurationOperations", - "maxEventOperations", - "maxPromiseOperations", - "maxRequestOperations", - "maxResourceOperations", - "maxTimerOperations", - "maxObjectOperations", - ]), - ); - }); - - it("recovers static object reads, writes, spreads, and destructuring", () => { - const ir = analyzeJavaScriptSemantics(` - const source = { token: "TOKEN", count: 1 }; - const { token } = source; - const copy = { ...source }; - source.count = 2; - const read = source.token; - `); - - expect( - ir.objectOperations.map(({ kind, propertyName, resolution }) => ({ - kind, - propertyName, - resolution, - })), - ).toEqual( - expect.arrayContaining([ - { - kind: "destructure", - propertyName: "token", - resolution: "complete", - }, - { kind: "spread", propertyName: null, resolution: "complete" }, - { kind: "write", propertyName: "count", resolution: "complete" }, - { kind: "read", propertyName: "token", resolution: "complete" }, - ]), - ); - }); - - it("does not project positional argument flow after a spread", () => { - const ir = analyzeJavaScriptSemantics(` - function target(first, second, third) { return third; } - target(one, ...rest, three); - `); - - expect(ir.argumentFlows.map(({ argumentIndex }) => argumentIndex)).toEqual([ - 0, - ]); - }); - - it("applies independent hard bounds to every new semantic fact family", () => { - const ir = analyzeJavaScriptSemantics( - ` - const captured = 1; - function target(value, other) { return value + other + captured; } - target(1, 2); - target(3, 4); - object[first]; - object[second]; - `, - { - maxCallSites: 1, - maxCallArguments: 1, - maxArgumentFlows: 0, - maxCallReturnFlows: 0, - maxClosureCaptures: 0, - maxFrontiers: 1, - }, - ); - - expect(ir.callSites).toHaveLength(1); - expect(ir.callSites[0]?.arguments).toEqual([ - expect.objectContaining({ index: 0, spread: false }), - ]); - expect(ir.argumentFlows).toEqual([]); - expect(ir.callReturnFlows).toEqual([]); - expect(ir.closureCaptures).toEqual([]); - expect(ir.frontiers).toHaveLength(1); - expect(ir.coverage).toMatchObject({ - status: "truncated", - limitsReached: expect.arrayContaining([ - "maxCallSites", - "maxCallArguments", - "maxArgumentFlows", - "maxCallReturnFlows", - "maxClosureCaptures", - "maxFrontiers", - ]), - }); - }); - - it("recovers only direct return sites with literal and unknown object fields", () => { - const ir = analyzeJavaScriptSemantics(` - export default function parseMarkdown(value) { - if (value.startsWith("# ")) - return { type: "heading", depth: 1, text: value.slice(2) }; - function nested() { return { type: "nested" }; } - const arrow = () => ({ type: "arrow" }); - void nested; void arrow; - return { type: "paragraph", text: value.replaceAll("x", "y") }; - } - `); - - const parse = onlyCallable(ir, "parseMarkdown"); - expect(parse.returnCoverage).toEqual({ - status: "complete", - retainedCount: 2, - omittedCount: 0, - limitsReached: [], - }); - expect(parse.returnSites).toHaveLength(2); - expect(parse.returnSites[0]?.value).toMatchObject({ - status: "object", - unknownProperties: false, - omittedProperties: 0, - properties: expect.arrayContaining([ - { name: "depth", value: { status: "literal", value: 1 } }, - { name: "type", value: { status: "literal", value: "heading" } }, - { - name: "text", - value: { - status: "unknown", - reason: "Unsupported CallExpression value.", - }, - }, - ]), - }); - expect(onlyCallable(ir, "nested").returnSites).toHaveLength(1); - expect(onlyCallable(ir, "arrow").returnSites).toHaveLength(1); - expect(ir.moduleLinks).toContainEqual( - expect.objectContaining({ - exportedName: "default", - callableId: parse.callableId, - }), - ); - }); - - it("retains partial property coverage, empty returns, and return limits", () => { - const partial = analyzeJavaScriptSemantics(` - const spread = () => ({ type: "spread", ...dynamic }); - const computed = () => ({ type: "computed", [key]: 1 }); - function noReturn() { throw new Error("stop"); } - function emptyReturn() { return; } - `); - expect(onlyCallable(partial, "spread").returnSites[0]?.value).toMatchObject( - { - status: "object", - unknownProperties: true, - omittedProperties: null, - }, - ); - expect( - onlyCallable(partial, "computed").returnSites[0]?.value, - ).toMatchObject({ - status: "object", - unknownProperties: true, - omittedProperties: 1, - }); - expect(onlyCallable(partial, "noReturn").returnSites).toEqual([]); - expect(onlyCallable(partial, "emptyReturn").returnSites[0]?.value).toEqual({ - status: "unknown", - reason: "Return has no value.", - }); - - const limited = analyzeJavaScriptSemantics( - "function many(value) { if (value) return 1; return 2; }", - { maxReturnSites: 1 }, - ); - expect(onlyCallable(limited, "many")).toMatchObject({ - returnSites: [{ value: { status: "literal", value: 1 } }], - returnCoverage: { - status: "truncated", - retainedCount: 1, - omittedCount: 1, - limitsReached: ["maxReturnSites"], - }, - }); - expect(limited.coverage.limitsReached).toContain("maxReturnSites"); - }); - - it("fails closed on assignment ambiguity, alias cycles, and lattice limits", () => { - const ambiguous = analyzeJavaScriptSemantics(` - let current = "first"; - current = "second"; - const left = right; - const right = left; - `); - expect(topLevelBinding(ambiguous, "current").value).toMatchObject({ - status: "ambiguous", - }); - expect(topLevelBinding(ambiguous, "left").value).toMatchObject({ - status: "cycle", - }); - expect(topLevelBinding(ambiguous, "right").provenance).toMatchObject({ - status: "cycle", - }); - - const limited = analyzeJavaScriptSemantics( - 'const channel = enabled ? "one" : "two";', - { maxUnionValues: 1 }, - ); - expect(topLevelBinding(limited, "channel").value).toEqual({ - status: "limit-reached", - reason: "maxUnionValues reached.", - }); - expect(limited.coverage).toMatchObject({ - status: "truncated", - limitsReached: ["maxUnionValues"], - }); - expect(limited.coverage.omittedCount).toBeGreaterThan(0); - }); - - it("does not invent exact module paths for dynamic property access", () => { - const ir = analyzeJavaScriptSemantics(` - const key = getKey(); - const dynamicMember = require("electron")[key]; - const { [key]: dynamicBinding } = require("electron"); - `); - - expect(topLevelBinding(ir, "dynamicMember").provenance).toMatchObject({ - status: "unknown", - origins: [], - }); - expect(topLevelBinding(ir, "dynamicBinding").provenance).toMatchObject({ - status: "unknown", - origins: [], - }); - expect( - ir.moduleLinks.filter(({ localName }) => - ["dynamicMember", "dynamicBinding"].includes(localName ?? ""), - ), - ).toEqual([]); - }); - - it("bounds retained scopes and bindings without corrupting outer resolution", () => { - const ir = analyzeJavaScriptSemantics( - ` - const retained = "yes"; - const omitted = "no"; - retained; - function nested(value) { return retained + value; } - `, - { maxBindings: 1, maxCallables: 0, maxScopes: 1 }, - ); - - expect(ir.bindings.map(({ name }) => name)).toEqual(["retained"]); - expect(ir.callables).toEqual([]); - expect(ir.coverage.status).toBe("truncated"); - expect(ir.coverage.limitsReached).toEqual( - expect.arrayContaining(["maxBindings", "maxCallables", "maxScopes"]), - ); - expect(ir.references.filter(({ name }) => name === "retained")).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - resolution: "resolved", - bindingId: topLevelBinding(ir, "retained").bindingId, - }), - expect.objectContaining({ resolution: "unknown", bindingId: null }), - ]), - ); - }); - - it("reports reference, module-link, depth, and object-property limits", () => { - const retained = analyzeJavaScriptSemantics( - ` - const first = require("first"); - const second = require("second"); - first; - second; - `, - { maxModuleLinks: 1, maxReferences: 1 }, - ); - expect(retained.moduleLinks).toHaveLength(1); - expect(retained.references).toHaveLength(1); - expect(retained.coverage.limitsReached).toEqual( - expect.arrayContaining(["maxModuleLinks", "maxReferences"]), - ); - - const object = analyzeJavaScriptSemantics( - "const object = { first: 1, second: 2 };", - { maxObjectProperties: 1 }, - ); - expect(topLevelBinding(object, "object").value).toMatchObject({ - status: "object", - unknownProperties: true, - }); - expect(object.coverage.limitsReached).toContain("maxObjectProperties"); - - const depth = analyzeJavaScriptSemantics( - 'const first = "value"; const second = first;', - { maxValueDepth: 1 }, - ); - expect(topLevelBinding(depth, "second").value).toMatchObject({ - status: "limit-reached", - }); - expect(depth.coverage.limitsReached).toContain("maxValueDepth"); - }); - - it("is deterministic and returns failed coverage for an unparseable source", () => { - const source = 'const { value: renamed } = require("fixture");'; - expect(analyzeJavaScriptSemantics(source)).toEqual( - analyzeJavaScriptSemantics(source), - ); - expect(analyzeJavaScriptSemantics("function {")).toMatchObject({ - scopes: [], - bindings: [], - callables: [], - references: [], - coverage: { status: "failed", omittedCount: null }, - }); - }); - - it("does not mark parser-recovered duplicate bindings complete", () => { - const ir = analyzeJavaScriptSemantics( - "const duplicate = 'first'; const duplicate = 'second';", - ); - - expect(ir.coverage).toMatchObject({ - status: "partial", - omittedCount: 0, - }); - expect(topLevelBinding(ir, "duplicate").value).toMatchObject({ - status: "ambiguous", - }); - expect(ir.limitations.join(" ")).toMatch(/parser recovered/iu); - }); - - it("keeps parser-recovered return shapes partial", () => { - const ir = analyzeJavaScriptSemantics( - ` - export default function recovered(value) { - const duplicate = 1; - const duplicate = 2; - return { type: "item", text: render(value) }; - } - `, - ); - const recovered = onlyCallable(ir, "recovered"); - - expect(ir.coverage.status).toBe("partial"); - expect(recovered.returnCoverage).toMatchObject({ - status: "partial", - retainedCount: 1, - omittedCount: 0, - }); - expect(recovered.returnSites[0]?.value).toMatchObject({ - status: "object", - properties: expect.arrayContaining([ - { name: "type", value: { status: "literal", value: "item" } }, - expect.objectContaining({ - name: "text", - value: expect.objectContaining({ status: "unknown" }), - }), - ]), - }); - }); - - it.prop([fc.string({ maxLength: 512 })])( - "fails closed for arbitrary bounded source text", - (source) => { - const ir = analyzeJavaScriptSemantics(source, { - maxBindings: 64, - maxCallables: 64, - maxModuleLinks: 64, - maxReferences: 256, - maxScopes: 64, - }); - - expect(ir.schema).toBe("JavaScriptSemanticIR"); - expect(["complete", "partial", "truncated", "failed"]).toContain( - ir.coverage.status, - ); - if (ir.coverage.status === "failed") { - expect(ir.scopes).toEqual([]); - expect(ir.bindings).toEqual([]); - expect(ir.callables).toEqual([]); - } - }, - ); -}); - -const programScope = (ir: JavaScriptSemanticIr) => { - const scope = ir.scopes.find(({ kind }) => kind === "program"); - if (scope === undefined) throw new Error("Missing program scope"); - return scope; -}; - -const bindingsNamed = ( - ir: JavaScriptSemanticIr, - name: string, -): JavaScriptSemanticBinding[] => - ir.bindings.filter(({ name: candidate }) => candidate === name); - -const onlyBinding = ( - ir: JavaScriptSemanticIr, - name: string, -): JavaScriptSemanticBinding => { - const bindings = bindingsNamed(ir, name); - expect(bindings).toHaveLength(1); - const binding = bindings[0]; - if (binding === undefined) throw new Error(`Missing binding ${name}`); - return binding; -}; - -const onlyCallable = (ir: JavaScriptSemanticIr, name: string) => { - const callables = ir.callables.filter( - ({ name: candidate }) => candidate === name, - ); - if (callables.length !== 1 || callables[0] === undefined) - throw new Error(`Expected one callable named ${name}`); - return callables[0]; -}; - -const topLevelBinding = ( - ir: JavaScriptSemanticIr, - name: string, -): JavaScriptSemanticBinding => { - const scopeId = programScope(ir).scopeId; - const binding = ir.bindings.find( - ({ name: candidate, scopeId: candidateScope }) => - candidate === name && candidateScope === scopeId, - ); - if (binding === undefined) - throw new Error(`Missing top-level binding ${name}`); - return binding; -}; - -const origin = (binding: JavaScriptSemanticBinding) => { - expect(binding.provenance.status).toBe("module"); - expect(binding.provenance.origins).toHaveLength(1); - const value = binding.provenance.origins[0]; - if (value === undefined) - throw new Error(`Missing origin for ${binding.name}`); - return value; -}; diff --git a/tests/javascriptSemanticGraphBuilder.test.ts b/tests/javascriptSemanticGraphBuilder.test.ts deleted file mode 100644 index 4e77fa8ac..000000000 --- a/tests/javascriptSemanticGraphBuilder.test.ts +++ /dev/null @@ -1,578 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { buildJavaScriptSemanticGraph } from "../src/application/JavaScriptSemanticGraphBuilder.js"; -import type { JavaScriptArtifactAnalysis } from "../src/application/JavaScriptArtifactAnalysisTypes.js"; -import type { JavaScriptArtifactFile } from "../src/application/JavaScriptArtifactFiles.js"; -import { queryJavaScriptSemanticGraph } from "../src/domain/javascriptSemanticQuery.js"; -import { analyzeJavaScriptSemantics } from "../src/domain/javascriptSemanticAnalysis.js"; -import { - DEFAULT_JAVASCRIPT_SEMANTIC_LIMITS, - type JavaScriptSemanticLimits, -} from "../src/domain/javascriptSemanticIr.js"; - -const SHA256 = "a".repeat(64); -const GRAPH_ID = `jag_${"b".repeat(64)}`; - -describe("JavaScript semantic companion graph", () => { - it("projects closure and direct interprocedural flow without execution", () => { - const graph = graphFor(` - const outer = 2; - function add(value) { return value + outer; } - const input = 40; - const answer = add(input); - `); - - const relations = new Set(graph.relations.map(({ relation }) => relation)); - for (const expected of [ - "argument-to-parameter", - "calls", - "captures", - "defines", - "reads", - "returns-to-call", - ] as const) - expect(relations.has(expected)).toBe(true); - expect(graph.coverage.families).toHaveLength(12); - expect(graph.limitations).toContain( - "The semantic graph contains static syntax observations and conservative relationship candidates; it does not claim runtime execution.", - ); - const parameter = graph.nodes.find( - ({ kind, label }) => kind === "parameter" && label === "value", - ); - if (parameter === undefined) throw new Error("Expected parameter node"); - const provenance = queryJavaScriptSemanticGraph(graph, { - seed: { kind: "semantic-node", node_id: parameter.node_id }, - direction: "backward-provenance", - include_ambiguous_dynamic_edges: true, - }); - expect(provenance.relations.map(({ relation }) => relation)).toEqual( - expect.arrayContaining([ - "aliases", - "argument-to-parameter", - "defines", - "reads", - ]), - ); - }); - - it("keeps dynamic calls explicit and returns bounded deterministic pages", () => { - const graph = graphFor(` - const handlers = { ready() { return 1; } }; - const key = process.argv[2]; - handlers[key](); - `); - expect(graph.unknowns.some(({ reason }) => reason === "dynamic-call")).toBe( - true, - ); - const seedNodeId = graph.relations.find( - ({ relation }) => relation === "defines", - )?.source_node_id; - expect(seedNodeId).toBeDefined(); - if (seedNodeId === undefined) throw new Error("Expected a definition seed"); - - const first = queryJavaScriptSemanticGraph(graph, { - seed: { kind: "semantic-node", node_id: seedNodeId }, - direction: "forward-influence", - include_ambiguous_dynamic_edges: true, - limits: { - max_seed_matches: 25, - max_nodes: 2_000, - max_edges: 4_000, - max_depth: 12, - max_functions: 500, - max_modules: 500, - page_size: 1, - }, - }); - const repeated = queryJavaScriptSemanticGraph(graph, { - seed: { kind: "semantic-node", node_id: seedNodeId }, - direction: "forward-influence", - include_ambiguous_dynamic_edges: true, - limits: first.applied_limits, - }); - expect(repeated).toEqual(first); - }); - - it("keeps ambiguous interprocedural flow out of the default traversal", () => { - const graph = graphFor(` - function left(value) { return value; } - function right(value) { return value; } - const input = 1; - const selected = Math.random() ? left : right; - selected(input); - `); - const input = graph.nodes.find( - ({ kind, label }) => kind === "binding" && label === "input", - ); - if (input === undefined) throw new Error("Expected input binding"); - const query = { - seed: { kind: "semantic-node" as const, node_id: input.node_id }, - direction: "forward-influence" as const, - allowed_relations: [ - "reads" as const, - "aliases" as const, - "argument-to-parameter" as const, - ], - }; - const strict = queryJavaScriptSemanticGraph(graph, query); - expect( - strict.relations.some( - ({ relation }) => relation === "argument-to-parameter", - ), - ).toBe(false); - expect(strict.status).toBe("ambiguous"); - const admitted = queryJavaScriptSemanticGraph(graph, { - ...query, - include_ambiguous_dynamic_edges: true, - }); - expect( - admitted.relations.filter( - ({ relation }) => relation === "argument-to-parameter", - ), - ).toHaveLength(2); - expect(admitted.status).toBe("ambiguous"); - }); - - it("connects caller results, direct returns, parameters, and captures", () => { - const graph = graphFor(` - const outer = 2; - function identity(value) { return value; } - function readOuter() { return outer; } - const input = 40; - const output = identity(input); - `); - const binding = (label: string) => - graph.nodes.find( - ({ kind, label: nodeLabel }) => - ["binding", "parameter"].includes(kind) && nodeLabel === label, - ); - const output = binding("output"); - const input = binding("input"); - const outer = binding("outer"); - const readOuter = graph.nodes.find( - ({ kind, label }) => kind === "function" && label === "readOuter", - ); - if ( - output === undefined || - input === undefined || - outer === undefined || - readOuter === undefined - ) - throw new Error("Expected semantic flow nodes"); - const provenance = queryJavaScriptSemanticGraph(graph, { - seed: { kind: "semantic-node", node_id: output.node_id }, - direction: "backward-provenance", - }); - expect(provenance.nodes.map(({ node_id }) => node_id)).toContain( - input.node_id, - ); - const influence = queryJavaScriptSemanticGraph(graph, { - seed: { kind: "semantic-node", node_id: outer.node_id }, - direction: "forward-influence", - allowed_relations: ["captures"], - }); - expect(influence.nodes.map(({ node_id }) => node_id)).toContain( - readOuter.node_id, - ); - const capture = graph.relations.find( - ({ relation }) => relation === "captures", - ); - expect(capture?.evidence.location).toMatchObject({ - available: true, - value: { kind: "source-range", source: "app.js" }, - }); - expect(capture?.evidence.location).not.toEqual(readOuter.evidence.location); - }); - - it("preserves semantic analyzer truncation in graph coverage", () => { - const graph = graphFor( - "const first = 1; const second = first; const third = second;", - { maxReferences: 1 }, - ); - expect(graph.coverage).toMatchObject({ - status: "partial", - truncated: true, - omitted_nodes: null, - omitted_relations: null, - limits: expect.arrayContaining([ - { name: "semantic.maxReferences", value: 1, unit: "items" }, - ]), - }); - expect( - graph.coverage.families.find( - ({ family }) => family === "promise-ownership", - ), - ).toMatchObject({ status: "unknown", retained_relations: 0 }); - }); - - it("projects bounded Promise ownership and unresolved sources", () => { - const graph = graphFor(` - async function run(value) { - const owned = new Promise((resolve) => resolve(value)); - await Promise.resolve(owned); - Promise.resolve(value).then(work).finally(cleanup); - value.then(work); - return Promise.all([owned, Promise.resolve(value)]); - } - `); - - const relations = new Set(graph.relations.map(({ relation }) => relation)); - for (const expected of [ - "aggregates", - "awaits", - "chains", - "creates-promise", - "detaches-task", - "owns", - "returns-task", - ] as const) - expect(relations.has(expected)).toBe(true); - expect( - graph.nodes.filter(({ kind }) => kind === "promise"), - ).not.toHaveLength(0); - expect( - graph.nodes.find( - ({ kind, properties }) => - kind === "promise" && - properties.method === "all" && - properties.ownership === "returned", - ), - ).toBeDefined(); - expect( - graph.unknowns.some( - ({ family, reason }) => - family === "promise-ownership" && reason === "ambiguous-target", - ), - ).toBe(true); - expect( - graph.coverage.families.find( - ({ family }) => family === "promise-ownership", - ), - ).toMatchObject({ status: "partial" }); - }); - - it("keeps duplicate function fingerprints ambiguous", () => { - const graph = graphFor(` - function first(value) { return value + 1; } - function second(input) { return input + 1; } - `); - - expect(graph.fingerprints).toHaveLength(2); - const digest = graph.fingerprints[0]?.fingerprint_sha256; - expect(digest).toBeDefined(); - expect(graph.fingerprints[1]?.fingerprint_sha256).toBe(digest); - if (digest === undefined) throw new Error("Expected fingerprint"); - const query = queryJavaScriptSemanticGraph(graph, { - seed: { kind: "function", fingerprint_sha256: digest }, - direction: "forward-influence", - }); - expect(query.status).toBe("ambiguous"); - expect(query.summary.total_seed_matches).toBe(2); - }); - - it("projects literal events and exact timer cancellation", () => { - const graph = graphFor(` - function run(bus, dynamicName) { - const handle = setInterval(tick, 10); - bus.on("ready", handler); - bus.once(dynamicName, handler); - bus.emit("ready"); - bus.removeListener("ready", handler); - clearInterval(handle); - } - `); - - const relations = new Set(graph.relations.map(({ relation }) => relation)); - for (const expected of [ - "cancels-timer", - "dispatches-candidate", - "registers-listener", - "removes-listener", - "schedules-timer", - ] as const) - expect(relations.has(expected)).toBe(true); - expect( - graph.nodes.find( - ({ kind, properties }) => - kind === "event" && properties.event_name === "ready", - ), - ).toBeDefined(); - expect( - graph.unknowns.some( - ({ family, detail }) => - family === "event" && detail.includes("Dynamic once event name"), - ), - ).toBe(true); - expect( - graph.coverage.families.find(({ family }) => family === "event"), - ).toMatchObject({ status: "partial" }); - expect( - graph.coverage.families.find(({ family }) => family === "timer"), - ).toMatchObject({ status: "partial" }); - }); - - it("projects child-process creation, I/O, listeners, and signals", () => { - const graph = graphFor(` - import { spawn } from "node:child_process"; - function run(command) { - const child = spawn( - command, - ["--mode", "fast"], - { env: process.env, stdio: "pipe" }, - ); - child.on("exit", onExit); - child.once("error", onError); - child.kill("SIGTERM"); - } - `); - - const relations = new Set(graph.relations.map(({ relation }) => relation)); - for (const expected of [ - "connects-stdio", - "forwards-signal", - "listens-error", - "listens-exit", - "spawns", - "supplies-argv", - "supplies-env", - ] as const) - expect(relations.has(expected)).toBe(true); - expect( - graph.nodes.find( - ({ kind, properties }) => - kind === "child-process" && - properties.method === "spawn" && - properties.command === null, - ), - ).toBeDefined(); - expect( - graph.unknowns.some( - ({ family, detail }) => - family === "child-process" && - detail.includes("command remains unresolved"), - ), - ).toBe(true); - expect( - graph.coverage.families.find(({ family }) => family === "child-process"), - ).toMatchObject({ status: "partial" }); - }); - - it("projects config precedence, request fields, and boundaries", () => { - const graph = graphFor(` - import { readFileSync } from "node:fs"; - async function run(schema) { - const endpoint = process.env.API_URL ?? "https://fallback.test"; - const raw = readFileSync("./config.json", "utf8"); - const parsed = JSON.parse(raw); - const port = Number(process.argv[2]); - const validated = schema.parse(parsed); - const response = await fetch(endpoint, { - method: "POST", - body: validated, - }); - return response.json(); - } - `); - - const relations = new Set(graph.relations.map(({ relation }) => relation)); - for (const expected of [ - "coerces", - "constructs-request", - "consumed-by", - "defaults", - "overrides", - "parses", - "reads-argv", - "reads-config", - "reads-environment", - "supplies-request-field", - ] as const) - expect(relations.has(expected)).toBe(true); - expect( - graph.nodes.find( - ({ kind, properties }) => - kind === "request" && properties.method === "fetch", - ), - ).toBeDefined(); - expect( - graph.nodes.filter(({ kind }) => kind === "boundary"), - ).not.toHaveLength(0); - expect( - graph.coverage.families.find(({ family }) => family === "configuration"), - ).toMatchObject({ status: "partial" }); - expect( - graph.coverage.families.find(({ family }) => family === "request"), - ).toMatchObject({ status: "partial" }); - expect( - graph.coverage.families.find(({ family }) => family === "boundary"), - ).toMatchObject({ status: "partial" }); - }); - - it("projects built-in resource acquisition and release", () => { - const graph = graphFor(` - import { open } from "node:fs/promises"; - import { connect } from "node:net"; - async function run(path) { - const file = await open(path); - const socket = connect({ port: 9000 }); - await file.close(); - socket.end(); - } - `); - - const relations = new Set(graph.relations.map(({ relation }) => relation)); - expect(relations.has("acquires")).toBe(true); - expect(relations.has("releases")).toBe(true); - expect(graph.nodes.filter(({ kind }) => kind === "resource")).toHaveLength( - 2, - ); - expect( - graph.coverage.families.find( - ({ family }) => family === "resource-lifecycle", - ), - ).toMatchObject({ status: "partial" }); - }); - - it("projects literal seeds and static object flow", () => { - const graph = graphFor(` - const source = { token: "TOKEN", count: 1 }; - const { token } = source; - const copy = { ...source }; - source.count = 2; - const read = source.token; - `); - - const relations = new Set(graph.relations.map(({ relation }) => relation)); - for (const expected of [ - "destructures", - "reads-property", - "spreads", - "writes-property", - ] as const) - expect(relations.has(expected)).toBe(true); - expect( - graph.nodes.find( - ({ kind, properties }) => - kind === "property-slot" && properties.name === "token", - ), - ).toBeDefined(); - const literal = queryJavaScriptSemanticGraph(graph, { - seed: { kind: "literal", value: "TOKEN" }, - direction: "forward-influence", - }); - expect(literal.status).toBe("ambiguous"); - expect(literal.summary.total_seed_matches).toBe(3); - const property = queryJavaScriptSemanticGraph(graph, { - seed: { kind: "property", name: "token" }, - direction: "forward-influence", - }); - expect(property.status).toBe("found"); - }); - - it("does not promote transformed return expressions to resolved aliases", () => { - const graph = graphFor(` - function increment(value) { return value + 1; } - const input = 1; - const output = increment(input); - `); - const returnNode = graph.nodes.find(({ kind }) => kind === "return-site"); - if (returnNode === undefined) throw new Error("Expected return site"); - expect( - graph.relations.filter( - ({ relation, target_node_id }) => - relation === "aliases" && target_node_id === returnNode.node_id, - ), - ).toEqual([expect.objectContaining({ resolution: "candidate" })]); - const output = graph.nodes.find( - ({ kind, label }) => kind === "binding" && label === "output", - ); - const input = graph.nodes.find( - ({ kind, label }) => kind === "binding" && label === "input", - ); - if (output === undefined || input === undefined) - throw new Error("Expected caller bindings"); - const strict = queryJavaScriptSemanticGraph(graph, { - seed: { kind: "semantic-node", node_id: output.node_id }, - direction: "backward-provenance", - }); - expect(strict.nodes.map(({ node_id }) => node_id)).not.toContain( - input.node_id, - ); - expect(strict.status).toBe("ambiguous"); - }); - - it("uses unavailable evidence when no source produced semantic IR", () => { - const graph = buildJavaScriptSemanticGraph({ - rootArtifactSha256: SHA256, - applicationGraph: { graph_id: GRAPH_ID, nodes: [] }, - analysis: emptyAnalysis(), - }); - expect(graph.nodes).toHaveLength(1); - expect(graph.nodes[0]?.evidence).toMatchObject({ - authority: "unknown", - state: "unavailable", - location: { available: false, reason: "not-observed" }, - }); - }); -}); - -const emptyAnalysis = (): JavaScriptArtifactAnalysis => ({ - files: [], - packages: [], - json_modules: [], - html_scripts: [], - source_maps: [], - visited_ast_nodes: 0, - findings: 0, - modules: 0, - parse_failures: 0, - truncated_scopes: 0, - limitations: [], -}); - -const graphFor = ( - source: string, - inputLimits: Partial = {}, -) => { - const file: JavaScriptArtifactFile = { - path: "app.js", - container_sha256: SHA256, - sha256: SHA256, - bytes: Buffer.byteLength(source), - inventory_artifact_id: `art_${SHA256}`, - kind: "javascript", - unpacked: false, - text: { included: true, value: source }, - }; - const semanticLimits = { - ...DEFAULT_JAVASCRIPT_SEMANTIC_LIMITS, - ...inputLimits, - }; - const analysis: JavaScriptArtifactAnalysis = { - files: [ - { - file, - javascript: null, - semantic: { - ir: analyzeJavaScriptSemantics(source, inputLimits), - limits: semanticLimits, - }, - }, - ], - packages: [], - json_modules: [], - html_scripts: [], - source_maps: [], - visited_ast_nodes: 0, - findings: 0, - modules: 0, - parse_failures: 0, - truncated_scopes: 0, - limitations: [], - }; - return buildJavaScriptSemanticGraph({ - rootArtifactSha256: SHA256, - applicationGraph: { graph_id: GRAPH_ID, nodes: [] }, - analysis, - }); -}; diff --git a/tests/javascriptSemanticGraphQuery.test.ts b/tests/javascriptSemanticGraphQuery.test.ts deleted file mode 100644 index 8fbfbfd3f..000000000 --- a/tests/javascriptSemanticGraphQuery.test.ts +++ /dev/null @@ -1,517 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import type { ApplicationGraphEvidence } from "../src/domain/javascriptApplicationEvidenceSchemas.js"; -import { - JAVASCRIPT_SEMANTIC_NODE_KINDS, - JAVASCRIPT_SEMANTIC_RELATION_FAMILIES, - JAVASCRIPT_SEMANTIC_RELATIONS, -} from "../src/domain/javascriptSemanticGraphSchemas.js"; -import { - createJavaScriptSemanticFingerprint, - createJavaScriptSemanticGraph, - createJavaScriptSemanticGraphNode, - createJavaScriptSemanticGraphRelation, - createJavaScriptSemanticGraphUnknown, - type JavaScriptSemanticGraph, - type JavaScriptSemanticGraphNode, -} from "../src/domain/javascriptSemanticGraph.js"; -import { - parseJavaScriptSemanticGraph, - serializeJavaScriptSemanticGraph, -} from "../src/domain/javascriptSemanticGraphSerialization.js"; -import { queryJavaScriptSemanticGraph } from "../src/domain/javascriptSemanticQuery.js"; -import { javaScriptSemanticQueryInputSchema } from "../src/domain/javascriptSemanticQuerySchemas.js"; - -const SHA = "a".repeat(64); -const JAG_ID = `jag_${"b".repeat(64)}`; -const completeCoverage = { - status: "complete" as const, - truncated: false, - omitted_count: 0, - limits: [], -}; - -const evidence = ( - state: "observed" | "inferred" = "observed", -): ApplicationGraphEvidence => ({ - authority: - state === "observed" - ? "ast-static-analysis" - : "static-relationship-inference", - state, - confidence: state === "observed" ? "exact" : "high", - artifact: { available: true, artifact_id: `art_${SHA}`, sha256: SHA }, - location: { - available: true, - value: { - kind: "source-range", - source: "bundle.js", - start: { line: 1, column: 0 }, - end: { line: 1, column: 10 }, - }, - }, - extractor: { - name: "test", - version: "1", - operation: "recover-semantic-relation", - executable_sha256: null, - }, - coverage: completeCoverage, - limitations: - state === "observed" - ? [] - : ["Static reachability does not prove runtime execution."], - evidence_ids: [], -}); - -const unknownEvidence = (): ApplicationGraphEvidence => ({ - authority: "unknown", - state: "unknown", - confidence: "unknown", - artifact: { - available: false, - reason: "unknown", - detail: "Unknown artifact.", - }, - location: { - available: false, - reason: "unresolved", - detail: "Dynamic call target.", - }, - extractor: { - name: "test", - version: "1", - operation: "retain-dynamic-call", - executable_sha256: null, - }, - coverage: { - status: "unknown", - truncated: false, - omitted_count: null, - limits: [], - }, - limitations: ["Dynamic call target remains unknown."], - evidence_ids: [], -}); - -const node = ( - kind: (typeof JAVASCRIPT_SEMANTIC_NODE_KINDS)[number], - role: string, - properties: Record = {}, - functionNodeId: string | null = null, -): JavaScriptSemanticGraphNode => - createJavaScriptSemanticGraphNode({ - kind, - identity: { - artifact_sha256: SHA, - module_path: "bundle.js", - source_range: { - start: { line: 1, column: role.length }, - end: { line: 1, column: role.length + 1 }, - }, - role_key: role, - }, - function_node_id: functionNodeId, - application_node_ids: [], - label: role, - properties, - evidence: evidence(), - }); - -const fixtureGraph = (withUnknown = false): JavaScriptSemanticGraph => { - const module = node("module", "module"); - const literal = node("literal", "literal", { value: "TOKEN" }); - const binding = node("binding", "binding"); - const callable = node("function", "function"); - const request = node("request", "request", { - endpoint: "https://example.invalid/v1", - }); - const relations = [ - createJavaScriptSemanticGraphRelation({ - source_node_id: literal.node_id, - target_node_id: binding.node_id, - relation: "defines", - resolution: "resolved", - properties: {}, - evidence: evidence("inferred"), - }), - createJavaScriptSemanticGraphRelation({ - source_node_id: binding.node_id, - target_node_id: callable.node_id, - relation: "captures", - resolution: "resolved", - properties: {}, - evidence: evidence("inferred"), - }), - createJavaScriptSemanticGraphRelation({ - source_node_id: callable.node_id, - target_node_id: request.node_id, - relation: "constructs-request", - resolution: "resolved", - properties: {}, - evidence: evidence("inferred"), - }), - ]; - const dynamic = createJavaScriptSemanticGraphUnknown({ - node_id: callable.node_id, - family: "call-flow", - relation_kinds: ["calls"], - reason: "dynamic-call", - detail: "Computed callee is unresolved.", - candidate_node_ids: [], - evidence: unknownEvidence(), - }); - const unknowns = withUnknown ? [dynamic] : []; - const coverageFamilies = JAVASCRIPT_SEMANTIC_RELATION_FAMILIES.map( - (family) => ({ - family, - status: - withUnknown && family === "call-flow" - ? ("unknown" as const) - : ("complete" as const), - retained_relations: relations.filter(({ relation }) => - relation === "defines" - ? family === "data-flow" - : relation === "captures" - ? family === "closure" - : family === "request", - ).length, - omitted_relations: withUnknown && family === "call-flow" ? null : 0, - unknown_ids: - withUnknown && family === "call-flow" ? [dynamic.unknown_id] : [], - }), - ); - const fingerprint = createJavaScriptSemanticFingerprint({ - function_node_id: callable.node_id, - algorithm: "rea.javascript-semantic-function/v1", - status: "complete", - components: { - parameter_arity: 0, - normalized_ast_sha256: "1".repeat(64), - control_flow_sha256: "2".repeat(64), - relation_shape_sha256: "3".repeat(64), - literal_set_sha256: "4".repeat(64), - effects: ["network"], - }, - limitations: [], - evidence: evidence("inferred"), - }); - return createJavaScriptSemanticGraph({ - schema: "JavaScriptSemanticRelationGraph", - schema_version: 1, - root_artifact_sha256: SHA, - application_graph_id: JAG_ID, - root_node_ids: [module.node_id], - nodes: [request, callable, binding, literal, module], - relations, - fingerprints: [fingerprint], - unknowns, - coverage: { - status: withUnknown ? "partial" : "complete", - truncated: false, - omitted_nodes: withUnknown ? null : 0, - omitted_relations: withUnknown ? null : 0, - limits: [], - families: coverageFamilies, - }, - limitations: withUnknown ? ["Dynamic call target remains unknown."] : [], - }); -}; - -const graphWithCandidateEdge = (): JavaScriptSemanticGraph => { - const graph = fixtureGraph(); - const literal = graph.nodes.find(({ kind }) => kind === "literal"); - const request = graph.nodes.find(({ kind }) => kind === "request"); - if (literal === undefined || request === undefined) - throw new TypeError("Semantic fixture nodes are missing"); - const candidate = createJavaScriptSemanticGraphRelation({ - source_node_id: literal.node_id, - target_node_id: request.node_id, - relation: "supplies-request-field", - resolution: "candidate", - properties: { field: "authorization" }, - evidence: evidence("inferred"), - }); - const { graph_id: _graphId, ...input } = graph; - return createJavaScriptSemanticGraph({ - ...input, - relations: [...graph.relations, candidate], - coverage: { - ...graph.coverage, - families: graph.coverage.families.map((family) => - family.family === "request" - ? { - ...family, - retained_relations: family.retained_relations + 1, - } - : family, - ), - }, - }); -}; - -const graphWithDuplicateLiteral = (): JavaScriptSemanticGraph => { - const graph = fixtureGraph(); - const duplicate = node("literal", "literal-copy", { value: "TOKEN" }); - const { graph_id: _graphId, ...input } = graph; - return createJavaScriptSemanticGraph({ - ...input, - nodes: [...graph.nodes, duplicate], - }); -}; - -describe("JavaScript semantic relation graph", () => { - it("defines every required v1 relation family and canonicalizes records", () => { - expect(JAVASCRIPT_SEMANTIC_RELATIONS).toContain("argument-to-parameter"); - expect(JAVASCRIPT_SEMANTIC_RELATIONS).toContain("detaches-task"); - expect(JAVASCRIPT_SEMANTIC_RELATIONS).toContain("forwards-signal"); - expect(JAVASCRIPT_SEMANTIC_RELATIONS).toContain("validates"); - const graph = fixtureGraph(); - expect( - parseJavaScriptSemanticGraph( - JSON.parse(serializeJavaScriptSemanticGraph(graph)), - ), - ).toEqual(graph); - expect(graph.nodes.map(({ node_id }) => node_id)).toEqual( - graph.nodes.map(({ node_id }) => node_id).toSorted(), - ); - }); - - it("rejects stale identities, dangling endpoints, and incomplete coverage claims", () => { - const graph = fixtureGraph(); - const { graph_id: _graphId, ...graphInput } = graph; - expect(() => - parseJavaScriptSemanticGraph({ - ...graph, - graph_id: `jsrg_${"0".repeat(64)}`, - }), - ).toThrow(); - expect(() => - createJavaScriptSemanticGraph({ - ...graphInput, - relations: [ - { - ...graph.relations[0], - target_node_id: `jsrg_node_${"f".repeat(64)}`, - }, - ], - }), - ).toThrow(); - expect(() => - createJavaScriptSemanticGraph({ - ...graphInput, - coverage: { ...graph.coverage, omitted_nodes: 1 }, - }), - ).toThrow(/Complete graph coverage/u); - const graphWithUnknown = fixtureGraph(true); - const { graph_id: _unknownGraphId, ...unknownGraphInput } = - graphWithUnknown; - const originalUnknown = graphWithUnknown.unknowns[0]; - if (originalUnknown === undefined) - throw new Error("Expected unknown frontier"); - const { unknown_id: _unknownId, ...unknownInput } = originalUnknown; - expect(() => - createJavaScriptSemanticGraph({ - ...unknownGraphInput, - unknowns: [ - createJavaScriptSemanticGraphUnknown({ - ...unknownInput, - candidate_node_ids: [`jsrg_node_${"f".repeat(64)}`], - }), - ], - }), - ).toThrow(/candidate node is absent/u); - }); -}); - -describe("JavaScript semantic query", () => { - it("bounds relevant unknown frontiers independently from relation pages", () => { - const graph = fixtureGraph(true); - const callable = graph.nodes.find(({ kind }) => kind === "function"); - if (callable === undefined) throw new Error("Expected callable node"); - const result = queryJavaScriptSemanticGraph(graph, { - seed: { kind: "semantic-node", node_id: callable.node_id }, - direction: "forward-influence", - limits: { max_unknowns: 0 }, - }); - expect(result).toMatchObject({ - status: "truncated", - unknowns: [], - summary: { relevant_unknowns: 1, retained_unknowns: 0 }, - coverage: { status: "truncated" }, - }); - }); - - it("traces deterministic forward influence and pages without changing identity", () => { - const graph = fixtureGraph(); - const input = { - seed: { kind: "literal" as const, value: "TOKEN" }, - direction: "forward-influence" as const, - expected: { role: "sink" as const, classes: ["request" as const] }, - limits: { - max_seed_matches: 25, - max_nodes: 20, - max_edges: 20, - max_depth: 12, - max_functions: 10, - max_modules: 10, - page_size: 2, - }, - }; - const first = queryJavaScriptSemanticGraph(graph, input); - expect(first).toMatchObject({ - status: "found", - summary: { traversed_nodes: 4, traversed_relations: 3 }, - page: { offset: 0, size: 2 }, - }); - expect(first.expected_match_node_ids).toHaveLength(1); - expect(first.page.next_cursor).not.toBeNull(); - const second = queryJavaScriptSemanticGraph(graph, { - ...input, - cursor: first.page.next_cursor, - }); - expect(second.query_id).toBe(first.query_id); - expect(second.page).toMatchObject({ - offset: 2, - size: 1, - next_cursor: null, - }); - expect(second.relations[0]?.relation_id).not.toBe( - first.relations[0]?.relation_id, - ); - }); - - it("keeps relevant dynamic frontiers unknown", () => { - const result = queryJavaScriptSemanticGraph(fixtureGraph(true), { - seed: { kind: "literal", value: "TOKEN" }, - direction: "forward-influence", - }); - expect(result.status).toBe("found"); - expect(result.coverage.status).toBe("partial"); - expect(result.unknowns).toMatchObject([ - { reason: "dynamic-call", relation_kinds: ["calls"] }, - ]); - }); - - it("excludes candidate edges unless the caller explicitly opts in", () => { - const graph = graphWithCandidateEdge(); - const input = { - seed: { kind: "literal" as const, value: "TOKEN" }, - direction: "forward-influence" as const, - allowed_relations: ["supplies-request-field" as const], - }; - const conservative = queryJavaScriptSemanticGraph(graph, input); - const optedIn = queryJavaScriptSemanticGraph(graph, { - ...input, - include_ambiguous_dynamic_edges: true, - }); - expect(conservative.summary.traversed_relations).toBe(0); - expect(optedIn.summary.traversed_relations).toBe(1); - expect(conservative.status).toBe("ambiguous"); - expect(conservative.coverage.status).toBe("partial"); - expect(optedIn.status).toBe("ambiguous"); - expect(optedIn.relations).toMatchObject([ - { relation: "supplies-request-field", resolution: "candidate" }, - ]); - }); - - it("bounds seed ambiguity before traversal", () => { - const result = queryJavaScriptSemanticGraph(graphWithDuplicateLiteral(), { - seed: { kind: "literal", value: "TOKEN" }, - direction: "forward-influence", - limits: { - max_seed_matches: 1, - max_nodes: 20, - max_edges: 20, - max_depth: 12, - max_functions: 10, - max_modules: 10, - page_size: 10, - }, - }); - expect(result.summary).toMatchObject({ - total_seed_matches: 2, - retained_seed_matches: 1, - }); - expect(result.status).toBe("truncated"); - expect(result.coverage.frontier).toContainEqual( - expect.objectContaining({ reason: "max-seed-matches", depth: 0 }), - ); - }); - - it("reports exact caller limits and a deterministic truncation frontier", () => { - const result = queryJavaScriptSemanticGraph(fixtureGraph(), { - seed: { kind: "literal", value: "TOKEN" }, - direction: "forward-influence", - limits: { - max_seed_matches: 1, - max_nodes: 2, - max_edges: 10, - max_depth: 12, - max_functions: 10, - max_modules: 10, - page_size: 10, - }, - }); - expect(result.status).toBe("truncated"); - expect(result.coverage.frontier).toMatchObject([{ reason: "max-nodes" }]); - expect(result.applied_limits.max_nodes).toBe(2); - expect(result.accepted_limit_ranges.max_nodes).toEqual({ - minimum: 1, - maximum: 50_000, - }); - }); - - it.each([ - { - limit: { max_edges: 1 }, - reason: "max-edges", - }, - { - limit: { max_depth: 0 }, - reason: "max-depth", - }, - ])("reports the $reason frontier", ({ limit, reason }) => { - const result = queryJavaScriptSemanticGraph(fixtureGraph(), { - seed: { kind: "literal", value: "TOKEN" }, - direction: "forward-influence", - limits: { - max_seed_matches: 10, - max_nodes: 20, - max_edges: limit.max_edges ?? 20, - max_depth: limit.max_depth ?? 12, - max_functions: 10, - max_modules: 10, - page_size: 10, - }, - }); - expect(result.status).toBe("truncated"); - expect(result.coverage.frontier).toContainEqual( - expect.objectContaining({ reason }), - ); - }); - - it("rejects out-of-range limits and cursors from another query", () => { - expect(() => - javaScriptSemanticQueryInputSchema.parse({ - seed: { kind: "literal", value: "TOKEN" }, - direction: "forward-influence", - limits: { max_nodes: 50_001 }, - }), - ).toThrow(); - const graph = fixtureGraph(); - const first = queryJavaScriptSemanticGraph(graph, { - seed: { kind: "literal", value: "TOKEN" }, - direction: "forward-influence", - limits: { page_size: 1 }, - }); - expect(() => - queryJavaScriptSemanticGraph(graph, { - seed: { kind: "endpoint", value: "https://example.invalid/v1" }, - direction: "backward-provenance", - limits: { page_size: 1 }, - cursor: first.page.next_cursor, - }), - ).toThrow(/cursor does not match/u); - }); -}); diff --git a/tests/managedArtifactProvider.test.ts b/tests/managedArtifactProvider.test.ts deleted file mode 100644 index 8a1c5e397..000000000 --- a/tests/managedArtifactProvider.test.ts +++ /dev/null @@ -1,713 +0,0 @@ -import { createHash } from "node:crypto"; -import { writeFile } from "node:fs/promises"; -import { join } from "node:path"; - -import { describe, expect, it } from "vitest"; - -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; - -import { runProviderAnalysis } from "../src/application/DirectAnalysis.js"; -import type { AnalysisExecution } from "../src/application/AnalysisProvider.js"; -import { - managedArtifactInspectionSchema, - managedMemberInspectionSchema, - managedNativeBoundaryInspectionSchema, -} from "../src/domain/managedArtifact.js"; -import type { BinaryTarget } from "../src/domain/binaryTarget.js"; -import { parseBinaryTarget } from "../src/domain/binaryTarget.js"; -import { parseEvidence } from "../src/domain/evidence.js"; -import { ManagedStaticProvider } from "../src/dotnet/ManagedStaticProvider.js"; -import { inspectManagedArtifactBytes } from "../src/dotnet/ManagedArtifactInspector.js"; -import { inspectManagedMembersBytes } from "../src/dotnet/ManagedMemberInspector.js"; -import { inspectManagedNativeBoundariesBytes } from "../src/dotnet/ManagedNativeBoundaryInspector.js"; -import { - buildManagedPeFixture, - buildNativePeFixture, -} from "./fixtures/managedPe.js"; - -const limits = { - referenceOffset: 0, - referenceLimit: 100, - resourceOffset: 0, - resourceLimit: 100, - attributeOffset: 0, - attributeLimit: 100, - maxMetadataBytes: 1024 * 1024, - maxTableRows: 1_000, - maxHeapItemBytes: 1024 * 1024, -}; - -const memberLimits = { - typeOffset: 0, - typeLimit: 100, - methodOffset: 0, - methodLimit: 100, - fieldOffset: 0, - fieldLimit: 100, - memberRefOffset: 0, - memberRefLimit: 100, - edgeOffset: 0, - edgeLimit: 100, - instructionAnchorLimit: 100, - maxMetadataBytes: 1024 * 1024, - maxTableRows: 1_000, - maxHeapItemBytes: 1024 * 1024, - maxMethodBodyBytes: 1024 * 1024, - maxMethodInstructions: 1_000, -}; - -const nativeBoundaryLimits = { - moduleRefOffset: 0, - moduleRefLimit: 100, - importOffset: 0, - importLimit: 100, - implementationOffset: 0, - implementationLimit: 100, - maxMetadataBytes: 1024 * 1024, - maxTableRows: 1_000, - maxHeapItemBytes: 1024 * 1024, -}; - -describe("managed PE/CLI static provider", () => { - it("inventories module, assembly, framework, references, and resources without loading CLR code", () => { - const resource = Buffer.from("source-owned resource"); - const bytes = buildManagedPeFixture({ resourceData: resource }); - const result = inspectManagedArtifactBytes(bytes, target(bytes), limits); - - expect(result.classification).toMatchObject({ - status: "managed", - runtime_family: "modern-dotnet", - implementation: "cil", - managed_architecture: "anycpu", - }); - expect(result.pe.cli).toMatchObject({ - flag_names: ["il-only"], - entry_point: { kind: "metadata-token", value: "0x06000001" }, - }); - expect(result.module).toMatchObject({ - name: "Fixture.dll", - mvid: "00112233-4455-6677-8899-aabbccddeeff", - token: "0x00000001", - }); - expect(result.assembly).toMatchObject({ - name: "Fixture.Managed", - version: "1.2.3.4", - token: "0x20000001", - }); - expect(result.target_frameworks).toEqual([".NETCoreApp,Version=v8.0"]); - expect(result.references).toMatchObject({ - total: 1, - returned: 1, - complete: true, - items: [expect.objectContaining({ name: "System.Runtime" })], - }); - expect(result.resources.items).toEqual([ - expect.objectContaining({ - name: "Fixture.resources", - embedded: true, - visibility: "private", - data_length: resource.length, - data_sha256: createHash("sha256").update(resource).digest("hex"), - }), - ]); - expect(result.coverage).toMatchObject({ state: "complete", issues: [] }); - }); - - it("keeps classification evidence complete when caller pages visible references", () => { - const bytes = buildManagedPeFixture({ - references: ["System.Runtime", "UnityEngine.CoreModule"], - }); - const result = inspectManagedArtifactBytes(bytes, target(bytes), { - ...limits, - referenceLimit: 1, - }); - - expect(result.classification.runtime_family).toBe("unity-mono"); - expect(result.references).toMatchObject({ - total: 2, - returned: 1, - complete: false, - }); - expect(result.coverage.state).toBe("partial"); - expect(result.coverage.issues).toEqual([]); - }); - - it("accepts CLI metadata GUIDs without RFC UUID version or variant bits", () => { - const bytes = buildManagedPeFixture({ - mvid: Buffer.from("3aebc60edc4a544b1f458b4ed40b33b1", "hex"), - }); - const result = inspectManagedArtifactBytes(bytes, target(bytes), limits); - const members = inspectManagedMembersBytes( - bytes, - target(bytes), - memberLimits, - ); - const boundaries = inspectManagedNativeBoundariesBytes( - bytes, - target(bytes), - nativeBoundaryLimits, - ); - - expect(result.module?.mvid).toBe("0ec6eb3a-4adc-4b54-1f45-8b4ed40b33b1"); - expect(result.classification.status).toBe("managed"); - expect(members.identity_scope.requires_mvid).toBe(result.module?.mvid); - expect(boundaries.identity_scope.requires_mvid).toBe(result.module?.mvid); - }); - - it("decodes ECMA-335 pointer and byref element types without swapping them", () => { - const bytes = buildManagedPeFixture({ - fieldSignature: Buffer.from([0x06, 0x1d, 0x0f, 0x0f, 0x08]), - methodSignature: Buffer.from([ - 0x00, 0x02, 0x10, 0x08, 0x0f, 0x08, 0x10, 0x0e, - ]), - }); - const result = inspectManagedMembersBytes( - bytes, - target(bytes), - memberLimits, - ); - - expect(result.fields.items[0]?.signature).toMatchObject({ - parse_status: "decoded", - field_type: "i4**[]", - }); - expect(result.methods.items[0]?.signature).toMatchObject({ - parse_status: "decoded", - return_type: "i4&", - parameter_types: ["i4*", "string&"], - }); - }); - - it("reads fat method header size from the full flags-and-size word", () => { - const il = Buffer.from([ - 0x21, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x26, 0x22, 0x00, - 0x00, 0x80, 0x3f, 0x26, 0x23, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf0, - 0x3f, 0x26, 0xfe, 0x06, 0x01, 0x00, 0x00, 0x06, 0x26, 0x2a, - ]); - const header = Buffer.alloc(12); - header.writeUInt16LE(0x3013, 0); - header.writeUInt16LE(8, 2); - header.writeUInt32LE(il.length, 4); - const bytes = buildManagedPeFixture({ - ilBody: Buffer.concat([header, il]), - }); - const result = inspectManagedMembersBytes( - bytes, - target(bytes), - memberLimits, - ); - - expect(result.methods.items[0]?.body).toMatchObject({ - status: "present", - header_format: "fat", - il_size: il.length, - il_sha256: createHash("sha256").update(il).digest("hex"), - opcode_counts: { - "ldc.i8": 1, - pop: 4, - "ldc.r4": 1, - "ldc.r8": 1, - ldftn: 1, - ret: 1, - }, - issue: null, - }); - }); - - it("does not normalize reserved CIL opcodes as operand-free instructions", () => { - const bytes = buildManagedPeFixture({ - ilBody: Buffer.from([0x0a, 0x24, 0x2a]), - }); - const result = inspectManagedMembersBytes( - bytes, - target(bytes), - memberLimits, - ); - - expect(result.methods.items[0]?.body).toMatchObject({ - status: "malformed", - il_size: 2, - normalized_il_sha256: null, - decoded_instruction_count: 0, - issue: "Unsupported CIL opcode 0x24 at IL offset 0", - }); - }); - - it("keeps the documented decoded-CIL v1 golden vector stable", () => { - const il = Buffer.from([0x00, 0x2a]); - const tinyBytes = buildManagedPeFixture({ - ilBody: Buffer.from([0x0a, ...il]), - }); - const fatHeader = Buffer.alloc(12); - fatHeader.writeUInt16LE(0x3013, 0); - fatHeader.writeUInt16LE(32, 2); - fatHeader.writeUInt32LE(il.length, 4); - fatHeader.writeUInt32LE(0x1100_0001, 8); - const fatBytes = buildManagedPeFixture({ - ilBody: Buffer.concat([fatHeader, il]), - }); - const tiny = inspectManagedMembersBytes( - tinyBytes, - target(tinyBytes), - memberLimits, - ); - const fat = inspectManagedMembersBytes( - fatBytes, - target(fatBytes), - memberLimits, - ); - - expect(tiny.methods.items[0]?.body).toMatchObject({ - status: "present", - header_format: "tiny", - max_stack: 8, - init_locals: false, - local_var_sig_token: null, - il_size: 2, - il_sha256: createHash("sha256").update(il).digest("hex"), - normalized_il_sha256: - "5e5fad7741cb44bca3a4f045546b7449990da343f612f5f34c0ca30e9eee0636", - opcode_counts: { nop: 1, ret: 1 }, - exception_regions: [], - }); - expect(fat.methods.items[0]?.body).toMatchObject({ - header_format: "fat", - max_stack: 32, - init_locals: true, - local_var_sig_token: "0x11000001", - il_sha256: tiny.methods.items[0]?.body.il_sha256, - normalized_il_sha256: tiny.methods.items[0]?.body.normalized_il_sha256, - }); - }); - - it("marks instruction-limited CIL as partial without assigning normalized identity", () => { - const bytes = buildManagedPeFixture(); - const result = inspectManagedMembersBytes(bytes, target(bytes), { - ...memberLimits, - maxMethodInstructions: 1, - }); - const il = bytes.subarray(0x0a01, 0x0a0d); - - expect(result.metadata.status).toBe("complete"); - expect(result.methods.items[0]?.body).toMatchObject({ - status: "partial", - il_size: 12, - il_sha256: createHash("sha256").update(il).digest("hex"), - normalized_il_sha256: null, - instruction_count: 1, - decoded_instruction_count: 1, - truncated_instructions: 1, - issue: - "Instruction decode reached max_method_instructions 1 before the end of the method body", - }); - expect(result.coverage).toEqual({ - state: "partial", - issues: [ - { - code: "limit-exceeded", - scope: "method.0x06000001.body.instructions", - offset: 0x0a00, - detail: - "Instruction decode reached max_method_instructions 1 before the end of the method body", - }, - ], - }); - expect(result.limitations.join(" ")).toContain( - "has no normalized CIL identity", - ); - }); - - it("inspects metadata members, signatures, CIL hashes, call edges, and field anchors", () => { - const bytes = buildManagedPeFixture(); - const result = inspectManagedMembersBytes( - bytes, - target(bytes), - memberLimits, - ); - - expect(result.identity_scope).toEqual({ - token_identity: "build-local", - requires_artifact_sha256: target(bytes).sha256, - requires_mvid: "00112233-4455-6677-8899-aabbccddeeff", - }); - expect(result.types.items).toEqual([ - expect.objectContaining({ - token: "0x02000001", - full_name: "Fixture.Program", - field_list: { first_row: 1, last_row: 1, count: 1 }, - method_list: { first_row: 1, last_row: 1, count: 1 }, - }), - ]); - expect(result.fields.items).toEqual([ - expect.objectContaining({ - token: "0x04000001", - declaring_type: "Fixture.Program", - name: "counter", - signature: expect.objectContaining({ - kind: "field", - parse_status: "decoded", - field_type: "i4", - }), - }), - ]); - expect(result.methods.items).toEqual([ - expect.objectContaining({ - token: "0x06000001", - declaring_type: "Fixture.Program", - name: "Main", - rva: 0x2800, - signature: expect.objectContaining({ - kind: "method", - parse_status: "decoded", - return_type: "void", - parameter_types: [], - }), - body: expect.objectContaining({ - status: "present", - header_format: "tiny", - file_offset: 0x0a00, - il_size: 12, - instruction_count: 4, - decoded_instruction_count: 4, - truncated_instructions: 0, - opcode_counts: { "ldarg.0": 1, ldfld: 1, call: 1, ret: 1 }, - anchors: [ - { - il_offset: 1, - opcode: "ldfld", - operand_kind: "field", - operand: "0x04000001", - }, - { - il_offset: 6, - opcode: "call", - operand_kind: "method", - operand: "0x0a000001", - }, - ], - }), - }), - ]); - expect(result.member_refs.items).toEqual([ - expect.objectContaining({ - token: "0x0a000001", - name: ".ctor", - signature: expect.objectContaining({ - kind: "method", - parse_status: "decoded", - parameter_types: ["string"], - }), - }), - ]); - expect(result.call_edges.items).toEqual([ - { - caller_token: "0x06000001", - caller: "Fixture.Program.Main", - opcode: "call", - target_token: "0x0a000001", - target_kind: "member-ref", - target_name: ".ctor", - }, - ]); - expect(result.field_accesses.items).toEqual([ - { - method_token: "0x06000001", - method: "Fixture.Program.Main", - opcode: "ldfld", - field_token: "0x04000001", - field_name: "counter", - }, - ]); - expect(result.coverage).toMatchObject({ state: "complete", issues: [] }); - }); - - it("inspects managed/native PInvoke declarations without verifying native exports", () => { - const bytes = buildManagedPeFixture({ - pinvoke: { - moduleName: "user32.dll", - importName: "MessageBoxW", - mappingFlags: 0x0345, - }, - readyToRun: true, - }); - const result = inspectManagedNativeBoundariesBytes( - bytes, - target(bytes), - nativeBoundaryLimits, - ); - - expect(result.cli_native).toMatchObject({ - il_only: true, - ready_to_run_signature: true, - managed_native_header_rva: 0x2700, - managed_native_header_size: 4, - }); - expect(result.module_refs.items).toEqual([ - { - token: "0x1a000001", - row_offset: expect.any(Number), - name: "user32.dll", - }, - ]); - expect(result.pinvoke_imports.items).toEqual([ - expect.objectContaining({ - token: "0x1c000001", - member_token: "0x06000001", - member_kind: "method", - member_name: "Main", - import_name: "MessageBoxW", - import_scope_token: "0x1a000001", - import_scope_name: "user32.dll", - no_mangle: true, - char_set: "unicode", - call_convention: "stdcall", - supports_last_error: true, - verification: "managed-declaration-only", - }), - ]); - expect(result.native_implementations.items).toEqual([ - expect.objectContaining({ - token: "0x06000001", - name: "Main", - pinvoke_declared: true, - boundary_kind: "pinvoke", - body_interpretation: "native-or-runtime", - }), - ]); - expect(result.summary).toMatchObject({ - module_ref_count: 1, - pinvoke_import_count: 1, - native_implementation_count: 1, - ready_to_run: true, - mixed_mode_or_native_header: true, - }); - expect(result.limitations).toContain( - "P/Invoke rows prove managed import declarations only; this inspection does not verify that a native library, export, thunk, or provider-qualified function exists.", - ); - }); - - it("keeps member evidence bounded and typed for pagination and unavailable metadata", () => { - const bytes = buildManagedPeFixture(); - const paged = inspectManagedMembersBytes(bytes, target(bytes), { - ...memberLimits, - methodLimit: 0 + 1, - instructionAnchorLimit: 1, - }); - expect(paged.methods).toMatchObject({ - total: 1, - returned: 1, - complete: true, - }); - expect(paged.methods.items[0]?.body.anchors).toHaveLength(1); - - const nativeBytes = buildNativePeFixture(); - const native = inspectManagedMembersBytes( - nativeBytes, - target(nativeBytes), - memberLimits, - ); - expect(native.metadata.status).toBe("absent"); - expect(native.coverage.state).toBe("unavailable"); - - const malformedBytes = buildManagedPeFixture({ - corruptMetadataSignature: true, - }); - const malformed = inspectManagedMembersBytes( - malformedBytes, - target(malformedBytes), - memberLimits, - ); - expect(malformed.metadata.status).toBe("malformed"); - expect(malformed.coverage.issues).toEqual([ - expect.objectContaining({ code: "invalid-metadata-root" }), - ]); - }); - - it("reports native PE, malformed metadata, and bounded metadata failures as typed results", () => { - const native = inspectManagedArtifactBytes( - buildNativePeFixture(), - target(buildNativePeFixture()), - limits, - ); - expect(native.classification.status).toBe("not-managed"); - expect(native.metadata.status).toBe("absent"); - expect(native.coverage.state).toBe("unavailable"); - - const malformedBytes = buildManagedPeFixture({ - corruptMetadataSignature: true, - }); - const malformed = inspectManagedArtifactBytes( - malformedBytes, - target(malformedBytes), - limits, - ); - expect(malformed.classification.status).toBe("malformed"); - expect(malformed.coverage.issues).toEqual([ - expect.objectContaining({ code: "invalid-metadata-root" }), - ]); - - const unsupportedTableBytes = buildManagedPeFixture({ - metadataValidMaskExtra: 1n << 50n, - }); - const unsupportedTable = inspectManagedArtifactBytes( - unsupportedTableBytes, - target(unsupportedTableBytes), - limits, - ); - expect(unsupportedTable.coverage.issues).toEqual([ - expect.objectContaining({ code: "invalid-tables" }), - ]); - - const limited = inspectManagedArtifactBytes( - buildManagedPeFixture(), - target(buildManagedPeFixture()), - { ...limits, maxMetadataBytes: 256 }, - ); - expect(limited.metadata.status).toBe("partial"); - expect(limited.coverage.issues).toEqual([ - expect.objectContaining({ code: "limit-exceeded" }), - ]); - }); - - it("executes as a read-only auxiliary provider with digest, cancellation, and format boundaries", async () => { - const directory = await createTestTempDirectory("rea-managed-provider-"); - const bytes = buildManagedPeFixture(); - const path = join(directory, "fixture.exe"); - await writeFile(path, bytes); - const parsed = await parseBinaryTarget(path); - expect(parsed.ok).toBe(true); - if (!parsed.ok) return; - - const client = new ManagedStaticProvider().createClient(parsed.value); - const observed = await client.execute("inspect_managed_artifact", {}); - expect(observed.ok).toBe(true); - if (!observed.ok) return; - expect(asManagedResult(observed.value)).toMatchObject({ - classification: { status: "managed", runtime_family: "modern-dotnet" }, - artifact: { path, sha256: parsed.value.sha256, format: "pe" }, - }); - expect(observed.value.provider.id).toBe("rea-dotnet-static"); - expect(observed.value.subject).toMatchObject({ path, format: "pe" }); - - const members = await client.execute("inspect_managed_members", {}); - expect(members.ok).toBe(true); - if (!members.ok) return; - expect(asManagedMemberResult(members.value)).toMatchObject({ - artifact: { path, sha256: parsed.value.sha256, format: "pe" }, - methods: { total: 1 }, - call_edges: { total: 1 }, - field_accesses: { total: 1 }, - }); - - const boundaries = await client.execute( - "inspect_managed_native_boundaries", - {}, - ); - expect(boundaries.ok).toBe(true); - if (!boundaries.ok) return; - expect(asManagedNativeBoundaryResult(boundaries.value)).toMatchObject({ - artifact: { path, sha256: parsed.value.sha256, format: "pe" }, - module_refs: { total: 0 }, - pinvoke_imports: { total: 0 }, - native_implementations: { total: 0 }, - }); - - const cancelled = new AbortController(); - cancelled.abort(); - const cancelledResult = await client.execute( - "inspect_managed_artifact", - {}, - { signal: cancelled.signal }, - ); - expect(cancelledResult).toMatchObject({ - ok: false, - error: { _tag: "AnalysisCancelledError" }, - }); - - const changed = Buffer.from(bytes); - changed[0x300] = 0; - await writeFile(path, changed); - const staleDigest = await client.execute("inspect_managed_artifact", {}); - expect(staleDigest).toMatchObject({ - ok: false, - error: { _tag: "EvidenceIntegrityError" }, - }); - - await client.close(); - const nativeClient = new ManagedStaticProvider().createClient({ - ...parsed.value, - format: "elf", - }); - await expect( - nativeClient.execute("inspect_managed_artifact", {}), - ).resolves.toMatchObject({ - ok: false, - error: { _tag: "AnalysisCapabilityUnavailableError" }, - }); - }); - - it("has the same evidence shape through direct CLI analysis plumbing", async () => { - const directory = await createTestTempDirectory("rea-managed-cli-"); - const path = join(directory, "fixture.exe"); - await writeFile(path, buildManagedPeFixture()); - - const evidence = parseEvidence( - await runProviderAnalysis(path, "inspect_managed_artifact", { - reference_limit: 1, - }), - ); - expect(evidence).toMatchObject({ - operation: "inspect_managed_artifact", - provider: { id: "rea-dotnet-static" }, - subject: { local_path: path, format: "pe" }, - normalized_result: { - classification: { status: "managed", runtime_family: "modern-dotnet" }, - references: { limit: 1 }, - }, - }); - - const memberEvidence = parseEvidence( - await runProviderAnalysis(path, "inspect_managed_members", {}), - ); - expect(memberEvidence).toMatchObject({ - operation: "inspect_managed_members", - provider: { id: "rea-dotnet-static" }, - subject: { local_path: path, format: "pe" }, - normalized_result: { - identity_scope: { token_identity: "build-local" }, - methods: { total: 1 }, - }, - }); - - const boundaryEvidence = parseEvidence( - await runProviderAnalysis(path, "inspect_managed_native_boundaries", {}), - ); - expect(boundaryEvidence).toMatchObject({ - operation: "inspect_managed_native_boundaries", - provider: { id: "rea-dotnet-static" }, - subject: { local_path: path, format: "pe" }, - normalized_result: { - identity_scope: { token_identity: "build-local" }, - pinvoke_imports: { total: 0 }, - }, - }); - }); -}); - -const target = (bytes: Buffer): BinaryTarget => ({ - path: "/fixture.exe", - sha256: createHash("sha256").update(bytes).digest("hex"), - kind: "executable", - format: "pe", - architecture: "x86", - availableArchitectures: ["x86"], -}); - -const asManagedResult = (execution: AnalysisExecution) => - managedArtifactInspectionSchema.parse(execution.result); - -const asManagedMemberResult = (execution: AnalysisExecution) => - managedMemberInspectionSchema.parse(execution.result); - -const asManagedNativeBoundaryResult = (execution: AnalysisExecution) => - managedNativeBoundaryInspectionSchema.parse(execution.result); diff --git a/tests/managedMcp.test.ts b/tests/managedMcp.test.ts deleted file mode 100644 index a882bc321..000000000 --- a/tests/managedMcp.test.ts +++ /dev/null @@ -1,372 +0,0 @@ -import { writeFile } from "node:fs/promises"; -import { join } from "node:path"; - -import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; -import type { CallToolResult } from "@modelcontextprotocol/server"; -import { describe, expect, it } from "vitest"; -import { z } from "zod"; - -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; - -import { AnalysisProviderRegistry } from "../src/application/AnalysisProviderRegistry.js"; -import { BinarySession } from "../src/application/BinarySession.js"; -import { createPermissionAuthority } from "../src/application/PermissionAuthority.js"; -import { SessionProviderRouter } from "../src/application/SessionProviderRouter.js"; -import { MANAGED_NATIVE_VERIFICATION_EXAMPLE } from "../src/contracts/managedWorkflowExamples.js"; -import type { PermissionCeiling } from "../src/domain/permissionPolicy.js"; -import { ManagedStaticProvider } from "../src/dotnet/ManagedStaticProvider.js"; -import { createServer } from "../src/server/createServer.js"; -import { buildManagedPeFixture } from "./fixtures/managedPe.js"; - -describe("managed artifact MCP tools", () => { - it("opens a managed PE and executes the managed static provider through MCP", async () => { - const directory = await createTestTempDirectory("rea-managed-mcp-"); - const path = join(directory, "fixture.exe"); - const rightPath = join(directory, "fixture-renamed.exe"); - const runtimePath = join(directory, "dotnet"); - await writeFile(path, buildManagedPeFixture()); - await writeFile(runtimePath, "#!/bin/sh\n"); - await writeFile( - rightPath, - buildManagedPeFixture({ methodName: "Renamed" }), - ); - const session = new BinarySession( - SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), [ - new ManagedStaticProvider(), - ]), - ); - const runtimeCeiling: PermissionCeiling = { - capability: "managed_runtime", - roots: [directory], - executables: [runtimePath], - environment_names: [], - network: "none", - mount: false, - }; - const authority = await createPermissionAuthority( - [runtimeCeiling], - [ - { - ...runtimeCeiling, - grant_id: "administrator:managed_runtime", - lifetime: "administrator", - operation_identity: null, - expires_at: null, - }, - ], - ); - if (!authority.ok) throw authority.error; - const server = createServer(session, session, { - permissionAuthority: authority.value, - managedRuntimePolicy: { - enabled: true, - roots: [directory], - executablePath: runtimePath, - }, - availabilityPolicy: () => ({ - processCaptureEnabled: false, - evidenceFileRoots: 0, - investigationInputRoots: 0, - browserObservationEnabled: false, - electronObservationEnabled: false, - javascriptReplayEnabled: false, - managedRuntimeEnabled: true, - }), - }); - const client = new Client({ name: "managed-mcp-test", version: "1.0.0" }); - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - try { - await server.connect(serverTransport); - await client.connect(clientTransport); - const tools = await client.listTools(); - expect(tools.tools.map(({ name }) => name)).toContain( - "inspect_managed_artifact", - ); - expect(tools.tools.map(({ name }) => name)).toContain( - "inspect_managed_members", - ); - expect(tools.tools.map(({ name }) => name)).toContain( - "inspect_managed_native_boundaries", - ); - expect(tools.tools.map(({ name }) => name)).toContain( - "compare_managed_members", - ); - expect(tools.tools.map(({ name }) => name)).toContain( - "import_managed_reconstruction", - ); - expect(tools.tools.map(({ name }) => name)).toContain( - "verify_managed_native_boundaries", - ); - expect(tools.tools.map(({ name }) => name)).toContain( - "plan_managed_runtime_correlation", - ); - for (const evidence of [ - MANAGED_NATIVE_VERIFICATION_EXAMPLE.managed_boundaries, - ...MANAGED_NATIVE_VERIFICATION_EXAMPLE.native_observations, - ]) - expect(session.recordEvidence(evidence)).toMatchObject({ ok: true }); - const verifiedNative = structured( - await client.callTool({ - name: "verify_managed_native_boundaries", - arguments: { - managed_boundaries_evidence_id: - MANAGED_NATIVE_VERIFICATION_EXAMPLE.managed_boundaries - .evidence_id, - native_observation_evidence_ids: - MANAGED_NATIVE_VERIFICATION_EXAMPLE.native_observations.map( - ({ evidence_id: evidenceId }) => evidenceId, - ), - limits: MANAGED_NATIVE_VERIFICATION_EXAMPLE.limits, - }, - }), - ); - - expect(verifiedNative).toMatchObject({ - evidence_id: expect.stringMatching(/^ev_[a-f0-9]{64}$/u), - evidence_uri: expect.stringMatching(/^rea:\/\/evidence\/ev_/u), - result: { - summary: { verified: 1 }, - algorithm: { token_to_address_mapping: "not-inferred" }, - }, - }); - const nativeEvidence = - MANAGED_NATIVE_VERIFICATION_EXAMPLE.native_observations[0]; - if (nativeEvidence === undefined) - throw new Error("missing native Evidence"); - const wrongManagedReference = await client.callTool({ - name: "verify_managed_native_boundaries", - arguments: { - managed_boundaries_evidence_id: nativeEvidence.evidence_id, - native_observation_evidence_ids: [ - MANAGED_NATIVE_VERIFICATION_EXAMPLE.managed_boundaries.evidence_id, - ], - }, - }); - expect(wrongManagedReference).toMatchObject({ - isError: true, - structuredContent: { - error: { - code: "evidence_integrity_mismatch", - details: { - reason: "wrong_operation", - actual: "inspect_macho", - }, - }, - }, - }); - - const inspected = structured( - await client.callTool({ - name: "inspect_managed_artifact", - arguments: { path, reference_limit: 1 }, - }), - ); - - expect(inspected).toMatchObject({ - result: { - classification: { - status: "managed", - runtime_family: "modern-dotnet", - }, - references: { limit: 1 }, - }, - }); - - const membersResult = structured( - await client.callTool({ - name: "inspect_managed_members", - arguments: { method_limit: 1 }, - }), - ); - const members = sessionEvidence(session, membersResult); - - expect(members).toMatchObject({ - operation: "inspect_managed_members", - provider: { id: "rea-dotnet-static" }, - subject: { local_path: path, format: "pe" }, - normalized_result: { - identity_scope: { token_identity: "build-local" }, - methods: { total: 1, returned: 1 }, - call_edges: { total: 1 }, - field_accesses: { total: 1 }, - }, - }); - - const boundaries = structured( - await client.callTool({ - name: "inspect_managed_native_boundaries", - arguments: { import_limit: 1 }, - }), - ); - - expect(boundaries).toMatchObject({ - result: { - identity_scope: { token_identity: "build-local" }, - pinvoke_imports: { total: 0, limit: 1 }, - native_implementations: { total: 0 }, - }, - }); - - await client.callTool({ - name: "open_binary", - arguments: { path: rightPath }, - }); - const rightMembersResult = structured( - await client.callTool({ - name: "inspect_managed_members", - arguments: { method_limit: 1 }, - }), - ); - const rightMembers = sessionEvidence(session, rightMembersResult); - const comparedResult = structured( - await client.callTool({ - name: "compare_managed_members", - arguments: { - left_evidence_id: members.evidence_id, - right_evidence_id: rightMembers.evidence_id, - limits: { - max_method_matches: 100, - max_field_matches: 100, - max_candidates: 10, - }, - }, - }), - ); - const compared = sessionEvidence(session, comparedResult); - - expect(compared).toMatchObject({ - operation: "compare_managed_members", - provider: { id: "rea-dotnet-workflows" }, - confidence: "inferred", - normalized_result: { - algorithm: { name_matching: "not-used" }, - matching: { exact_il_signature: 1 }, - }, - }); - - const method = z - .record(z.string(), z.unknown()) - .parse(members.normalized_result).methods; - const methodItem = z - .object({ - items: z.array( - z.object({ - token: z.string(), - signature: z.object({ raw_sha256: z.string() }), - body: z.object({ normalized_il_sha256: z.string().nullable() }), - }), - ), - }) - .parse(method).items[0]; - expect(methodItem).toBeDefined(); - if (methodItem === undefined) return; - const importedResult = structured( - await client.callTool({ - name: "import_managed_reconstruction", - arguments: { - static_members_evidence_id: members.evidence_id, - decompiler: { - name: "ilspycmd", - version: "9.1.0.7988", - family: "ilspy", - executable_sha256: null, - options: ["--type", "Example.Program"], - }, - methods: [ - { - token: methodItem.token, - signature_sha256: methodItem.signature.raw_sha256, - normalized_il_sha256: methodItem.body.normalized_il_sha256, - reconstruction: { - kind: "decompiled-csharp", - language: "csharp", - text: "internal static void Main() { }", - }, - }, - ], - notes: ["synthetic MCP import"], - }, - }), - ); - const imported = sessionEvidence(session, importedResult); - - expect(imported).toMatchObject({ - operation: "import_managed_reconstruction", - provider: { id: "rea-dotnet-workflows" }, - confidence: "inferred", - normalized_result: { - executed: false, - summary: { imported_methods: 1, decompiled_csharp_methods: 1 }, - methods: [ - { - token: methodItem.token, - validation: { canonical_observation: false }, - }, - ], - }, - }); - const plannedResult = structured( - await client.callTool({ - name: "plan_managed_runtime_correlation", - arguments: { - static_members_evidence_id: members.evidence_id, - method: { - token: methodItem.token, - signature_sha256: methodItem.signature.raw_sha256, - normalized_il_sha256: methodItem.body.normalized_il_sha256, - }, - requested_effect: "attach", - host: { - os: "linux", - clr_family: "dotnet", - architecture: "x86_64", - }, - bounds: { - timeout_ms: 5000, - max_threads: 32, - max_output_bytes: 65536, - allow_network: false, - allow_ui: false, - }, - }, - }), - ); - const planned = sessionEvidence(session, plannedResult); - - expect(planned).toMatchObject({ - operation: "plan_managed_runtime_correlation", - provider: { id: "rea-dotnet-workflows" }, - confidence: "derived", - normalized_result: { - executed: false, - authority_model: { capability: "managed_runtime" }, - requested_runtime: { effect: "attach", network: "none" }, - effect_taxonomy: { attaches_process: true }, - }, - }); - } finally { - await Promise.all([client.close(), server.close()]); - await session.close(); - } - }, 30_000); -}); - -const structured = (result: CallToolResult): Record => { - if ( - typeof result.structuredContent !== "object" || - result.structuredContent === null - ) - throw new Error("missing structured result"); - return z.record(z.string(), z.unknown()).parse(result.structuredContent); -}; - -const sessionEvidence = ( - session: BinarySession, - result: Readonly>, -): Record => { - const evidenceId = z.string().parse(result.evidence_id); - const evidence = session.evidenceById(evidenceId); - if (evidence === undefined) throw new Error("missing session Evidence"); - return z.record(z.string(), z.unknown()).parse(evidence); -}; diff --git a/tests/verifierRun.test.ts b/tests/process-global/verifierRun.test.ts similarity index 99% rename from tests/verifierRun.test.ts rename to tests/process-global/verifierRun.test.ts index 4bb8cd736..ecd108e57 100644 --- a/tests/verifierRun.test.ts +++ b/tests/process-global/verifierRun.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from "vitest"; import { completeVerifierRun, createVerifierRun, -} from "../scripts/lib/verifier-run.mjs"; +} from "../../scripts/lib/verifier-run.mjs"; const execFileAsync = promisify(execFile); diff --git a/tests/process-global/vitestConfiguration.test.ts b/tests/process-global/vitestConfiguration.test.ts new file mode 100644 index 000000000..179a84636 --- /dev/null +++ b/tests/process-global/vitestConfiguration.test.ts @@ -0,0 +1,123 @@ +import { execFile } from "node:child_process"; +import { readdir, readFile } from "node:fs/promises"; +import { availableParallelism } from "node:os"; +import { join, relative, resolve } from "node:path"; +import { promisify } from "node:util"; + +import { describe, expect, it } from "vitest"; + +import vitestConfiguration, { + MAX_TEST_WORKERS, + TEST_PROJECTS, +} from "../../vitest.config.js"; + +const execute = promisify(execFile); +const EXPECTED_PROJECTS = [ + "acceptance", + "adapters", + "boundary", + "composition", + "conformance", + "domain", + "evaluation", + "process-global", + "services", +]; +const ALLOWED_DIRECT_TEMPORARY_ROOTS = new Set([ + "tests/support/workspace/workspaceFixture.ts", + "tests/acceptance/setup/packageInstallWorkflow.test.ts", + "tests/boundary/filesystem/temporaryDirectory.test.ts", + "tests/boundary/filesystem/referenceSourceReader.test.ts", + "tests/boundary/process/providerProcess.test.ts", +]); + +describe("Vitest project configuration", () => { + it("keeps deterministic execution retry-free and locally quiet", () => { + expect(vitestConfiguration.test?.coverage?.enabled).toBe(false); + expect(vitestConfiguration.test?.reporters).toEqual(["default"]); + expect(vitestConfiguration.test?.retry).toBe(0); + expect(MAX_TEST_WORKERS).toBe(Math.min(4, availableParallelism())); + expect(TEST_PROJECTS.map(({ name }) => name).sort()).toEqual( + EXPECTED_PROJECTS, + ); + }); + + it("classifies every deterministic test in exactly one project", async () => { + const { stdout } = await execute( + process.execPath, + [ + resolve("node_modules/vitest/vitest.mjs"), + "list", + "--filesOnly", + "--staticParse", + ], + { cwd: process.cwd(), maxBuffer: 4 * 1_024 * 1_024 }, + ); + const classified = parseProjects(stdout); + const repositoryTests = [ + ...(await testFiles("src")), + ...(await testFiles("tests")), + ].sort(); + + expect( + [...classified.values()] + .filter((owners) => owners.length > 1) + .map((owners) => owners.join(", ")), + ).toEqual([]); + expect([...classified.keys()].sort()).toEqual(repositoryTests); + }, 20_000); + + it("keeps direct temporary-root creation behind the workspace seam", async () => { + const violations: string[] = []; + for (const path of await repositoryTypeScriptFiles("tests")) { + const source = await readFile(path, "utf8"); + if ( + /\b(?:mkdtemp|tmpdir)\s*\(/u.test(source) && + !ALLOWED_DIRECT_TEMPORARY_ROOTS.has(path) + ) { + violations.push(path); + } + } + expect(violations).toEqual([]); + }); +}); + +const parseProjects = (output: string): Map => { + const classified = new Map(); + for (const line of output.split("\n")) { + const trimmed = line.trim(); + const separator = trimmed.indexOf("] "); + if (!trimmed.startsWith("[") || separator < 2) continue; + const project = trimmed.slice(1, separator); + const path = trimmed.slice(separator + 2); + if (path.length === 0) continue; + const owners = classified.get(path) ?? []; + owners.push(project); + classified.set(path, owners); + } + return classified; +}; + +const testFiles = async (root: string): Promise => { + const files: string[] = []; + for (const entry of await readdir(root, { withFileTypes: true })) { + const path = join(root, entry.name); + if (entry.isDirectory()) files.push(...(await testFiles(path))); + else if (entry.isFile() && entry.name.endsWith(".test.ts")) + files.push(relative(process.cwd(), path)); + } + return files; +}; + +const repositoryTypeScriptFiles = async (root: string): Promise => { + const files: string[] = []; + for (const entry of await readdir(root, { withFileTypes: true })) { + const path = join(root, entry.name); + if (entry.isDirectory()) { + files.push(...(await repositoryTypeScriptFiles(path))); + } else if (entry.isFile() && entry.name.endsWith(".ts")) { + files.push(relative(process.cwd(), path)); + } + } + return files; +}; diff --git a/tests/processCapture.test.ts b/tests/processCapture.test.ts deleted file mode 100644 index 7ec32646d..000000000 --- a/tests/processCapture.test.ts +++ /dev/null @@ -1,2593 +0,0 @@ -import { readFile, rm, symlink, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { describe, expect, it } from "vitest"; -import WebSocket from "ws"; -import { fileURLToPath } from "node:url"; -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; - -import { startLoopbackReplay } from "../src/application/LoopbackReplay.js"; -import { - captureProcessScenario, - probeProcessCaptureCapability, - ProcessCaptureError, -} from "../src/application/ProcessHarness.js"; -import { snapshotRoots } from "../src/application/FilesystemSnapshot.js"; -import { - buildCaptureResult, - prepareProcessCapture, - type ProcessPreparationHost, -} from "../src/application/ProcessCaptureLifecycle.js"; -import { ProcessCheckpoints } from "../src/application/ProcessCheckpoints.js"; -import { createProcessCaptureJournal } from "../src/application/ProcessCaptureJournal.js"; -import { normalizeProcessSamples } from "../src/application/ProcessNormalization.js"; -import { - isInitializedPtyRoot, - readLinuxChildren, -} from "../src/application/ProcessSampling.js"; -import { TerminalRenderer } from "../src/application/TerminalRenderer.js"; -import { - authorizeProcessScenario, - compareProcessCaptures, - digestProcessCommitment, - LEGACY_PROCESS_CAPTURE_MESSAGE, - parseProcessCapture, - parseProcessScenario, - processCaptureSchema, - validateProcessCapture, - type ProcessCapture, - type ProcessCaptureEventJournalEntry, - type ProcessExecutionPolicy, -} from "../src/domain/processCapture.js"; - -const processFixture = fileURLToPath( - new URL("./fixtures/processFidelity.mjs", import.meta.url), -); -const execFileAsync = promisify(execFile); - -const emptyCapture = (): ProcessCapture => { - const normalization = { - paths: true, - pids: true, - ports: true, - time_bucket_ms: 10, - patterns: [], - }; - const scenario = { executable_sha256: "0".repeat(64) }; - const comparisonContract = {}; - const shimPlan: readonly unknown[] = []; - const replayPlan = {}; - return { - schema_version: 4, - manifest: { - rea_version: "test", - provider_version: "3", - platform: process.platform, - architecture: process.arch, - pty_backend: "node-pty", - started_at: "2026-01-01T00:00:00.000Z", - completed_at: "2026-01-01T00:00:00.001Z", - scenario, - comparison_contract: comparisonContract, - shim_plan: shimPlan, - replay_plan: replayPlan, - full_scenario_sha256: digestProcessCommitment(scenario), - comparison_contract_sha256: digestProcessCommitment(comparisonContract), - executable_sha256: "0".repeat(64), - normalization_sha256: digestProcessCommitment(normalization), - shim_plan_sha256: digestProcessCommitment(shimPlan), - replay_plan_sha256: digestProcessCommitment(replayPlan), - }, - normalization, - frames: [], - rendered_frames: [], - interaction_events: [], - exit: { code: 0, signal: null, reason: "exited" }, - settlement: { - state: "quiesced", - elapsed_ms: 50, - cleanup_outcome: "not_required", - }, - process_samples: [], - filesystem_checkpoints: [ - { name: "before", at_ms: 0, files: [], effects: [], truncated: false }, - { - name: "after_settlement", - at_ms: 50, - files: [], - effects: [], - truncated: false, - }, - ], - shim_events: [], - protocol_events: [], - replay_transitions: [], - reactive_run: null, - event_journal: [], - files_before: [], - files_after: [], - filesystem_effects: [], - truncated: false, - limitations: [], - residual_unknowns: [], - cleanup: { - owned_process_group: "verified", - temporary_root: "removed", - }, - }; -}; - -describe("process capture domain", () => { - const base = { - approved: true as const, - executable: "/bin/sh", - working_directory: "/tmp", - }; - - it("returns detached terminal and filesystem checkpoint observations", async () => { - const observed: string[] = []; - const renderer = new TerminalRenderer({ - columns: 40, - rows: 12, - scrollback: 100, - maxFrames: 10, - maxBytes: 10_000, - normalize: (value) => value, - recordEvent: (collection, index) => - observed.push(`${collection}:${String(index)}`), - }); - renderer.write("A", 20); - renderer.resize(40, 12, 10); - const frames = await renderer.frames(); - expect(frames.map(({ at_ms }) => at_ms)).toEqual([20, 10]); - expect(observed).toEqual(["rendered_frames:0", "rendered_frames:1"]); - if (frames[0] !== undefined) Reflect.set(frames[0], "cursor_x", 999); - expect((await renderer.frames())[0]?.cursor_x).not.toBe(999); - await renderer.dispose(); - - const scenario = parseProcessScenario(base); - const checkpoints = new ProcessCheckpoints( - scenario, - Date.now(), - { files: [], truncated: false }, - { signal: undefined }, - ); - const first = await checkpoints.finish({ files: [], truncated: false }); - if (first[0] !== undefined) Reflect.set(first[0], "name", "tampered"); - const second = await checkpoints.finish({ files: [], truncated: false }); - expect(second[0]?.name).toBe("before"); - await checkpoints.dispose(); - }); - - it("preserves rendered observation order instead of timestamp sorting", () => { - const capture = emptyCapture(); - const renderedFrames: ProcessCapture["rendered_frames"] = [ - { - sequence: 0, - at_ms: 20, - columns: 1, - rows: 1, - cursor_x: 0, - cursor_y: 0, - active_buffer: "normal", - lines: ["first"], - serialized_state: "first", - }, - { - sequence: 1, - at_ms: 10, - columns: 1, - rows: 1, - cursor_x: 0, - cursor_y: 0, - active_buffer: "normal", - lines: ["second"], - serialized_state: "second", - }, - ]; - const result = buildCaptureResult({ - frames: [], - reactiveRun: null, - exit: { exitCode: 0, reason: "exited" }, - samples: [], - replay: { - httpUrl: "http://127.0.0.1", - websocketUrl: "ws://127.0.0.1/ws", - events: [], - transitions: [], - truncated: false, - close: () => Promise.resolve(), - }, - before: { files: [], truncated: false }, - after: { files: [], truncated: false }, - truncated: false, - scenario: parseProcessScenario(base), - rootPid: 1, - samplingPartial: false, - renderedFrames, - interactions: [], - checkpoints: capture.filesystem_checkpoints, - shimEvents: [], - settlement: capture.settlement, - manifest: capture.manifest, - eventJournal: [], - }); - - expect(result.rendered_frames).toEqual(renderedFrames); - }); - - it("cleans the temporary root when capture home creation fails", async () => { - const cleaned: string[] = []; - const host: ProcessPreparationHost = { - createTemporaryRoot: () => Promise.resolve("/tmp/rea-process-fixture"), - createHome: () => Promise.reject(new Error("mkdir failed")), - cleanup: (path) => { - cleaned.push(path); - return Promise.resolve(); - }, - }; - - await expect( - prepareProcessCapture( - parseProcessScenario(base), - { - enabled: true, - executableRoots: ["/bin"], - workingRoots: ["/tmp"], - allowedEnvironment: [], - allowExternalNetwork: true, - }, - undefined, - host, - ), - ).rejects.toThrow("mkdir failed"); - expect(cleaned).toEqual(["/tmp/rea-process-fixture"]); - }); - - it("normalizes every sampled process identifier in command text", () => { - const samples = normalizeProcessSamples( - [ - { - at_ms: 0, - pid: 101, - parent_pid: 0, - process_group_id: 101, - session_id: 101, - command: "root 101", - }, - { - at_ms: 10, - pid: 202, - parent_pid: 101, - process_group_id: 101, - session_id: 101, - command: "child 202 peer=101 unrelated 1202", - }, - ], - parseProcessScenario(base), - 101, - ); - - expect(samples[1]?.command).toBe("child peer= unrelated 1202"); - }); - - it("collects and deduplicates children from every Linux thread", async () => { - const signal = new AbortController().signal; - expect( - await readLinuxChildren(100, signal, { - taskIds: () => Promise.resolve([100, 101, 102]), - children: (_pid, taskId) => - Promise.resolve( - taskId === 100 ? "201 202" : taskId === 101 ? "202 203" : "", - ), - }), - ).toEqual([201, 202, 203]); - }); - - it("admits PTY samples only after stable session and token setup", () => { - const initialized = { - pid: 100, - parent_pid: 10, - process_group_id: 100, - session_id: 100, - startTime: "200", - }; - expect( - isInitializedPtyRoot({ - rootPid: 100, - expectedRunId: "run-token", - before: { ...initialized, process_group_id: 10, session_id: 10 }, - observedRunId: undefined, - after: initialized, - }), - ).toBe(false); - expect( - isInitializedPtyRoot({ - rootPid: 100, - expectedRunId: "run-token", - before: initialized, - observedRunId: "run-token", - after: { ...initialized, startTime: "201" }, - }), - ).toBe(false); - expect( - isInitializedPtyRoot({ - rootPid: 100, - expectedRunId: "run-token", - before: initialized, - observedRunId: "run-token", - after: initialized, - }), - ).toBe(true); - expect( - isInitializedPtyRoot({ - rootPid: 100, - expectedRunId: "run-token", - before: { ...initialized, session_id: null }, - observedRunId: "run-token", - after: { ...initialized, session_id: null }, - }), - ).toBe(true); - }); - - it("keeps interaction and shim residual uncertainty in separate scopes", () => { - const baseCapture = emptyCapture(); - const interaction = parseProcessCapture({ - ...baseCapture, - residual_unknowns: [ - { scope: "interaction", reason: "Interaction capture was partial." }, - ], - }); - const shim = parseProcessCapture({ - ...baseCapture, - residual_unknowns: [ - { scope: "shim", reason: "Shim capture was partial." }, - ], - }); - - expect(compareProcessCaptures(interaction, baseCapture)).toMatchObject({ - status: "unknown", - terminal: "unchanged", - interaction: "unknown", - shim: "unchanged", - }); - expect(compareProcessCaptures(shim, baseCapture)).toMatchObject({ - status: "unknown", - terminal: "unchanged", - interaction: "unchanged", - shim: "unknown", - }); - }); - - it("cancels filesystem snapshots before traversing declared roots", async () => { - const controller = new AbortController(); - controller.abort(); - await expect( - snapshotRoots( - parseProcessScenario({ - ...base, - filesystem_roots: ["/tmp"], - }), - controller.signal, - ), - ).rejects.toMatchObject({ name: "AbortError" }); - }); - - it("parses bounded scenarios and rejects unordered events", () => { - expect(parseProcessScenario(base).timeout_ms).toBe(30_000); - expect(() => - parseProcessScenario({ - ...base, - events: [ - { type: "input", at_ms: 2, data: "a" }, - { type: "input", at_ms: 1, data: "b" }, - ], - }), - ).toThrow(/ordered/); - expect(() => - parseProcessScenario({ - ...base, - environment: { HOME: "/unsafe" }, - }), - ).toThrow(/reserved/); - expect(() => - parseProcessScenario({ - ...base, - events: [{ type: "input", at_ms: 31_000, data: "late" }], - }), - ).toThrow(/after the scenario timeout/); - }); - - it("requires explicit operator approval for host network access", () => { - expect( - authorizeProcessScenario(parseProcessScenario(base), { - enabled: true, - executableRoots: ["/bin"], - workingRoots: ["/tmp"], - allowedEnvironment: [], - allowExternalNetwork: false, - }), - ).toEqual({ - allowed: false, - reason: "host network access is not approved by operator policy", - }); - }); - - it("refuses paths and environment outside operator policy", () => { - const scenario = parseProcessScenario({ - ...base, - environment: { TOKEN: "secret" }, - }); - expect( - authorizeProcessScenario(scenario, { - enabled: true, - executableRoots: ["/bin"], - workingRoots: ["/tmp"], - allowedEnvironment: [], - allowExternalNetwork: true, - }), - ).toEqual({ - allowed: false, - reason: "scenario requests an environment variable not allowed by policy", - }); - }); - - it("never considers truncated captures equivalent", () => { - const capture = { - schema_version: 4 as const, - manifest: emptyCapture().manifest, - settlement: emptyCapture().settlement, - normalization: { - paths: true, - pids: true, - ports: true, - time_bucket_ms: 10, - patterns: [], - }, - frames: [], - rendered_frames: [], - interaction_events: [], - exit: { code: 0, signal: null, reason: "exited" as const }, - process_samples: [], - filesystem_checkpoints: emptyCapture().filesystem_checkpoints, - shim_events: [], - protocol_events: [], - replay_transitions: [], - reactive_run: null, - files_before: [], - files_after: [], - filesystem_effects: [], - truncated: true, - limitations: [], - residual_unknowns: [], - cleanup: { - owned_process_group: "verified" as const, - temporary_root: "removed" as const, - }, - }; - expect(compareProcessCaptures(capture, capture).status).toBe("truncated"); - }); - - it("rejects altered v4 commitments and accepts canonical key reordering", () => { - const capture = emptyCapture(); - expect(parseProcessCapture(capture)).toEqual(capture); - expect(digestProcessCommitment({ second: 2, first: 1 })).toBe( - digestProcessCommitment({ first: 1, second: 2 }), - ); - expect(() => - parseProcessCapture({ - ...capture, - manifest: { - ...capture.manifest, - normalization_sha256: "f".repeat(64), - }, - }), - ).toThrow("normalization_sha256"); - expect(() => - parseProcessCapture({ - ...capture, - manifest: { - ...capture.manifest, - executable_sha256: "f".repeat(64), - }, - }), - ).toThrow("executable_sha256"); - }); - - it("accepts old captures without a journal and validates complete journals", () => { - const journal = createProcessCaptureJournal(); - const observed: ProcessCaptureEventJournalEntry[] = []; - const unsubscribe = journal.subscribe((entry) => observed.push(entry)); - journal.recordEvent("lifecycle", 0); - const settlementEntry = journal.record("lifecycle", 1); - unsubscribe(); - journal.recordEvent("frames", 0); - expect(journal.entries).toEqual([ - { capture_order: 0, collection: "lifecycle", index: 0 }, - { capture_order: 1, collection: "lifecycle", index: 1 }, - { capture_order: 2, collection: "frames", index: 0 }, - ]); - expect(observed).toEqual(journal.entries.slice(0, 2)); - expect(settlementEntry).toEqual(journal.entries[1]); - - const nestedJournal = createProcessCaptureJournal(); - const notifications: string[] = []; - nestedJournal.subscribe((entry) => { - notifications.push(`a:${String(entry.capture_order)}`); - if (entry.capture_order === 0) nestedJournal.record("frames", 1); - }); - nestedJournal.subscribe((entry) => - notifications.push(`b:${String(entry.capture_order)}`), - ); - nestedJournal.record("frames", 0); - expect(notifications).toEqual(["a:0", "b:0", "a:1", "b:1"]); - - const capture = emptyCapture(); - const { event_journal: _eventJournal, ...oldCapture } = capture; - expect(parseProcessCapture(oldCapture).event_journal).toEqual([]); - - const eventJournal = [ - { capture_order: 0, collection: "filesystem_checkpoints", index: 0 }, - { capture_order: 1, collection: "lifecycle", index: 0 }, - { capture_order: 2, collection: "lifecycle", index: 1 }, - { capture_order: 3, collection: "filesystem_checkpoints", index: 1 }, - ] as const; - expect( - validateProcessCapture({ ...capture, event_journal: eventJournal }), - ).toEqual([]); - - for (const [candidate, message] of [ - [ - eventJournal.map((entry, index) => - index === 1 ? { ...entry, capture_order: 2 } : entry, - ), - "contiguous", - ], - [ - eventJournal.map((entry, index) => - index === 3 - ? { ...entry, collection: "lifecycle" as const, index: 1 } - : entry, - ), - "unique", - ], - [eventJournal.slice(0, 3), "every captured observation"], - [ - eventJournal.map((entry, index) => - index === 3 ? { ...entry, index: 2 } : entry, - ), - "outside", - ], - ] as const) { - expect( - validateProcessCapture({ ...capture, event_journal: candidate }), - ).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - message: expect.stringContaining(message), - }), - ]), - ); - } - }); - - it.each([ - ["machine limit", 1, 5, 10], - ["transition use limit", 5, 1, 10], - ["state visit limit", 5, 5, 1], - ])( - "rejects replay journals that exceed the declared %s", - (_name, maxTransitions, maxUses, maxVisits) => { - const replayPlan = { - machine: { - initial_state: "active", - states: [ - { name: "active", max_visits: maxVisits }, - { name: "complete", terminal: true }, - ], - transitions: [ - { - id: "again", - from: "active", - to: "active", - trigger: { - protocol: "websocket_message", - path: "/ws", - body: "again", - }, - actions: [{ type: "websocket_send", data: "again" }], - max_uses: maxUses, - }, - { - id: "finish", - from: "active", - to: "complete", - trigger: { - protocol: "websocket_message", - path: "/ws", - body: "finish", - }, - actions: [{ type: "websocket_send", data: "done" }], - max_uses: 1, - }, - ], - max_transitions: maxTransitions, - }, - }; - const capture = emptyCapture(); - const candidate: ProcessCapture = { - ...capture, - manifest: { - ...capture.manifest, - replay_plan: replayPlan, - replay_plan_sha256: digestProcessCommitment(replayPlan), - }, - replay_transitions: [0, 1].map((sequence) => ({ - sequence, - at_ms: sequence, - transition_id: "again", - state_before: "active", - state_after: "active", - sensitive_aliases: [], - })), - }; - - expect(validateProcessCapture(candidate)).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - message: expect.stringContaining("limit"), - }), - ]), - ); - }, - ); - - it("tells agents and users to recapture unsupported v3 evidence", () => { - const legacy = { ...emptyCapture(), schema_version: 3 }; - expect(() => parseProcessCapture(legacy)).toThrow( - LEGACY_PROCESS_CAPTURE_MESSAGE, - ); - const parsed = processCaptureSchema.safeParse(legacy); - expect(parsed.success).toBe(false); - if (parsed.success) throw new Error("expected legacy capture rejection"); - expect(parsed.error.issues[0]?.message).toBe( - LEGACY_PROCESS_CAPTURE_MESSAGE, - ); - }); - - it("requires compatible contracts and enforces capture age through a clock seam", () => { - const capture = emptyCapture(); - expect(() => - compareProcessCaptures(capture, { - ...capture, - manifest: { - ...capture.manifest, - comparison_contract: { changed: true }, - comparison_contract_sha256: digestProcessCommitment({ - changed: true, - }), - }, - }), - ).toThrow("incompatible comparison contracts"); - expect(() => - compareProcessCaptures(capture, capture, { - maxCaptureAgeMs: 1, - now: () => Date.parse("2026-01-01T00:00:01.000Z"), - }), - ).toThrow("max_capture_age_ms"); - }); - - it("classifies missing observations as unknown and one-sided evidence as added", () => { - const base = { - schema_version: 4 as const, - manifest: emptyCapture().manifest, - settlement: emptyCapture().settlement, - normalization: { - paths: true, - pids: true, - ports: true, - time_bucket_ms: 10, - patterns: [], - }, - frames: [], - rendered_frames: [], - interaction_events: [], - exit: { code: 0, signal: null, reason: "exited" as const }, - process_samples: [], - filesystem_checkpoints: emptyCapture().filesystem_checkpoints, - shim_events: [], - protocol_events: [], - replay_transitions: [], - reactive_run: null, - files_before: [], - files_after: [], - filesystem_effects: [], - truncated: false, - limitations: [], - residual_unknowns: [], - cleanup: { - owned_process_group: "verified" as const, - temporary_root: "removed" as const, - }, - }; - const added = compareProcessCaptures(base, { - ...base, - frames: [{ sequence: 0, at_ms: 0, data: "new" }], - rendered_frames: [ - { - sequence: 0, - at_ms: 0, - columns: 3, - rows: 1, - cursor_x: 3, - cursor_y: 0, - active_buffer: "normal", - lines: ["new"], - serialized_state: "new", - }, - ], - }); - expect(added.terminal).toBe("added"); - expect(added.status).toBe("changed"); - const unknown = compareProcessCaptures( - { ...base, residual_unknowns: [{ scope: "process", reason: "sampled" }] }, - base, - ); - expect(unknown.process).toBe("unknown"); - expect(unknown.shim).toBe("unchanged"); - expect(unknown.status).toBe("unknown"); - }); - - it("compares raw terminal chunks even when rendered states agree", () => { - const capture = { - schema_version: 4 as const, - manifest: emptyCapture().manifest, - settlement: emptyCapture().settlement, - normalization: { - paths: true, - pids: true, - ports: true, - time_bucket_ms: 10, - patterns: [], - }, - frames: [{ sequence: 0, at_ms: 0, data: "bar" }], - rendered_frames: [], - interaction_events: [], - exit: { code: 0, signal: null, reason: "exited" as const }, - process_samples: [], - filesystem_checkpoints: emptyCapture().filesystem_checkpoints, - shim_events: [], - protocol_events: [], - replay_transitions: [], - reactive_run: null, - files_before: [], - files_after: [], - filesystem_effects: [], - truncated: false, - limitations: [], - residual_unknowns: [], - cleanup: { - owned_process_group: "verified" as const, - temporary_root: "removed" as const, - }, - }; - - const comparison = compareProcessCaptures(capture, { - ...capture, - frames: [{ sequence: 0, at_ms: 0, data: "foo\rbar" }], - }); - expect(comparison.terminal).toBe("changed"); - expect(comparison.first_divergence).toMatchObject({ - status: "found", - dimension: "terminal", - }); - }); - - it("keeps filesystem evidence unknown when stable snapshots match", () => { - const capture = { - schema_version: 4 as const, - manifest: emptyCapture().manifest, - settlement: emptyCapture().settlement, - normalization: { - paths: true, - pids: true, - ports: true, - time_bucket_ms: 10, - patterns: [], - }, - frames: [], - rendered_frames: [], - interaction_events: [], - exit: { code: 0, signal: null, reason: "exited" as const }, - process_samples: [], - filesystem_checkpoints: emptyCapture().filesystem_checkpoints, - shim_events: [], - protocol_events: [], - replay_transitions: [], - reactive_run: null, - files_before: [], - files_after: [], - filesystem_effects: [], - truncated: false, - limitations: [], - residual_unknowns: [ - { scope: "filesystem" as const, reason: "watcher unavailable" }, - ], - cleanup: { - owned_process_group: "verified" as const, - temporary_root: "removed" as const, - }, - }; - - const comparison = compareProcessCaptures(capture, capture); - expect(comparison.filesystem).toBe("unknown"); - expect(comparison.status).toBe("unknown"); - - const complete = { ...capture, residual_unknowns: [] }; - const transient = compareProcessCaptures(complete, { - ...complete, - filesystem_checkpoints: [ - { name: "before", at_ms: 0, files: [], effects: [], truncated: false }, - { - name: "during_run", - at_ms: 10, - files: [], - effects: [], - truncated: false, - }, - { - name: "after_settlement", - at_ms: 50, - files: [], - effects: [], - truncated: false, - }, - ], - }); - expect(transient.filesystem).toBe("changed"); - expect(transient.status).toBe("changed"); - }); - - it("detects changes in normalized process sample metadata", () => { - const capture = { - schema_version: 4 as const, - manifest: emptyCapture().manifest, - settlement: emptyCapture().settlement, - normalization: { - paths: true, - pids: true, - ports: true, - time_bucket_ms: 10, - patterns: [], - }, - frames: [], - rendered_frames: [], - interaction_events: [], - exit: { code: 0, signal: null, reason: "exited" as const }, - process_samples: [ - { - at_ms: 10, - pid: 1, - parent_pid: 0, - process_group_id: 1, - session_id: 1, - command: "worker", - }, - ], - filesystem_checkpoints: emptyCapture().filesystem_checkpoints, - shim_events: [], - protocol_events: [], - replay_transitions: [], - reactive_run: null, - files_before: [], - files_after: [], - filesystem_effects: [], - truncated: false, - limitations: [], - residual_unknowns: [], - cleanup: { - owned_process_group: "verified" as const, - temporary_root: "removed" as const, - }, - }; - const changed = { - ...capture, - process_samples: [ - { - at_ms: 20, - pid: 1, - parent_pid: 2, - process_group_id: 1, - session_id: 1, - command: "worker", - }, - ], - }; - - const comparison = compareProcessCaptures(capture, changed); - expect(comparison.process).toBe("changed"); - expect(comparison.status).toBe("changed"); - }); - - it("compares replay state transitions as protocol evidence", () => { - const replayPlan = { - machine: { - initial_state: "anonymous", - states: [ - { name: "anonymous" }, - { name: "authenticated", terminal: true }, - ], - transitions: [ - { - id: "login", - from: "anonymous", - to: "authenticated", - trigger: { protocol: "http", method: "POST", path: "/login" }, - captures: [ - { - variable: "token", - value: { source: "request_json", path: ["token"] }, - sensitive: true, - }, - ], - actions: [{ type: "http_response", status: 204, body: "" }], - max_uses: 1, - }, - ], - max_transitions: 1, - }, - }; - const empty = emptyCapture(); - const baseline = { - ...empty, - manifest: { - ...empty.manifest, - replay_plan: replayPlan, - replay_plan_sha256: digestProcessCommitment(replayPlan), - }, - }; - const transitioned = { - ...baseline, - replay_transitions: [ - { - sequence: 0, - at_ms: 0, - transition_id: "login", - state_before: "anonymous", - state_after: "authenticated", - sensitive_aliases: ["token"], - }, - ], - }; - - expect(compareProcessCaptures(baseline, transitioned)).toMatchObject({ - status: "changed", - protocol: "added", - }); - }); -}); - -describe("process capture adapter", () => { - it("serves bounded HTTP and WebSocket replay on loopback", async () => { - const scenario = parseProcessScenario({ - approved: true, - executable: "/bin/sh", - working_directory: "/tmp", - replay: { - http: [{ method: "GET", path: "/ready", status: 201, body: "ready" }], - websocket_messages: ["welcome"], - }, - }); - const replay = await startLoopbackReplay(scenario); - try { - const response = await fetch(`${replay.httpUrl}/ready`); - expect(response.status).toBe(201); - expect(await response.text()).toBe("ready"); - const websocketMessage = await new Promise( - (resolveMessage, rejectMessage) => { - const socket = new WebSocket(replay.websocketUrl); - socket.once("message", (value) => { - resolveMessage(value.toString()); - socket.close(); - }); - socket.once("error", rejectMessage); - }, - ); - expect(websocketMessage).toBe("welcome"); - expect(replay.events.map((event) => event.protocol)).toContain( - "websocket", - ); - } finally { - await replay.close(); - } - }); - - it("matches bounded HTTP scripts without persisting request secrets", async () => { - const scenario = parseProcessScenario({ - approved: true, - executable: "/bin/sh", - working_directory: "/tmp", - replay: { - http: [ - { - method: "POST", - path: "/callback", - request_headers: { authorization: "Bearer fixture-secret" }, - request_body: "credential-body", - status: 302, - response_headers: { location: "/done" }, - body: "redirecting", - max_calls: 1, - }, - { - method: "GET", - path: "/disconnect", - status: 200, - body: "", - disconnect: true, - }, - ], - }, - }); - const replay = await startLoopbackReplay(scenario); - try { - const request = () => - fetch(`${replay.httpUrl}/callback`, { - method: "POST", - headers: { authorization: "Bearer fixture-secret" }, - body: "credential-body", - redirect: "manual", - }); - const matched = await request(); - expect(matched.status).toBe(302); - expect(matched.headers.get("location")).toBe("/done"); - expect((await request()).status).toBe(409); - expect((await fetch(`${replay.httpUrl}/missing`)).status).toBe(404); - await expect(fetch(`${replay.httpUrl}/disconnect`)).rejects.toThrow(); - expect(replay.events.map(({ outcome }) => outcome)).toEqual( - expect.arrayContaining([ - "matched", - "script_exhausted", - "unmatched", - "disconnected", - ]), - ); - expect(JSON.stringify(replay.events)).not.toContain("fixture-secret"); - expect(JSON.stringify(replay.events)).not.toContain("credential-body"); - } finally { - await replay.close(); - } - }); - - it("consumes ordered WebSocket reconnect scripts and reports exhaustion", async () => { - const scenario = parseProcessScenario({ - approved: true, - executable: "/bin/sh", - working_directory: "/tmp", - replay: { - websocket_connections: [ - { - messages: [{ data: "first", delay_ms: 1 }], - disconnect_after: true, - }, - { - messages: [{ data: "second" }], - disconnect_after: true, - }, - ], - }, - }); - const replay = await startLoopbackReplay(scenario); - const receive = () => - new Promise((resolveMessage, rejectMessage) => { - const socket = new WebSocket(replay.websocketUrl); - socket.once("message", (value) => resolveMessage(value.toString())); - socket.once("error", rejectMessage); - }); - try { - await expect(receive()).resolves.toBe("first"); - await expect(receive()).resolves.toBe("second"); - await new Promise((resolveClose, rejectClose) => { - const socket = new WebSocket(replay.websocketUrl); - socket.once("close", () => resolveClose()); - socket.once("error", rejectClose); - }); - expect(replay.events.at(-1)?.outcome).toBe("script_exhausted"); - } finally { - await replay.close(); - } - }); - - it("serializes WebSocket transition actions across rapid messages", async () => { - const scenario = parseProcessScenario({ - approved: true, - executable: "/bin/sh", - working_directory: "/tmp", - replay: { - machine: { - initial_state: "connecting", - states: [ - { name: "connecting" }, - { name: "first" }, - { name: "second" }, - { name: "complete", terminal: true }, - ], - transitions: [ - { - id: "connect", - from: "connecting", - to: "first", - trigger: { protocol: "websocket_connect", path: "/ws" }, - actions: [{ type: "websocket_send", data: "ready" }], - max_uses: 1, - }, - { - id: "first", - from: "first", - to: "second", - trigger: { - protocol: "websocket_message", - path: "/ws", - body: "first", - }, - actions: [ - { type: "delay", duration_ms: 30 }, - { type: "websocket_send", data: "first-done" }, - ], - max_uses: 1, - }, - { - id: "second", - from: "second", - to: "complete", - trigger: { - protocol: "websocket_message", - path: "/ws", - body: "second", - }, - actions: [{ type: "websocket_send", data: "second-done" }], - max_uses: 1, - }, - ], - max_transitions: 3, - }, - }, - }); - const replay = await startLoopbackReplay(scenario); - try { - const messages = await new Promise((resolve, reject) => { - const received: string[] = []; - const socket = new WebSocket(replay.websocketUrl); - socket.on("message", (value) => { - received.push(value.toString()); - if (received.length === 1) { - socket.send("first"); - socket.send("second"); - } - if (received.length === 3) { - socket.close(); - resolve(received); - } - }); - socket.once("error", reject); - }); - expect(messages).toEqual(["ready", "first-done", "second-done"]); - } finally { - await replay.close(); - } - }); - - it("drains queued machine work before exposing finalized snapshots", async () => { - const scenario = parseProcessScenario({ - approved: true, - executable: "/bin/sh", - working_directory: "/tmp", - replay: { - machine: { - initial_state: "connecting", - states: [ - { name: "connecting" }, - { name: "first" }, - { name: "second" }, - { name: "complete", terminal: true }, - ], - transitions: [ - { - id: "connect", - from: "connecting", - to: "first", - trigger: { protocol: "websocket_connect", path: "/ws" }, - actions: [{ type: "websocket_send", data: "ready" }], - max_uses: 1, - }, - { - id: "first", - from: "first", - to: "second", - trigger: { - protocol: "websocket_message", - path: "/ws", - body: "first", - }, - actions: [ - { type: "delay", duration_ms: 30 }, - { type: "disconnect" }, - ], - max_uses: 1, - }, - { - id: "second", - from: "second", - to: "complete", - trigger: { - protocol: "websocket_message", - path: "/ws", - body: "second", - }, - actions: [{ type: "websocket_send", data: "done" }], - max_uses: 1, - }, - ], - max_transitions: 3, - }, - }, - }); - const replay = await startLoopbackReplay(scenario); - await new Promise((resolve, reject) => { - const socket = new WebSocket(replay.websocketUrl); - socket.once("message", () => { - socket.send("first"); - socket.send("second"); - setTimeout(resolve, 5); - }); - socket.once("error", reject); - }); - - await replay.close(); - const finalized = replay.transitions; - expect(finalized.map(({ transition_id }) => transition_id)).toEqual([ - "connect", - "first", - "second", - ]); - await new Promise((resolve) => setTimeout(resolve, 40)); - expect(replay.transitions).toEqual(finalized); - }); - - it("enforces replay duration before normalizing recorded timestamps", async () => { - const scenario = parseProcessScenario({ - approved: true, - executable: "/bin/sh", - working_directory: "/tmp", - normalization: { time_bucket_ms: 60_000 }, - replay: { - machine: { - initial_state: "waiting", - states: [{ name: "waiting" }, { name: "complete", terminal: true }], - transitions: [ - { - id: "late", - from: "waiting", - to: "complete", - trigger: { protocol: "http", method: "GET", path: "/late" }, - actions: [{ type: "http_response", status: 200, body: "late" }], - max_uses: 1, - }, - ], - max_transitions: 1, - limits: { duration_ms: 5 }, - }, - }, - }); - const replay = await startLoopbackReplay(scenario); - try { - await new Promise((resolve) => setTimeout(resolve, 20)); - const response = await fetch(`${replay.httpUrl}/late`); - expect(response.status).toBe(409); - expect(replay.events.at(-1)).toMatchObject({ - at_ms: 0, - outcome: "limit_exhausted", - }); - } finally { - await replay.close(); - } - }); - - it("runs a login and reconnect replay machine inside process capture", async () => { - const root = await createTestTempDirectory("rea-replay-machine-test-"); - const script = join(root, "client.mjs"); - await writeFile( - script, - [ - 'const token = "machine-secret";', - 'await fetch(`${process.env.REA_REPLAY_HTTP_URL}/login`, { method: "POST", body: JSON.stringify({ token }) });', - "const api = await fetch(`${process.env.REA_REPLAY_HTTP_URL}/api`, { headers: { authorization: token } });", - 'if (await api.text() !== "connect") throw new Error("bad API response");', - "const connect = (acknowledge) => new Promise((resolve, reject) => {", - " const socket = new WebSocket(process.env.REA_REPLAY_WEBSOCKET_URL);", - " socket.addEventListener('error', reject);", - " socket.addEventListener('message', (event) => {", - " if (acknowledge) socket.send('ack');", - " else { console.log(`reply:${event.data}`); socket.close(); resolve(); }", - " });", - " if (acknowledge) socket.addEventListener('close', () => resolve());", - "});", - "await connect(true);", - "await connect(false);", - ].join("\n"), - ); - const scenario = parseProcessScenario({ - approved: true, - executable: process.execPath, - arguments: [script], - working_directory: root, - replay: { - machine: { - initial_state: "login", - states: [ - { name: "login" }, - { name: "api" }, - { name: "socket" }, - { name: "ack" }, - { name: "reconnect" }, - { name: "complete", terminal: true }, - ], - transitions: [ - { - id: "login", - from: "login", - to: "api", - trigger: { protocol: "http", method: "POST", path: "/login" }, - captures: [ - { - variable: "token", - value: { source: "request_json", path: ["token"] }, - sensitive: true, - }, - ], - actions: [{ type: "http_response", status: 204, body: "" }], - max_uses: 1, - }, - { - id: "api", - from: "api", - to: "socket", - trigger: { protocol: "http", method: "GET", path: "/api" }, - guards: [ - { - variable: "token", - value: { source: "request_header", name: "authorization" }, - }, - ], - actions: [ - { type: "http_response", status: 200, body: "connect" }, - ], - max_uses: 1, - }, - { - id: "initial_socket", - from: "socket", - to: "ack", - trigger: { protocol: "websocket_connect", path: "/ws" }, - actions: [{ type: "websocket_send", data: "welcome" }], - max_uses: 1, - }, - { - id: "ack", - from: "ack", - to: "reconnect", - trigger: { - protocol: "websocket_message", - path: "/ws", - body: "ack", - }, - actions: [{ type: "disconnect" }], - max_uses: 1, - }, - { - id: "reconnect", - from: "reconnect", - to: "complete", - trigger: { protocol: "websocket_connect", path: "/ws" }, - actions: [{ type: "websocket_send", data: "done" }], - max_uses: 1, - }, - ], - max_transitions: 5, - }, - }, - }); - try { - const capability = await probeProcessCaptureCapability(); - if (!capability.available) return; - const capture = await captureProcessScenario(scenario, { - enabled: true, - executableRoots: [dirname(process.execPath)], - workingRoots: [root], - allowedEnvironment: [], - allowExternalNetwork: true, - }); - expect(capture.ok).toBe(true); - if (!capture.ok) throw capture.error; - expect( - capture.value.replay_transitions.map( - ({ transition_id }) => transition_id, - ), - ).toEqual(["login", "api", "initial_socket", "ack", "reconnect"]); - expect(capture.value.frames.map(({ data }) => data).join("")).toContain( - "reply:done", - ); - expect(JSON.stringify(capture.value)).not.toContain("machine-secret"); - expect(capture.value.manifest.replay_plan_sha256).toBe( - digestProcessCommitment(scenario.replay), - ); - expect( - capture.value.event_journal?.filter( - ({ collection }) => collection === "replay_transitions", - ), - ).toHaveLength(capture.value.replay_transitions.length); - for (const [index, entry] of ( - capture.value.event_journal ?? [] - ).entries()) - if (entry.collection === "replay_transitions") - expect(capture.value.event_journal?.[index - 1]?.collection).toBe( - "protocol_events", - ); - } finally { - await rm(root, { recursive: true, force: true }); - } - }, 20_000); - - it("captures PTY, filesystem, descendants, HTTP replay, and redacts environment", async () => { - const root = await createTestTempDirectory("rea-harness-test-"); - const script = join(root, "fixture.mjs"); - await writeFile( - script, - [ - 'import { writeFile } from "node:fs/promises";', - 'import { spawn } from "node:child_process";', - 'await writeFile(new URL("result.txt", `file://${process.cwd()}/`), "created");', - "const response = await fetch(`${process.env.REA_REPLAY_HTTP_URL}/probe`);", - "console.log(`reply:${await response.text()}`);", - "console.log(`sensitive:${process.env.SECRET}`);", - 'const child = spawn(process.execPath, ["-e", "setTimeout(()=>{}, 150)"], { stdio: "ignore" });', - "await new Promise((resolve) => setTimeout(resolve, 80));", - "child.kill();", - ].join("\n"), - ); - const policy: ProcessExecutionPolicy = { - enabled: true, - executableRoots: [dirname(process.execPath)], - workingRoots: [root], - allowedEnvironment: ["SECRET"], - allowExternalNetwork: true, - }; - const scenario = parseProcessScenario({ - approved: true, - executable: process.execPath, - arguments: [script], - working_directory: root, - filesystem_roots: [root], - environment: { SECRET: "do-not-record" }, - secret_aliases: ["SECRET"], - replay: { - http: [{ method: "GET", path: "/probe", status: 200, body: "ok" }], - }, - }); - try { - const capability = await probeProcessCaptureCapability(); - if (!capability.available) { - expect(capability.reason).toMatch(/native PTY/); - return; - } - const capture = await captureProcessScenario(scenario, policy); - expect(capture.ok).toBe(true); - if (!capture.ok) throw capture.error; - expect( - capture.value.frames.map((frame) => frame.data).join(""), - ).toContain("reply:ok"); - expect( - capture.value.frames.map((frame) => frame.data).join(""), - ).toContain("sensitive:"); - expect( - capture.value.files_after.some((file) => - file.path.endsWith("result.txt"), - ), - ).toBe(true); - expect( - capture.value.filesystem_effects.some( - (effect) => - effect.path.endsWith(":result.txt") && effect.status === "created", - ), - ).toBe(true); - expect( - capture.value.protocol_events.some( - (event) => event.protocol === "http" && event.path === "/probe", - ), - ).toBe(true); - expect(JSON.stringify(capture.value)).not.toContain("do-not-record"); - expect(await readFile(join(root, "result.txt"), "utf8")).toBe("created"); - expect(JSON.stringify(capture.value.files_after)).not.toContain(root); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); - - it("drives a process from terminal observations and retains the reactive transition journal", async () => { - const root = await createTestTempDirectory("rea-reactive-harness-test-"); - const script = join(root, "reactive.mjs"); - await writeFile( - script, - [ - 'import { createInterface } from "node:readline";', - "const input = createInterface({ input: process.stdin, terminal: false });", - 'process.stdout.write("Ready\\n");', - 'input.once("line", () => { process.stdout.write("Done\\n"); input.close(); });', - ].join("\n"), - ); - const scenario = parseProcessScenario({ - approved: true, - executable: process.execPath, - arguments: [script], - working_directory: root, - reactive: { - version: 1, - initial_state: "waiting", - deadline_ms: 5_000, - states: [ - { - id: "waiting", - max_visits: 1, - deadline_ms: 2_000, - on: [ - { - id: "answer", - priority: 0, - max_uses: 1, - when: { - kind: "terminal_text", - view: "decoded", - encoding: "utf8", - literal: "Ready", - case_sensitive: true, - control_sequences: "include", - occurrence: 1, - since: { kind: "scenario_start" }, - consume: true, - }, - actions: [ - { type: "send_input", data: "accepted\n", sensitive: true }, - { type: "close_stdin" }, - ], - target: { kind: "goto", state: "finishing" }, - }, - ], - }, - { - id: "finishing", - max_visits: 1, - deadline_ms: 2_000, - on: [ - { - id: "complete", - priority: 0, - max_uses: 1, - when: { - kind: "terminal_text", - view: "decoded", - encoding: "utf8", - literal: "Done", - case_sensitive: true, - control_sequences: "include", - occurrence: 1, - since: { kind: "state_entry" }, - consume: true, - }, - actions: [], - target: { kind: "finish", outcome: "passed" }, - }, - ], - }, - ], - }, - }); - try { - const capability = await probeProcessCaptureCapability(); - if (!capability.available) return; - const result = await captureProcessScenario(scenario, { - enabled: true, - executableRoots: [dirname(process.execPath)], - workingRoots: [root], - allowedEnvironment: [], - allowExternalNetwork: true, - }); - if (!result.ok) throw result.error; - expect(result.ok).toBe(true); - expect(result.value.reactive_run).toMatchObject({ - status: "finished", - outcome: "passed", - active_state: "finishing", - }); - expect( - result.value.reactive_run?.transitions.map( - ({ transition_id }) => transition_id, - ), - ).toEqual(["answer", "complete"]); - expect(result.value.interaction_events).toContainEqual( - expect.objectContaining({ - type: "input", - data: "", - outcome: "dispatched", - }), - ); - expect(result.value.interaction_events).toContainEqual( - expect.objectContaining({ - type: "stdin_close", - data: "", - outcome: "dispatched", - }), - ); - expect(result.value.frames.map(({ data }) => data).join("")).toContain( - "Done", - ); - expect(result.value.manifest.comparison_contract).toHaveProperty( - "reactive", - ); - expect( - JSON.stringify(result.value.manifest.comparison_contract), - ).not.toContain("accepted"); - expect(JSON.stringify(result.value.manifest.scenario)).not.toContain( - "accepted", - ); - const reactiveRun = result.value.reactive_run; - if (reactiveRun === null) throw new Error("reactive result missing"); - const lastReactiveOrder = result.value.event_journal - ?.filter(({ collection }) => - [ - "frames", - "interaction_events", - "filesystem_checkpoints", - "shim_events", - ].includes(collection), - ) - .at(-1)?.capture_order; - if (lastReactiveOrder === undefined) - throw new Error("reactive observation order missing"); - expect( - validateProcessCapture({ - ...result.value, - reactive_run: { - ...reactiveRun, - controls: [ - ...reactiveRun.controls, - { - sequence: reactiveRun.controls.length, - kind: "target_lost", - after_capture_order: lastReactiveOrder, - }, - ], - }, - }), - ).toContainEqual( - expect.objectContaining({ - path: `reactive_run.controls[${String(reactiveRun.controls.length)}]`, - message: "reactive control cannot follow a finished run", - }), - ); - expect( - validateProcessCapture({ - ...result.value, - reactive_run: { - ...reactiveRun, - transitions: reactiveRun.transitions.map((transition, index) => - index === 0 - ? { ...transition, transition_id: "fabricated" } - : transition, - ), - }, - }), - ).toContainEqual( - expect.objectContaining({ - path: "reactive_run.transitions[0]", - message: - "reactive transition is not declared by its recorded source state", - }), - ); - expect( - validateProcessCapture({ - ...result.value, - reactive_run: { - status: "finished", - outcome: "passed", - active_state: "waiting", - transitions: [], - controls: [], - }, - }), - ).toContainEqual( - expect.objectContaining({ - path: "reactive_run.outcome", - message: "reactive outcome differs from deterministic journal replay", - }), - ); - const suffix = reactiveRun.transitions - .slice(1) - .map((transition, index) => ({ - ...transition, - sequence: index, - })); - expect( - validateProcessCapture({ - ...result.value, - reactive_run: { ...reactiveRun, transitions: suffix }, - }), - ).toContainEqual( - expect.objectContaining({ - path: "reactive_run.transitions[0].state_before", - message: - "reactive transition journal must start at the declared initial state", - }), - ); - expect( - validateProcessCapture({ - ...result.value, - reactive_run: { - ...reactiveRun, - transitions: reactiveRun.transitions.map((transition, index) => - index === 0 - ? { - ...transition, - trigger_event_ids: [], - action_event_ids: [], - } - : transition, - ), - }, - }), - ).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - path: "reactive_run.transitions", - message: expect.stringContaining( - "reactive transition journal is not reproduced", - ), - }), - expect.objectContaining({ - path: "reactive_run.transitions[0].action_event_ids", - message: expect.stringContaining( - "recorded action observations do not match proposed effects", - ), - }), - ]), - ); - expect( - validateProcessCapture({ - ...result.value, - reactive_run: { - ...reactiveRun, - status: "running", - outcome: null, - }, - }), - ).toContainEqual( - expect.objectContaining({ - message: "completed capture requires a finished reactive outcome", - }), - ); - expect( - validateProcessCapture({ - ...result.value, - reactive_run: { - ...reactiveRun, - transitions: [ - reactiveRun.transitions[0]!, - { ...reactiveRun.transitions[0]!, sequence: 1 }, - ], - }, - }), - ).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - message: "reactive transition states must be contiguous", - }), - expect.objectContaining({ - message: - "reactive transition differs from deterministic journal replay", - }), - ]), - ); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); - - it("records target loss before post-exit settlement can win the deadline race", async () => { - const root = await createTestTempDirectory( - "rea-reactive-target-loss-test-", - ); - const script = join(root, "exit.mjs"); - await writeFile(script, 'process.stdout.write("exiting\\n");\n'); - const scenario = parseProcessScenario({ - approved: true, - executable: process.execPath, - arguments: [script], - working_directory: root, - settle_ms: 500, - reactive: { - version: 1, - initial_state: "waiting", - deadline_ms: 5_000, - states: [ - { - id: "waiting", - max_visits: 1, - // Leave process startup scheduling headroom; this test targets - // post-exit settlement ordering, not a 300ms startup deadline. - deadline_ms: 2_000, - on: [ - { - id: "unreachable", - priority: 0, - max_uses: 1, - when: { - kind: "terminal_text", - view: "decoded", - encoding: "utf8", - literal: "never-produced", - case_sensitive: true, - control_sequences: "include", - occurrence: 1, - since: { kind: "scenario_start" }, - consume: true, - }, - actions: [], - target: { kind: "finish", outcome: "passed" }, - }, - ], - }, - ], - }, - }); - try { - const capability = await probeProcessCaptureCapability(); - if (!capability.available) return; - const result = await captureProcessScenario(scenario, { - enabled: true, - executableRoots: [dirname(process.execPath)], - workingRoots: [root], - allowedEnvironment: [], - allowExternalNetwork: true, - }); - expect(result.ok).toBe(true); - if (!result.ok) throw result.error; - expect(result.value.reactive_run).toMatchObject({ - status: "finished", - outcome: "target_lost", - active_state: "waiting", - transitions: [], - controls: [ - expect.objectContaining({ - kind: "target_lost", - }), - ], - }); - const frame = result.value.frames.at(-1); - const journal = result.value.event_journal; - const reactiveRun = result.value.reactive_run; - if (frame === undefined || journal === undefined || reactiveRun === null) - throw new Error("ordered reactive evidence missing"); - const laterCaptureOrder = journal.length; - const withLaterMatch = { - ...result.value, - frames: [ - ...result.value.frames, - { - ...frame, - sequence: result.value.frames.length, - at_ms: frame.at_ms + 1, - data: "never-produced", - }, - ], - event_journal: [ - ...journal, - { - capture_order: laterCaptureOrder, - collection: "frames" as const, - index: result.value.frames.length, - }, - ], - }; - expect( - validateProcessCapture(withLaterMatch).filter(({ path }) => - path.startsWith("reactive_run"), - ), - ).toEqual([]); - expect( - validateProcessCapture({ - ...withLaterMatch, - reactive_run: { - ...reactiveRun, - controls: reactiveRun.controls.map((control) => ({ - ...control, - after_capture_order: laterCaptureOrder, - })), - }, - }), - ).toContainEqual( - expect.objectContaining({ - path: "reactive_run.outcome", - message: "reactive outcome differs from deterministic journal replay", - }), - ); - } finally { - await rm(root, { recursive: true, force: true }); - } - }, 10_000); - - it("renders terminal state, records shim invocations, and captures literal checkpoints", async () => { - const root = await createTestTempDirectory("rea-v3-test-"); - const script = join(root, "scenario.mjs"); - await writeFile( - script, - [ - 'import { spawnSync } from "node:child_process";', - 'const result = spawnSync("codex", ["--version"], { encoding: "utf8" });', - 'const node = spawnSync("node", ["--version"], { encoding: "utf8" });', - "process.stdout.write(`probe:${result.stdout}`);", - "process.stdout.write(`runtime:${node.stdout}`);", - ].join("\n"), - ); - try { - const capability = await probeProcessCaptureCapability(); - if (!capability.available) return; - const result = await captureProcessScenario( - parseProcessScenario({ - approved: true, - executable: process.execPath, - arguments: [script], - working_directory: root, - checkpoints: [ - { - name: "probe_seen", - trigger: { type: "terminal_literal", value: "probe:codex 1.2.3" }, - }, - ], - command_shims: [ - { - name: "codex", - routes: [ - { - arguments: ["--version"], - outputs: [ - { at_ms: 0, stream: "stdout", data: "codex 1.2.3\n" }, - ], - termination: { type: "exit", code: 0 }, - }, - ], - }, - { - name: "node", - routes: [ - { - arguments: ["--version"], - outputs: [ - { at_ms: 0, stream: "stdout", data: "node 9.8.7\n" }, - ], - termination: { type: "exit", code: 0 }, - }, - ], - }, - ], - reactive: { - version: 1, - initial_state: "waiting_for_shim", - deadline_ms: 5_000, - states: [ - { - id: "waiting_for_shim", - max_visits: 1, - deadline_ms: 2_000, - on: [ - { - id: "record_shim", - priority: 0, - max_uses: 1, - when: { - kind: "event", - source: "shim", - exact: { - command: "codex", - route_index: 0, - arguments: ["--version"], - working_directory: "", - outcome: "matched", - }, - ignore_fields: ["sequence", "at_ms"], - since: { kind: "scenario_start" }, - consume: true, - cardinality: { min: 1, max: 1 }, - }, - actions: [ - { type: "checkpoint", name: "reactive_shim_seen" }, - ], - target: { - kind: "goto", - state: "waiting_for_checkpoint", - }, - }, - ], - }, - { - id: "waiting_for_checkpoint", - max_visits: 1, - deadline_ms: 2_000, - on: [ - { - id: "finish_checkpoint", - priority: 0, - max_uses: 1, - when: { - kind: "event", - source: "filesystem", - exact: { - name: "reactive_shim_seen", - files: [], - effects: [], - truncated: false, - }, - ignore_fields: ["at_ms"], - since: { kind: "scenario_start" }, - consume: true, - cardinality: { min: 1, max: 1 }, - }, - actions: [], - target: { kind: "finish", outcome: "passed" }, - }, - ], - }, - ], - }, - }), - { - enabled: true, - executableRoots: [dirname(process.execPath)], - workingRoots: [root], - allowedEnvironment: [], - allowExternalNetwork: true, - }, - ); - expect(result.ok).toBe(true); - if (!result.ok) throw result.error; - expect(result.value.schema_version).toBe(4); - expect(result.value.rendered_frames.at(-1)?.lines.join("\n")).toContain( - "probe:codex 1.2.3", - ); - expect(result.value.shim_events).toEqual([ - expect.objectContaining({ - command: "codex", - arguments: ["--version"], - outcome: "matched", - }), - expect.objectContaining({ - command: "node", - arguments: ["--version"], - outcome: "matched", - }), - ]); - expect( - result.value.filesystem_checkpoints.map(({ name }) => name), - ).toEqual( - expect.arrayContaining([ - "before", - "reactive_shim_seen", - "probe_seen", - "after_settlement", - ]), - ); - expect(result.value.reactive_run).toMatchObject({ - status: "finished", - outcome: "passed", - transitions: [ - expect.objectContaining({ - transition_id: "record_shim", - trigger_event_ids: ["obs.shim_events.0"], - action_event_ids: [ - expect.stringMatching(/^obs\.filesystem_checkpoints\./u), - ], - }), - expect.objectContaining({ - transition_id: "finish_checkpoint", - trigger_event_ids: [ - expect.stringMatching(/^obs\.filesystem_checkpoints\./u), - ], - }), - ], - }); - } finally { - await rm(root, { recursive: true, force: true }); - } - }, 20_000); - - it("runs the committed multi-source reactive fixture deterministically", async () => { - const root = await createTestTempDirectory("rea-reactive-e2e-"); - const script = fileURLToPath( - new URL("./fixtures/processReactiveScenario.mjs", import.meta.url), - ); - const event = ( - source: "process" | "filesystem" | "http" | "websocket" | "shim", - exact: Record, - ignoreFields: readonly string[], - ) => ({ - kind: "event" as const, - source, - exact, - ignore_fields: ignoreFields, - since: { kind: "scenario_start" as const }, - consume: true, - cardinality: { min: 1, max: 1 }, - }); - const run = () => - captureProcessScenario( - parseProcessScenario({ - approved: true, - executable: process.execPath, - arguments: [script, "codex"], - working_directory: root, - replay: { - http: [ - { - method: "GET", - path: "/reactive", - status: 200, - body: "ok", - }, - ], - websocket_messages: ["reactive-server"], - }, - command_shims: [ - { - name: "codex", - routes: [ - { - arguments: ["probe"], - outputs: [], - termination: { type: "exit", code: 0 }, - }, - ], - }, - ], - reactive: { - version: 1, - initial_state: "ready", - deadline_ms: 8_000, - states: [ - { - id: "ready", - max_visits: 1, - deadline_ms: 3_000, - on: [ - { - id: "checkpoint_ready", - priority: 0, - max_uses: 1, - when: { - kind: "terminal_text", - view: "decoded", - encoding: "utf8", - literal: "Ready", - case_sensitive: true, - control_sequences: "include", - occurrence: 1, - since: { kind: "scenario_start" }, - consume: true, - }, - actions: [{ type: "checkpoint", name: "ready_snapshot" }], - target: { kind: "goto", state: "collecting" }, - }, - ], - }, - { - id: "collecting", - max_visits: 1, - deadline_ms: 5_000, - on: [ - { - id: "finish_all_sources", - priority: 0, - max_uses: 1, - when: { - kind: "all", - triggers: [ - { - kind: "any", - triggers: [ - { - kind: "terminal_text", - view: "decoded", - encoding: "utf8", - literal: "Collecting", - case_sensitive: true, - control_sequences: "include", - occurrence: 1, - since: { kind: "scenario_start" }, - consume: false, - }, - { - kind: "terminal_text", - view: "decoded", - encoding: "utf8", - literal: "unreachable-alternative", - case_sensitive: true, - control_sequences: "include", - occurrence: 1, - since: { kind: "scenario_start" }, - consume: false, - }, - ], - }, - event( - "process", - { - pid: 3, - parent_pid: 1, - command: "rea-reactive-worker", - process_group_id: 1, - session_id: 1, - }, - ["at_ms"], - ), - event( - "filesystem", - { - name: "ready_snapshot", - files: [], - effects: [], - truncated: false, - }, - ["at_ms"], - ), - event( - "shim", - { - command: "codex", - route_index: 0, - arguments: ["probe"], - working_directory: "", - outcome: "matched", - }, - ["sequence", "at_ms"], - ), - { - kind: "sequence", - triggers: [ - event( - "http", - { - protocol: "http", - direction: "request", - method: "GET", - path: "/reactive", - data: "", - outcome: "matched", - }, - ["sequence", "at_ms"], - ), - event( - "websocket", - { - protocol: "websocket", - direction: "received", - method: null, - path: "/ws", - data: "reactive-client", - outcome: "matched", - }, - ["sequence", "at_ms"], - ), - ], - }, - ], - }, - actions: [], - target: { kind: "finish", outcome: "passed" }, - }, - ], - }, - ], - }, - }), - { - enabled: true, - executableRoots: [dirname(process.execPath)], - workingRoots: [root], - allowedEnvironment: [], - allowExternalNetwork: true, - }, - ); - try { - const capability = await probeProcessCaptureCapability(); - if (!capability.available) return; - const first = await run(); - const second = await run(); - if (!first.ok) throw first.error; - if (!second.ok) throw second.error; - expect(first.ok).toBe(true); - expect(second.ok).toBe(true); - const trace = (capture: ProcessCapture) => - capture.reactive_run?.transitions.map((transition) => ({ - transition_id: transition.transition_id, - from: transition.state_before, - to: transition.state_after, - outcome: transition.outcome, - trigger_collections: transition.trigger_event_ids.map( - (id) => id.split(".")[1], - ), - })); - expect( - first.value.reactive_run?.outcome, - JSON.stringify({ - run: first.value.reactive_run, - processes: first.value.process_samples, - checkpoints: first.value.filesystem_checkpoints, - shims: first.value.shim_events, - protocols: first.value.protocol_events, - }), - ).toBe("passed"); - expect(trace(first.value)).toEqual(trace(second.value)); - expect(trace(first.value)).toEqual([ - expect.objectContaining({ transition_id: "checkpoint_ready" }), - expect.objectContaining({ - transition_id: "finish_all_sources", - trigger_collections: expect.arrayContaining([ - "frames", - "process_samples", - "filesystem_checkpoints", - "shim_events", - "protocol_events", - ]), - }), - ]); - for (const outcome of ["action_rejected", "target_lost"] as const) { - const failedActionCapture = { - ...first.value, - reactive_run: { - status: "finished" as const, - outcome, - active_state: "ready", - transitions: [], - controls: [], - }, - }; - expect( - validateProcessCapture(failedActionCapture).filter(({ path }) => - path.startsWith("reactive_run"), - ), - ).toEqual([]); - } - expect(first.value.truncated).toBe(false); - } finally { - await rm(root, { recursive: true, force: true }); - } - }, 30_000); - - it("does not follow or disclose symlink targets outside declared roots", async () => { - const root = await createTestTempDirectory("rea-symlink-test-"); - await symlink("/etc/passwd", join(root, "escape")); - try { - const capability = await probeProcessCaptureCapability(); - if (!capability.available) return; - const result = await captureProcessScenario( - parseProcessScenario({ - approved: true, - executable: "/usr/bin/true", - working_directory: root, - filesystem_roots: [root], - }), - { - enabled: true, - executableRoots: ["/usr/bin"], - workingRoots: [root], - allowedEnvironment: [], - allowExternalNetwork: true, - }, - ); - expect(result.ok).toBe(true); - if (!result.ok) throw result.error; - const escaped = result.value.files_after.find((file) => - file.path.endsWith(":escape"), - ); - expect(escaped?.symlink_target).toBe(""); - expect(result.value.truncated).toBe(true); - expect(JSON.stringify(result.value.files_after)).not.toContain(root); - expect(JSON.stringify(result.value.files_after)).not.toContain( - "/etc/passwd", - ); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); - - it("does not launch when policy denies capture", async () => { - const scenario = parseProcessScenario({ - approved: true, - executable: "/bin/sh", - working_directory: "/tmp", - }); - const result = await captureProcessScenario(scenario, { - enabled: false, - executableRoots: [], - workingRoots: [], - allowedEnvironment: [], - allowExternalNetwork: true, - }); - expect(result.ok).toBe(false); - if (result.ok) throw new Error("expected policy refusal"); - expect(result.error).toBeInstanceOf(ProcessCaptureError); - }); - - it("distinguishes timeout from cancellation and cleans both runs", async () => { - const capability = await probeProcessCaptureCapability(); - if (!capability.available) return; - const policy: ProcessExecutionPolicy = { - enabled: true, - executableRoots: [dirname(process.execPath)], - workingRoots: [dirname(processFixture)], - allowedEnvironment: [], - allowExternalNetwork: true, - }; - const timedOut = await captureProcessScenario( - parseProcessScenario({ - approved: true, - executable: process.execPath, - arguments: [processFixture, "hang"], - working_directory: dirname(processFixture), - timeout_ms: 50, - idle_timeout_ms: 5_000, - }), - policy, - ); - expect(timedOut.ok).toBe(true); - if (!timedOut.ok) throw timedOut.error; - expect(timedOut.value.exit.reason).toBe("timeout"); - expect(timedOut.value.cleanup).toEqual({ - owned_process_group: "verified", - temporary_root: "removed", - }); - - const controller = new AbortController(); - setTimeout(() => controller.abort(), 50); - const cancelled = await captureProcessScenario( - parseProcessScenario({ - approved: true, - executable: process.execPath, - arguments: [processFixture, "hang"], - working_directory: dirname(processFixture), - timeout_ms: 5_000, - idle_timeout_ms: 5_000, - }), - policy, - controller.signal, - ); - expect(cancelled.ok).toBe(false); - if (cancelled.ok) throw new Error("expected cancellation"); - expect(cancelled.error.message).toContain("cancelled"); - }); - - it("captures source-owned interactive, resize, Unicode, and signal behavior", async () => { - const capability = await probeProcessCaptureCapability(); - if (!capability.available) return; - const result = await captureProcessScenario( - parseProcessScenario({ - approved: true, - executable: process.execPath, - arguments: [processFixture, "interactive"], - working_directory: dirname(processFixture), - events: [ - { type: "input", at_ms: 100, data: "answer" }, - { type: "resize", at_ms: 300, columns: 100, rows: 40 }, - { type: "signal", at_ms: 700, signal: "SIGINT" }, - ], - normalization: { time_bucket_ms: 60_000 }, - timeout_ms: 2_000, - idle_timeout_ms: 2_000, - }), - { - enabled: true, - executableRoots: [dirname(process.execPath)], - workingRoots: [dirname(processFixture)], - allowedEnvironment: [], - allowExternalNetwork: true, - }, - ); - if (!result.ok) throw result.error; - expect(result.ok).toBe(true); - const output = result.value.frames.map(({ data }) => data).join(""); - expect(output).toContain("prompt>"); - expect(output).toContain("input:answer unicode:雪"); - expect(output).toContain("resize:100x40"); - expect(output).toContain("signal:SIGINT"); - expect(result.value.exit.code).toBe(0); - }); - - it("dispatches scheduled events before a silent PTY produces output", async () => { - const capability = await probeProcessCaptureCapability(); - if (!capability.available) return; - const result = await captureProcessScenario( - parseProcessScenario({ - approved: true, - executable: process.execPath, - arguments: [processFixture, "silent-interactive"], - working_directory: dirname(processFixture), - events: [ - { type: "resize", at_ms: 25, columns: 100, rows: 40 }, - { type: "input", at_ms: 50, data: "answer" }, - ], - timeout_ms: 2_000, - idle_timeout_ms: 2_000, - }), - { - enabled: true, - executableRoots: [ - join(dirname(process.execPath), "missing"), - dirname(process.execPath), - ], - workingRoots: [ - join(dirname(processFixture), "missing"), - dirname(processFixture), - ], - allowedEnvironment: [], - allowExternalNetwork: true, - }, - ); - expect(result.ok).toBe(true); - if (!result.ok) throw result.error; - expect(result.value.frames.map(({ data }) => data).join("")).toContain( - "input:answer", - ); - expect(result.value.interaction_events).toMatchObject([ - { type: "resize", outcome: "dispatched" }, - { type: "input", outcome: "dispatched" }, - ]); - }); - - it("samples and cleans a source-owned child and grandchild process tree", async () => { - const capability = await probeProcessCaptureCapability(); - if (!capability.available) return; - const result = await captureProcessScenario( - parseProcessScenario({ - approved: true, - executable: process.execPath, - arguments: [processFixture, "tree"], - working_directory: dirname(processFixture), - timeout_ms: 2_000, - idle_timeout_ms: 2_000, - }), - { - enabled: true, - executableRoots: [dirname(process.execPath)], - workingRoots: [dirname(processFixture)], - allowedEnvironment: [], - allowExternalNetwork: true, - }, - ); - expect(result.ok).toBe(true); - if (!result.ok) throw result.error; - const commands = result.value.process_samples.map(({ command }) => command); - expect(commands.some((command) => command.includes("tree-child"))).toBe( - true, - ); - expect(commands.some((command) => command.includes("forks.js"))).toBe( - false, - ); - expect( - commands.some((command) => command.includes("tree-grandchild")), - ).toBe(true); - expect(JSON.stringify(result.value.process_samples)).not.toContain( - dirname(processFixture), - ); - const { stdout } = await execFileAsync("ps", ["-axo", "command="]); - expect(stdout).not.toContain(`${processFixture} tree-child`); - expect(stdout).not.toContain(`${processFixture} tree-grandchild`); - }, 20_000); -}); diff --git a/tests/processCaptureElicitation.test.ts b/tests/processCaptureElicitation.test.ts deleted file mode 100644 index afc81cbf4..000000000 --- a/tests/processCaptureElicitation.test.ts +++ /dev/null @@ -1,623 +0,0 @@ -import { isInputRequiredResult } from "@modelcontextprotocol/server"; -import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; -import { serveStdio } from "@modelcontextprotocol/server/stdio"; -import { mkdir, rm } from "node:fs/promises"; -import { dirname } from "node:path"; -import { describe, expect, it, vi } from "vitest"; -import { z } from "zod"; - -import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; - -import { BinarySession } from "../src/application/BinarySession.js"; -import { PermissionAuthority } from "../src/application/PermissionAuthority.js"; -import { createPermissionPolicy } from "../src/domain/permissionPolicy.js"; -import { silentLogger } from "../src/logger.js"; -import { createServer } from "../src/server/createServer.js"; -import { observed } from "./fixtures/analysisExecution.js"; -import { - authorizeProcessCaptureWithElicitation, - PROCESS_CAPTURE_ELICITATION_POLICY, - type ProcessCaptureElicitationState, -} from "../src/server/ProcessCaptureElicitation.js"; - -const request = { - capability: "process_capture" as const, - roots: ["/tmp"], - executables: [process.execPath], - environment_names: ["PATH"], - network: "external" as const, - mount: false, - operation_identity: "capture:test", -}; -const now = Date.parse("2026-07-18T00:00:00.000Z"); - -describe("process-capture MCP elicitation", () => { - it("completes signed consent through the modern MCP client and server", async () => { - const root = await createTestTempDirectory("rea-elicit-mcp-"); - const authority = new PermissionAuthority( - createPermissionPolicy([ - { - capability: "process_capture", - roots: [root], - executables: [process.execPath], - environment_names: [], - network: "external", - mount: false, - }, - ]), - ); - const session = new BinarySession(() => ({ - execute: () => Promise.resolve(observed(null)), - close: () => Promise.resolve(), - })); - const createTestServer = () => - createServer(session, session, { - logger: silentLogger, - permissionAuthority: authority, - processPolicy: { - enabled: true, - executableRoots: [dirname(process.execPath)], - workingRoots: [root], - allowedEnvironment: [], - allowExternalNetwork: true, - }, - }); - const client = new Client( - { name: "process-elicit", version: "1" }, - { - capabilities: { elicitation: { form: {} } }, - versionNegotiation: { - mode: { - pin: PROCESS_CAPTURE_ELICITATION_POLICY.protocolVersions[0], - }, - }, - inputRequired: { autoFulfill: true, maxRounds: 3 }, - }, - ); - let prompts = 0; - client.setRequestHandler("elicitation/create", () => { - prompts += 1; - return Promise.resolve({ - action: "accept" as const, - content: { lifetime: "session" }, - }); - }); - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - const server = serveStdio(createTestServer, { - transport: serverTransport, - legacy: "reject", - }); - try { - await client.connect(clientTransport); - const captured = await client.callTool({ - name: "capture_process_scenario", - arguments: { - approved: true, - executable: process.execPath, - arguments: ["-e", "process.exit(0)"], - working_directory: root, - }, - }); - expect(captured.isError).not.toBe(true); - expect(prompts).toBe(1); - } finally { - await Promise.allSettled([client.close(), server.close()]); - await rm(root, { recursive: true, force: true }); - } - }, 20_000); - - it.each(["tampered", "expired"] as const)( - "rejects %s signed state through the modern MCP client and server", - async (failure) => { - const startedAt = Date.parse("2026-07-23T00:00:00.000Z"); - vi.useFakeTimers({ toFake: ["Date"] }); - vi.setSystemTime(startedAt); - const root = await createTestTempDirectory("rea-elicit-state-"); - const authority = new PermissionAuthority( - createPermissionPolicy([ - { - capability: "process_capture", - roots: [root], - executables: [process.execPath], - environment_names: [], - network: "external", - mount: false, - }, - ]), - ); - const session = new BinarySession(() => ({ - execute: () => Promise.resolve(observed(null)), - close: () => Promise.resolve(), - })); - const client = new Client( - { name: "process-elicit-state", version: "1" }, - { - capabilities: { elicitation: { form: {} } }, - versionNegotiation: { - mode: { - pin: PROCESS_CAPTURE_ELICITATION_POLICY.protocolVersions[0], - }, - }, - inputRequired: { autoFulfill: false, maxRounds: 3 }, - }, - ); - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - const server = serveStdio( - () => - createServer(session, session, { - logger: silentLogger, - permissionAuthority: authority, - processPolicy: { - enabled: true, - executableRoots: [dirname(process.execPath)], - workingRoots: [root], - allowedEnvironment: [], - allowExternalNetwork: true, - }, - }), - { transport: serverTransport, legacy: "reject" }, - ); - const call = { - name: "capture_process_scenario", - arguments: { - approved: true, - executable: process.execPath, - arguments: ["-e", "process.exit(0)"], - working_directory: root, - }, - }; - try { - await client.connect(clientTransport); - const required = await client.callTool(call, { - allowInputRequired: true, - }); - expect(isInputRequiredResult(required)).toBe(true); - if (!isInputRequiredResult(required)) return; - const requestState = required.requestState; - expect(requestState).toEqual(expect.any(String)); - if (requestState === undefined) return; - if (failure === "expired") - vi.setSystemTime( - startedAt + - (PROCESS_CAPTURE_ELICITATION_POLICY.stateTtlSeconds + 1) * 1_000, - ); - const echoedState = - failure === "tampered" - ? `${requestState.startsWith("A") ? "B" : "A"}${requestState.slice(1)}` - : requestState; - const retryMethod: string = "tools/call"; - await expect( - client.request( - { - method: retryMethod, - params: { - ...call, - requestState: echoedState, - inputResponses: { - process_capture_grant: { - action: "accept", - content: { lifetime: "session" }, - }, - }, - }, - }, - z.unknown(), - ), - ).rejects.toMatchObject({ - code: -32_602, - data: { reason: "invalid_request_state" }, - }); - expect( - await authority.authorize( - { - capability: "process_capture", - roots: [root], - executables: [process.execPath], - environment_names: [], - network: "external", - mount: false, - operation_identity: `capture_process_scenario:${process.execPath}`, - }, - "read", - ), - ).toMatchObject({ ok: false }); - } finally { - await Promise.allSettled([client.close(), server.close()]); - await rm(root, { recursive: true, force: true }); - vi.useRealTimers(); - } - }, - ); - - it("grants and reuses a signed session scope on the modern protocol", async () => { - const authority = ceilingOnlyAuthority(); - let state: ProcessCaptureElicitationState | undefined; - const elicitation = { - stateCodec: { - mint: (value: ProcessCaptureElicitationState) => { - state = value; - return Promise.resolve("signed-state"); - }, - verify: () => verifiedState(state), - }, - supported: () => true, - now: () => now, - consumedNonces: new Map(), - }; - const first = await authorizeProcessCaptureWithElicitation( - authority, - request, - requestContext(), - elicitation, - ); - expect(isInputRequiredResult(first)).toBe(true); - expect(first).toMatchObject({ requestState: "signed-state" }); - expect(first).toMatchObject({ - inputRequests: { - process_capture_grant: { - params: { - message: expect.stringContaining("Mount access: no"), - }, - }, - }, - }); - expect(state?.request).toEqual(request); - - const accepted = await authorizeProcessCaptureWithElicitation( - authority, - request, - requestContext(state, { - action: "accept", - content: { lifetime: "session" }, - }), - elicitation, - ); - expect(isInputRequiredResult(accepted)).toBe(false); - if (isInputRequiredResult(accepted)) return; - expect(accepted.ok).toBe(true); - - const reused = await authorizeProcessCaptureWithElicitation( - authority, - request, - requestContext(), - elicitation, - ); - expect(isInputRequiredResult(reused)).toBe(false); - if (!isInputRequiredResult(reused)) expect(reused.ok).toBe(true); - }); - - it("fails closed for legacy clients and altered continuations", async () => { - const authority = ceilingOnlyAuthority(); - let state: ProcessCaptureElicitationState | undefined; - const elicitation = { - stateCodec: { - mint: (value: ProcessCaptureElicitationState) => { - state = value; - return Promise.resolve("signed-state"); - }, - verify: () => verifiedState(state), - }, - supported: () => false, - now: () => now, - consumedNonces: new Map(), - }; - const legacy = await authorizeProcessCaptureWithElicitation( - authority, - request, - requestContext(), - elicitation, - ); - expect(isInputRequiredResult(legacy)).toBe(false); - if (!isInputRequiredResult(legacy)) expect(legacy.ok).toBe(false); - - const modern = { ...elicitation, supported: () => true }; - await authorizeProcessCaptureWithElicitation( - authority, - request, - requestContext(), - modern, - ); - const altered = await authorizeProcessCaptureWithElicitation( - authority, - { ...request, operation_identity: "capture:altered" }, - requestContext(state, { - action: "accept", - content: { lifetime: "session" }, - }), - modern, - ); - expect(isInputRequiredResult(altered)).toBe(false); - if (!isInputRequiredResult(altered)) expect(altered.ok).toBe(false); - }); - - it("binds signed continuation state to exact origins", async () => { - const authority = ceilingOnlyAuthority([ - "https://example.test", - "https://other.test", - ]); - let state: ProcessCaptureElicitationState | undefined; - const elicitation = { - stateCodec: { - mint: (value: ProcessCaptureElicitationState) => { - state = value; - return Promise.resolve("signed-state"); - }, - verify: () => verifiedState(state), - }, - supported: () => true, - now: () => now, - consumedNonces: new Map(), - }; - const scoped = { ...request, origins: ["https://example.test"] }; - const prompted = await authorizeProcessCaptureWithElicitation( - authority, - scoped, - requestContext(), - elicitation, - ); - expect(prompted).toMatchObject({ - inputRequests: { - process_capture_grant: { - params: { - message: expect.stringContaining("Origins: https://example.test"), - }, - }, - }, - }); - - const altered = await authorizeProcessCaptureWithElicitation( - authority, - { ...scoped, origins: ["https://other.test"] }, - requestContext(state, { - action: "accept", - content: { lifetime: "session" }, - }), - elicitation, - ); - - expect(isInputRequiredResult(altered)).toBe(false); - if (!isInputRequiredResult(altered)) expect(altered.ok).toBe(false); - }); - - it("consumes once grants and rejects continuation replay", async () => { - const authority = ceilingOnlyAuthority(); - let state: ProcessCaptureElicitationState | undefined; - const elicitation = { - stateCodec: { - mint: (value: ProcessCaptureElicitationState) => { - state = value; - return Promise.resolve("signed-state"); - }, - verify: () => verifiedState(state), - }, - supported: () => true, - now: () => now, - consumedNonces: new Map(), - }; - await authorizeProcessCaptureWithElicitation( - authority, - request, - requestContext(), - elicitation, - ); - const response = { - action: "accept", - content: { lifetime: "once" }, - }; - const accepted = await authorizeProcessCaptureWithElicitation( - authority, - request, - requestContext(state, response), - elicitation, - ); - if (isInputRequiredResult(accepted)) throw new Error("grant was not used"); - expect(accepted.ok).toBe(true); - - const replayed = await authorizeProcessCaptureWithElicitation( - authority, - request, - requestContext(state, response), - elicitation, - ); - expect(isInputRequiredResult(replayed)).toBe(false); - if (!isInputRequiredResult(replayed)) expect(replayed.ok).toBe(false); - - const next = await authorizeProcessCaptureWithElicitation( - authority, - request, - requestContext(), - elicitation, - ); - expect(isInputRequiredResult(next)).toBe(true); - - const declined = await authorizeProcessCaptureWithElicitation( - authority, - request, - requestContext(state, { action: "decline" }), - elicitation, - ); - expect(isInputRequiredResult(declined)).toBe(false); - const acceptedAfterDecline = await authorizeProcessCaptureWithElicitation( - authority, - request, - requestContext(state, { - action: "accept", - content: { lifetime: "session" }, - }), - elicitation, - ); - expect(isInputRequiredResult(acceptedAfterDecline)).toBe(false); - if (!isInputRequiredResult(acceptedAfterDecline)) - expect(acceptedAfterDecline.ok).toBe(false); - }); - - it("bounds replay state and prunes entries at the signed-state TTL", async () => { - const authority = ceilingOnlyAuthority(); - let state: ProcessCaptureElicitationState | undefined; - const consumedNonces = new Map(); - const elicitation = { - stateCodec: { - mint: (value: ProcessCaptureElicitationState) => { - state = value; - return Promise.resolve("signed-state"); - }, - verify: () => verifiedState(state), - }, - supported: () => true, - now: () => now, - consumedNonces, - }; - await authorizeProcessCaptureWithElicitation( - authority, - request, - requestContext(), - elicitation, - ); - const future = now + 600_000; - for (let index = 0; index < 4_096; index += 1) - consumedNonces.set(`occupied-${String(index)}`, future); - - const rejectedAtCapacity = await authorizeProcessCaptureWithElicitation( - authority, - request, - requestContext(state, { - action: "accept", - content: { lifetime: "session" }, - }), - elicitation, - ); - expect(isInputRequiredResult(rejectedAtCapacity)).toBe(false); - if (!isInputRequiredResult(rejectedAtCapacity)) - expect(rejectedAtCapacity.ok).toBe(false); - - consumedNonces.set("occupied-0", now - 1); - const acceptedAfterPrune = await authorizeProcessCaptureWithElicitation( - authority, - request, - requestContext(state, { - action: "accept", - content: { lifetime: "session" }, - }), - elicitation, - ); - expect(isInputRequiredResult(acceptedAfterPrune)).toBe(false); - if (!isInputRequiredResult(acceptedAfterPrune)) - expect(acceptedAfterPrune.ok).toBe(true); - expect(consumedNonces.has("occupied-0")).toBe(false); - expect(consumedNonces.size).toBe(4_096); - }); - - it.each(["result", "throw"] as const)( - "revokes an elicited grant when final authorization ends with %s", - async (failure) => { - const root = await createTestTempDirectory("rea-elicit-rollback-"); - const scoped = { ...request, roots: [root] }; - const policy = createPermissionPolicy([ - { - capability: "process_capture", - roots: scoped.roots, - executables: scoped.executables, - environment_names: scoped.environment_names, - network: scoped.network, - mount: scoped.mount, - }, - ]); - class FailingFinalAuthority extends PermissionAuthority { - private authorizationCount = 0; - - override async authorize( - ...arguments_: Parameters - ): ReturnType { - this.authorizationCount += 1; - const result = await super.authorize(...arguments_); - if (failure === "result" && this.authorizationCount === 2) - await rm(root, { recursive: true, force: true }); - if (failure === "throw" && this.authorizationCount === 3) - throw new Error("synthetic final authorization rejection"); - return result; - } - } - const authority = new FailingFinalAuthority(policy); - let state: ProcessCaptureElicitationState | undefined; - const elicitation = { - stateCodec: { - mint: (value: ProcessCaptureElicitationState) => { - state = value; - return Promise.resolve("signed-state"); - }, - verify: () => verifiedState(state), - }, - supported: () => true, - now: () => now, - consumedNonces: new Map(), - }; - await authorizeProcessCaptureWithElicitation( - authority, - scoped, - requestContext(), - elicitation, - ); - const finalAuthorization = authorizeProcessCaptureWithElicitation( - authority, - scoped, - requestContext(state, { - action: "accept", - content: { lifetime: "session" }, - }), - elicitation, - ); - if (failure === "throw") - await expect(finalAuthorization).rejects.toThrow( - "synthetic final authorization rejection", - ); - else { - const failed = await finalAuthorization; - expect(isInputRequiredResult(failed)).toBe(false); - if (!isInputRequiredResult(failed)) expect(failed.ok).toBe(false); - await mkdir(root); - } - const retry = await authority.authorize(scoped, "read", { - elicitationSupported: true, - }); - expect(retry.ok).toBe(false); - }, - ); -}); - -const ceilingOnlyAuthority = ( - origins?: readonly string[], -): PermissionAuthority => - new PermissionAuthority( - createPermissionPolicy([ - { - capability: "process_capture", - roots: request.roots, - executables: request.executables, - environment_names: request.environment_names, - ...(origins === undefined ? {} : { origins }), - network: request.network, - mount: request.mount, - }, - ]), - ); - -const requestContext = ( - state?: ProcessCaptureElicitationState, - response?: Record, -) => ({ - mcpReq: { - requestState: () => state, - ...(response === undefined - ? {} - : { inputResponses: { process_capture_grant: response } }), - }, -}); - -const verifiedState = ( - state: ProcessCaptureElicitationState | undefined, -): Promise => { - if (state === undefined) throw new Error("state was not minted"); - return Promise.resolve(state); -}; diff --git a/tests/processOwnership.test.ts b/tests/processOwnership.test.ts deleted file mode 100644 index 0dfe2666d..000000000 --- a/tests/processOwnership.test.ts +++ /dev/null @@ -1,617 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - cleanupOwnedProcessGroup, - cleanupWindowsProcessTree, - observeOwnedProcessLineage, - observeOwnedProcessGroup, - parseProcessEnvironment, - selectCapturedProcessGroupIds, - type ProcessOwnershipHost, - type WindowsProcessTreeHost, -} from "../src/process/ProcessOwnership.js"; -import { matchesOwnedProcessCommand } from "../src/process/ProcessCommandIdentity.js"; - -const ownership = { - runId: "run-token", - leaderPid: 100, - processGroupId: 100, -}; - -const host = ( - environments: Readonly>>>, -): { - readonly adapter: ProcessOwnershipHost; - readonly signalGroup: ReturnType; -} => { - const signalGroup = vi.fn(); - return { - adapter: { - listProcesses: () => - Promise.resolve( - Object.keys(environments).map((pid) => ({ - pid: Number(pid), - parentPid: Number(pid) === 100 ? 1 : 100, - processGroupId: 100, - state: "S", - command: "fixture", - })), - ), - environment: (pid) => Promise.resolve(environments[pid] ?? {}), - signalGroup, - }, - signalGroup, - }; -}; - -describe("owned process-group cleanup", () => { - it("excludes sampled groups whose leader was outside the captured tree", () => { - expect( - selectCapturedProcessGroupIds(100, [ - { pid: 100, process_group_id: 42 }, - { pid: 100, process_group_id: 100 }, - { pid: 101, process_group_id: 42 }, - { pid: 102, process_group_id: 102 }, - { pid: 103, process_group_id: 102 }, - ]), - ).toEqual([100, 102]); - }); - - it("drops nameless Linux environment entries", () => { - expect( - parseProcessEnvironment("=ignored\0REA_PROCESS_RUN_ID=owned\0EMPTY=\0"), - ).toEqual({ REA_PROCESS_RUN_ID: "owned", EMPTY: "" }); - }); - - it("matches macOS executable names after process-table normalization", () => { - expect( - matchesOwnedProcessCommand( - "node /tmp/fake-launcher.mjs", - "/opt/node/bin/node", - "darwin", - ), - ).toBe(true); - expect( - matchesOwnedProcessCommand("(node)", "/opt/node/bin/node", "darwin"), - ).toBe(true); - expect( - matchesOwnedProcessCommand( - "node /tmp/fake-launcher.mjs", - "/opt/python/bin/python3", - "darwin", - ), - ).toBe(false); - expect( - matchesOwnedProcessCommand( - "node /tmp/fake-launcher.mjs", - "/opt/node/bin/node", - "linux", - ), - ).toBe(false); - expect( - matchesOwnedProcessCommand("(node)", "/opt/node/bin/node", "linux"), - ).toBe(false); - }); - - it("signals only a group whose every member carries the run token", async () => { - const { adapter, signalGroup } = host({ - 100: { REA_PROCESS_RUN_ID: "run-token" }, - 101: { REA_PROCESS_RUN_ID: "run-token" }, - }); - expect(await cleanupOwnedProcessGroup(ownership, adapter)).toEqual({ - cleaned: true, - signaled: true, - }); - expect(signalGroup).toHaveBeenCalledWith(100, "SIGKILL"); - }); - - it("validates and signals rooted descendant groups once, root first", async () => { - const processes = [ - { pid: 100, parentPid: 1, processGroupId: 100 }, - { pid: 110, parentPid: 100, processGroupId: 100 }, - { pid: 101, parentPid: 100, processGroupId: 101 }, - { pid: 102, parentPid: 101, processGroupId: 102 }, - { pid: 103, parentPid: 102, processGroupId: 102 }, - ].map((process) => ({ - ...process, - state: "S", - command: "fixture", - })); - const signalGroup = vi.fn(); - const adapter: ProcessOwnershipHost = { - listProcesses: () => Promise.resolve(processes), - environment: () => Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }), - signalGroup, - }; - - await expect(cleanupOwnedProcessGroup(ownership, adapter)).resolves.toEqual( - { cleaned: true, signaled: true }, - ); - expect(signalGroup.mock.calls).toEqual([ - [100, "SIGKILL"], - [101, "SIGKILL"], - [102, "SIGKILL"], - ]); - }); - - it("leaves an unrelated Hopper process group outside the rooted tree untouched", async () => { - const processes = [ - { pid: 100, parentPid: 1, processGroupId: 100, command: "rea-hopper" }, - { pid: 101, parentPid: 100, processGroupId: 101, command: "owned-child" }, - { - pid: 900, - parentPid: 1, - processGroupId: 900, - command: - "/Applications/Hopper Disassembler.app/Contents/MacOS/Hopper Disassembler", - }, - ].map((process) => ({ ...process, state: "S" })); - const environment = vi.fn((pid: number) => - Promise.resolve({ - REA_PROCESS_RUN_ID: pid === 900 ? "unrelated-run" : "run-token", - }), - ); - const signalGroup = vi.fn(); - const adapter: ProcessOwnershipHost = { - listProcesses: () => Promise.resolve(processes), - environment, - signalGroup, - }; - - await expect(cleanupOwnedProcessGroup(ownership, adapter)).resolves.toEqual( - { cleaned: true, signaled: true }, - ); - expect(signalGroup.mock.calls).toEqual([ - [100, "SIGKILL"], - [101, "SIGKILL"], - ]); - expect(environment.mock.calls.some(([pid]) => pid === 900)).toBe(false); - }); - - it("fails before signaling when any descendant-group member is unowned", async () => { - const signalGroup = vi.fn(); - const adapter: ProcessOwnershipHost = { - listProcesses: () => - Promise.resolve([ - { - pid: 100, - parentPid: 1, - processGroupId: 100, - state: "S", - command: "fixture", - }, - { - pid: 101, - parentPid: 100, - processGroupId: 101, - state: "S", - command: "child-session", - }, - { - pid: 104, - parentPid: 101, - processGroupId: 101, - state: "S", - command: "child-helper", - }, - ]), - environment: (pid) => - Promise.resolve({ - REA_PROCESS_RUN_ID: pid === 104 ? "other-run" : "run-token", - }), - signalGroup, - }; - - await expect(cleanupOwnedProcessGroup(ownership, adapter)).resolves.toEqual( - { - cleaned: false, - reason: "process tree contains an unowned or PID-reused process", - failures: [{ pid: 104, reason: "run-token-mismatch" }], - }, - ); - expect(signalGroup).not.toHaveBeenCalled(); - }); - - it("fails before signaling when a descendant group leader is not rooted", async () => { - const signalGroup = vi.fn(); - const adapter: ProcessOwnershipHost = { - listProcesses: () => - Promise.resolve([ - { - pid: 100, - parentPid: 1, - processGroupId: 100, - state: "S", - command: "fixture", - }, - { - pid: 105, - parentPid: 100, - processGroupId: 101, - state: "S", - command: "descendant", - }, - { - pid: 101, - parentPid: 999, - processGroupId: 101, - state: "S", - command: "unrelated-group-leader", - }, - ]), - environment: () => Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }), - signalGroup, - }; - - await expect(cleanupOwnedProcessGroup(ownership, adapter)).resolves.toEqual( - { - cleaned: false, - reason: - "descendant process-group leader identity could not be verified", - }, - ); - expect(signalGroup).not.toHaveBeenCalled(); - }); - - it("records sorted launcher and descendant lineage after token checks", async () => { - const { adapter } = host({ - 102: { REA_PROCESS_RUN_ID: "run-token" }, - 100: { REA_PROCESS_RUN_ID: "run-token" }, - 101: { REA_PROCESS_RUN_ID: "run-token" }, - }); - - await expect( - observeOwnedProcessLineage(ownership, adapter), - ).resolves.toEqual({ - status: "verified", - observedAt: expect.any(String), - lineage: { - schemaVersion: 1, - runId: "run-token", - launcherPid: 100, - launcherParentPid: 1, - processGroupId: 100, - descendants: [ - { pid: 101, parentPid: 100, processGroupId: 100 }, - { pid: 102, parentPid: 100, processGroupId: 100 }, - ], - }, - }); - }); - - it("walks the PPID tree across process-group boundaries", async () => { - const environment = vi.fn((pid: number) => - Promise.resolve({ - REA_PROCESS_RUN_ID: pid === 103 ? "unrelated-run" : "run-token", - }), - ); - const adapter: ProcessOwnershipHost = { - listProcesses: () => - Promise.resolve([ - { - pid: 103, - parentPid: 999, - processGroupId: 100, - state: "S", - command: "unrelated-same-group", - }, - { - pid: 102, - parentPid: 101, - processGroupId: 102, - state: "S", - command: "grandchild-session", - }, - { - pid: 100, - parentPid: 1, - processGroupId: 100, - state: "S", - command: "fixture", - }, - { - pid: 101, - parentPid: 100, - processGroupId: 101, - state: "S", - command: "child-group", - }, - ]), - environment, - signalGroup: vi.fn(), - }; - - await expect( - observeOwnedProcessLineage(ownership, adapter), - ).resolves.toEqual({ - status: "verified", - observedAt: expect.any(String), - lineage: { - schemaVersion: 1, - runId: "run-token", - launcherPid: 100, - launcherParentPid: 1, - processGroupId: 100, - descendants: [ - { pid: 101, parentPid: 100, processGroupId: 101 }, - { pid: 102, parentPid: 101, processGroupId: 102 }, - ], - }, - }); - expect(environment.mock.calls.map(([pid]) => pid)).toEqual([100, 101, 102]); - }); - - it("fails closed when a descendant in another process group lacks the token", async () => { - const adapter: ProcessOwnershipHost = { - listProcesses: () => - Promise.resolve([ - { - pid: 100, - parentPid: 1, - processGroupId: 100, - state: "S", - command: "fixture", - }, - { - pid: 101, - parentPid: 100, - processGroupId: 101, - state: "S", - command: "child-session", - }, - ]), - environment: (pid) => - Promise.resolve({ - REA_PROCESS_RUN_ID: pid === 100 ? "run-token" : "other-run", - }), - signalGroup: vi.fn(), - }; - - await expect( - observeOwnedProcessLineage(ownership, adapter), - ).resolves.toEqual({ - status: "unavailable", - observedAt: expect.any(String), - runId: "run-token", - launcherPid: 100, - processGroupId: 100, - reason: "process lineage contains an unowned or PID-reused process", - }); - }); - - it("does not publish lineage when any member fails ownership checks", async () => { - const { adapter } = host({ - 100: { REA_PROCESS_RUN_ID: "run-token" }, - 101: { REA_PROCESS_RUN_ID: "other-run" }, - }); - - await expect( - observeOwnedProcessLineage(ownership, adapter), - ).resolves.toEqual({ - status: "unavailable", - observedAt: expect.any(String), - runId: "run-token", - launcherPid: 100, - processGroupId: 100, - reason: "process lineage contains an unowned or PID-reused process", - }); - }); - - it("fails closed for stale metadata or an unrelated concurrent process", async () => { - const { adapter, signalGroup } = host({ - 100: { REA_PROCESS_RUN_ID: "run-token" }, - 101: { REA_PROCESS_RUN_ID: "different-run" }, - }); - expect(await cleanupOwnedProcessGroup(ownership, adapter)).toEqual({ - cleaned: false, - reason: "process tree contains an unowned or PID-reused process", - failures: [{ pid: 101, reason: "run-token-mismatch" }], - }); - expect(signalGroup).not.toHaveBeenCalled(); - }); - - it("checks every member and aggregates ownership read failures", async () => { - const environment = vi.fn((pid: number) => { - if (pid === 100) - return Promise.reject(new Error("transient procfs read")); - return Promise.resolve( - pid === 101 - ? { REA_PROCESS_RUN_ID: "different-run" } - : { REA_PROCESS_RUN_ID: "run-token" }, - ); - }); - const signalGroup = vi.fn(); - const adapter: ProcessOwnershipHost = { - listProcesses: () => - Promise.resolve( - [100, 101, 102].map((pid) => ({ - pid, - parentPid: pid === 100 ? 1 : 100, - processGroupId: 100, - state: "S", - command: "fixture", - })), - ), - environment, - signalGroup, - }; - - expect(await cleanupOwnedProcessGroup(ownership, adapter)).toEqual({ - cleaned: false, - reason: "process tree contains an unowned or PID-reused process", - failures: [ - { pid: 100, reason: "environment-unreadable" }, - { pid: 101, reason: "run-token-mismatch" }, - ], - }); - expect(environment.mock.calls.map(([pid]) => pid)).toEqual([100, 101, 102]); - expect(signalGroup).not.toHaveBeenCalled(); - }); - - it("accepts a member that exits during ownership revalidation", async () => { - const liveLauncher = { - pid: 100, - parentPid: 1, - processGroupId: 100, - state: "S", - command: "fixture", - }; - const listProcesses = vi - .fn() - .mockResolvedValueOnce([liveLauncher]) - .mockResolvedValue([]); - const signalGroup = vi.fn(); - const adapter: ProcessOwnershipHost = { - listProcesses, - environment: () => - Promise.reject(new Error("process exited before environment read")), - signalGroup, - }; - - await expect(cleanupOwnedProcessGroup(ownership, adapter)).resolves.toEqual( - { - cleaned: true, - signaled: false, - }, - ); - expect(signalGroup).not.toHaveBeenCalled(); - }); - - it("ignores exited zombie members during live ownership checks", async () => { - const environment = vi.fn((pid: number) => - Promise.resolve(pid === 101 ? {} : { REA_PROCESS_RUN_ID: "run-token" }), - ); - const signalGroup = vi.fn(); - const adapter: ProcessOwnershipHost = { - listProcesses: () => - Promise.resolve([ - { - pid: 100, - parentPid: 1, - processGroupId: 100, - state: "S", - command: "fixture", - }, - { - pid: 101, - parentPid: 100, - processGroupId: 100, - state: "Z", - command: "[node] ", - }, - ]), - environment, - signalGroup, - }; - - expect(await cleanupOwnedProcessGroup(ownership, adapter)).toEqual({ - cleaned: true, - signaled: true, - }); - expect(environment.mock.calls).toEqual([[100], [100]]); - expect(signalGroup).toHaveBeenCalledWith(100, "SIGKILL"); - }); - - it("observes a zombie-only group as settled", async () => { - const environment = vi.fn(() => Promise.resolve({})); - const adapter: ProcessOwnershipHost = { - listProcesses: () => - Promise.resolve([ - { - pid: 101, - parentPid: 1, - processGroupId: 100, - state: "Z+", - command: "[node] ", - }, - ]), - environment, - signalGroup: vi.fn(), - }; - - expect(await observeOwnedProcessGroup(ownership, adapter)).toEqual({ - state: "empty", - }); - expect(environment).not.toHaveBeenCalled(); - }); - - it("is idempotent when the owned group has already exited", async () => { - const { adapter } = host({}); - expect(await cleanupOwnedProcessGroup(ownership, adapter)).toEqual({ - cleaned: true, - signaled: false, - }); - }); - - it("fails closed when the launcher command identity changes", async () => { - const { adapter, signalGroup } = host({ - 100: { REA_PROCESS_RUN_ID: "run-token" }, - }); - expect( - await cleanupOwnedProcessGroup( - { - ...ownership, - expectedCommand: "/owned/hopper", - expectedParentPid: 1, - }, - adapter, - ), - ).toEqual({ - cleaned: false, - reason: - "owned launcher command identity did not match (observed=fixture; expected=/owned/hopper)", - }); - expect(signalGroup).not.toHaveBeenCalled(); - }); - - it("fails closed when the launcher parent identity changes", async () => { - const { adapter, signalGroup } = host({ - 100: { REA_PROCESS_RUN_ID: "run-token" }, - }); - expect( - await cleanupOwnedProcessGroup( - { ...ownership, expectedCommand: "fixture", expectedParentPid: 999 }, - adapter, - ), - ).toEqual({ - cleaned: false, - reason: "owned launcher parent identity did not match", - }); - expect(signalGroup).not.toHaveBeenCalled(); - }); -}); - -describe("Windows P0 process-tree cleanup", () => { - it("reports whether taskkill signaled or found an exited tree", async () => { - const terminated: WindowsProcessTreeHost = { - terminateTree: () => Promise.resolve("terminated"), - }; - const missing: WindowsProcessTreeHost = { - terminateTree: () => Promise.resolve("missing"), - }; - - await expect(cleanupWindowsProcessTree(42, terminated)).resolves.toEqual({ - cleaned: true, - signaled: true, - }); - await expect(cleanupWindowsProcessTree(42, missing)).resolves.toEqual({ - cleaned: true, - signaled: false, - }); - }); - - it("keeps invalid identity and termination failures explicit", async () => { - const failing: WindowsProcessTreeHost = { - terminateTree: () => Promise.reject(new Error("taskkill failed")), - }; - - await expect(cleanupWindowsProcessTree(0, failing)).resolves.toEqual({ - cleaned: false, - reason: "Windows process-tree PID is invalid", - }); - await expect(cleanupWindowsProcessTree(42, failing)).resolves.toEqual({ - cleaned: false, - reason: - "Windows P0 process-tree termination failed; Job Object ownership is unavailable", - }); - }); -}); diff --git a/tests/processReactiveRuntime.test.ts b/tests/processReactiveRuntime.test.ts deleted file mode 100644 index f27483c23..000000000 --- a/tests/processReactiveRuntime.test.ts +++ /dev/null @@ -1,678 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - createProcessObservation, - type ProcessObservationSource, -} from "../src/domain/processObservation.js"; -import { - commitProcessReactiveProposal, - createProcessReactiveSnapshot, - reduceProcessReactiveScenario, -} from "../src/domain/processReactiveRuntime.js"; -import { - PROCESS_REACTIVE_LIMITS, - processReactiveScenarioSchema, - type ProcessReactiveAction, - type ProcessReactiveScenario, -} from "../src/domain/processReactiveScenario.js"; - -const terminalTrigger = (literal = "Ready") => ({ - kind: "terminal_text" as const, - view: "decoded" as const, - encoding: "utf8" as const, - literal, - case_sensitive: true, - control_sequences: "include" as const, - occurrence: 1, - since: { kind: "scenario_start" as const }, - consume: false, -}); - -const scenarioWith = ( - transitions: readonly unknown[], -): ProcessReactiveScenario => - processReactiveScenarioSchema.parse({ - version: 1, - initial_state: "starting", - deadline_ms: 30_000, - states: [ - { - id: "starting", - max_visits: 4, - deadline_ms: 5_000, - on: transitions, - }, - ], - }); - -const finish = ( - id: string, - when: unknown, - priority = 100, - actions: readonly unknown[] = [], -) => ({ - id, - priority, - max_uses: 4, - when, - actions, - target: { kind: "finish", outcome: "passed" }, -}); - -const collectionFor = (source: ProcessObservationSource) => { - switch (source) { - case "terminal_raw": - return "frames" as const; - case "terminal_rendered": - return "rendered_frames" as const; - case "process": - return "process_samples" as const; - case "filesystem": - return "filesystem_checkpoints" as const; - case "shim": - return "shim_events" as const; - case "http": - case "websocket": - return "protocol_events" as const; - case "replay_transition": - return "replay_transitions" as const; - case "interaction": - return "interaction_events" as const; - case "lifecycle": - return "lifecycle" as const; - } -}; - -const observation = ( - source: ProcessObservationSource, - order: number, - payload: unknown, -) => - createProcessObservation({ - source, - source_sequence: order, - captured_at_ms: order, - subject_id: null, - location: { - collection: collectionFor(source), - index: order, - capture_order: order, - }, - payload, - }); - -const succeededEffect = (action: ProcessReactiveAction, order: number) => ({ - status: "succeeded" as const, - observation: - action.type === "checkpoint" - ? observation("filesystem", order, { name: action.name }) - : observation("interaction", order, { - type: - action.type === "send_input" - ? "input" - : action.type === "resize" - ? "resize" - : action.type === "close_stdin" - ? "stdin_close" - : "signal", - data: - action.type === "send_input" - ? action.sensitive - ? `` - : action.data - : action.type === "resize" - ? `${String(action.columns)}x${String(action.rows)}` - : action.type === "close_stdin" - ? "" - : action.signal, - outcome: "dispatched", - }), -}); - -const offer = ( - scenario: ProcessReactiveScenario, - snapshot: ReturnType, - value: ReturnType, -) => { - const proposed = reduceProcessReactiveScenario(scenario, snapshot, { - kind: "observation", - observation: value, - }); - return proposed.kind === "proposal" - ? commitProcessReactiveProposal( - scenario, - proposed, - proposed.effects.map((effect, index) => ({ - ...succeededEffect( - effect, - (proposed.snapshot.observations.at(-1)?.capture_order ?? -1) + - index + - 1, - ), - })), - ) - : proposed; -}; - -describe("process reactive runtime", () => { - it("matches decoded terminal text across PTY chunks and emits declarative effects", () => { - const scenario = scenarioWith([ - finish("ready", terminalTrigger(), 100, [ - { type: "send_signal", target: { kind: "root" }, signal: "SIGINT" }, - { type: "checkpoint", name: "ready" }, - ]), - ]); - const first = offer( - scenario, - createProcessReactiveSnapshot(scenario), - observation("terminal_raw", 0, { - sequence: 0, - at_ms: 0, - data: "Re", - }), - ); - expect(first.kind).toBe("waiting"); - const second = offer( - scenario, - first.snapshot, - observation("terminal_raw", 1, { - sequence: 1, - at_ms: 1, - data: "ady\n", - }), - ); - expect(second.kind).toBe("transition"); - if (second.kind !== "transition") return; - expect(second.record).toEqual({ - sequence: 0, - transition_id: "ready", - state_before: "starting", - state_after: null, - outcome: "passed", - trigger_event_ids: ["obs.frames.0", "obs.frames.1"], - action_event_ids: [ - "obs.interaction_events.2", - "obs.filesystem_checkpoints.3", - ], - action_types: ["send_signal", "checkpoint"], - }); - }); - - it("treats redaction-shaped live input as ordinary sensitive data", () => { - const scenario = scenarioWith([ - finish("ready", terminalTrigger(), 100, [ - { - type: "send_input", - data: "", - sensitive: true, - }, - ]), - ]); - const decision = offer( - scenario, - createProcessReactiveSnapshot(scenario), - observation("terminal_raw", 0, { data: "Ready" }), - ); - expect(decision).toMatchObject({ - kind: "transition", - record: { outcome: "passed" }, - }); - }); - - it("reports only the minimal terminal chunk span containing the occurrence", () => { - const scenario = scenarioWith([finish("ready", terminalTrigger())]); - let snapshot = createProcessReactiveSnapshot(scenario); - for (const [order, data] of ["unrelated\n", "Re"].entries()) { - const decision = offer( - scenario, - snapshot, - observation("terminal_raw", order, { data }), - ); - expect(decision.kind).toBe("waiting"); - snapshot = decision.snapshot; - } - const matched = offer( - scenario, - snapshot, - observation("terminal_raw", 2, { data: "ady" }), - ); - expect(matched.kind).toBe("transition"); - if (matched.kind === "transition") - expect(matched.record.trigger_event_ids).toEqual([ - "obs.frames.1", - "obs.frames.2", - ]); - }); - - it("does not commit state, checkpoints, consumption, or history before effects succeed", () => { - const scenario = scenarioWith([ - finish("ready", { ...terminalTrigger(), consume: true }, 100, [ - { type: "checkpoint", name: "ready" }, - { type: "send_signal", target: { kind: "root" }, signal: "SIGINT" }, - ]), - ]); - const proposed = reduceProcessReactiveScenario( - scenario, - createProcessReactiveSnapshot(scenario), - { - kind: "observation", - observation: observation("terminal_raw", 0, { data: "Ready" }), - }, - ); - expect(proposed.kind).toBe("proposal"); - if (proposed.kind !== "proposal") return; - expect(proposed.snapshot).toMatchObject({ - status: "running", - checkpoints: [], - consumed_event_ids: [], - transitions: [], - }); - const rejected = commitProcessReactiveProposal(scenario, proposed, [ - { - status: "succeeded", - observation: observation("filesystem", 1, { name: "ready" }), - }, - { status: "rejected" }, - ]); - expect(rejected).toMatchObject({ - kind: "finished", - outcome: "action_rejected", - snapshot: { - checkpoints: [], - consumed_event_ids: [], - transitions: [], - }, - }); - expect( - commitProcessReactiveProposal(scenario, proposed, [ - { status: "target_lost" }, - { - status: "succeeded", - observation: observation("interaction", 2, { - type: "signal", - data: "SIGINT", - outcome: "dispatched", - }), - }, - ]), - ).toMatchObject({ kind: "finished", outcome: "target_lost" }); - - const committed = commitProcessReactiveProposal(scenario, proposed, [ - { - status: "succeeded", - observation: observation("filesystem", 1, { name: "ready" }), - }, - { - status: "succeeded", - observation: observation("interaction", 2, { - type: "signal", - data: "SIGINT", - outcome: "dispatched", - }), - }, - ]); - expect(committed.snapshot).toMatchObject({ - checkpoints: [ - { - name: "ready", - event_id: "obs.filesystem_checkpoints.1", - capture_order: 1, - }, - ], - }); - expect( - commitProcessReactiveProposal(scenario, proposed, [ - { - status: "succeeded", - observation: observation("filesystem", 1, { name: "other" }), - }, - { - status: "succeeded", - observation: observation("interaction", 2, { - type: "signal", - data: "SIGINT", - outcome: "dispatched", - }), - }, - ]), - ).toMatchObject({ kind: "finished", outcome: "action_rejected" }); - const changedScenario = scenarioWith([ - finish("ready", { ...terminalTrigger(), consume: true }, 100, [ - { type: "checkpoint", name: "changed" }, - { type: "send_signal", target: { kind: "root" }, signal: "SIGINT" }, - ]), - ]); - expect( - commitProcessReactiveProposal(changedScenario, proposed, [ - succeededEffect({ type: "checkpoint", name: "ready" }, 1), - succeededEffect( - { - type: "send_signal", - target: { kind: "root" }, - signal: "SIGINT", - }, - 2, - ), - ]), - ).toMatchObject({ kind: "finished", outcome: "target_lost" }); - expect( - commitProcessReactiveProposal(scenario, proposed, [ - { - status: "succeeded", - observation: observation("filesystem", 1, { name: "ready" }), - }, - { - status: "succeeded", - observation: { - ...observation("filesystem", 2, { name: "ready" }), - event_id: "obs.filesystem_checkpoints.1", - }, - }, - ]), - ).toMatchObject({ kind: "finished", outcome: "action_rejected" }); - }); - - it("preserves overflow when no any branch matches", () => { - const scenario = scenarioWith([ - finish("bounded_any", { - kind: "any", - triggers: [ - terminalTrigger("never"), - { - kind: "event", - source: "shim", - exact: { name: "also-never" }, - ignore_fields: [], - since: { kind: "scenario_start" }, - consume: false, - cardinality: { min: 1, max: 1 }, - }, - ], - }), - ]); - const decision = reduceProcessReactiveScenario( - scenario, - createProcessReactiveSnapshot(scenario), - { - kind: "observation", - observation: observation("terminal_raw", 0, { - data: "x".repeat(PROCESS_REACTIVE_LIMITS.evaluationWork + 1), - }), - }, - ); - expect(decision).toMatchObject({ - kind: "finished", - outcome: "capture_incomplete", - }); - }); - - it("fails closed at the deterministic per-reduction work budget", () => { - const transitions = Array.from( - { length: PROCESS_REACTIVE_LIMITS.predicates }, - (_, index) => - finish(`event_${String(index)}`, { - kind: "event", - source: "shim", - exact: { name: `expected_${String(index)}` }, - ignore_fields: ["sequence"], - since: { kind: "scenario_start" }, - consume: false, - cardinality: { min: 1, max: 1 }, - }), - ); - const scenario = scenarioWith(transitions); - const retained = Array.from({ length: 128 }, (_, order) => - observation("shim", order, { name: "other", sequence: order }), - ); - const snapshot = { - ...createProcessReactiveSnapshot(scenario), - observations: retained, - }; - const decision = offer( - scenario, - snapshot, - observation("shim", retained.length, { - name: "other", - sequence: retained.length, - }), - ); - expect(decision).toMatchObject({ - kind: "finished", - outcome: "capture_incomplete", - }); - }); - - it("selects a unique priority and reports equal-priority ambiguity", () => { - const preferred = scenarioWith([ - finish("fallback", terminalTrigger(), 20), - finish("preferred", terminalTrigger(), 10), - ]); - const selected = offer( - preferred, - createProcessReactiveSnapshot(preferred), - observation("terminal_raw", 0, { data: "Ready" }), - ); - expect(selected.kind).toBe("transition"); - if (selected.kind === "transition") - expect(selected.record.transition_id).toBe("preferred"); - - for (const transitions of [ - [finish("one", terminalTrigger()), finish("two", terminalTrigger())], - [finish("two", terminalTrigger()), finish("one", terminalTrigger())], - ]) { - const ambiguous = scenarioWith(transitions); - const decision = offer( - ambiguous, - createProcessReactiveSnapshot(ambiguous), - observation("terminal_raw", 0, { data: "Ready" }), - ); - expect(decision).toMatchObject({ - kind: "finished", - outcome: "ambiguous_match", - }); - } - }); - - it("evaluates ordered and repeated predicates without array-order arbitration", () => { - const event = (name: string) => ({ - kind: "event" as const, - source: "shim" as const, - exact: { name }, - ignore_fields: ["sequence" as const], - since: { kind: "scenario_start" as const }, - consume: true, - cardinality: { min: 1, max: 1 }, - }); - const scenario = scenarioWith([ - finish("sequence", { - kind: "sequence", - triggers: [ - event("start"), - { kind: "repeat", trigger: event("tick"), min: 2, max: 2 }, - event("done"), - ], - }), - ]); - let snapshot = createProcessReactiveSnapshot(scenario); - for (const [order, name] of ["start", "tick", "tick"].entries()) { - const decision = offer( - scenario, - snapshot, - observation("shim", order, { name, sequence: order }), - ); - expect(decision.kind).toBe("waiting"); - snapshot = decision.snapshot; - } - const decision = offer( - scenario, - snapshot, - observation("shim", 3, { name: "done", sequence: 3 }), - ); - expect(decision.kind).toBe("transition"); - if (decision.kind === "transition") - expect(decision.record.trigger_event_ids).toEqual([ - "obs.shim_events.0", - "obs.shim_events.1", - "obs.shim_events.2", - "obs.shim_events.3", - ]); - }); - - it("applies state-entry frontiers and explicit consumption", () => { - const secondTrigger = { - ...terminalTrigger(), - since: { kind: "state_entry" as const }, - consume: true, - }; - const scenario = processReactiveScenarioSchema.parse({ - version: 1, - initial_state: "one", - deadline_ms: 30_000, - states: [ - { - id: "one", - max_visits: 1, - deadline_ms: 5_000, - on: [ - { - ...finish("advance", terminalTrigger()), - target: { kind: "goto", state: "two" }, - }, - ], - }, - { - id: "two", - max_visits: 1, - deadline_ms: 5_000, - on: [finish("finish", secondTrigger)], - }, - ], - }); - const advanced = offer( - scenario, - createProcessReactiveSnapshot(scenario), - observation("terminal_raw", 0, { data: "Ready" }), - ); - expect(advanced.kind).toBe("transition"); - const waiting = offer( - scenario, - advanced.snapshot, - observation("shim", 1, { name: "unrelated" }), - ); - expect(waiting.kind).toBe("waiting"); - const finished = offer( - scenario, - waiting.snapshot, - observation("terminal_raw", 2, { data: "Ready" }), - ); - expect(finished.kind).toBe("transition"); - expect(finished.snapshot.consumed_event_ids).toEqual(["obs.frames.2"]); - }); - - it("rejects an exhausted state visit before proposing effects", () => { - const scenario = processReactiveScenarioSchema.parse({ - version: 1, - initial_state: "loop", - deadline_ms: 30_000, - states: [ - { - id: "loop", - max_visits: 1, - deadline_ms: 5_000, - on: [ - { - ...finish("again", terminalTrigger(), 100, [ - { - type: "send_signal", - target: { kind: "root" }, - signal: "SIGKILL", - }, - ]), - target: { kind: "goto", state: "loop" }, - }, - ], - }, - ], - }); - const decision = reduceProcessReactiveScenario( - scenario, - createProcessReactiveSnapshot(scenario), - { - kind: "observation", - observation: observation("terminal_raw", 0, { data: "Ready" }), - }, - ); - expect(decision).toMatchObject({ - kind: "finished", - outcome: "capture_incomplete", - }); - expect(decision).not.toHaveProperty("effects"); - }); - - it("classifies deadlines and non-monotonic observation input", () => { - const scenario = scenarioWith([finish("ready", terminalTrigger())]); - const initial = createProcessReactiveSnapshot(scenario); - expect( - reduceProcessReactiveScenario(scenario, initial, { - kind: "state_deadline", - state_id: "starting", - state_entry_capture_order: 0, - }), - ).toMatchObject({ kind: "finished", outcome: "predicate_timeout" }); - expect( - reduceProcessReactiveScenario(scenario, initial, { - kind: "state_deadline", - state_id: "previous", - state_entry_capture_order: 0, - }), - ).toMatchObject({ kind: "waiting", snapshot: initial }); - expect( - reduceProcessReactiveScenario(scenario, initial, { - kind: "scenario_deadline", - }), - ).toMatchObject({ kind: "finished", outcome: "scenario_deadline" }); - expect( - reduceProcessReactiveScenario(scenario, initial, { kind: "cancelled" }), - ).toMatchObject({ kind: "finished", outcome: "cancelled" }); - expect( - reduceProcessReactiveScenario(scenario, initial, { - kind: "cleanup_failed", - }), - ).toMatchObject({ kind: "finished", outcome: "cleanup_failed" }); - const passed = offer( - scenario, - initial, - observation("terminal_raw", 0, { data: "Ready" }), - ); - expect( - reduceProcessReactiveScenario(scenario, passed.snapshot, { - kind: "cleanup_failed", - }), - ).toMatchObject({ kind: "finished", outcome: "cleanup_failed" }); - const first = offer( - scenario, - initial, - observation("shim", 1, { name: "later" }), - ); - const invalid = offer( - scenario, - first.snapshot, - observation("shim", 0, { name: "earlier" }), - ); - expect(invalid).toMatchObject({ - kind: "finished", - outcome: "capture_incomplete", - }); - const duplicate = observation("shim", 1, { name: "later" }); - expect(offer(scenario, first.snapshot, duplicate)).toMatchObject({ - kind: "finished", - outcome: "capture_incomplete", - }); - expect( - offer(scenario, first.snapshot, { - ...duplicate, - payload: { name: "changed" }, - }), - ).toMatchObject({ kind: "finished", outcome: "capture_incomplete" }); - }); -}); diff --git a/tests/replayMachineRuntime.test.ts b/tests/replayMachineRuntime.test.ts deleted file mode 100644 index 500c69eae..000000000 --- a/tests/replayMachineRuntime.test.ts +++ /dev/null @@ -1,581 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { replayMachineSchema } from "../src/domain/replayMachine.js"; -import { ReplayMachineRuntime } from "../src/domain/replayMachineRuntime.js"; - -const machine = replayMachineSchema.parse({ - initial_state: "login", - states: [ - { name: "login" }, - { name: "api" }, - { name: "complete", terminal: true }, - ], - transitions: [ - { - id: "capture_token", - from: "login", - to: "api", - priority: 10, - trigger: { protocol: "http", method: "POST", path: "/login" }, - captures: [ - { - variable: "session_value", - value: { source: "request_json", path: ["session_value"] }, - sensitive: true, - }, - ], - actions: [{ type: "http_response", status: 204, headers: {}, body: "" }], - max_uses: 1, - }, - { - id: "authorize_api", - from: "api", - to: "complete", - priority: 10, - trigger: { protocol: "http", method: "GET", path: "/api" }, - guards: [ - { - variable: "session_value", - value: { source: "request_header", name: "authorization" }, - }, - ], - actions: [ - { type: "http_response", status: 200, headers: {}, body: "done" }, - ], - max_uses: 1, - }, - ], - max_transitions: 2, -}); - -const event = ( - method: string, - path: string, - body = "", - headers: Readonly> = {}, -) => ({ - protocol: "http" as const, - connection: "not_applicable" as const, - at_ms: 0, - method, - path, - body, - headers, -}); - -describe("replay machine runtime", () => { - it("captures secret values in memory and journals aliases only", () => { - const runtime = new ReplayMachineRuntime(machine); - expect( - runtime.dispatch( - event("POST", "/login", '{"session_value":"test-auth-token"}'), - ), - ).toMatchObject({ - outcome: "matched", - transition: { - transition_id: "capture_token", - sensitive_aliases: ["session_value"], - }, - }); - const expectedRedaction = ["Bearer <", "secret", ":session_value>"].join( - "", - ); - expect(runtime.redact("Bearer test-auth-token")).toBe(expectedRedaction); - expect( - runtime.dispatch( - event("GET", "/api", "", { Authorization: "test-auth-token" }), - ), - ).toMatchObject({ outcome: "matched" }); - expect(JSON.stringify(runtime.timeline)).not.toContain("test-auth-token"); - expect(runtime.state).toBe("complete"); - }); - - it("classifies wrong-state, guard, capture, and exhaustion failures", () => { - expect( - new ReplayMachineRuntime(machine).dispatch(event("GET", "/api")), - ).toMatchObject({ outcome: "invalid_state" }); - expect( - new ReplayMachineRuntime(machine).dispatch(event("POST", "/login", "{}")), - ).toMatchObject({ outcome: "invalid_capture" }); - const runtime = new ReplayMachineRuntime(machine); - runtime.dispatch( - event("POST", "/login", '{"session_value":"test-auth-token"}'), - ); - expect( - runtime.dispatch( - event("GET", "/api", "", { - authorization: "test-token-placeholder", - }), - ), - ).toMatchObject({ - outcome: "guard_failed", - }); - const bounded = new ReplayMachineRuntime( - replayMachineSchema.parse({ - initial_state: "active", - states: [{ name: "active" }, { name: "done", terminal: true }], - transitions: [ - { - id: "once", - from: "active", - to: "active", - trigger: { protocol: "http", method: "GET", path: "/once" }, - actions: [ - { type: "http_response", status: 200, headers: {}, body: "ok" }, - ], - max_uses: 1, - }, - { - id: "finish", - from: "active", - to: "done", - trigger: { protocol: "http", method: "GET", path: "/done" }, - actions: [ - { type: "http_response", status: 200, headers: {}, body: "ok" }, - ], - max_uses: 1, - }, - ], - max_transitions: 2, - }), - ); - expect(bounded.dispatch(event("GET", "/once"))).toMatchObject({ - outcome: "matched", - }); - expect(bounded.dispatch(event("GET", "/once"))).toMatchObject({ - outcome: "transition_exhausted", - }); - }); - - it("matches bounded headers and bodies and distinguishes wrong-state reconnects", () => { - const runtime = new ReplayMachineRuntime( - replayMachineSchema.parse({ - initial_state: "connected", - states: [{ name: "connected" }, { name: "done", terminal: true }], - transitions: [ - { - id: "finish", - from: "connected", - to: "done", - trigger: { - protocol: "http", - method: "POST", - path: "/finish", - headers: { "content-type": "application/json" }, - body: '{"done":true}', - }, - actions: [ - { type: "http_response", status: 204, headers: {}, body: "" }, - ], - max_uses: 1, - }, - { - id: "initial_socket", - from: "connected", - to: "connected", - trigger: { protocol: "websocket_connect", path: "/ws" }, - actions: [{ type: "disconnect" }], - max_uses: 1, - }, - ], - max_transitions: 2, - }), - ); - expect(runtime.dispatch(event("POST", "/finish", "wrong"))).toMatchObject({ - outcome: "unmatched", - }); - expect( - runtime.dispatch({ - protocol: "websocket_connect", - connection: "initial", - at_ms: 0, - method: null, - path: "/ws", - headers: {}, - body: "", - }), - ).toMatchObject({ outcome: "matched" }); - expect( - runtime.dispatch({ - protocol: "websocket_connect", - connection: "reconnect", - at_ms: 0, - method: null, - path: "/ws", - headers: {}, - body: "", - }), - ).toMatchObject({ outcome: "transition_exhausted" }); - expect( - runtime.dispatch( - event("POST", "/finish", '{"done":true}', { - "content-type": "application/json", - }), - ), - ).toMatchObject({ outcome: "matched" }); - expect( - runtime.dispatch({ - protocol: "websocket_connect", - connection: "reconnect", - at_ms: 0, - method: null, - path: "/ws", - headers: {}, - body: "", - }), - ).toMatchObject({ outcome: "unexpected_reconnect" }); - }); - - it("enforces state, connection, message, byte, and duration limits", () => { - const limitedMachine = replayMachineSchema.parse({ - initial_state: "active", - states: [ - { name: "active", max_visits: 1 }, - { name: "done", terminal: true }, - ], - transitions: [ - { - id: "loop", - from: "active", - to: "active", - trigger: { protocol: "websocket_message", path: "/ws" }, - actions: [{ type: "websocket_send", data: "ack" }], - max_uses: 2, - }, - { - id: "finish", - from: "active", - to: "done", - trigger: { protocol: "http", method: "GET", path: "/done" }, - actions: [ - { type: "http_response", status: 200, headers: {}, body: "ok" }, - ], - max_uses: 1, - }, - ], - max_transitions: 3, - limits: { connections: 1, messages: 1, bytes: 100, duration_ms: 10 }, - }); - const stateLimited = new ReplayMachineRuntime(limitedMachine); - expect( - stateLimited.dispatch({ - protocol: "websocket_message", - connection: "not_applicable", - at_ms: 0, - method: null, - path: "/ws", - headers: {}, - body: "one", - }), - ).toMatchObject({ outcome: "limit_exhausted" }); - const messageLimited = new ReplayMachineRuntime( - replayMachineSchema.parse({ - ...limitedMachine, - states: limitedMachine.states.map((state) => - state.name === "active" ? { ...state, max_visits: 3 } : state, - ), - }), - ); - const message = { - protocol: "websocket_message" as const, - connection: "not_applicable" as const, - at_ms: 0, - method: null, - path: "/ws", - headers: {}, - body: "one", - }; - expect(messageLimited.dispatch(message)).toMatchObject({ - outcome: "matched", - }); - expect(messageLimited.dispatch(message)).toMatchObject({ - outcome: "limit_exhausted", - }); - const durationLimited = new ReplayMachineRuntime(limitedMachine); - expect( - durationLimited.dispatch({ ...event("GET", "/done"), at_ms: 11 }), - ).toMatchObject({ outcome: "limit_exhausted" }); - expect( - new ReplayMachineRuntime(limitedMachine).dispatch({ - ...event("GET", "/done"), - at_ms: Number.NaN, - }), - ).toMatchObject({ outcome: "limit_exhausted" }); - const monotonic = new ReplayMachineRuntime(limitedMachine); - expect( - monotonic.dispatch({ ...event("GET", "/missing"), at_ms: 5 }), - ).toMatchObject({ outcome: "unmatched" }); - expect( - monotonic.dispatch({ ...event("GET", "/missing"), at_ms: 4 }), - ).toMatchObject({ outcome: "limit_exhausted" }); - const byteLimited = new ReplayMachineRuntime( - replayMachineSchema.parse({ - ...limitedMachine, - limits: { ...limitedMachine.limits, bytes: 4 }, - }), - ); - expect( - byteLimited.dispatch(event("GET", "/missing", "12345")), - ).toMatchObject({ outcome: "limit_exhausted" }); - const connectionLimited = new ReplayMachineRuntime(limitedMachine); - const connect = { - protocol: "websocket_connect" as const, - connection: "initial" as const, - at_ms: 0, - method: null, - path: "/unknown", - headers: {}, - body: "", - }; - expect(connectionLimited.dispatch(connect)).toMatchObject({ - outcome: "unmatched", - }); - expect( - connectionLimited.dispatch({ ...connect, connection: "reconnect" }), - ).toMatchObject({ outcome: "limit_exhausted" }); - }); - - it("compares captured JSON values canonically", () => { - const objectMachine = replayMachineSchema.parse({ - initial_state: "capture", - states: [ - { name: "capture" }, - { name: "guard" }, - { name: "done", terminal: true }, - ], - transitions: [ - { - id: "capture_profile", - from: "capture", - to: "guard", - trigger: { protocol: "http", method: "POST", path: "/capture" }, - captures: [ - { - variable: "profile", - value: { source: "request_json", path: ["profile"] }, - }, - ], - actions: [ - { type: "http_response", status: 204, headers: {}, body: "" }, - ], - max_uses: 1, - }, - { - id: "guard_profile", - from: "guard", - to: "done", - trigger: { protocol: "http", method: "POST", path: "/guard" }, - guards: [ - { - variable: "profile", - value: { source: "request_json", path: ["profile"] }, - }, - ], - actions: [ - { type: "http_response", status: 204, headers: {}, body: "" }, - ], - max_uses: 1, - }, - ], - max_transitions: 2, - }); - const runtime = new ReplayMachineRuntime(objectMachine); - - expect( - runtime.dispatch( - event("POST", "/capture", '{"profile":{"name":"A","id":1}}'), - ), - ).toMatchObject({ outcome: "matched" }); - expect( - runtime.dispatch( - event("POST", "/guard", '{"profile":{"id":1,"name":"A"}}'), - ), - ).toMatchObject({ outcome: "matched" }); - }); - - it("does not traverse inherited properties in JSON capture paths", () => { - const inheritedPathMachine = replayMachineSchema.parse({ - ...machine, - transitions: machine.transitions.map((transition, index) => - index === 0 - ? { - ...transition, - captures: [ - { - variable: "session_value", - value: { - source: "request_json" as const, - path: ["__proto__", "toString"], - }, - sensitive: true, - }, - ], - } - : transition, - ), - }); - - expect( - new ReplayMachineRuntime(inheritedPathMachine).dispatch( - event("POST", "/login", "{}"), - ), - ).toMatchObject({ outcome: "invalid_capture" }); - }); - - it("rejects non-string sensitive captures and retains rotated values", () => { - expect( - new ReplayMachineRuntime(machine).dispatch( - event("POST", "/login", '{"session_value":1234}'), - ), - ).toMatchObject({ outcome: "invalid_capture" }); - - const rotatingMachine = replayMachineSchema.parse({ - initial_state: "active", - states: [ - { name: "active", max_visits: 3 }, - { name: "done", terminal: true }, - ], - transitions: [ - { - id: "rotate", - from: "active", - to: "active", - trigger: { protocol: "http", method: "POST", path: "/rotate" }, - captures: [ - { - variable: "session_value", - value: { source: "request_json", path: ["value"] }, - sensitive: true, - }, - ], - actions: [ - { type: "http_response", status: 204, headers: {}, body: "" }, - ], - max_uses: 2, - }, - { - id: "finish", - from: "active", - to: "done", - trigger: { protocol: "http", method: "GET", path: "/done" }, - actions: [ - { type: "http_response", status: 204, headers: {}, body: "" }, - ], - max_uses: 1, - }, - ], - max_transitions: 3, - }); - const rotating = new ReplayMachineRuntime(rotatingMachine); - rotating.dispatch(event("POST", "/rotate", '{"value":"alpha"}')); - rotating.dispatch(event("POST", "/rotate", '{"value":"beta"}')); - const alias = ["<", "secret", ":session_value>"].join(""); - - expect(rotating.redact("alpha beta")).toBe(`${alias} ${alias}`); - }); - - it("falls back to a lower-priority transition after a preferred one is exhausted", () => { - const fallbackMachine = replayMachineSchema.parse({ - initial_state: "active", - states: [ - { name: "active", max_visits: 2 }, - { name: "done", terminal: true }, - ], - transitions: [ - { - id: "preferred", - from: "active", - to: "active", - priority: 10, - trigger: { protocol: "http", method: "GET", path: "/next" }, - actions: [ - { type: "http_response", status: 200, headers: {}, body: "again" }, - ], - max_uses: 1, - }, - { - id: "fallback", - from: "active", - to: "done", - priority: 20, - trigger: { protocol: "http", method: "GET", path: "/next" }, - actions: [ - { type: "http_response", status: 200, headers: {}, body: "done" }, - ], - max_uses: 1, - }, - ], - max_transitions: 2, - }); - const runtime = new ReplayMachineRuntime(fallbackMachine); - - expect(runtime.dispatch(event("GET", "/next"))).toMatchObject({ - outcome: "matched", - transition: { transition_id: "preferred" }, - }); - expect(runtime.dispatch(event("GET", "/next"))).toMatchObject({ - outcome: "matched", - transition: { transition_id: "fallback" }, - }); - }); - - it("rejects non-finite parsed JSON captures and guard values", () => { - const jsonMachine = replayMachineSchema.parse({ - initial_state: "capture", - states: [ - { name: "capture" }, - { name: "guard" }, - { name: "done", terminal: true }, - ], - transitions: [ - { - id: "capture_value", - from: "capture", - to: "guard", - trigger: { protocol: "http", method: "POST", path: "/capture" }, - captures: [ - { - variable: "value", - value: { source: "request_json", path: ["value"] }, - }, - ], - actions: [ - { type: "http_response", status: 204, headers: {}, body: "" }, - ], - max_uses: 1, - }, - { - id: "guard_value", - from: "guard", - to: "done", - trigger: { protocol: "http", method: "POST", path: "/guard" }, - guards: [ - { - variable: "value", - value: { source: "request_json", path: ["value"] }, - }, - ], - actions: [ - { type: "http_response", status: 204, headers: {}, body: "" }, - ], - max_uses: 1, - }, - ], - max_transitions: 2, - }); - - expect( - new ReplayMachineRuntime(jsonMachine).dispatch( - event("POST", "/capture", '{"value":1e400}'), - ), - ).toMatchObject({ outcome: "invalid_capture" }); - - const guarded = new ReplayMachineRuntime(jsonMachine); - expect( - guarded.dispatch(event("POST", "/capture", '{"value":null}')), - ).toMatchObject({ outcome: "matched" }); - expect( - guarded.dispatch(event("POST", "/guard", '{"value":1e400}')), - ).toMatchObject({ outcome: "guard_failed" }); - }); -}); diff --git a/tests/setup.test.ts b/tests/setup.test.ts deleted file mode 100644 index 17f25ddb1..000000000 --- a/tests/setup.test.ts +++ /dev/null @@ -1,725 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - registrationPermissionEnvironment, - runSetup, - type ClientConfigurationInspection, - type ClientConfigurationResult, - type SetupClient, - type SetupHost, - type SetupOptions, - type SetupHopperInstallResult, - type SetupProgressEvent, - type SetupProviderEnvironment, -} from "../src/application/Setup.js"; -import type { - DoctorCheck, - DoctorReport, - DoctorScope, -} from "../src/application/Doctor.js"; -import type { LinuxDistribution } from "../src/application/LinuxHopper.js"; - -class FakeSetupHost implements SetupHost { - readonly platform: NodeJS.Platform; - nodeVersion = "25.1.0"; - version: string | undefined = "14.5"; - distribution: LinuxDistribution | undefined; - hopper: string | undefined; - ghidra: string | undefined; - javaHome: string | undefined; - hopperInstallSucceeds = true; - skill: "installed" | "unchanged" | "failed" = "installed"; - clients: readonly SetupClient[] = []; - clientResults = new Map(); - clientInspections = new Map(); - hopperInstalls = 0; - hopperReplaceRequests: boolean[] = []; - configurations = 0; - skillInstalls = 0; - doctorCalls = 0; - doctorScopes: Array = []; - checkedHopperPaths: Array = []; - configuredProviderEnvironments: SetupProviderEnvironment[] = []; - doctorHealthy: boolean | undefined; - scopedDoctorHealthy: boolean | undefined; - linuxDemoRuntimeMissing = false; - unsupportedHopperVersion = false; - - constructor(platform: NodeJS.Platform = "darwin") { - this.platform = platform; - } - - macosVersion = (): Promise => - Promise.resolve(this.version); - linuxDistribution = (): Promise => - Promise.resolve(this.distribution); - hopperPath = (): Promise => Promise.resolve(this.hopper); - providerEnvironment = (): Promise => - Promise.resolve({ - ...(this.hopper === undefined - ? {} - : { HOPPER_LAUNCHER_PATH: this.hopper }), - ...(this.ghidra === undefined ? {} : { GHIDRA_INSTALL_DIR: this.ghidra }), - ...(this.javaHome === undefined ? {} : { JAVA_HOME: this.javaHome }), - }); - installHopper = ( - replaceExisting: boolean, - ): Promise => { - this.hopperInstalls += 1; - this.hopperReplaceRequests.push(replaceExisting); - this.linuxDemoRuntimeMissing = false; - if (this.hopperInstallSucceeds) { - this.hopper = "/manual/Hopper"; - return Promise.resolve({ - status: "installed", - launcherPath: this.hopper, - }); - } - return Promise.resolve({ - status: "failed", - code: "download_failed", - remediation: "Download failed.", - }); - }; - detectedClients = (): Promise => - Promise.resolve(this.clients); - configureClient = ( - client: SetupClient, - providerEnvironment: SetupProviderEnvironment, - ): Promise => { - this.configurations += 1; - this.configuredProviderEnvironments.push(providerEnvironment); - return Promise.resolve( - this.clientResults.get(client.name) ?? { status: "configured" }, - ); - }; - clientNeedsConfigure = ( - client: SetupClient, - providerEnvironment: SetupProviderEnvironment, - ): Promise => { - this.checkedHopperPaths.push(providerEnvironment.HOPPER_LAUNCHER_PATH); - return Promise.resolve( - this.clientResults.get(client.name)?.status !== "unchanged", - ); - }; - inspectClientConfiguration = ( - client: SetupClient, - ): Promise => - Promise.resolve( - this.clientInspections.get(client.name) ?? { status: "update" }, - ); - skillNeedsInstall = (): Promise => - Promise.resolve(this.skill !== "unchanged"); - installSkill = (): Promise<"installed" | "unchanged" | "failed"> => { - this.skillInstalls += 1; - return Promise.resolve(this.skill); - }; - doctor = (scope?: DoctorScope): Promise => { - this.doctorCalls += 1; - this.doctorScopes.push(scope); - const checks: DoctorCheck[] = [ - ...(this.linuxDemoRuntimeMissing - ? ([ - { - name: "hopper-demo-runtime", - ok: false, - classification: "missing_dependency", - }, - ] as const) - : []), - ...(this.unsupportedHopperVersion - ? ([ - { - name: "hopper-version", - ok: false, - classification: "config_drift", - detail: this.hopper ?? "", - }, - ] as const) - : []), - ]; - const environmentHealthy = - this.doctorHealthy ?? - (this.hopper !== undefined && - !this.linuxDemoRuntimeMissing && - !this.unsupportedHopperVersion); - const healthy = - scope === undefined - ? environmentHealthy - : (this.scopedDoctorHealthy ?? environmentHealthy); - return Promise.resolve({ - healthy, - environment_healthy: environmentHealthy, - scope: { - mode: scope === undefined ? "audit-wide" : "explicit", - clients: scope?.clients ?? [], - providers: scope?.providers ?? [], - skill: scope?.skill === true, - target: null, - }, - scope_checks: checks, - informational_checks: [], - ...(this.hopper === undefined ? {} : { hopperPath: this.hopper }), - checks, - }); - }; -} - -const options = (approved: boolean, installHopper = false): SetupOptions => ({ - approved, - installHopper, - structured: true, -}); - -describe("setup workflow", () => { - it("propagates only the explicit non-secret investigation root policy", () => { - expect( - registrationPermissionEnvironment({ - REA_INVESTIGATION_INPUT_ROOTS_JSON: '["/approved/apps"]', - AUTHORIZATION: "secret", - }), - ).toEqual({ - REA_INVESTIGATION_INPUT_ROOTS_JSON: '["/approved/apps"]', - }); - expect(registrationPermissionEnvironment({})).toEqual({}); - }); - - it("returns a complete plan without mutation", async () => { - const host = new FakeSetupHost(); - host.clients = [{ name: "cursor", configPath: "/cursor.json" }]; - const result = await runSetup(options(false), host); - expect(result.status).toBe("needs_confirmation"); - expect(result.plannedActions.map(({ kind }) => kind)).toEqual([ - "install_hopper", - "configure_client", - "install_skill", - ]); - expect(host.hopperInstalls).toBe(0); - expect(host.configurations).toBe(0); - expect(result.remediation).toBe( - "Review the setup plan, then rerun interactively or with --yes.", - ); - }); - - it("reports ready after an accepted Linux plan installs healthy Hopper", async () => { - const host = new FakeSetupHost("linux"); - host.distribution = { - id: "ubuntu", - versionId: "24.04", - packageFamily: "deb", - supported: true, - }; - const result = await runSetup( - { ...options(false), structured: false }, - host, - () => Promise.resolve(true), - ); - expect(result.status).toBe("ready"); - expect(result.appliedActions).toEqual([ - "installed_hopper", - "installed_skill", - ]); - expect(host.hopperInstalls).toBe(1); - expect(result.remediation).toBeUndefined(); - }); - - it("requires first-run activation after installing Hopper on macOS", async () => { - const host = new FakeSetupHost(); - const result = await runSetup( - { ...options(false), structured: false }, - host, - () => Promise.resolve(true), - ); - expect(result.status).toBe("needs_human"); - expect(result.remediation).toContain("choose its demo mode"); - }); - - it("declines an interactive plan without mutation", async () => { - const host = new FakeSetupHost(); - const result = await runSetup( - { ...options(false), structured: false }, - host, - () => Promise.resolve(false), - ); - expect(result.status).toBe("planned"); - expect(result.appliedActions).toEqual([]); - expect(host.hopperInstalls).toBe(0); - expect(host.doctorCalls).toBe(1); - }); - - it("treats an empty interactive selection as cancellation", async () => { - const host = new FakeSetupHost(); - host.clients = [{ name: "codex", configPath: "/codex.toml" }]; - - const result = await runSetup( - { ...options(false), structured: false }, - host, - () => Promise.resolve({ approved: false, selectedActionIds: [] }), - ); - - expect(result.status).toBe("planned"); - expect(result.plannedActions).toEqual([]); - expect(result.appliedActions).toEqual([]); - expect(host.configurations).toBe(0); - expect(host.skillInstalls).toBe(0); - expect(host.hopperInstalls).toBe(0); - expect(host.doctorCalls).toBe(1); - }); - - it("requires the Hopper flag for unattended setup", async () => { - const host = new FakeSetupHost(); - const result = await runSetup(options(true), host); - expect(result.status).toBe("needs_human"); - expect(host.hopperInstalls).toBe(0); - expect(result.appliedActions).toEqual(["installed_skill"]); - expect(result.remediation).toContain("--install-hopper"); - expect(result.remediation).toBe( - "Hopper is optional for non-Hopper providers. Rerun with --yes --install-hopper for deep native analysis.", - ); - }); - - it("installs Hopper when unattended authorization is explicit", async () => { - const host = new FakeSetupHost("linux"); - host.distribution = { - id: "ubuntu", - versionId: "24.04", - packageFamily: "deb", - supported: true, - }; - const result = await runSetup(options(true, true), host); - expect(result.appliedActions).toEqual([ - "installed_hopper", - "installed_skill", - ]); - expect(host.hopperInstalls).toBe(1); - expect(result.status).toBe("ready"); - expect(result.remediation).toBeUndefined(); - }); - - it("reuses existing Hopper and configures detected clients", async () => { - const host = new FakeSetupHost(); - host.hopper = "/Applications/Hopper"; - host.clients = [ - { name: "claude", configPath: "/claude.json" }, - { name: "cursor", configPath: "/cursor.json" }, - ]; - const result = await runSetup(options(true), host); - expect(result.status).toBe("ready"); - expect(result.appliedActions).toEqual([ - "configured_claude", - "configured_cursor", - "installed_skill", - ]); - expect(host.hopperInstalls).toBe(0); - }); - - it("reports append-only lifecycle progress for each selected action", async () => { - const host = new FakeSetupHost(); - host.hopper = "/Applications/Hopper"; - host.clients = [ - { - name: "codex", - displayName: "Codex", - configPath: "/codex.toml", - }, - ]; - const progress: SetupProgressEvent[] = []; - await runSetup( - { ...options(true), onProgress: (event) => progress.push(event) }, - host, - ); - expect(progress).toEqual([ - { actionId: "configure_client:codex", label: "Codex", state: "started" }, - { - actionId: "configure_client:codex", - label: "Codex", - state: "completed", - }, - { - actionId: "install_skill", - label: "REA reverse-engineering skill", - state: "started", - }, - { - actionId: "install_skill", - label: "REA reverse-engineering skill", - state: "completed", - }, - ]); - }); - - it("plans and propagates BYO Ghidra without installing software", async () => { - const host = new FakeSetupHost("linux"); - host.distribution = { - id: "ubuntu", - versionId: "24.04", - packageFamily: "deb", - supported: true, - }; - host.ghidra = "/opt/ghidra_12.1.2_PUBLIC"; - host.javaHome = "/usr/lib/jvm/jdk-21"; - host.doctorHealthy = true; - host.skill = "unchanged"; - host.clients = [{ name: "cursor", configPath: "/cursor.json" }]; - - const plan = await runSetup(options(false), host); - expect(plan.plannedActions.map(({ kind }) => kind)).toEqual([ - "configure_client", - ]); - expect(plan.plannedActions[0]?.detail).toContain( - "GHIDRA_INSTALL_DIR=/opt/ghidra_12.1.2_PUBLIC", - ); - expect(plan.plannedActions[0]?.detail).toContain( - "JAVA_HOME=/usr/lib/jvm/jdk-21", - ); - expect(host.hopperInstalls).toBe(0); - expect(host.configurations).toBe(0); - - const applied = await runSetup(options(true), host); - expect(applied.status).toBe("ready"); - expect(host.hopperInstalls).toBe(0); - expect(host.configuredProviderEnvironments).toContainEqual({ - GHIDRA_INSTALL_DIR: "/opt/ghidra_12.1.2_PUBLIC", - JAVA_HOME: "/usr/lib/jvm/jdk-21", - }); - }); - - it("reinstalls existing Hopper when explicitly requested", async () => { - const host = new FakeSetupHost(); - host.hopper = "/Applications/Hopper"; - host.skill = "unchanged"; - host.clients = [{ name: "codex", configPath: "/codex.toml" }]; - host.clientResults.set("codex", { status: "unchanged" }); - - const result = await runSetup(options(true, true), host); - - expect(result.plannedActions.map(({ kind }) => kind)).toEqual([ - "install_hopper", - "configure_client", - ]); - expect(result.appliedActions).toEqual(["installed_hopper"]); - expect(host.hopperInstalls).toBe(1); - expect(host.hopperReplaceRequests).toEqual([true]); - expect(host.configurations).toBe(0); - expect(host.checkedHopperPaths).toEqual(["/manual/Hopper"]); - }); - - it("omits an aligned managed skill from an otherwise empty plan", async () => { - const host = new FakeSetupHost(); - host.hopper = "/Applications/Hopper"; - host.skill = "unchanged"; - const result = await runSetup(options(false), host); - expect(result.status).toBe("ready"); - expect(result.plannedActions).toEqual([]); - expect(result.appliedActions).toEqual([]); - expect(host.skillInstalls).toBe(0); - }); - - it("installs missing Linux demo dependencies for existing Hopper", async () => { - const host = new FakeSetupHost("linux"); - host.distribution = { - id: "ubuntu", - versionId: "24.04", - packageFamily: "deb", - supported: true, - }; - host.hopper = "/opt/hopper/bin/Hopper"; - host.linuxDemoRuntimeMissing = true; - const result = await runSetup( - { ...options(false), structured: false }, - host, - () => Promise.resolve(true), - ); - expect(result.status).toBe("ready"); - expect(host.hopperInstalls).toBe(1); - expect(result.plannedActions.map(({ kind }) => kind)).toContain( - "install_hopper", - ); - }); - - it("does not reinstall Hopper for an unsupported configured path", async () => { - const host = new FakeSetupHost("linux"); - host.distribution = { - id: "ubuntu", - versionId: "24.04", - packageFamily: "deb", - supported: true, - }; - host.hopper = "/custom/Hopper"; - host.unsupportedHopperVersion = true; - const result = await runSetup( - { ...options(false), structured: false }, - host, - () => Promise.resolve(true), - ); - expect(result.status).toBe("needs_human"); - expect(host.hopperInstalls).toBe(0); - expect(result.plannedActions.map(({ kind }) => kind)).not.toContain( - "install_hopper", - ); - }); - - it("reports a failed Hopper installation without configuring clients", async () => { - const host = new FakeSetupHost(); - host.hopperInstallSucceeds = false; - host.clients = [{ name: "cursor", configPath: "/cursor.json" }]; - const result = await runSetup(options(true, true), host); - expect(result.status).toBe("needs_human"); - expect(result.code).toBe("download_failed"); - expect(result.remediation).toBe("Download failed."); - expect(host.configurations).toBe(0); - }); - - it("blocks malformed client configuration before Hopper or file mutation", async () => { - const host = new FakeSetupHost(); - host.clients = [ - { name: "codex", displayName: "Codex", configPath: "/codex.toml" }, - ]; - host.clientInspections.set("codex", { - status: "invalid", - remediation: "Repair the existing TOML.", - }); - - const result = await runSetup(options(true, true), host); - - expect(result.status).toBe("needs_human"); - expect(result.remediation).toBe("Codex: Repair the existing TOML."); - expect(host.hopperInstalls).toBe(0); - expect(host.configurations).toBe(0); - expect(host.skillInstalls).toBe(0); - }); - - it("blocks malformed configuration before unrelated writes with an existing provider", async () => { - const host = new FakeSetupHost(); - host.hopper = "/Applications/Hopper"; - host.clients = [ - { - name: "claude_desktop", - displayName: "Claude Desktop", - configPath: "/claude.json", - }, - { name: "cursor", displayName: "Cursor", configPath: "/cursor.json" }, - ]; - host.clientInspections.set("claude_desktop", { - status: "invalid", - remediation: "Repair the existing JSON.", - }); - - const result = await runSetup(options(true), host); - - expect(result.status).toBe("needs_human"); - expect(result.remediation).toBe( - "Claude Desktop: Repair the existing JSON.", - ); - expect(host.configurations).toBe(0); - expect(host.skillInstalls).toBe(0); - }); - - it("plans a client update when Hopper installation changes its desired environment", async () => { - const host = new FakeSetupHost(); - host.clients = [ - { name: "codex", displayName: "Codex", configPath: "/codex.toml" }, - ]; - host.clientInspections.set("codex", { status: "already_current" }); - - const result = await runSetup(options(false), host); - - expect( - result.plannedActions.map(({ id, operation }) => ({ - id, - operation, - })), - ).toContainEqual({ - id: "configure_client:codex", - operation: "update", - }); - expect(host.configurations).toBe(0); - }); - - it("executes only actions selected by the interactive adapter", async () => { - const host = new FakeSetupHost(); - host.hopper = "/Applications/Hopper"; - host.clients = [ - { name: "codex", displayName: "Codex", configPath: "/codex.toml" }, - { name: "cursor", displayName: "Cursor", configPath: "/cursor.json" }, - ]; - - const result = await runSetup( - { ...options(false), structured: false }, - host, - () => - Promise.resolve({ - approved: true, - selectedActionIds: ["configure_client:codex"], - }), - ); - - expect(result.plannedActions.map(({ id }) => id)).toEqual([ - "configure_client:codex", - ]); - expect(result.clients).toEqual({ codex: { status: "configured" } }); - expect(host.configurations).toBe(1); - expect(host.skillInstalls).toBe(0); - }); - - it.each([ - [ - "path", - "Agent configuration path could not be safely verified. Check its permissions and, if it is a symbolic link, verify that the link resolves to a regular file owned by the current user, then rerun setup.", - ], - [ - "backup", - "Agent configuration could not be backed up, so no change was made. Check file permissions, then rerun setup.", - ], - [ - "write", - "Agent configuration could not be updated. Check file permissions, then rerun setup.", - ], - [ - "readback", - "Agent configuration could not be verified after writing. Repair the configuration file or restore its `.rea.backup`, then rerun setup.", - ], - ] as const)("explains %s configuration recovery", async (reason, message) => { - const host = new FakeSetupHost(); - host.hopper = "/Applications/Hopper"; - host.clients = [{ name: "internal_client_key", configPath: "/agent.json" }]; - host.clientResults.set("internal_client_key", { status: "failed", reason }); - const result = await runSetup(options(true), host); - expect(result.status).toBe("needs_human"); - expect(result.remediation).toBe(message); - expect(result.remediation).not.toContain("internal_client_key"); - }); - - it("records every detected client outcome after an earlier failure", async () => { - const host = new FakeSetupHost(); - host.hopper = "/Applications/Hopper"; - host.clients = [ - { name: "first", configPath: "/first.json" }, - { name: "second", configPath: "/second.json" }, - { name: "third", configPath: "/third.json" }, - ]; - host.clientResults.set("first", { status: "failed", reason: "write" }); - host.clientResults.set("second", { status: "configured" }); - host.clientResults.set("third", { status: "failed", reason: "readback" }); - - const result = await runSetup(options(true), host); - - expect(host.configurations).toBe(3); - expect(result.clients).toEqual({ - first: { status: "failed", reason: "write" }, - second: { status: "configured" }, - third: { status: "failed", reason: "readback" }, - }); - expect(result.appliedActions).toEqual(["configured_second"]); - expect(result.status).toBe("needs_human"); - expect(result.remediation).toContain("could not be updated"); - }); - - it("explains skill installation recovery", async () => { - const host = new FakeSetupHost(); - host.hopper = "/Applications/Hopper"; - host.skill = "failed"; - const result = await runSetup(options(true), host); - expect(result.status).toBe("needs_human"); - expect(result.remediation).toBe( - "REA analysis skill could not be installed or verified. Check permissions for `~/.agents/skills`, then rerun setup.", - ); - }); - - it("delegates remaining unhealthy checks to doctor remediation", async () => { - const host = new FakeSetupHost(); - host.hopper = "/Applications/Hopper"; - host.doctorHealthy = false; - const result = await runSetup(options(true), host); - expect(result.remediation).toBe( - "Run rea doctor and apply each reported remediation.", - ); - }); - - it("uses requested readiness scope without hiding environment drift", async () => { - const host = new FakeSetupHost(); - host.hopper = "/Applications/Hopper"; - host.doctorHealthy = false; - host.scopedDoctorHealthy = true; - const readinessScope = { - clients: ["codex"], - providers: [] as string[], - skill: true, - }; - - const result = await runSetup( - { - ...options(true), - proposeHopper: false, - installSkill: false, - readinessScope, - }, - host, - ); - - expect(result).toMatchObject({ - status: "ready", - doctor: { healthy: true, environment_healthy: false }, - }); - expect(host.doctorScopes).toEqual(expect.arrayContaining([readinessScope])); - }); - - it("explains unsupported Node and macOS recovery", async () => { - const platformHost = new FakeSetupHost("win32"); - expect((await runSetup(options(true), platformHost)).remediation).toBe( - "REA supports Hopper on macOS and selected 64-bit Linux distributions.", - ); - - const nodeHost = new FakeSetupHost(); - nodeHost.nodeVersion = "20.0.0"; - expect((await runSetup(options(true), nodeHost)).remediation).toBe( - "Install Node.js 22.19+ or 24.11+ and rerun setup.", - ); - - const macHost = new FakeSetupHost(); - macHost.version = "11.7"; - expect((await runSetup(options(true), macHost)).remediation).toBe( - "Upgrade to macOS 12 or newer.", - ); - }); - - it("rejects unsupported hosts before mutation", async () => { - const host = new FakeSetupHost("linux"); - host.distribution = { - id: "debian", - versionId: "13", - packageFamily: "deb", - supported: false, - }; - const result = await runSetup(options(true, true), host); - expect(result.status).toBe("needs_human"); - expect(host.hopperInstalls).toBe(0); - expect(result.remediation).toBe( - "Automated Hopper setup supports Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux; configure an existing supported provider instead.", - ); - }); - - it("configures BYO Ghidra on Linux outside Hopper's installer matrix", async () => { - const host = new FakeSetupHost("linux"); - host.distribution = { - id: "debian", - versionId: "13", - packageFamily: "deb", - supported: false, - }; - host.ghidra = "/opt/ghidra_12.1.2_PUBLIC"; - host.javaHome = "/usr/lib/jvm/jdk-21"; - host.doctorHealthy = true; - host.skill = "unchanged"; - host.clients = [{ name: "codex", configPath: "/codex.toml" }]; - - const result = await runSetup(options(true), host); - - expect(result.status).toBe("ready"); - expect(host.hopperInstalls).toBe(0); - expect(host.configuredProviderEnvironments).toContainEqual({ - GHIDRA_INSTALL_DIR: "/opt/ghidra_12.1.2_PUBLIC", - JAVA_HOME: "/usr/lib/jvm/jdk-21", - }); - }); -}); diff --git a/tests/support/cli/cliFixture.ts b/tests/support/cli/cliFixture.ts new file mode 100644 index 000000000..ad67889ea --- /dev/null +++ b/tests/support/cli/cliFixture.ts @@ -0,0 +1,69 @@ +import { resolve } from "node:path"; + +import { + processTest, + type ProcessResult, + type TestProcesses, +} from "../process/processFixture.js"; + +/** Caller-controlled values for one compiled REA CLI invocation. */ +export interface CliInvocation { + readonly arguments: readonly string[]; + readonly cwd?: string; + readonly environment?: NodeJS.ProcessEnv; + readonly input?: string | Uint8Array; + readonly timeoutMs?: number; +} + +/** Normalized result from the compiled REA CLI. */ +export interface CliResult extends ProcessResult { + readonly json: unknown | undefined; +} + +/** Compiled REA CLI driver owned by one test. */ +export interface TestCli { + run(invocation: CliInvocation): Promise; +} + +const parseJson = (stdout: string): unknown | undefined => { + const document = stdout.trim(); + if (document.length === 0) return undefined; + try { + return JSON.parse(document) as unknown; + } catch { + return undefined; + } +}; + +/** Create a compiled CLI driver backed by the supplied process registry. */ +export const createTestCli = (processes: TestProcesses): TestCli => ({ + run: async ({ + arguments: arguments_, + cwd, + environment, + input, + timeoutMs, + }) => { + const result = await processes.run( + process.execPath, + [resolve("scripts/rea.mjs"), ...arguments_], + { + cwd: cwd ?? process.cwd(), + env: { + PATH: process.env.PATH ?? "", + ...environment, + }, + ...(input === undefined ? {} : { input }), + ...(timeoutMs === undefined ? {} : { timeoutMs }), + }, + ); + return { ...result, json: parseJson(result.stdout) }; + }, +}); + +/** Vitest API extended with an owned process registry and compiled CLI driver. */ +export const cliTest = processTest.extend<{ cli: TestCli }>({ + cli: async ({ processes }, use) => { + await use(createTestCli(processes)); + }, +}); diff --git a/tests/support/fixtures.ts b/tests/support/fixtures.ts new file mode 100644 index 000000000..672c6fdee --- /dev/null +++ b/tests/support/fixtures.ts @@ -0,0 +1,61 @@ +import { cliTest } from "./cli/cliFixture.js"; +import { + createTestLoopback, + type TestLoopback, +} from "./network/loopbackFixture.js"; +import { createTestMcp, type TestMcp } from "./mcp/mcpFixture.js"; +import { + createTestWorkspace, + removeTestWorkspace, + type TestWorkspace, +} from "./workspace/workspaceFixture.js"; + +const withWorkspace = cliTest.extend<{ workspace: TestWorkspace }>({ + // oxlint-disable-next-line no-empty-pattern -- Vitest parses fixture dependencies from object destructuring. + workspace: async ({}, use) => { + const workspace = await createTestWorkspace(); + try { + await use(workspace); + } finally { + await removeTestWorkspace(workspace.root); + } + }, +}); + +const withLoopback = withWorkspace.extend<{ loopback: TestLoopback }>({ + // oxlint-disable-next-line no-empty-pattern -- Vitest parses fixture dependencies from object destructuring. + loopback: async ({}, use) => { + const loopback = createTestLoopback(); + try { + await use(loopback); + } finally { + await loopback.close(); + } + }, +}); + +/** Vitest API with all reusable, test-scoped REA resource fixtures. */ +export const fixtureTest = withLoopback.extend<{ mcp: TestMcp }>({ + // oxlint-disable-next-line no-empty-pattern -- Vitest parses fixture dependencies from object destructuring. + mcp: async ({}, use) => { + const mcp = createTestMcp(); + try { + await use(mcp); + } finally { + await mcp.close(); + } + }, +}); + +export type { CliInvocation, CliResult, TestCli } from "./cli/cliFixture.js"; +export type { + LoopbackEndpoint, + TestLoopback, +} from "./network/loopbackFixture.js"; +export type { McpClientIdentity, TestMcp } from "./mcp/mcpFixture.js"; +export type { + ProcessResult, + ProcessRunOptions, + TestProcesses, +} from "./process/processFixture.js"; +export type { TestWorkspace } from "./workspace/workspaceFixture.js"; diff --git a/tests/support/mcp/mcpFixture.ts b/tests/support/mcp/mcpFixture.ts new file mode 100644 index 000000000..2eef34a96 --- /dev/null +++ b/tests/support/mcp/mcpFixture.ts @@ -0,0 +1,72 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import type { McpServer } from "@modelcontextprotocol/server"; +import { test as base } from "vitest"; + +/** Client identity used during an in-memory MCP negotiation. */ +export interface McpClientIdentity { + readonly name: string; + readonly version: string; +} + +/** Registry for production MCP servers and SDK clients owned by one test. */ +export interface TestMcp { + connect(server: McpServer, identity?: McpClientIdentity): Promise; + close(): Promise; +} + +const DEFAULT_CLIENT_IDENTITY: McpClientIdentity = { + name: "rea-vitest", + version: "1.0.0", +}; + +/** Create an MCP connection registry for a fixture composition root. */ +export const createTestMcp = (): TestMcp => { + const connections: { readonly client: Client; readonly server: McpServer }[] = + []; + + return { + connect: async (server, identity = DEFAULT_CLIENT_IDENTITY) => { + const client = new Client(identity); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + } catch (cause) { + await Promise.allSettled([client.close(), server.close()]); + throw cause; + } + connections.push({ client, server }); + return client; + }, + close: async () => { + const results = await Promise.allSettled( + [...connections].reverse().map(async ({ client, server }) => { + try { + await client.close(); + } finally { + await server.close(); + } + }), + ); + const failure = results.find( + (result): result is PromiseRejectedResult => + result.status === "rejected", + ); + if (failure !== undefined) throw failure.reason; + }, + }; +}; + +/** Vitest API extended with production-server/in-memory-client MCP wiring. */ +export const mcpTest = base.extend<{ mcp: TestMcp }>({ + // oxlint-disable-next-line no-empty-pattern -- Vitest parses fixture dependencies from object destructuring. + mcp: async ({}, use) => { + const mcp = createTestMcp(); + try { + await use(mcp); + } finally { + await mcp.close(); + } + }, +}); diff --git a/tests/support/network/loopbackFixture.ts b/tests/support/network/loopbackFixture.ts new file mode 100644 index 000000000..404222a7b --- /dev/null +++ b/tests/support/network/loopbackFixture.ts @@ -0,0 +1,72 @@ +import { createServer as createHttpServer } from "node:http"; +import type { RequestListener } from "node:http"; +import type { AddressInfo, Server, Socket } from "node:net"; + +/** A listening server bound to an ephemeral IPv4 loopback port. */ +export interface LoopbackEndpoint { + readonly host: "127.0.0.1"; + readonly port: number; + readonly origin: string; +} + +/** Registry for loopback servers and accepted sockets owned by one test. */ +export interface TestLoopback { + listen(server: Server): Promise; + http(listener: RequestListener): Promise; + close(): Promise; +} + +const closeServer = (server: Server): Promise => + new Promise((resolve, reject) => { + if (!server.listening) { + resolve(); + return; + } + server.close((error) => { + if (error === undefined) resolve(); + else reject(error); + }); + }); + +/** Create a loopback registry for a fixture composition root. */ +export const createTestLoopback = (): TestLoopback => { + const servers: Server[] = []; + const sockets = new Set(); + + const listen = async (server: Server): Promise => { + server.on("connection", (socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.removeListener("error", reject); + resolve(); + }); + }); + servers.push(server); + const address = server.address() as AddressInfo; + return { + host: "127.0.0.1", + port: address.port, + origin: `http://127.0.0.1:${address.port}`, + }; + }; + + return { + listen, + http: (listener) => listen(createHttpServer(listener)), + close: async () => { + for (const socket of sockets) socket.destroy(); + const results = await Promise.allSettled( + [...servers].reverse().map(closeServer), + ); + const failure = results.find( + (result): result is PromiseRejectedResult => + result.status === "rejected", + ); + if (failure !== undefined) throw failure.reason; + }, + }; +}; diff --git a/tests/support/process/processFixture.ts b/tests/support/process/processFixture.ts new file mode 100644 index 000000000..f96497c7d --- /dev/null +++ b/tests/support/process/processFixture.ts @@ -0,0 +1,157 @@ +import { + spawn as nodeSpawn, + type ChildProcessWithoutNullStreams, + type SpawnOptionsWithoutStdio, +} from "node:child_process"; + +import { test as base } from "vitest"; + +const SHUTDOWN_GRACE_MS = 1_000; + +/** Captured output and termination state from one child process. */ +export interface ProcessResult { + readonly exitCode: number | null; + readonly signal: NodeJS.Signals | null; + readonly stdout: string; + readonly stderr: string; +} + +/** Options for a bounded, output-capturing child process invocation. */ +export interface ProcessRunOptions extends SpawnOptionsWithoutStdio { + readonly timeoutMs?: number; + readonly input?: string | Uint8Array; +} + +/** Registry for child processes owned by one test. */ +export interface TestProcesses { + spawn( + command: string, + arguments_: readonly string[], + options?: SpawnOptionsWithoutStdio, + ): ChildProcessWithoutNullStreams; + run( + command: string, + arguments_: readonly string[], + options?: ProcessRunOptions, + ): Promise; + shutdown(): Promise; +} + +const waitForExit = ( + child: ChildProcessWithoutNullStreams, + timeoutMs: number, +): Promise => + new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve(true); + return; + } + const timer = setTimeout(() => { + child.removeListener("exit", onExit); + resolve(false); + }, timeoutMs); + timer.unref(); + const onExit = (): void => { + clearTimeout(timer); + resolve(true); + }; + child.once("exit", onExit); + }); + +const stopChild = async ( + child: ChildProcessWithoutNullStreams, +): Promise => { + if (child.exitCode !== null || child.signalCode !== null) return; + child.kill("SIGTERM"); + if (await waitForExit(child, SHUTDOWN_GRACE_MS)) return; + child.kill("SIGKILL"); + await waitForExit(child, SHUTDOWN_GRACE_MS); +}; + +/** Create a child-process registry for a fixture composition root. */ +export const createTestProcessRegistry = (): TestProcesses => { + const children = new Set(); + + const spawn = ( + command: string, + arguments_: readonly string[], + options: SpawnOptionsWithoutStdio = {}, + ): ChildProcessWithoutNullStreams => { + const child = nodeSpawn(command, arguments_, { + ...options, + stdio: ["pipe", "pipe", "pipe"], + }); + children.add(child); + child.once("exit", () => children.delete(child)); + return child; + }; + + return { + spawn, + run: async (command, arguments_, options = {}) => { + const { input, timeoutMs, ...spawnOptions } = options; + const child = spawn(command, arguments_, spawnOptions); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk)); + child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk)); + if (input === undefined) child.stdin.end(); + else child.stdin.end(input); + + let timedOut = false; + let killTimeout: NodeJS.Timeout | undefined; + const timeout = + timeoutMs === undefined + ? undefined + : setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + killTimeout = setTimeout(() => { + child.kill("SIGKILL"); + }, SHUTDOWN_GRACE_MS); + killTimeout.unref(); + }, timeoutMs); + timeout?.unref(); + + const result = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", (exitCode, signal) => + resolve({ + exitCode, + signal, + stdout: Buffer.concat(stdout).toString("utf8"), + stderr: Buffer.concat(stderr).toString("utf8"), + }), + ); + }).finally(() => { + if (timeout !== undefined) clearTimeout(timeout); + if (killTimeout !== undefined) clearTimeout(killTimeout); + }); + if (timedOut) { + throw new Error(`Test process exceeded its ${timeoutMs}ms deadline`, { + cause: result, + }); + } + return result; + }, + shutdown: async () => { + await Promise.allSettled([...children].reverse().map(stopChild)); + if (children.size > 0) { + throw new Error(`${children.size} test child process(es) leaked`); + } + }, + }; +}; + +/** Vitest API extended with a bounded child-process registry. */ +export const processTest = base.extend<{ processes: TestProcesses }>({ + // oxlint-disable-next-line no-empty-pattern -- Vitest parses fixture dependencies from object destructuring. + processes: async ({}, use) => { + const processes = createTestProcessRegistry(); + try { + await use(processes); + } finally { + await processes.shutdown(); + } + }, +}); diff --git a/tests/support/workspace/workspaceFixture.ts b/tests/support/workspace/workspaceFixture.ts new file mode 100644 index 000000000..db8bb551a --- /dev/null +++ b/tests/support/workspace/workspaceFixture.ts @@ -0,0 +1,88 @@ +import { + mkdir, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; + +/** An isolated filesystem and environment projection owned by one test. */ +export interface TestWorkspace { + readonly root: string; + readonly home: string; + readonly xdgConfigHome: string; + readonly xdgCacheHome: string; + readonly environment: NodeJS.ProcessEnv; + path(...segments: readonly string[]): string; + mkdir(relativePath: string): Promise; + read(relativePath: string): Promise; + write(relativePath: string, contents: string | Uint8Array): Promise; +} + +/** Remove a workspace previously returned by createTestWorkspace. */ +export const removeTestWorkspace = async (root: string): Promise => { + await rm(root, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 25, + }); +}; + +/** Create one isolated workspace for a fixture composition root. */ +export const createTestWorkspace = async ( + prefix = "rea-test-workspace-", +): Promise => { + const canonicalTemporaryRoot = await realpath(tmpdir()); + const root = await realpath( + await mkdtemp(join(canonicalTemporaryRoot, prefix)), + ); + const home = join(root, "home"); + const xdgConfigHome = join(root, "xdg", "config"); + const xdgCacheHome = join(root, "xdg", "cache"); + await Promise.all([ + mkdir(home, { recursive: true }), + mkdir(xdgConfigHome, { recursive: true }), + mkdir(xdgCacheHome, { recursive: true }), + ]); + + const resolvePath = (...segments: readonly string[]): string => { + const path = resolve(root, ...segments); + const displacement = relative(root, path); + if (displacement === ".." || displacement.startsWith(`..${sep}`)) { + throw new TypeError("Test workspace paths must remain beneath its root"); + } + if (isAbsolute(displacement)) { + throw new TypeError("Test workspace paths must remain beneath its root"); + } + return path; + }; + + return { + root, + home, + xdgConfigHome, + xdgCacheHome, + environment: { + HOME: home, + XDG_CONFIG_HOME: xdgConfigHome, + XDG_CACHE_HOME: xdgCacheHome, + }, + path: resolvePath, + mkdir: async (relativePath) => { + const path = resolvePath(relativePath); + await mkdir(path, { recursive: true }); + return path; + }, + read: (relativePath) => readFile(resolvePath(relativePath), "utf8"), + write: async (relativePath, contents) => { + const path = resolvePath(relativePath); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, contents); + return path; + }, + }; +}; diff --git a/tests/vitestConfiguration.test.ts b/tests/vitestConfiguration.test.ts deleted file mode 100644 index 29fb50edb..000000000 --- a/tests/vitestConfiguration.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { execFile } from "node:child_process"; -import { readdir } from "node:fs/promises"; -import { availableParallelism } from "node:os"; -import { join, resolve } from "node:path"; -import { promisify } from "node:util"; - -import { describe, expect, it } from "vitest"; - -import vitestConfiguration, { - SERIAL_INTEGRATION_TESTS, - SUBPROCESS_TESTS, -} from "../vitest.config.js"; - -const execute = promisify(execFile); - -describe("Vitest project configuration", () => { - it("keeps coverage and noisy reporting out of the local test loop", () => { - const continuousIntegration = process.env.CI === "true"; - - expect(vitestConfiguration.test?.coverage?.enabled).toBe(false); - expect(vitestConfiguration.test?.reporters).toEqual(["default"]); - expect(vitestConfiguration.test?.retry).toBe(continuousIntegration ? 2 : 0); - expect(vitestConfiguration.test?.maxWorkers).toBe( - Math.min(2, availableParallelism()), - ); - }); - - it("collects exact disjoint unit, subprocess, and serial test sets", async () => { - const { stdout } = await execute( - process.execPath, - [ - resolve("node_modules/vitest/vitest.mjs"), - "list", - "--filesOnly", - "--staticParse", - ], - { cwd: process.cwd(), maxBuffer: 4 * 1_024 * 1_024 }, - ); - const projects = parseProjects(stdout); - const parallel = projects.get("parallel") ?? []; - const subprocess = projects.get("subprocess") ?? []; - const serial = projects.get("serial-integration") ?? []; - const repositoryTests = await testFiles("tests"); - - expect(serial).toEqual([...SERIAL_INTEGRATION_TESTS].sort()); - expect(parallel.filter((path) => serial.includes(path))).toEqual([]); - expect(subprocess).toEqual([...SUBPROCESS_TESTS].sort()); - expect(subprocess.filter((path) => serial.includes(path))).toEqual([]); - expect(parallel.filter((path) => subprocess.includes(path))).toEqual([]); - expect( - [...new Set([...parallel, ...subprocess, ...serial])].sort(), - ).toEqual(repositoryTests); - }, 20_000); -}); - -const parseProjects = (output: string): Map => { - const projects = new Map(); - for (const line of output.split("\n")) { - const trimmed = line.trim(); - const separator = trimmed.indexOf("] "); - if (!trimmed.startsWith("[") || separator < 2) continue; - const project = trimmed.slice(1, separator); - const path = trimmed.slice(separator + 2); - if (path.length === 0) continue; - const paths = projects.get(project) ?? []; - paths.push(path); - projects.set(project, paths); - } - for (const paths of projects.values()) paths.sort(); - return projects; -}; - -const testFiles = async (root: string): Promise => { - const files: string[] = []; - for (const entry of await readdir(root, { withFileTypes: true })) { - const path = join(root, entry.name); - if (entry.isDirectory()) files.push(...(await testFiles(path))); - else if (entry.isFile() && entry.name.endsWith(".test.ts")) - files.push(path); - } - return files.sort(); -}; diff --git a/vitest.config.ts b/vitest.config.ts index 7fc5abf80..e14e70cc9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,92 +4,87 @@ import { join } from "node:path"; import { defineConfig } from "vitest/config"; -export const TEST_FILES = ["tests/**/*.test.ts"]; -const IS_CONTINUOUS_INTEGRATION = process.env.CI === "true"; -// Lazy MCP integration tests are memory-bound while Vite transforms tool families. -const MAX_TEST_WORKERS = 2; -const MAX_UNIT_TEST_WORKERS = 1; -const PROCESS_CAPTURE_TEST = "tests/processCapture.test.ts"; const CANONICAL_TEMPORARY_DIRECTORY = realpathSync(tmpdir()); -export const SERIAL_INTEGRATION_TESTS = [ - "tests/applicationWorkflowCli.test.ts", - "tests/browserCli.test.ts", - "tests/cliOutput.test.ts", - "tests/electronCli.test.ts", - "tests/javascriptArtifactReconstruction.test.ts", - PROCESS_CAPTURE_TEST, - "tests/runtime.test.ts", +const COVERAGE_ENABLED = process.argv.some((argument) => + argument.startsWith("--coverage"), +); +export const MAX_TEST_WORKERS = Math.min(4, availableParallelism()); + +export const TEST_PROJECTS = [ + { + name: "domain", + include: ["src/domain/**/*.test.ts"], + pool: "threads" as const, + maxWorkers: MAX_TEST_WORKERS, + }, + { + name: "services", + include: ["src/application/**/*.test.ts"], + pool: "threads" as const, + maxWorkers: MAX_TEST_WORKERS, + }, + { + name: "adapters", + include: [ + "src/{artifacts,browser,dotnet,ghidra,hopper,native,process,reference,replay}/**/*.test.ts", + ], + pool: "forks" as const, + maxWorkers: MAX_TEST_WORKERS, + }, + { + name: "composition", + include: ["tests/composition/**/*.test.ts"], + pool: "threads" as const, + maxWorkers: MAX_TEST_WORKERS, + }, + { + name: "boundary", + include: ["tests/boundary/**/*.test.ts"], + pool: "forks" as const, + maxWorkers: MAX_TEST_WORKERS, + }, + { + name: "acceptance", + include: ["tests/acceptance/**/*.test.ts"], + pool: "forks" as const, + maxWorkers: 1, + }, + { + name: "process-global", + include: ["tests/process-global/**/*.test.ts"], + pool: "forks" as const, + fileParallelism: false, + }, + { + name: "conformance", + include: ["tests/conformance/**/*.test.ts"], + pool: "threads" as const, + maxWorkers: MAX_TEST_WORKERS, + }, + { + name: "evaluation", + include: ["tests/evaluation/**/*.test.ts"], + pool: "threads" as const, + maxWorkers: MAX_TEST_WORKERS, + }, ]; -// Keep tests that launch child processes or production runtimes in the forks pool. -// In-memory MCP and filesystem-seam tests remain safe in the faster threads pool. -export const SUBPROCESS_TESTS = [ - "tests/applicationWorkflowVerifier.test.ts", - "tests/bridgeLauncher.test.ts", - "tests/bridgeRegex.test.ts", - "tests/browserProcessStartup.test.ts", - "tests/cliPolicyCommands.test.ts", - "tests/cliSetup.test.ts", - "tests/dependencyInstall.test.ts", - "tests/dispatcher.test.ts", - "tests/ghidraClient.test.ts", - "tests/ghidraWindowsFixture.test.ts", - "tests/hopperClient.test.ts", - "tests/installScript.test.ts", - "tests/javascriptReplayWorker.test.ts", - "tests/javascriptRuntimeReconciliation.test.ts", - "tests/linuxPrivateDisplay.test.ts", - "tests/mcpDoctor.test.ts", - "tests/processCli.test.ts", - "tests/providerProcess.test.ts", - "tests/runtimeExecutableDiagnostics.test.ts", - "tests/scanTodos.test.ts", - "tests/sessionMcp.test.ts", - "tests/verifierRun.test.ts", - "tests/vitestConfiguration.test.ts", -]; -const isolatedProjects = { - maxWorkers: Math.min(MAX_TEST_WORKERS, availableParallelism()), - projects: [ - { - extends: true as const, - test: { - name: "parallel", - include: TEST_FILES, - exclude: [...SERIAL_INTEGRATION_TESTS, ...SUBPROCESS_TESTS], - pool: "threads", - maxWorkers: Math.min(MAX_UNIT_TEST_WORKERS, availableParallelism()), - sequence: { groupOrder: 0 }, - }, - }, - { - extends: true as const, - test: { - name: "subprocess", - include: SUBPROCESS_TESTS, - pool: "forks", - maxWorkers: Math.min(MAX_TEST_WORKERS, availableParallelism()), - sequence: { groupOrder: 1 }, - }, - }, - { - extends: true as const, - test: { - name: "serial-integration", - include: SERIAL_INTEGRATION_TESTS, - fileParallelism: false, - pool: "forks", - sequence: { groupOrder: 2 }, - }, - }, - ], -}; + +const projects = TEST_PROJECTS.map((project) => ({ + extends: true as const, + test: project, +})); export default defineConfig({ test: { env: { TMPDIR: CANONICAL_TEMPORARY_DIRECTORY }, - ...isolatedProjects, - retry: IS_CONTINUOUS_INTEGRATION ? 2 : 0, + maxWorkers: MAX_TEST_WORKERS, + projects, + retry: 0, reporters: ["default"], + // Boundary projects may compete with TypeScript, docs, and package checks + // under Turbo. Keep the deadline bounded while avoiding false failures from + // host-level CPU and filesystem contention. + testTimeout: COVERAGE_ENABLED ? 30_000 : 15_000, coverage: { enabled: false, provider: "v8", @@ -103,6 +98,18 @@ export default defineConfig({ branches: 60, functions: 60, lines: 68, + "src/domain/**": { + statements: 80, + branches: 75, + functions: 75, + lines: 80, + }, + "src/contracts/**": { + statements: 85, + branches: 80, + functions: 80, + lines: 85, + }, }, reporter: ["text", "text-summary"], },