From e07b81649e96668e5ce166cfc6db3b59a7777697 Mon Sep 17 00:00:00 2001
From: morluto <76467478+morluto@users.noreply.github.com>
Date: Mon, 5 Oct 2026 21:15:04 +0800
Subject: [PATCH] refactor!: simplify runtime tools and remove replay engines
(#572)
* fix(hopper): report unavailable procedure body coverage
* refactor!: simplify runtime tools and remove replay engines
Keep direct inspection, capture, and comparison tools. Bind handlers to named contracts and let agents compose experiments with commands and local fixture servers.
BREAKING CHANGE: Remove controlled replay, Node characterization, finite replay-machine, managed runtime planning, and redundant string-tracing tools. Retire process replay, shims, reactive scenarios, custom checkpoints, and active browser replay and origin-scope inputs.
---
.github/workflows/real-javascript-replay.yml | 40 --
AGENTS.md | 10 +-
README.md | 44 +-
README_ar.md | 24 +-
README_ja.md | 10 +-
README_ko.md | 10 +-
README_zh.md | 10 +-
bridge/hopper_bridge.py | 21 +-
...controlled-replay-authority-and-sandbox.md | 15 +-
...ged-code-evidence-and-provider-boundary.md | 43 +-
docs/adr/README.md | 10 +-
docs/architecture.mermaid | 1 -
docs/browser-scenario-contract.md | 43 +-
docs/controlled-javascript-replay.md | 97 ---
docs/error-contract.schema.json | 34 -
docs/javascript-application-graph.md | 2 +-
docs/javascript-application-workflows.md | 30 +-
docs/javascript-runtime-observation.md | 2 +-
docs/managed-code-analysis.md | 58 +-
docs/mcp-contracts.md | 8 +-
docs/process-capture.md | 278 ++------
docs/product-catalog.json | 37 +-
docs/roadmap.md | 4 +-
docs/testing.md | 11 +-
docs/tool-design.md | 16 +-
.../managed-conformance-ledger.json | 13 +-
.../managed-conformance-manifest.json | 18 +-
docs/windows-ghidra-p0.md | 6 +-
package.json | 1 -
scripts/lib/browser-scenario-verifier.mjs | 2 -
scripts/lib/browser-screenshot-e2e.mjs | 1 -
scripts/lib/managed-completion-report.mjs | 5 -
scripts/verify-agent-experience.mjs | 10 +-
scripts/verify-managed-conformance.mjs | 57 +-
scripts/verify-package-managed.mjs | 70 --
scripts/verify-package.mjs | 2 +-
scripts/verify-real-browser.mjs | 7 +-
scripts/verify-real-javascript-replay.mjs | 314 ---------
skills/reverse-engineer-anything/SKILL.md | 14 +-
.../references/controlled-replay.md | 12 -
.../references/javascript-applications.md | 5 +-
src/application/BrowserScenarioEvidence.ts | 5 +-
src/application/CapabilityInventory.test.ts | 21 -
src/application/CapabilityInventory.ts | 12 -
src/application/CommandShimReplay.ts | 202 ------
.../ControlledReplayHostSupport.test.ts | 12 -
.../ControlledReplayHostSupport.ts | 5 -
.../Doctor.runtimeExecutables.test.ts | 66 --
src/application/Doctor.test.ts | 27 -
src/application/Doctor.ts | 35 +-
src/application/DoctorDiagnostics.ts | 44 +-
src/application/DoctorScope.ts | 3 +-
src/application/ElectronActiveEvidence.ts | 4 +-
.../EnhancedLiteralTracing.test.ts | 24 -
src/application/EnhancedLiteralTracing.ts | 17 +-
src/application/EnhancedTools.ts | 24 +-
.../InstrumentedJavaScriptReplayHost.ts | 63 --
.../JavaScriptReplayPlanning.test.ts | 107 ---
src/application/JavaScriptReplayPlanning.ts | 325 ---------
src/application/JavaScriptReplayService.ts | 430 ------------
src/application/LoopbackReplay.ts | 531 ---------------
src/application/LoopbackReplayRecorder.ts | 180 -----
.../ManagedRuntimeCorrelationService.ts | 95 ---
.../NodeRuntimeCharacterizationService.ts | 342 ----------
src/application/ProcessCaptureEnvironment.ts | 40 +-
src/application/ProcessCaptureJournal.test.ts | 29 +-
src/application/ProcessCaptureJournal.ts | 51 +-
src/application/ProcessCaptureLifecycle.ts | 74 +-
.../ProcessCaptureObservation.test.ts | 50 +-
src/application/ProcessCheckpoints.ts | 216 ------
src/application/ProcessEvidence.test.ts | 41 +-
src/application/ProcessEvidence.ts | 4 +-
src/application/ProcessFilesystemEffects.ts | 45 ++
src/application/ProcessHarness.test.ts | 37 +
src/application/ProcessHarness.ts | 181 +----
src/application/ProcessNormalization.ts | 30 -
.../ProcessReactiveCoordinator.test.ts | 301 --------
src/application/ProcessReactiveCoordinator.ts | 300 --------
...cessReactiveCoordinatorFailures.fixture.ts | 77 ---
...ProcessReactiveCoordinatorFailures.test.ts | 198 ------
src/application/ProcessReactiveEffects.ts | 254 -------
src/application/ProcessReactiveHarness.ts | 185 -----
.../ProcessReactiveObservations.ts | 164 -----
src/application/ProcessTimer.test.ts | 12 -
.../RuntimeExecutableDiagnostics.ts | 390 -----------
...RuntimeExecutableDiagnostics.win32.test.ts | 80 ---
src/application/SetupSkill.ts | 1 -
src/browser/BrowserScenario.test.ts | 123 +---
.../PlaywrightBrowserScenarioProvider.test.ts | 2 -
.../PlaywrightBrowserScenarioProvider.ts | 1 -
.../PlaywrightScenarioArtifacts.test.ts | 3 -
src/browser/PlaywrightScenarioArtifacts.ts | 34 +-
src/browser/PlaywrightScenarioBrowser.ts | 18 -
src/browser/PlaywrightScenarioEvents.test.ts | 5 -
src/browser/PlaywrightScenarioEvents.ts | 117 +---
src/browser/PlaywrightScenarioSession.ts | 164 +----
src/cli.ts | 4 +-
src/cli/managedCommands.ts | 35 -
src/cliApplicationCommands.ts | 120 ----
src/cliCommandNames.ts | 5 -
src/cliEnvironmentSeam.test.ts | 4 -
src/cliProcessCommands.ts | 46 +-
src/config.test.ts | 28 -
src/config/environment.ts | 34 -
src/config/parseConfig.ts | 10 -
src/config/runtimeConfiguration.ts | 11 -
src/config/types.ts | 4 -
src/contracts/applicationToolContracts.ts | 142 +---
src/contracts/browserScenarioToolContracts.ts | 2 +-
src/contracts/browserToolContracts.ts | 1 -
src/contracts/enhancedInputs.ts | 1 -
src/contracts/errorSchemas.ts | 1 -
src/contracts/managedWorkflowExamples.ts | 23 -
src/contracts/managedWorkflowToolContracts.ts | 34 -
src/contracts/replayMachineExample.ts | 38 --
src/contracts/sessionToolSchemas.ts | 2 -
src/contracts/toolContractExamples.ts | 3 -
src/contracts/toolContracts.test.ts | 6 -
src/contracts/toolContracts.ts | 29 +-
src/contracts/toolEffects.ts | 17 -
src/contracts/toolOutputSchemaGroups.ts | 7 -
src/doctorRuntime.ts | 44 +-
src/domain/analysisErrorPresentation.ts | 7 -
src/domain/analysisErrorProjection.test.ts | 13 +-
src/domain/analysisErrorProjection.ts | 10 -
src/domain/browserScenario.test.ts | 78 +--
src/domain/browserScenario.ts | 162 +----
src/domain/browserScenarioCapture.test.ts | 8 +-
src/domain/browserScenarioCapture.ts | 1 -
src/domain/browserScenarioDiff.test.ts | 1 -
src/domain/browserScenarioDiff.ts | 4 +-
src/domain/browserScenarioValues.ts | 55 --
src/domain/changedBehavior.test.ts | 12 +-
src/domain/changedBehavior.ts | 2 -
src/domain/errors.ts | 16 +-
.../javascriptExportInstrumentation.test.ts | 99 ---
src/domain/javascriptExportInstrumentation.ts | 129 ----
src/domain/javascriptExportShapeComparison.ts | 7 +-
...scriptExportShapeComparisonSchemas.test.ts | 5 -
.../javascriptExportShapeComparisonSchemas.ts | 5 -
src/domain/javascriptReplay.ts | 420 ------------
src/domain/managedRuntimeCorrelation.ts | 207 ------
src/domain/nodeRuntimeCharacterization.ts | 90 ---
src/domain/processCapture.comparison.test.ts | 56 +-
.../processCapture.traceProjection.test.ts | 62 --
src/domain/processCapture.ts | 142 +---
src/domain/processCapture.validation.test.ts | 75 --
src/domain/processCaptureExample.ts | 9 -
.../processCaptureReactiveSchema.test.ts | 34 -
src/domain/processCaptureReactiveSchema.ts | 77 ---
.../processCaptureReactiveValidation.ts | 275 --------
src/domain/processCaptureValidation.ts | 227 +-----
src/domain/processComparison.ts | 40 +-
src/domain/processObservation.test.ts | 46 +-
src/domain/processObservation.ts | 33 +-
.../processReactiveCheckpointDataflow.ts | 41 --
src/domain/processReactiveMatching.ts | 275 --------
src/domain/processReactiveRuntime.fixture.ts | 144 ----
src/domain/processReactiveRuntime.test.ts | 531 ---------------
src/domain/processReactiveRuntime.ts | 287 --------
src/domain/processReactiveScenario.test.ts | 504 --------------
src/domain/processReactiveScenario.ts | 426 ------------
.../processReactiveScenarioPreflight.ts | 133 ----
src/domain/processReactiveTransition.ts | 310 ---------
src/domain/processScenario.test.ts | 319 ++-------
src/domain/processScenario.ts | 174 +----
src/domain/processTraceAdapters.test.ts | 165 -----
src/domain/processTraceComparison.fixture.ts | 110 +--
src/domain/processTraceComparison.test.ts | 229 +++----
src/domain/processTraceDimensionProjection.ts | 26 +-
src/domain/processTraceEvaluation.ts | 7 -
src/domain/processTraceSpecification.test.ts | 163 +----
src/domain/reconstructionVerification.test.ts | 29 +-
.../reconstructionVerificationSchemas.ts | 12 +-
src/domain/replayMachine.test.ts | 332 ---------
src/domain/replayMachine.ts | 383 -----------
src/domain/replayMachineRun.test.ts | 503 --------------
src/domain/replayMachineRun.ts | 206 ------
.../replayMachineRuntime.captures.test.ts | 308 ---------
src/domain/replayMachineRuntime.test.ts | 333 ---------
src/domain/replayMachineRuntime.ts | 462 -------------
src/domain/replayMachineUncapped.test.ts | 129 ----
src/domain/replayMachineValues.ts | 93 ---
src/domain/runtimeCharacterization.test.ts | 63 --
src/domain/runtimeCharacterization.ts | 95 ---
src/domain/staticRuntimeCorrelation.test.ts | 16 +-
src/domain/staticRuntimeCorrelation.ts | 11 +-
src/generatedMcpToolCatalog.ts | 2 +-
src/main.ts | 1 -
src/main/transport.ts | 8 -
src/replay/JavaScriptReplayWorker.ts | 560 ---------------
src/replay/JavaScriptReplayWorkerTypes.ts | 49 --
src/replay/LinuxJavaScriptReplayRunner.ts | 313 ---------
src/replay/LinuxJavaScriptReplaySandbox.ts | 152 -----
src/replay/LinuxRuntimeClosure.ts | 61 --
src/replay/LinuxSeccompPolicy.ts | 58 --
src/replay/ReplayOutcome.ts | 141 ----
src/replay/ReplayProcessLifecycle.ts | 121 ----
src/replay/ReplaySeccompFile.ts | 29 -
src/replay/ReplayWorkerProtocol.test.ts | 62 --
src/replay/ReplayWorkerProtocol.ts | 90 ---
src/replay/SystemJavaScriptReplayHost.ts | 220 ------
src/replay/seededRandom.test.ts | 43 --
src/replay/seededRandom.ts | 53 --
src/server/createServer.ts | 27 -
src/server/registerApplicationTools.ts | 4 -
.../characterization.ts | 89 ---
.../controlledReplay.ts | 53 --
.../registerApplicationTools/helpers.ts | 12 +-
src/server/registerApplicationTools/types.ts | 2 -
src/server/registerArtifactComparisonTool.ts | 4 +-
src/server/registerBundleComparisonTool.ts | 4 +-
src/server/registerCloseLifecycleTool.ts | 2 +-
src/server/registerEnhancedTools.ts | 56 +-
src/server/registerFunctionComparisonTool.ts | 4 +-
src/server/registerInvestigationTools.ts | 36 +-
src/server/registerManagedWorkflowTools.ts | 2 -
.../registerManagedWorkflowTools/contract.ts | 5 +-
.../planManagedRuntimeCorrelation.ts | 51 --
.../registerManagedWorkflowTools/types.ts | 2 -
src/server/registerProcessComparisonTool.ts | 6 +-
src/server/registerReplayMachineTool.ts | 28 -
src/server/registerSessionRecordTools.ts | 27 +-
src/server/registerSessionStatusTool.ts | 4 +-
src/server/registerSessionTools.ts | 87 +--
src/server/sessionAvailabilityPolicy.ts | 4 +-
.../applications/applicationReplayMcp.test.ts | 240 -------
.../applicationWorkflowCli.test.ts | 61 +-
.../applicationWorkflowVerifier.test.ts | 4 -
tests/boundary/cli/browserCli.test.ts | 1 -
tests/boundary/cli/productCatalog.test.ts | 4 -
.../boundary/cli/projectedErrorStatus.test.ts | 16 -
.../javascriptExportShapeComparison.test.ts | 8 +-
.../managedRuntimeCorrelation.test.ts | 178 -----
.../nodeRuntimeCharacterization.test.ts | 282 --------
.../filesystem/replaySeccompFile.test.ts | 44 --
.../filesystem/replaySourceRead.test.ts | 79 ---
tests/boundary/mcp/browserScenarioMcp.test.ts | 3 -
tests/boundary/mcp/createServer.test.ts | 1 -
....01-lists-the-complete-target-open.test.ts | 12 +-
tests/boundary/mcp/mainReload.test.ts | 85 +--
tests/boundary/mcp/managedMcp.test.ts | 49 --
tests/boundary/mcp/replayMachineMcp.test.ts | 70 --
tests/boundary/mcp/serverUnknownsMcp.test.ts | 15 +-
tests/boundary/mcp/toolSchemaValidity.test.ts | 18 -
.../process/processCaptureLifecycle.test.ts | 100 +--
.../process/processCaptureMultisource.test.ts | 253 -------
.../process/processCaptureReactive.test.ts | 399 -----------
.../processCaptureReplayMachine.test.ts | 215 ------
.../processCaptureReplayTransport.test.ts | 380 -----------
.../process/processCaptureTerminal.test.ts | 175 +----
.../runtimeExecutableDiagnostics.test.ts | 202 ------
.../hopper/hopperBridgeFacade.test.ts | 77 +++
.../providers/replay/javascriptReplay.test.ts | 435 ------------
.../replay/javascriptReplayWorker.test.ts | 644 ------------------
tests/fixtures/golden/README.md | 2 +-
tests/fixtures/hopperBridgeFacadeProbe.py | 32 +
typedoc.json | 8 +-
258 files changed, 1193 insertions(+), 22444 deletions(-)
delete mode 100644 .github/workflows/real-javascript-replay.yml
delete mode 100644 docs/controlled-javascript-replay.md
delete mode 100644 scripts/verify-real-javascript-replay.mjs
delete mode 100644 skills/reverse-engineer-anything/references/controlled-replay.md
delete mode 100644 src/application/CommandShimReplay.ts
delete mode 100644 src/application/ControlledReplayHostSupport.test.ts
delete mode 100644 src/application/ControlledReplayHostSupport.ts
delete mode 100644 src/application/Doctor.runtimeExecutables.test.ts
delete mode 100644 src/application/InstrumentedJavaScriptReplayHost.ts
delete mode 100644 src/application/JavaScriptReplayPlanning.test.ts
delete mode 100644 src/application/JavaScriptReplayPlanning.ts
delete mode 100644 src/application/JavaScriptReplayService.ts
delete mode 100644 src/application/LoopbackReplay.ts
delete mode 100644 src/application/LoopbackReplayRecorder.ts
delete mode 100644 src/application/ManagedRuntimeCorrelationService.ts
delete mode 100644 src/application/NodeRuntimeCharacterizationService.ts
delete mode 100644 src/application/ProcessCheckpoints.ts
create mode 100644 src/application/ProcessFilesystemEffects.ts
delete mode 100644 src/application/ProcessReactiveCoordinator.test.ts
delete mode 100644 src/application/ProcessReactiveCoordinator.ts
delete mode 100644 src/application/ProcessReactiveCoordinatorFailures.fixture.ts
delete mode 100644 src/application/ProcessReactiveCoordinatorFailures.test.ts
delete mode 100644 src/application/ProcessReactiveEffects.ts
delete mode 100644 src/application/ProcessReactiveHarness.ts
delete mode 100644 src/application/ProcessReactiveObservations.ts
delete mode 100644 src/application/RuntimeExecutableDiagnostics.ts
delete mode 100644 src/application/RuntimeExecutableDiagnostics.win32.test.ts
delete mode 100644 src/config/runtimeConfiguration.ts
delete mode 100644 src/contracts/replayMachineExample.ts
delete mode 100644 src/domain/javascriptExportInstrumentation.test.ts
delete mode 100644 src/domain/javascriptExportInstrumentation.ts
delete mode 100644 src/domain/javascriptReplay.ts
delete mode 100644 src/domain/managedRuntimeCorrelation.ts
delete mode 100644 src/domain/nodeRuntimeCharacterization.ts
delete mode 100644 src/domain/processCaptureReactiveSchema.test.ts
delete mode 100644 src/domain/processCaptureReactiveSchema.ts
delete mode 100644 src/domain/processCaptureReactiveValidation.ts
delete mode 100644 src/domain/processReactiveCheckpointDataflow.ts
delete mode 100644 src/domain/processReactiveMatching.ts
delete mode 100644 src/domain/processReactiveRuntime.fixture.ts
delete mode 100644 src/domain/processReactiveRuntime.test.ts
delete mode 100644 src/domain/processReactiveRuntime.ts
delete mode 100644 src/domain/processReactiveScenario.test.ts
delete mode 100644 src/domain/processReactiveScenario.ts
delete mode 100644 src/domain/processReactiveScenarioPreflight.ts
delete mode 100644 src/domain/processReactiveTransition.ts
delete mode 100644 src/domain/replayMachine.test.ts
delete mode 100644 src/domain/replayMachine.ts
delete mode 100644 src/domain/replayMachineRun.test.ts
delete mode 100644 src/domain/replayMachineRun.ts
delete mode 100644 src/domain/replayMachineRuntime.captures.test.ts
delete mode 100644 src/domain/replayMachineRuntime.test.ts
delete mode 100644 src/domain/replayMachineRuntime.ts
delete mode 100644 src/domain/replayMachineUncapped.test.ts
delete mode 100644 src/domain/replayMachineValues.ts
delete mode 100644 src/domain/runtimeCharacterization.test.ts
delete mode 100644 src/domain/runtimeCharacterization.ts
delete mode 100644 src/replay/JavaScriptReplayWorker.ts
delete mode 100644 src/replay/JavaScriptReplayWorkerTypes.ts
delete mode 100644 src/replay/LinuxJavaScriptReplayRunner.ts
delete mode 100644 src/replay/LinuxJavaScriptReplaySandbox.ts
delete mode 100644 src/replay/LinuxRuntimeClosure.ts
delete mode 100644 src/replay/LinuxSeccompPolicy.ts
delete mode 100644 src/replay/ReplayOutcome.ts
delete mode 100644 src/replay/ReplayProcessLifecycle.ts
delete mode 100644 src/replay/ReplaySeccompFile.ts
delete mode 100644 src/replay/ReplayWorkerProtocol.test.ts
delete mode 100644 src/replay/ReplayWorkerProtocol.ts
delete mode 100644 src/replay/SystemJavaScriptReplayHost.ts
delete mode 100644 src/replay/seededRandom.test.ts
delete mode 100644 src/replay/seededRandom.ts
delete mode 100644 src/server/registerApplicationTools/characterization.ts
delete mode 100644 src/server/registerApplicationTools/controlledReplay.ts
delete mode 100644 src/server/registerManagedWorkflowTools/planManagedRuntimeCorrelation.ts
delete mode 100644 src/server/registerReplayMachineTool.ts
delete mode 100644 tests/acceptance/applications/applicationReplayMcp.test.ts
delete mode 100644 tests/boundary/filesystem/managedRuntimeCorrelation.test.ts
delete mode 100644 tests/boundary/filesystem/nodeRuntimeCharacterization.test.ts
delete mode 100644 tests/boundary/filesystem/replaySeccompFile.test.ts
delete mode 100644 tests/boundary/filesystem/replaySourceRead.test.ts
delete mode 100644 tests/boundary/mcp/replayMachineMcp.test.ts
delete mode 100644 tests/boundary/process/processCaptureMultisource.test.ts
delete mode 100644 tests/boundary/process/processCaptureReactive.test.ts
delete mode 100644 tests/boundary/process/processCaptureReplayMachine.test.ts
delete mode 100644 tests/boundary/process/processCaptureReplayTransport.test.ts
delete mode 100644 tests/boundary/process/runtimeExecutableDiagnostics.test.ts
delete mode 100644 tests/boundary/providers/replay/javascriptReplay.test.ts
delete mode 100644 tests/boundary/providers/replay/javascriptReplayWorker.test.ts
diff --git a/.github/workflows/real-javascript-replay.yml b/.github/workflows/real-javascript-replay.yml
deleted file mode 100644
index 9181555f4..000000000
--- a/.github/workflows/real-javascript-replay.yml
+++ /dev/null
@@ -1,40 +0,0 @@
-name: Real JavaScript replay verification
-
-on:
- pull_request:
- paths:
- - ".github/workflows/real-javascript-replay.yml"
- - "scripts/verify-real-javascript-replay.mjs"
- - "src/replay/**"
- - "src/application/JavaScriptReplay*"
- - "src/domain/javascriptReplay.ts"
- - "tests/fixtures/replay/**"
- - "package.json"
- - "package-lock.json"
- workflow_dispatch:
-
-concurrency:
- group: real-replay-${{ github.event.pull_request.number || github.ref }}
- cancel-in-progress: ${{ github.event_name == 'pull_request' }}
-
-env:
- HUSKY: "0"
-
-permissions:
- contents: read
-
-jobs:
- real-replay:
- runs-on: ubuntu-latest
- timeout-minutes: 10
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
- with:
- node-version-file: .nvmrc
- cache: npm
- - run: npm ci
- - run: sudo apt-get update && sudo apt-get install -y bubblewrap
- - run: npm run verify:replay
- env:
- REA_REPLAY_ALLOW_UNAVAILABLE: "true"
diff --git a/AGENTS.md b/AGENTS.md
index 9d765539d..03dacd951 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -20,7 +20,7 @@ REA is a local-only tool. Preserve caller-selected inputs, captured output, URLs
REA is a layered ESM TypeScript application. Dependencies flow inward from pure domain logic through contracts, providers, application workflows, and CLI/MCP adapters. See [docs/architecture.mermaid](docs/architecture.mermaid) for the component map.
- `src/domain/` owns pure provider-neutral semantics; `src/contracts/` owns caller-visible schemas and the canonical tool inventory.
-- `src/hopper/`, `src/ghidra/`, `src/browser/`, `src/replay/`, `src/native/`, `src/artifacts/`, and `src/dotnet/` own provider-specific boundaries. Keep provider protocols out of domain and application code.
+- `src/hopper/`, `src/ghidra/`, `src/browser/`, `src/native/`, `src/artifacts/`, and `src/dotnet/` own provider-specific boundaries. Keep provider protocols out of domain and application code.
- `src/application/` composes shared CLI/MCP workflows; `src/server/` translates MCP requests; `src/cli.ts` and `src/main.ts` are the CLI and MCP entry points.
- `src/process/` owns shared process lifecycle primitives, not provider wire protocols. `bridge/` contains provider-side adapters.
- `tests/` contains unit, composition, boundary, acceptance, and conformance tests. `scripts/verify-*` contains real-toolchain checks.
@@ -58,10 +58,14 @@ Leave meaningful target, action, capture, and output choices to the agent. A sel
Verify the representation consumed at the next boundary. Internal validator success does not establish JSON Schema validity or client compatibility: validate advertised input and output schemas against their declared dialect after SDK conversion, and keep generated contracts aligned. Use representative producer data and regressions that exercise the failed behavior. Platform and provider support claims require the corresponding real workflow; package startup, capability probes, and mock transport tests establish only their narrower claims. Report unverified coverage explicitly.
+Bind tool handlers to named contracts, preserving their exact input and output types. Catalog ordering is presentation metadata; changing that order must not select a different schema or operation for an existing handler.
+
## Designing MCP Tools
Start from the analyst question and desired result, not a provider API. Before adding a tool, inspect the existing contract and its nearest alternative.
+Let agents compose experiments with ordinary commands, scripts, and local fixture servers. REA tools should perform the requested inspection or capture and return evidence. A custom orchestration language, replay engine, or separate prepare/execute plan needs an observed requirement that those primitives cannot satisfy. A plan-only tool without an executor does not establish runtime behavior; expose the useful inspection directly.
+
- Prefer reusable, composable primitives: inspect one explicit object or
relationship and return evidence that can support different analyst
questions. Compose primitives into a workflow only for a recurring outcome
@@ -69,7 +73,7 @@ Start from the analyst question and desired result, not a provider API. Before a
business-domain interpretations out of general tool contracts.
- Use **inspect/search** tools for facts about a target or candidate set; use **trace** tools for relationships; use **compare** tools for explicitly paired inputs.
- Add a **workflow** when observed agent use shows a repeated sequence that REA can compose without losing analyst control. Return useful results inline so callers can choose their next action.
-- Use **observe/capture/replay** only when runtime activity is required, and declare authority and lifecycle effects in the contract.
+- Use **observe/capture** only when runtime activity is required, and declare authority and lifecycle effects in the contract.
- Extend an existing tool when intent and result contract are unchanged. Add a tool for a distinct analyst outcome or materially different authority.
- Keep caller-facing names and results provider-neutral. Put engine-specific behavior in provider adapters and report each provider's exact coverage.
- Keep results complete by default. Add a limit only when it follows from a real format, protocol, authority, or resource-safety constraint; explain truncation and unsupported facets. Keep observed, derived, inferred, and unknown results distinct.
@@ -80,7 +84,7 @@ See [docs/tool-design.md](docs/tool-design.md) for the design checklist. When us
## Testing Guidelines
-Name tests `*.test.ts`. Use Vitest and production seams (`tests/fixtures/`) rather than module mocks. Domain tests assert pure behavior; adapter tests use fake launcher/socket seams; MCP tests connect with the client SDK version pinned in `package.json`. Preserve the canonical tool inventory defined by `TOOL_CONTRACTS` and verified through `CATALOG_IDENTITY` and generated product metadata. Cover malformed input, cancellation, lifecycle cleanup, and actual format, protocol, host-permission, and target-identity boundaries. Do not add tests that merely freeze arbitrary caps or prescribed call sequences. Real Hopper, Ghidra, browser, JavaScript replay, managed conformance, and any real managed-tool claims cannot be replaced by mocks; use the corresponding `verify:*` command.
+Name tests `*.test.ts`. Use Vitest and production seams (`tests/fixtures/`) rather than module mocks. Domain tests assert pure behavior; adapter tests use fake launcher/socket seams; MCP tests connect with the client SDK version pinned in `package.json`. Preserve the canonical tool inventory defined by `TOOL_CONTRACTS` and verified through `CATALOG_IDENTITY` and generated product metadata. Cover malformed input, cancellation, lifecycle cleanup, and actual format, protocol, host-permission, and target-identity boundaries. Do not add tests that merely freeze arbitrary caps or prescribed call sequences. Real Hopper, Ghidra, browser, managed conformance, and any real managed-tool claims cannot be replaced by mocks; use the corresponding `verify:*` command.
Keep tool catalogs complete and self-describing; prefer capability- and session-scoped availability over schema truncation. Serialized bytes alone do not measure agent usability or model context cost.
diff --git a/README.md b/README.md
index 941a3bdd5..13413c78f 100644
--- a/README.md
+++ b/README.md
@@ -261,10 +261,10 @@ rea analyze /absolute/path/to/app --snapshot /absolute/path/to/analysis/app.json
rea analyze /absolute/path/to/app --snapshot /absolute/path/to/analysis/app.json
```
-Exact CLI evidence replays happen before any provider process starts. In MCP sessions,
+Exact CLI cached-evidence reads happen before any provider process starts. In MCP sessions,
pass `snapshot_path` to `open_binary` to import a snapshot atomically while
opening its matching target; MCP providers may still start before a cached call
-is replayed. Pass `snapshot_path` and, when required, `overwrite: true` to
+result is returned. Pass `snapshot_path` and, when required, `overwrite: true` to
`close_binary` to save atomically before Hopper resources are released. If the
save fails, REA deliberately leaves the session open.
@@ -306,17 +306,17 @@ See [native investigation](docs/native-investigation.md) for keyed archives, ins
| Tool family | Count | Examples |
| ------------------------- | ----: | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Native inspection | 39 | functions, pseudocode, assembly, strings, symbols, calls, references, annotations, byte reads, and file offsets |
-| Investigation workflows | 15 | app overviews, function dossiers, native APIs and dispatch, batch decompilation, feature traces, call paths, call graphs, Swift and Objective-C discovery |
+| Investigation workflows | 14 | app overviews, function dossiers, native APIs and dispatch, batch decompilation, feature traces, call paths, call graphs, Swift and Objective-C discovery |
| Native macOS utilities | 7 | Mach-O metadata, code signatures, plists, architectures, and Swift demangling without launching Hopper |
| Artifact graph | 5 | directory and package inventories, compiled Interface Builder files, Apple asset catalogs, and extraction |
-| Managed PE/CLI | 8 | .NET identity, metadata, CIL instructions, native dependencies, reconstruction imports, runtime-correlation plans, and build comparisons |
-| Browser observation | 9 | page structure, network metadata, scripts, source maps, WebMCP discovery, screenshots, capture comparisons, and browser scenarios |
-| Electron analysis | 5 | renderer observation, static app mapping, static/runtime reconciliation, and click/wait scenarios |
+| Managed PE/CLI | 7 | .NET identity, metadata, CIL instructions, native dependencies, reconstruction imports, and build comparisons |
+| Browser observation | 9 | page structure, network metadata, scripts, source maps, WebMCP discovery, screenshots, and capture comparisons |
+| Electron analysis | 5 | renderer observation, static app mapping, and static/runtime reconciliation |
| JavaScript runtime | 2 | Node/Electron Inspector target discovery, script locations, and execution-context events |
-| Application workflows | 10 | cross-layer feature traces, build comparisons, historical source mapping, return-shape comparison, Linux replay, and reconstruction checks |
-| Workspace and observation | 22 | sessions, evidence bundles, navigation context, finite replay machines, process/artifact/function comparisons, and open-question tracking |
+| Application workflows | 7 | cross-layer feature traces, build comparisons, historical source mapping, static return-shape comparison, and reconstruction checks |
+| Workspace and observation | 21 | sessions, evidence bundles, navigation context, process/artifact/function comparisons, and open-question tracking |
-The public interface describes what the agent is trying to learn. Providers decide how to answer. macOS utilities handle common semantic inspection without launching Hopper; Hopper handles deeper native analysis; the process harness implements controlled behavioral capture.
+The public interface describes what the agent is trying to learn. Providers decide how to answer. macOS utilities handle common semantic inspection without launching Hopper; Hopper handles deeper native analysis; the process harness records direct behavioral captures.
## Current status
@@ -327,9 +327,8 @@ REA supports native application, JavaScript, Electron, .NET, and browser investi
- **JavaScript and Electron:** Map modules, imports, source maps, routes, IPC channels, storage, and native add-ons without running the app. Compare builds and trace a feature across the recovered graph. Dynamic and ambiguous relationships remain unresolved. See [JavaScript application workflows](docs/javascript-application-workflows.md).
- **Websites:** Inspect a selected page in an existing Chrome-family browser. Capture page structure, network metadata, script evidence, and screenshots requested by the call. Passive observation does not navigate or execute page JavaScript. See [browser observation](docs/browser-observation.md).
- **Electron and Node runtime observation:** Inspect selected Electron pages or attach to a Node/Electron V8 Inspector target. Inspector observation records script locations and execution-context events; it does not infer imports, IPC activity, or which modules executed. See [runtime observation](docs/javascript-runtime-observation.md).
-- **.NET assemblies:** Inspect metadata and CIL instructions, compare builds, and check declared native dependencies without loading or running the assembly. Imported decompiler output is labeled as analyst inference. Runtime-correlation plans are supported, but an executor is not implemented. See [managed-code analysis](docs/managed-code-analysis.md).
+- **.NET assemblies:** Inspect metadata and CIL instructions, compare builds, and check declared native dependencies without loading or running the assembly. Imported decompiler output is labeled as analyst inference. See [managed-code analysis](docs/managed-code-analysis.md).
- **Controlled behavior capture:** Run process, browser, or Electron scenarios with the target, actions, and lifecycle declared in each request, then compare the resulting evidence. Missing observations cannot establish that two runs behaved the same way.
-- **JavaScript replay:** Run selected extracted modules in the Linux sandbox. Each execution request is bound to the exact plan digest; replay also requires the isolation controls described in [controlled JavaScript replay](docs/controlled-javascript-replay.md).
- **Evidence and comparison:** Save results with artifact identity, provider, locations, confidence, and limitations. Export or import bundles, compare artifacts and functions, and connect static findings to runtime observations without claiming causality from correlation.
- **Open questions:** Track unresolved findings, contradictions, and follow-up probes. Reconstruction checks report pass, fail, or unknown rather than treating missing evidence as a pass.
- **Guided workflows:** Start six [MCP investigation workflows](docs/mcp-prompts.md) with suggestions based on your current session.
@@ -396,10 +395,9 @@ The [current status](#current-status) section describes shipped capabilities. Th
### Next
-1. **Test replay isolation:** add hostile fixtures and test the Linux replay sandbox across more kernels.
-2. **Connect more application layers:** add static extractors and runtime observations to feature traces.
-3. **Extend .NET analysis:** improve comparisons of obfuscated assemblies and connect managed findings to verified native analysis. See the [managed-code guide](docs/managed-code-analysis.md).
-4. **Compare more runtime behavior:** expand process, protocol, filesystem, reconnect, and version-comparison coverage.
+1. **Connect more application layers:** add static extractors and runtime observations to feature traces.
+2. **Extend .NET analysis:** improve comparisons of obfuscated assemblies and connect managed findings to verified native analysis. See the [managed-code guide](docs/managed-code-analysis.md).
+3. **Compare more runtime behavior:** expand process, protocol, filesystem, reconnect, and version-comparison coverage.
### Later
@@ -537,7 +535,7 @@ The session also reports active work and cleanup status. If a caller times out,
`setup` returns `1` for `planned`, `needs_confirmation`, or `needs_human`
because configuration is not ready; rerun it after approval or remediation.
`doctor` returns `1` when required checks for its readiness scope are unhealthy.
-Unavailable optional replay prerequisites remain visible as informational
+Unavailable optional provider prerequisites remain visible as informational
diagnostics and do not block setup or unrelated providers. Output format, full envelopes,
filters, and token controls never change the operation status.
@@ -562,9 +560,9 @@ Closing a session shuts down REA's bridge and removes its temporary socket direc
## Process capture
Process capture runs the exact executable and scenario declared in the request,
-with the requested working directory, filesystem roots, environment, and
-network behavior. The process runs with your user permissions; Process Capture
-records behavior and is not a security sandbox.
+with the requested working directory and environment. Filesystem observation
+paths select what to snapshot. The process runs with your user permissions;
+Process Capture records behavior and is not a security sandbox.
Capture a scenario or compare two saved Process Capture Evidence records:
@@ -575,9 +573,9 @@ rea compare-process-captures authority.json reconstruction.json
```
The comparison reports each observed dimension separately and identifies the
-first terminal, interaction, exit, filesystem, protocol, process, or shim
-divergence. See [Process Capture](docs/process-capture.md) for scenario
-fields, command-shim replay, checkpoint triggers, limits, and safety behavior.
+first terminal, interaction, exit, filesystem, or process divergence.
+See [Process Capture](docs/process-capture.md) for scenario fields, limits, and
+evidence boundaries.
REA installs a prebuilt PTY backend for supported macOS, Linux, and Windows
architectures. If the capability check reports that the backend is unavailable,
@@ -596,7 +594,7 @@ verified or absent.
Analysis runs locally. REA communicates with Hopper and Ghidra through authenticated private local sockets. Your agent or model provider has its own data policy.
-Runtime requests act on the declared target and lifecycle. Analysis tools and launched targets run with your user permissions, and native UI capture still depends on macOS Accessibility and Screen Recording access. Extracted JavaScript replay runs in a separate Linux sandbox and requires the isolation controls described in [controlled JavaScript replay](docs/controlled-javascript-replay.md).
+Runtime requests act on the declared target and lifecycle. Analysis tools and launched targets run with your user permissions, and native UI capture still depends on macOS Accessibility and Screen Recording access. Static JavaScript analysis does not execute extracted modules; use direct browser, Electron, or process capture when runtime behavior is needed.
Windows Ghidra operations are blocked until REA implements the required process ownership, private-directory permissions, and safe-path checks. Report vulnerabilities through the private process in [SECURITY.md](SECURITY.md).
diff --git a/README_ar.md b/README_ar.md
index a13609f37..2b2bdbdde 100644
--- a/README_ar.md
+++ b/README_ar.md
@@ -200,18 +200,18 @@ rea setup
## منصة أدوات التحقيق
-| عائلة الأدوات | العدد | الاستخدام |
-| --------------------- | ----: | ------------------------------------------------------------------------------------------------- |
-| فحص الملفات التنفيذية | 39 | الدوال والشيفرة شبه المصدرية والتعليمات والسلاسل والرموز والمراجع والتعليقات |
-| التحليل المركب | 15 | النظرة العامة وتحليل الدوال وفك الترجمة الدفعي ومخطط الاستدعاء وفحص Swift وObjC |
-| أدوات macOS الأصلية | 7 | بيانات Mach-O والتوقيعات وplist والمعماريات واستعادة أسماء Swift |
-| الملفات والحزم | 5 | فحص الدلائل والحزم وInterface Builder وموارد Apple والاستخراج |
-| .NET PE/CLI | 8 | هوية التجميع والبيانات الوصفية وCIL والاستدعاءات الأصلية والمقارنة واستيراد إعادة البناء |
-| مراقبة المتصفح | 9 | الصفحات والبرامج النصية وخرائط المصدر وWebMCP والصور ومقارنة الالتقاط |
-| تحليل Electron | 5 | مراقبة الصفحات وبنية التطبيق وربط النتائج الثابتة بنتائج التشغيل والتفاعل المعتمد |
-| وقت تشغيل JavaScript | 2 | الاتصال بهدف Node/Electron Inspector موجود لمراقبة البرامج وسياقات التنفيذ |
-| سير عمل التطبيقات | 10 | تتبع الميزات ومقارنة الإصدارات وبنية قيم الإرجاع وإعادة التشغيل المعزولة والتحقق من إعادة الإنشاء |
-| جلسة الملف التنفيذي | 22 | تبديل الأهداف وحفظ الأدلة ومقارنة العمليات والدوال وتسجيل الأسئلة غير المحسومة |
+| عائلة الأدوات | العدد | الاستخدام |
+| --------------------- | ----: | ---------------------------------------------------------------------------------------- |
+| فحص الملفات التنفيذية | 39 | الدوال والشيفرة شبه المصدرية والتعليمات والسلاسل والرموز والمراجع والتعليقات |
+| التحليل المركب | 14 | النظرة العامة وتحليل الدوال وفك الترجمة الدفعي ومخطط الاستدعاء وفحص Swift وObjC |
+| أدوات macOS الأصلية | 7 | بيانات Mach-O والتوقيعات وplist والمعماريات واستعادة أسماء Swift |
+| الملفات والحزم | 5 | فحص الدلائل والحزم وInterface Builder وموارد Apple والاستخراج |
+| .NET PE/CLI | 7 | هوية التجميع والبيانات الوصفية وCIL والاستدعاءات الأصلية والمقارنة واستيراد إعادة البناء |
+| مراقبة المتصفح | 9 | الصفحات والبرامج النصية وخرائط المصدر وWebMCP والصور ومقارنة الالتقاط |
+| تحليل Electron | 5 | مراقبة الصفحات وبنية التطبيق وربط النتائج الثابتة بنتائج التشغيل |
+| وقت تشغيل JavaScript | 2 | الاتصال بهدف Node/Electron Inspector موجود لمراقبة البرامج وسياقات التنفيذ |
+| سير عمل التطبيقات | 7 | تتبع الميزات ومقارنة الإصدارات وبنية قيم الإرجاع والتحقق من إعادة الإنشاء |
+| جلسة الملف التنفيذي | 21 | تبديل الأهداف وحفظ الأدلة ومقارنة العمليات والدوال وتسجيل الأسئلة غير المحسومة |
## خطة العمل
diff --git a/README_ja.md b/README_ja.md
index 94e016ad1..27538f378 100644
--- a/README_ja.md
+++ b/README_ja.md
@@ -192,15 +192,15 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ
| ツール分類 | 数 | 用途 |
| -------------------- | --: | --------------------------------------------------------------------------- |
| バイナリ検査 | 39 | 関数、疑似コード、アセンブリ、文字列、シンボル、参照、注釈 |
-| 組み合わせた解析 | 15 | 概要、関数解析、一括逆コンパイル、呼び出しグラフ、Swift と ObjC |
+| 組み合わせた解析 | 14 | 概要、関数解析、一括逆コンパイル、呼び出しグラフ、Swift と ObjC |
| macOS ネイティブ | 7 | Mach-O メタデータ、署名、plist、アーキテクチャ、Swift 名の復元 |
| ファイルとパッケージ | 5 | ディレクトリとパッケージ、Interface Builder、Apple リソース、抽出 |
-| .NET PE/CLI | 8 | アセンブリ識別、メタデータ、CIL、ネイティブ呼び出し、比較、再構築の取り込み |
+| .NET PE/CLI | 7 | アセンブリ識別、メタデータ、CIL、ネイティブ呼び出し、比較、再構築の取り込み |
| ブラウザー観察 | 9 | ページ、スクリプト、ソースマップ、WebMCP、スクリーンショット、比較 |
-| Electron 解析 | 5 | ページ観察、アプリ構造、静的・実行時結果の対応付け、承認済み操作 |
+| Electron 解析 | 5 | ページ観察、アプリ構造、静的・実行時結果の対応付け |
| JavaScript 実行時 | 2 | 既存の Node/Electron Inspector に接続し、スクリプトと実行コンテキストを観察 |
-| アプリのワークフロー | 10 | 機能追跡、バージョン比較、戻り値構造の比較、隔離実行、再実装の検証 |
-| バイナリセッション | 22 | 対象切り替え、根拠の保存、プロセス・関数比較、未解決事項の記録 |
+| アプリのワークフロー | 7 | 機能追跡、バージョン比較、戻り値構造の比較、再実装の検証 |
+| バイナリセッション | 21 | 対象切り替え、根拠の保存、プロセス・関数比較、未解決事項の記録 |
## ロードマップ
diff --git a/README_ko.md b/README_ko.md
index 614929243..353c67b3f 100644
--- a/README_ko.md
+++ b/README_ko.md
@@ -193,15 +193,15 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이
| 도구 분류 | 수 | 용도 |
| ----------------- | --: | -------------------------------------------------------------------- |
| 바이너리 검사 | 39 | 함수, 의사 코드, 어셈블리, 문자열, 심볼, 참조, 주석 |
-| 결합 분석 | 15 | 개요, 함수 분석, 일괄 디컴파일, 호출 그래프, Swift와 ObjC 검사 |
+| 결합 분석 | 14 | 개요, 함수 분석, 일괄 디컴파일, 호출 그래프, Swift와 ObjC 검사 |
| macOS 네이티브 | 7 | Mach-O 메타데이터, 서명, plist, 아키텍처, Swift 이름 복원 |
| 파일과 패키지 | 5 | 디렉터리와 패키지 검사, Interface Builder, Apple 리소스, 추출 |
-| .NET PE/CLI | 8 | 어셈블리 식별, 메타데이터, CIL, 네이티브 호출, 비교, 재구성 가져오기 |
+| .NET PE/CLI | 7 | 어셈블리 식별, 메타데이터, CIL, 네이티브 호출, 비교, 재구성 가져오기 |
| 브라우저 관찰 | 9 | 페이지, 스크립트, 소스 맵, WebMCP, 스크린샷, 캡처 비교 |
-| Electron 분석 | 5 | 페이지 관찰, 앱 구조, 정적·런타임 결과 연결, 승인된 상호작용 |
+| Electron 분석 | 5 | 페이지 관찰, 앱 구조, 정적·런타임 결과 연결 |
| JavaScript 런타임 | 2 | 기존 Node/Electron Inspector에 연결해 스크립트와 실행 컨텍스트 관찰 |
-| 앱 워크플로 | 10 | 기능 추적, 버전 비교, 반환 구조 비교, 격리 실행, 재구현 검증 |
-| 바이너리 세션 | 22 | 대상 전환, 근거 저장, 프로세스·함수 비교, 미해결 항목 기록 |
+| 앱 워크플로 | 7 | 기능 추적, 버전 비교, 반환 구조 비교, 재구현 검증 |
+| 바이너리 세션 | 21 | 대상 전환, 근거 저장, 프로세스·함수 비교, 미해결 항목 기록 |
## 로드맵
diff --git a/README_zh.md b/README_zh.md
index f068114ae..457a7a59b 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -192,15 +192,15 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
| 工具类别 | 数量 | 用途 |
| ----------------- | ---: | --------------------------------------------------------- |
| 二进制检查 | 39 | 函数、伪代码、汇编、字符串、符号、段、引用与注释 |
-| 组合分析 | 15 | 概览、函数分析、批量反编译、调用图、Swift 与 ObjC 检查 |
+| 组合分析 | 14 | 概览、函数分析、批量反编译、调用图、Swift 与 ObjC 检查 |
| macOS 原生工具 | 7 | Mach-O 元数据、代码签名、plist、架构与 Swift 符号还原 |
| 制品检查 | 5 | 目录与软件包检查、Interface Builder、Apple 资源目录与提取 |
-| .NET PE/CLI | 8 | 程序集身份、元数据、CIL、原生调用声明、成员比较与重建导入 |
+| .NET PE/CLI | 7 | 程序集身份、元数据、CIL、原生调用声明、成员比较与重建导入 |
| 浏览器观察 | 9 | 页面、脚本、来源映射、WebMCP 工具、截图与捕获比较 |
-| Electron 分析 | 5 | 页面观察、应用结构、静态与运行时结果关联、批准的交互场景 |
+| Electron 分析 | 5 | 页面观察、应用结构与静态/运行时结果关联 |
| JavaScript 运行时 | 2 | 连接现有 Node/Electron Inspector,观察脚本与执行上下文 |
-| 应用工作流 | 10 | 功能追踪、版本比较、返回结构比较、隔离重放与重建验证 |
-| 二进制会话 | 22 | 目标切换、证据包、进程与函数比较、待解决问题记录 |
+| 应用工作流 | 7 | 功能追踪、版本比较、返回结构比较与重建验证 |
+| 二进制会话 | 21 | 目标切换、证据包、进程与函数比较、待解决问题记录 |
## 路线图
diff --git a/bridge/hopper_bridge.py b/bridge/hopper_bridge.py
index aec962633..b290f697c 100644
--- a/bridge/hopper_bridge.py
+++ b/bridge/hopper_bridge.py
@@ -241,6 +241,10 @@ def _procedure_identity(procedure):
"address": _hex(procedure.getEntryPoint()),
"name": _procedure_name(procedure),
"classification": None,
+ "body": {
+ "available": False,
+ "reason": "Hopper's public Python API does not expose complete function body ranges",
+ },
}
@@ -559,7 +563,11 @@ def _analyze_function(document, params):
referenced_strings.sort(key=lambda item: (int(item["address"], 16), int(item["source_address"], 16)))
referenced_names.sort(key=lambda item: (int(item["address"], 16), int(item["source_address"], 16)))
return {
- "procedure": {"address": _hex(procedure.getEntryPoint()), "name": _procedure_name(procedure), "classification": None, "signature": procedure.signatureString(), "locals": _procedure_locals(procedure)},
+ "procedure": {
+ **_procedure_identity(procedure),
+ "signature": procedure.signatureString(),
+ "locals": _procedure_locals(procedure),
+ },
"pseudocode": pseudo,
"assembly": assembly_lines,
"comments": comments,
@@ -770,7 +778,16 @@ def _dispatch(method, params):
if method == "procedure_info":
blocks = list(procedure.basicBlockIterator())
length = sum(max(0, block.getEndingAddress() - block.getStartingAddress()) for block in blocks)
- return {"name": _procedure_name(procedure), "entrypoint": _hex(procedure.getEntryPoint()), "basicblock_count": procedure.getBasicBlockCount(), "length": length, "signature": procedure.signatureString(), "locals": _procedure_locals(procedure), "classification": None}
+ return {
+ "name": _procedure_name(procedure),
+ "entrypoint": _hex(procedure.getEntryPoint()),
+ "basicblock_count": procedure.getBasicBlockCount(),
+ "length": length,
+ "signature": procedure.signatureString(),
+ "locals": _procedure_locals(procedure),
+ "classification": None,
+ "body": _procedure_identity(procedure)["body"],
+ }
if method == "set_address_name":
address = _address(document, params.get("address"))
try:
diff --git a/docs/adr/0002-controlled-replay-authority-and-sandbox.md b/docs/adr/0002-controlled-replay-authority-and-sandbox.md
index c46e164c9..6f5ae25de 100644
--- a/docs/adr/0002-controlled-replay-authority-and-sandbox.md
+++ b/docs/adr/0002-controlled-replay-authority-and-sandbox.md
@@ -1,15 +1,14 @@
# ADR-0002: Controlled JavaScript replay authority and sandbox policy
-- Status: Accepted
+- Status: Superseded; the controlled JavaScript replay tool was removed
- Date: 2026-07-16
-- Implementation status: Implemented by `run_controlled_replay` with a
- Linux-x86_64 Bubblewrap, seccomp, and delegated systemd user-cgroup backend.
- Hosts that cannot establish every required boundary fail closed.
+- Implementation status: The implementation was removed. The design below is
+ retained as historical context and does not describe a supported REA tool.
-> Historical design note: REA's separate permission-policy, grant, and
-> per-call approval workflow was later removed. Current tool requests directly
-> name their target and lifecycle. The sandbox and exact plan-digest behavior
-> described here remain part of controlled replay.
+> Historical design note: The tool, sandbox backend, and plan/execute workflow
+> described here are no longer part of REA. For runtime behavior, run probes
+> directly against the application and use the available browser, Electron, or
+> process capture workflows.
## Context
diff --git a/docs/adr/0003-managed-code-evidence-and-provider-boundary.md b/docs/adr/0003-managed-code-evidence-and-provider-boundary.md
index f8e32d30e..0e50414f4 100644
--- a/docs/adr/0003-managed-code-evidence-and-provider-boundary.md
+++ b/docs/adr/0003-managed-code-evidence-and-provider-boundary.md
@@ -1,11 +1,10 @@
# ADR-0003: Managed-code evidence and provider boundary
-> Historical design note: The admission record described below remains a
-> plan-only output. The separate administrator and per-call approval workflow
-> was removed; current tool requests directly declare their target and
-> lifecycle.
+> Historical design record: The non-executing runtime-correlation planning
+> tool described in earlier revisions was removed. This ADR's current
+> implementation status below covers only the static managed tools that ship.
-- Status: Accepted
+- Status: Superseded for runtime planning; static evidence boundary remains in force
- Date: 2026-07-16
- Implementation status: Read-only PE/CLI triage and exact identity are shipped
through `inspect_managed_artifact` / `rea inspect-managed-artifact`. Bounded
@@ -24,11 +23,8 @@ inspect-managed-members` provides CLI parity. Static member comparison and build
addresses. Decompiler reconstruction import is
shipped through `import_managed_reconstruction` /
`rea import-managed-reconstruction`; it records user-supplied C#/IL/pseudocode
- as analyst inference only after exact static member locks match. Non-executing
- runtime-correlation planning is shipped through
- `plan_managed_runtime_correlation` / `rea plan-managed-runtime-correlation`;
- it does not probe a runtime, establish confinement, attach, load, debug,
- reflect, instrument, invoke, or execute target code. Managed static graph
+ as analyst inference only after exact static member locks match. Managed
+ inspection does not execute assemblies or observe CLR internals. Managed static graph
projection is shipped through
`project_managed_application_graph` /
`rea project-managed-application-graph`; it projects authenticated managed
@@ -56,7 +52,7 @@ makes names especially weak while tokens remain coordinates in only one build.
REA already separates one selected deep native provider from disjoint auxiliary
providers under [ADR-0001](0001-provider-selection-and-analysis-profiles.md).
It also separates static evidence, passive observation, process capture, and
-controlled replay authorities. Managed analysis must fit those boundaries
+interactive observation authorities. Managed analysis must fit those boundaries
without requiring a CLR to inspect an artifact, loading target code into the
REA process, or treating a managed token as a native address.
@@ -362,22 +358,13 @@ emit full IL, decompiled source, runtime logs, user data, credentials, or
service/account material. No proprietary binary, hash-locked application
manifest, or derived dump is committed to REA.
-### 8. Keep runtime correlation planning distinct from execution
+### 8. Keep runtime behavior separate from static managed analysis
-Runtime-correlation planning records requested effects and bounds without
-probing the runtime or establishing confinement. It binds to the exact artifact
-SHA-256, MVID, method signature, and CIL/body shape observed by the static
-path, and records that no target code ran. A future executor requires its own
-provider and threat model before any effect can occur.
-
-Runtime admission must additionally validate host OS, CLR family, architecture,
-and supported build/tool versions; bound time, threads, outputs, UI and network
-effects; avoid real services and accounts; and own all created processes and
-artifacts through complete cleanup. Reflection-only loading is still a CLR load
-and is not a substitute for the static byte parser.
-
-Runtime execution is intentionally outside the current managed-code track and
-requires its own threat model and admission decision.
+No managed runtime planning or execution tool ships. Managed inspection does
+not execute assemblies or establish which methods ran. Where supported, direct
+process capture can record target-level inputs and outputs, but it does not
+prove CLR method execution or resolution. Internal managed behavior remains
+unknown until independently observed with suitable runtime evidence.
## Public contract consequences
@@ -444,9 +431,7 @@ or development-only oracles.
5. Add source-built, pinned real-tool, package, CLI, and MCP conformance.
Source-owned conformance is shipped; pinned external real-tool checks remain
planned.
-6. Add non-executing runtime-correlation planning. Shipped; runtime execution
- and confinement remain planned.
-7. Project authenticated managed static Evidence into the application graph.
+6. Project authenticated managed static Evidence into the application graph.
Shipped.
Every gate must keep unsupported ReadyToRun, NativeAOT, IL2CPP, mixed-mode, or
diff --git a/docs/adr/README.md b/docs/adr/README.md
index d6caea6fc..8511e67de 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -4,8 +4,8 @@ Accepted decisions describe the architecture that implementation PRs must
follow. Acceptance does not by itself mean the behavior is shipped; each record
states its implementation status separately.
-| ADR | Status | Implementation |
-| ------------------------------------------------------------------------------------------------------------------ | -------- | ----------------------------------------------------------------------------------------------------------- |
-| [0001: Provider selection and analysis profiles](0001-provider-selection-and-analysis-profiles.md) | Accepted | Provider selection and 22 Ghidra operations shipped on Linux/macOS; Windows analysis blocked |
-| [0002: Controlled JavaScript replay authority and sandbox policy](0002-controlled-replay-authority-and-sandbox.md) | Accepted | Linux x86_64 extracted-module replay shipped |
-| [0003: Managed-code evidence and provider boundary](0003-managed-code-evidence-and-provider-boundary.md) | Accepted | Eight static-analysis and runtime-planning tools shipped; native-body mapping and runtime execution planned |
+| ADR | Status | Implementation |
+| ------------------------------------------------------------------------------------------------------------------ | -------------------- | -------------------------------------------------------------------------------------------- |
+| [0001: Provider selection and analysis profiles](0001-provider-selection-and-analysis-profiles.md) | Accepted | Provider selection and 22 Ghidra operations shipped on Linux/macOS; Windows analysis blocked |
+| [0002: Controlled JavaScript replay authority and sandbox policy](0002-controlled-replay-authority-and-sandbox.md) | Superseded | Historical design; the controlled JavaScript replay tool was removed |
+| [0003: Managed-code evidence and provider boundary](0003-managed-code-evidence-and-provider-boundary.md) | Partially superseded | Seven static managed tools remain; runtime planning removed |
diff --git a/docs/architecture.mermaid b/docs/architecture.mermaid
index a4a21824d..a3860f342 100644
--- a/docs/architecture.mermaid
+++ b/docs/architecture.mermaid
@@ -24,7 +24,6 @@ graph TD
GHIDRA["src/ghidra/
Ghidra discovery, profile & function analysis
snapshot, Unix/Windows transport, bounded lifecycle"]
AUX["src/artifacts/ + src/native/
disjoint artifact and native capabilities"]
BROWSER["src/browser/
Loopback CDP transport
exact-origin passive observation"]
- REPLAY["src/replay/
Disposable JavaScript worker
Bubblewrap, seccomp, cgroup limits"]
end
subgraph "Provider Process Foundation"
diff --git a/docs/browser-scenario-contract.md b/docs/browser-scenario-contract.md
index 1fa3fcd84..cdd61588d 100644
--- a/docs/browser-scenario-contract.md
+++ b/docs/browser-scenario-contract.md
@@ -3,22 +3,21 @@
`capture_browser_scenario` accepts the provider-neutral
`browserScenarioSchema` through both MCP and the
`capture-browser-scenario INPUT_JSON` CLI command. The request is declarative:
-it admits a fixed action vocabulary, exact HTTP(S) origins, deterministic
-browser settings, explicit storage and request replay, and provider-owned
+it admits a fixed action vocabulary, explicit HTTP(S) targets, deterministic
+browser settings, explicit storage seeds, and provider-owned
liveness deadlines. `INPUT_JSON` may be inline JSON or a JSON file path.
The minimal request contains only browser launch/connect selection, `start_url`,
and at least one `actions` entry. Launch `headless` defaults to `true` and can
-be set to `false` where the host supports a visible browser. `allowed_origins`
-defaults to the origin of
-`start_url`; add exact origins only when navigation, storage, or replay needs
-another origin. Environment settings (including service workers blocked by
-default), empty storage, disabled request replay, and a final sanitized URL
-capture are supplied by default. Set `environment.service_workers` to `allow`
-when the application requires them. Add secret declarations, storage seeds,
-replay routes, and additional artifact or event capture only when the
-investigation needs them. Action, secret, storage,
-and replay-route counts are not capped. Duration, action, and navigation
+be set to `false` where the host supports a visible browser. Navigations follow
+the destinations named by the scenario and the page's ordinary browser requests;
+there is no separate origin allowlist or request-interception layer. Environment
+settings (including service workers blocked by default), empty storage, and a
+final sanitized URL capture are supplied by default. Set
+`environment.service_workers` to `allow` when the application requires them.
+Add secret declarations, storage seeds, and additional artifact or event
+capture only when needed. Action, secret, and storage counts are not capped.
+Duration, action, and navigation
timeouts remain fixed provider-owned liveness limits; the request does not
accept a caller-controlled `limits` object.
@@ -32,23 +31,21 @@ confirms that an attached external browser remains alive.
HTTP(S) URLs may include ordinary query values and fragments directly. Use
ordered structured query entries when a value needs to reference a declared
-environment-backed secret. Form, storage, cookie, and replay values are either
-literal strings or declared secret references. Raw URL userinfo credentials
-and provider-owned replay headers are rejected. Captured credential-header
+environment-backed secret. Form, storage, and cookie values are either literal
+strings or declared secret references. Raw URL userinfo credentials are rejected.
+Captured credential-header
values are not retained, and declared secret values are redacted automatically.
A secret may be declared solely to
redact matching observed content; every secret reference in an action, URL,
-storage value, or replay route still needs a declaration. Ordinary query values
+storage value still needs a declaration. Ordinary query values
and fragments remain intact. Durable results replace resolved secret values
with their secret references.
-Every start, navigation, storage, replay, and redirect origin must be in the
-effective origin set: the explicit `allowed_origins`, or the `start_url` origin
-when the list is omitted or empty. Unsupported action tags, unknown fields, duplicate step or
-route IDs, undeclared secret references, and provider-owned replay headers fail
-validation. HTTP and WebSocket routing enforces the same declared scope. Exact
-replay may abort unmatched requests or pass through only origins included in
-`allowed_origins`.
+The exact `start_url`, navigation destinations, and storage-seed origins define
+the inputs to a scenario; ordinary page requests are handled by the browser.
+Storage cookies are scoped to the page URL at each capture. Unsupported action
+tags, unknown fields, duplicate step IDs, and undeclared secret references fail
+validation.
The result is Evidence with an initial state followed by one record per
declared action. Each step reports action status, elapsed time, sanitized URLs,
diff --git a/docs/controlled-javascript-replay.md b/docs/controlled-javascript-replay.md
deleted file mode 100644
index 11f220021..000000000
--- a/docs/controlled-javascript-replay.md
+++ /dev/null
@@ -1,97 +0,0 @@
-# Controlled JavaScript replay
-
-`run_controlled_replay` executes only the extracted modules named in the request. It
-does not launch or drive the original application. Static inference, passive
-browser/Electron observation, isolated replay, and real application behavior
-remain different authorities.
-
-## Host prerequisites
-
-Replay requires the current Node.js runtime,
-`/usr/bin/bwrap`, `/usr/bin/systemd-run`, `/usr/bin/systemctl`, and
-`/usr/bin/bash`. Override executable paths only with the corresponding
-`REA_JAVASCRIPT_REPLAY_*_PATH` variable. Configuration creates the
-runtime configuration; REA setup never installs these host components. Check
-availability with `rea doctor`, which reports exact sandbox-probe failures
-without running module code.
-
-## Plan, review, execute
-
-Call the same CLI command or MCP tool twice. The first request uses
-`mode: "plan"`. Its response commits module paths and digests, closed dependency
-aliases, cases, deterministic providers, runtime/backend identities, resource
-limits, no-network policy, private filesystems, and a `plan_digest`.
-Runtime commitments include the exact worker, seccomp filter, Node executable,
-ELF loader, and shared-library source/destination paths and SHA-256 digests.
-
-The execute request repeats the same manifest and includes the exact digest
-returned by the plan request:
-
-```json
-{
- "mode": "execute",
- "plan_digest": ""
-}
-```
-
-The execute request directly selects that content-bound operation. There is no
-separate REA grant or approval field.
-
-REA rebuilds the plan. Any changed module, stub, case, runtime, backend, limit,
-or export commitment returns `plan_stale` before worker admission.
-
-Modules use `esm` or `commonjs-factory`. The latter accepts extracted Rspack
-factory syntax such as `123(module, exports, require) { ... }` and implements
-the bounded `require.d`, `require.r`, `require.n`, and `require.nmd` helpers.
-Every import or require must map to a declared alias. There is no ambient
-package resolution.
-
-Explicit cases can be combined with deterministic `parser-boundaries`,
-`sanitizer-boundaries`, or `clipboard-boundaries` generation. Supplying a
-`right` manifest runs both sides with fresh realms per case and reports
-`equal`, `changed`, or `unknown` comparisons.
-
-## Boundary and Evidence
-
-The worker receives module bytes over bounded stdin and runs under a fresh
-Bubblewrap user/PID/network/IPC/UTS namespace, an architecture-checked seccomp
-filter, an empty mount root with a descriptor-backed read-only Node closure,
-private tmpfs, and a transient systemd user cgroup. Host and external network,
-host writes, process spawning, workers, native addons, inspector access, and
-ambient environment are unavailable. Wall time, memory, swap, tasks, CPU,
-case input, worker-protocol input, output, stderr, depth, and node counts are
-independently bounded. The parent also requires an exact worker response shape
-and authenticates every returned case ID, order, and input digest. Values are
-projected through own data descriptors only; Proxy values and accessors are
-rejected without invoking their traps or getters.
-
-Returns, exceptions, serialization failures, denials, timeouts, OOMs, crashes,
-protocol failures, and cleanup state are observations with provider
-`rea-javascript-replay` and authority `controlled-replay`. An observation does
-not prove that the original renderer, preload, main process, browser, or remote
-service behaved identically.
-
-Each left/right run is retained as its own `observed` source Evidence. A
-differential envelope is `derived` and links those source Evidence IDs, so the
-comparison never erases the underlying observations.
-
-Optional `reproducer_export` is committed by the plan and writes to its
-caller-supplied path after the sandbox has stopped.
-The owner-only manifest is written only after complete sandbox cleanup. Source
-bytes are excluded unless `include_sources: true` was selected in the request.
-An export failure is retained in the result and does not erase a completed
-replay observation.
-
-## Local real-artifact verification
-
-`npm run verify:replay` uses source-owned parser and hostile fixtures. An
-operator can additionally supply a local ESM or Rspack/CommonJS-factory
-manifest without copying module source into the repository:
-
-```sh
-REA_REPLAY_INPUT_PATH=/absolute/replay-manifest.json npm run verify:replay
-```
-
-The verifier canonicalizes the manifest's module paths and prints only plan,
-module, and Evidence digests, case/comparison summaries, and cleanup state.
-This is the intended seam for local Notion-like parser/sanitizer benchmarks.
diff --git a/docs/error-contract.schema.json b/docs/error-contract.schema.json
index be8af90a4..5f276b551 100644
--- a/docs/error-contract.schema.json
+++ b/docs/error-contract.schema.json
@@ -566,40 +566,6 @@
},
"required": ["code", "category", "message", "retryable", "remediation"],
"additionalProperties": false
- },
- {
- "type": "object",
- "properties": {
- "code": { "type": "string", "const": "plan_stale" },
- "category": {
- "type": "string",
- "enum": [
- "invalid_input",
- "unsupported_provider",
- "integrity_mismatch",
- "truncated",
- "cancelled",
- "timeout",
- "unavailable",
- "execution_failure"
- ]
- },
- "message": { "type": "string", "minLength": 1 },
- "retryable": { "type": "boolean" },
- "remediation": {
- "type": "object",
- "properties": { "action": { "type": "string", "minLength": 1 } },
- "required": ["action"],
- "additionalProperties": false
- },
- "details": {
- "type": "object",
- "propertyNames": { "type": "string" },
- "additionalProperties": { "$ref": "#/$defs/__schema0" }
- }
- },
- "required": ["code", "category", "message", "retryable", "remediation"],
- "additionalProperties": false
}
],
"$defs": {
diff --git a/docs/javascript-application-graph.md b/docs/javascript-application-graph.md
index d62552cbf..bbe4093ad 100644
--- a/docs/javascript-application-graph.md
+++ b/docs/javascript-application-graph.md
@@ -97,7 +97,7 @@ correspondence is a separately identified cross-layer inference.
Every observation and edge carries all of the following:
- an authority, such as artifact bytes, AST analysis, static relationship
- inference, passive CDP runtime, cross-layer reconciliation, controlled replay,
+ inference, passive CDP runtime, and cross-layer reconciliation,
native analysis, historical reference, or user assertion;
- an epistemic state: `observed`, `inferred`, `unknown`, or `unavailable`;
- confidence independently bounded as exact, high, medium, low, or unknown;
diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md
index 38facfd19..e55dc4f95 100644
--- a/docs/javascript-application-workflows.md
+++ b/docs/javascript-application-workflows.md
@@ -132,8 +132,10 @@ unique occurrence on each side and pairing is reciprocal. Changes use JSON
Pointer paths with `added`, `removed`, `changed`, or `unknown` status. A missing
field is added or removed only when the relevant parent-property coverage is
complete on both shapes. The output includes exact selector candidates,
-omissions, Evidence links, coverage, limitations, and a separate controlled
-replay recommendation; it does not execute JavaScript.
+omissions, Evidence links, coverage, and limitations; it does not execute
+JavaScript. When runtime semantics matter, run behavioral probes directly
+against the relevant application versions and capture them through the
+available browser, Electron, or process workflows.
## CLI and verification
@@ -162,22 +164,10 @@ Because source Evidence and derived Evidence are retained by the normal session
ledger, evidence bundles and analysis snapshots can carry these records without
another persistence format.
-## Controlled replay boundary
+## Runtime evidence
-Static graph workflows never execute a graph node or recovered module.
-`run_controlled_replay` is the separate extracted-module boundary. A plan
-request and execute request share the same manifest; execution must include the
-exact plan digest, and the worker runs inside the mandatory Linux OS sandbox.
-The sandbox is an execution boundary; it does not make replay a real application
-runtime observation.
-
-Replay observations retain `controlled-replay` authority. They cannot
-promote static inference into passive runtime observation or prove that the
-original application, renderer, preload, main process, or remote service
-behaved identically.
-
-Module, explicit case, and preset-generated case lists have no fixed
-item-count ceiling. Replay plans commit the caller's resource budgets, and the
-worker protocol checks the complete request against that declared byte budget
-before probing executables or reading modules. Aggregate module-source and
-case-input bytes are likewise checked against the caller's declared budgets.
+Static graph workflows do not execute recovered code. When runtime semantics
+matter, run behavioral probes directly against the relevant application
+versions and capture them through the available browser, Electron, or process
+workflows. Those observations do not prove behavior in an unobserved app
+version or environment.
diff --git a/docs/javascript-runtime-observation.md b/docs/javascript-runtime-observation.md
index 0f0070547..e6b4b20e7 100644
--- a/docs/javascript-runtime-observation.md
+++ b/docs/javascript-runtime-observation.md
@@ -9,7 +9,7 @@ deterministic Evidence.
Each request supplies the loopback Inspector endpoint and, for observation, the
target ID. This surface is separate from browser CDP, Electron file-page
-inspection, Process Capture, and controlled JavaScript replay:
+inspection, and Process Capture:
```bash
rea list-javascript-runtime-targets http://127.0.0.1:9229 \
diff --git a/docs/managed-code-analysis.md b/docs/managed-code-analysis.md
index 583ee37cd..56cf46296 100644
--- a/docs/managed-code-analysis.md
+++ b/docs/managed-code-analysis.md
@@ -6,7 +6,7 @@ builds, and connect declared native calls to supplied native-analysis evidence.
CIL is Common Intermediate Language, the instruction format used by managed
assemblies.
-The eight shipped tools are:
+The seven shipped tools are:
| Task | MCP tool |
| --------------------------------------------------------------------------- | ----------------------------------- |
@@ -16,12 +16,12 @@ The eight shipped tools are:
| Compare members across builds and map build-local tokens | `compare_managed_members` |
| Check native call declarations against supplied export or function evidence | `verify_managed_native_boundaries` |
| Import decompiled code against verified static member identities | `import_managed_reconstruction` |
-| Plan a runtime experiment without executing it | `plan_managed_runtime_correlation` |
| Add managed findings to an application graph | `project_managed_application_graph` |
Each has a matching CLI command: replace underscores with hyphens and prefix
the name with `rea`, for example `rea inspect-managed-artifact`.
-Native-body bridge mapping and runtime execution remain planned.
+Native-body bridge mapping remains planned; managed runtime execution is not
+part of the current tool set.
This guide describes the implementation and verification of
[ADR-0003](adr/0003-managed-code-evidence-and-provider-boundary.md). The canonical
@@ -37,8 +37,8 @@ collapsing them:
3. What source-like or behavioral structure can be reconstructed or inferred?
4. Which findings survive an exact-build check or a cross-build structural
comparison?
-5. Which remaining questions require native analysis or a future runtime
- experiment?
+5. Which remaining questions require native analysis or runtime evidence that
+ static inspection cannot supply?
The ordinary workflow ends at question four. Static analysis never loads or
executes the target.
@@ -236,16 +236,16 @@ Tokens are always remapped through observed structure. A caller cannot carry
## Managed/native boundary rules
-| Boundary | Managed observation | Native observation | Permitted link |
-| ----------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
-| P/Invoke | Module, entry point, charset/calling-convention flags, declaring method | Import/export/symbol/function evidence from the selected deep provider | Exact declared-name/module link or qualified resolution inference |
-| COM | Interop attributes, GUIDs, imported interfaces, method signatures | Native registration/vtable evidence when independently available | Identifier/signature inference with explicit environment limitations |
-| C++/CLI | Managed declaration and implementation flags | Native body/function evidence | Only a provider-supported bridge observation; never token-as-address |
-| ReadyToRun | Component/header and per-method CIL/native availability | Native section/function evidence | Authenticated image mapping with format/profile version |
-| Unmanaged export | Export metadata/attribute when present | PE export and native thunk/function | Exact export identity plus provider-qualified address |
-| Single-file host | Bundle entry/component identity | Host and native component evidence | Outer bundle plus component digest/extent commitment |
-| Unity IL2CPP | Supported metadata entity with authenticated pairing | Generated native function/type evidence | Versioned IL2CPP mapping only; no invented CIL |
-| Runtime-resolved native | API/constant/data-flow candidate | Loaded-module/symbol observation only under separate runtime execution authority | Static candidate remains inference until separately observed |
+| Boundary | Managed observation | Native observation | Permitted link |
+| ----------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
+| P/Invoke | Module, entry point, charset/calling-convention flags, declaring method | Import/export/symbol/function evidence from the selected deep provider | Exact declared-name/module link or qualified resolution inference |
+| COM | Interop attributes, GUIDs, imported interfaces, method signatures | Native registration/vtable evidence when independently available | Identifier/signature inference with explicit environment limitations |
+| C++/CLI | Managed declaration and implementation flags | Native body/function evidence | Only a provider-supported bridge observation; never token-as-address |
+| ReadyToRun | Component/header and per-method CIL/native availability | Native section/function evidence | Authenticated image mapping with format/profile version |
+| Unmanaged export | Export metadata/attribute when present | PE export and native thunk/function | Exact export identity plus provider-qualified address |
+| Single-file host | Bundle entry/component identity | Host and native component evidence | Outer bundle plus component digest/extent commitment |
+| Unity IL2CPP | Supported metadata entity with authenticated pairing | Generated native function/type evidence | Versioned IL2CPP mapping only; no invented CIL |
+| Runtime-resolved native | API/constant/data-flow candidate | Loaded-module/symbol observation from an independently collected runtime source | Static candidate remains inference until separately observed |
Declared import inventory supports a bounded positive claim. Its absence does
not exclude dynamic resolution, generated code, protected code, native helpers,
@@ -402,25 +402,13 @@ The benchmark may prove that REA reproduces selected facts for that exact local
build. It cannot establish general support on its own; source-built conformance
remains the admission requirement.
-## Runtime admission boundary
+## Runtime behavior boundary
-The later runtime track is not an extension flag on a static operation. Its
-design must expose whether it attaches to an existing process, launches a new
-process, loads an assembly, uses reflection, installs a debugger/profiler, or
-instruments code. Each effect must be declared and checked against the exact
-build.
-
-At minimum, admission requires target SHA-256, MVID, normalized signature and
-body/CIL commitment, CLR family, OS, architecture, tool version, scenario
-limits, output policy, and cleanup ownership to match. Any mismatch fails before
-the experiment. It must not contact real services/accounts or claim that an
-instrumented path represents an ordinary launch unless that proposition is
-independently tested.
-
-The shipped `plan_managed_runtime_correlation` path returns an experiment plan
-and records that no target code was executed. There is no runtime executor.
-Runtime behavior questions therefore remain explicit unknowns with suggested
-probes; static support does not perform them.
+Managed inspection does not execute assemblies or observe CLR internals.
+Questions about actual behavior remain unknown until supported runtime
+evidence is collected. A direct process capture can retain declared inputs,
+outputs, filesystem and protocol activity for a target run; it does not prove
+which managed methods executed or how the CLR resolved them.
## Delivery sequence
@@ -442,9 +430,7 @@ The managed-code track advances as reviewable pull requests:
PE/CLI corpus shipped through `npm run verify:managed`; optional BYO
`ilspycmd` real-tool oracle shipped through `REA_ILSPY_CMD_PATH`; dnSpy and
pinned Windows checks remain planned); and
-8. non-executing runtime-correlation planning (shipped; no runtime prerequisite
- probe, execution, or confinement claim);
-9. managed static Evidence projection into the application graph (shipped).
+8. managed static Evidence projection into the application graph (shipped).
Each implementation PR updates generated product facts only for behavior it
actually ships and states which real-tool checks were performed.
diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md
index 216053253..5e4f5be6c 100644
--- a/docs/mcp-contracts.md
+++ b/docs/mcp-contracts.md
@@ -82,10 +82,10 @@ REA runs the declared request directly and does not infer a broader target or
action from it.
Host requirements remain in force. macOS may deny Accessibility,
-Screen Recording, or native mounting; Linux replay requires its sandbox
-prerequisites; provider tools require their selected analysis runtime. These
-failures are reported at the operation that needs them. A configured provider
-or an endpoint alone does not establish that a target is supported.
+Screen Recording, or native mounting; provider tools require their selected
+analysis runtime. These failures are reported at the operation that needs
+them. A configured provider or an endpoint alone does not establish that a
+target is supported.
Evidence bundles, snapshots, and extraction use the paths and output behavior
declared by their tools. Artifact extraction materializes the selected regular
diff --git a/docs/process-capture.md b/docs/process-capture.md
index 79126ccf9..37a295547 100644
--- a/docs/process-capture.md
+++ b/docs/process-capture.md
@@ -1,54 +1,33 @@
# Process capture
-Process Capture records one caller-selected command as Evidence. It is
-intended for authority-versus-reconstruction checks where terminal behavior,
-child lifetime, filesystem state, or dependency timing matters.
+Process Capture runs one caller-selected command and records bounded evidence
+about its terminal behavior, scheduled interactions, process lifetime, and
+selected filesystem state. Use it to compare two direct runs of the same
+scenario, such as an authority build and a reconstruction. It does not replay a
+previous execution or emulate dependencies.
-The harness records both raw PTY chunks and terminal states rendered by xterm.
-It also records scripted input, resize and signal delivery, sampled process
-metadata, named filesystem checkpoints, command-shim invocations, loopback
-HTTP/WebSocket exchanges, and process exit ownership. Missing or bounded
-observations remain explicit; a truncated capture is never treated as
-equivalent to another capture.
+The command runs with the current user's permissions and inherits the host
+environment before applying the scenario's explicit overrides. This is not a
+sandbox. Filesystem observation paths select what REA snapshots; they do not
+restrict what the process can read or write. The inherited environment is not
+recorded, so its influence may remain unknown.
-Every capture includes a run manifest with canonical SHA-256 commitments for
-the caller-selected scenario projection, comparison contract, executable,
-normalization rules, command-shim plan, and replay plan. The manifest also
-records the REA/provider versions, platform, architecture, PTY backend, and UTC
-start/completion timestamps. Capture creation, Evidence import, and comparison
-recompute the self-contained commitments and reject invalid lifecycle data.
+## Capture a command
-## Request and host behavior
+Write a JSON scenario and pass its path to the CLI:
-The scenario names the command and arguments, with optional working-directory,
-environment overrides, and filesystem observation paths. An omitted working
-directory uses the invocation's current directory. The process inherits the
-host environment, then applies any declared overrides. Filesystem observation
-paths select what REA records; they do not constrain what the child process can
-read or write. Process Capture is not a security sandbox: the target runs with
-the current user's permissions.
-
-Local output, arguments, environment overrides, URL query values, shim results,
-and replay bodies are preserved without credential-name or text-pattern
-redaction. Inputs explicitly marked `sensitive` use placeholders in Evidence.
-The inherited environment is not copied into the manifest; its unrecorded
-influence remains an explicit limitation.
-
-## Capture a scenario
-
-`rea capture-process` reads a JSON scenario and writes process capture
-Evidence to stdout:
-
-```bash
+```sh
rea capture-process ./scenario.json > capture.json
```
-A representative scenario is:
+For example:
```json
{
"executable": "node",
"arguments": ["./signup.mjs"],
+ "working_directory": ".",
+ "environment": { "APP_MODE": "test" },
"filesystem_observation_paths": ["./state"],
"terminal": { "columns": 80, "rows": 24, "scrollback": 1000 },
"events": [
@@ -58,196 +37,63 @@ A representative scenario is:
"data": "user@example.test\r",
"sensitive": true
},
- { "type": "resize", "at_ms": 500, "columns": 100, "rows": 30 }
+ { "type": "resize", "at_ms": 500, "columns": 100, "rows": 30 },
+ { "type": "signal", "at_ms": 1000, "signal": "SIGINT" }
],
- "checkpoints": [
- {
- "name": "signup_ready",
- "trigger": { "type": "terminal_literal", "value": "All set!" }
- },
- { "name": "root_exited", "trigger": { "type": "root_exit" } }
- ],
- "command_shims": [
- {
- "name": "codex",
- "routes": [
- {
- "arguments": ["--version"],
- "outputs": [
- { "at_ms": 0, "stream": "stdout", "data": "codex 1.2.3\n" }
- ],
- "termination": { "type": "exit", "code": 0 },
- "max_calls": 1
- }
- ]
- }
- ]
+ "timeout_ms": 30000,
+ "idle_timeout_ms": 30000,
+ "settle_ms": 100
}
```
-Input events marked `sensitive` are dispatched to the PTY but stored only as a
-byte-count placeholder. The command-shim directory is placed first in the
-captured process `PATH`; command lookup otherwise uses the inherited or
-scenario-overridden `PATH`.
+`executable` is required. `arguments`, `working_directory`, `environment`,
+`filesystem_observation_paths`, terminal settings, timed `events`, timeouts,
+resource limits, and normalization settings have defaults; see
+`processScenarioSchema` for the exact contract. Event times are milliseconds
+from launch, must be ordered, and must fall within `timeout_ms`. Inputs marked
+`sensitive` are sent to the process but persisted as a byte-count placeholder.
+Environment overrides are recorded in the scenario commitment; inherited
+values are not copied into Evidence.
-Scenario arguments, environment names, timed interactions, shim routes and
-output chunks, filesystem checkpoints, and static HTTP/WebSocket scripts are
-accepted without fixed item-count ceilings. The run still obeys its timeout
-and the configured output-byte, process, filesystem, protocol-event, body-byte,
-and connection budgets; each filesystem checkpoint uses those same
-bounded snapshot limits.
+The capture contains raw PTY output chunks and rendered terminal states,
+interaction dispatch outcomes, exit reason, sampled process-tree observations,
+and settlement status. When filesystem paths are selected, REA records
+`files_before` and `files_after`, then classifies observed entries as created,
+deleted, modified, or unchanged. These are bounded snapshots, not a syscall
+trace; short-lived changes between snapshots may be missed. With no selected
+paths, filesystem effects remain unknown.
-## Reactive process scenarios
+Output, file count, file size, process sampling, filesystem depth, total
+runtime, idle time, and post-exit settlement are bounded by the scenario's
+limits. The result marks truncated observations and residual unknowns rather
+than treating missing data as proof of equivalence. Cancellation and timeout
+run the same owned-process cleanup path. Settlement reports whether the
+sampled process group quiesced or whether cleanup was needed or unverifiable;
+sampling cannot prove that every short-lived or detached descendant was seen.
-Set `reactive` to a process reactive scenario declaration when interaction
-must follow observed output instead of guessed delays. The graph declares an
-initial state, absolute scenario and state deadlines, explicitly prioritized
-transitions, caller-chosen use and visit counts, trigger predicates, ordered
-actions, and either a next state or `passed` outcome.
+## Compare two captures
-The admitted runtime slice matches decoded UTF-8 terminal literals and exact
-`terminal_raw`, `interaction`, `process`, `filesystem`, `http`, `websocket`, or
-`shim` journal events. Process events are sampled observations, not an
-event-complete lifecycle. Filesystem events identify named checkpoints; shim
-events identify a command and route. Live predicates receive the same normalized
-and redacted payload later persisted in Evidence. Predicates may compose with
-`all`, `any`, `sequence`, and `repeat` nodes and may select a frontier at
-scenario start, state entry, a prior checkpoint, or an exact event ID. Actions
-can write PTY input, resize it, signal the root process, or capture a named
-filesystem checkpoint. `terminal_rendered`, `lifecycle`, and `replay_transition`
-event triggers and non-root signal selectors fail during preflight before the
-target launches.
+Compare saved capture Evidence with:
-Reactive results are recorded in `reactive_run`, including the terminal outcome,
-active state, matched event IDs, action evidence IDs, and deterministic
-transition journal. The reducer retains and evaluates every admitted
-observation through the scenario deadline; it has no fixed state, transition,
-predicate, action, repeat, or history-count ceilings. Recursive input depth has
-a stack safety guard. Terminal controls also commit the last admitted
-observation order, so later output cannot be moved ahead of a timeout,
-cancellation, or target loss during validation. If the process exits before the graph finishes,
-the outcome is `target_lost`; state/scenario deadlines remain distinct
-`predicate_timeout`/`scenario_deadline` outcomes. Timed `events` remain
-available for fixed schedules, but they are not silently translated into the
-reactive graph.
-
-## Checkpoints and deterministic shims
-
-Checkpoint triggers may use an elapsed time, a terminal literal and occurrence
-count, root exit, or settlement. REA always includes `before` and
-`after_settlement` snapshots. Snapshot effects are relative to the preceding
-checkpoint, so transient files remain visible even when the final tree matches
-the initial tree.
-
-Each command shim matches an exact argument array. Routes can emit timed stdout
-or stderr chunks and then exit or receive `SIGINT`, `SIGTERM`, or `SIGKILL`.
-Unmatched and exhausted calls are recorded. Shim observations are included in
-the capture without a separate fixed count ceiling. Other configured
-observation and byte budgets remain explicit in the capture result when they
-affect completeness.
-
-## Run a replay machine directly
-
-Use `rea run-replay-machine ./run.json` to validate a finite replay machine
-against an ordered `events` array without launching a process or opening a
-socket. MCP clients use `run_replay_machine` with the same `{ machine, events }`
-input. The result retains one decision per offered event, a transition journal
-with capture alias metadata, and one redacted action-table entry per used
-transition. It never returns request bodies, request headers, or captured
-values. It also reports the initial and final states, whether the final state is
-terminal, configured limits, and committed usage.
-
-The direct evaluator has no fixed state, transition, event, guard, capture,
-action, or action-byte count ceiling. It indexes transitions by protocol and
-path, evaluates events in order, and returns a decision for each offered event
-plus actions for used transitions. Caller-declared `max_uses`, `max_visits`,
-`max_transitions`, and protocol budgets determine the finite run; there is no
-upper schema cap on those values. Result size follows the input and is not
-truncated by an arbitrary item quota.
-
-Refused events remain data rather than aborting the run. Outcomes distinguish
-unmatched events, invalid states, failed guards, exhausted transitions, invalid
-captures, unexpected reconnects, and exhausted limits. This direct runner
-evaluates the same domain runtime used by loopback Process Capture, but performs
-no network or target execution.
-
-## Compare captures
-
-Compare two saved Evidence records with:
-
-```bash
+```sh
rea compare-process-captures authority.json reconstruction.json
```
-Pass an optional third path, `rea compare-process-captures authority.json
-reconstruction.json trace.json`, when valid executions may differ in declared
-concurrent ordering. MCP callers pass the same object as `trace_spec` to
-`compare_process_captures`. A trace specification declares exact named events
-and chooses one language:
+The comparison checks terminal, interaction, exit, filesystem, and
+process observations under a shared comparison contract. It reports observed
+differences with their locations, while residual unknowns or truncated
+observations prevent a complete-equivalence claim. Matching scenario
+commitments do not reveal whether redacted sensitive inputs were equal.
-- `partial_order` requires every event pair to be related by a
- `happens_before` edge (including transitive edges) or an explicit
- `unordered_groups` declaration. `not_before` expresses negative ordering
- constraints without inferring causality. Optional `prefix` and `suffix`
- arrays remain exact.
-- `finite_traces` lists the complete accepted variants. An `unordered` token
- accepts permutations of exactly its declared event multiset; it does not
- drop or broadly sort events.
+For scenarios where independent scheduling can change event order, the CLI
+also accepts an optional trace-specification JSON file as the third argument.
+That specification must state the exact events and ordering constraints to
+accept; it does not discard unmatched observations or make timestamps into
+causal evidence. Trace comparison returns `unknown` when a capture lacks the
+required complete event journal or contains relevant residual unknowns.
-Each event uses one source family (`terminal_raw`, `terminal_rendered`,
-`interaction`, `lifecycle`, `process`, `filesystem`, `http`, `websocket`,
-`shim`, or `replay_transition`), an exact JSON payload, and a discriminated
-cardinality: `required`, `optional`, `exact`, or `range`. Specifications with
-duplicate or overlapping predicates, unknown references, cycles, implicit
-ordering gaps, ordered/unordered conflicts, or finite variants that violate
-cardinality are rejected before comparison.
-
-Event payloads are exact by default. To compare schedules whose recorder
-metadata necessarily changes, an event may explicitly list top-level
-`ignore_fields` from the bounded set `sequence`, `at_ms`, `scheduled_at_ms`,
-`dispatched_at_ms`, and `elapsed_ms`; its `exact` object must omit those fields.
-All predicates for one source use the same ignore set, so relaxed metadata
-cannot create declaration-order matching ambiguity. Other payload fields are
-never dropped or normalized by trace comparison.
-
-Process Capture records a monotonic `event_journal` at observation time. Trace
-comparison uses only this capture order for cross-source causality; timestamps
-are retained as event data but never promoted into happens-before evidence.
-Older captures without a complete journal, truncated captures, and relevant
-residual unknowns return a trace verdict of `unknown`. A passing result retains
-each side's raw journal locations and identifies either the matched finite
-variant or the complete satisfied constraint set. A failure returns the first
-declaration-ordered predicate, cardinality, edge, prefix, suffix, or language
-violation with its relevant event locations.
-If both captures have the same invalid declared trace, the nested verdict is
-`nonconforming` while the top-level capture comparison remains unchanged. Side
-statuses and the diagnostic distinguish conformance from pairwise difference.
-
-The result classifies terminal, interaction, exit, filesystem, protocol,
-process, and shim behavior. `first_divergence` points to the earliest observed
-difference. Residual unknowns prevent a claim of complete equivalence, but they
-do not hide an observed difference in another dimension.
-
-Captures must have equal comparison-contract commitments, but may identify
-different scenarios and executables. MCP callers may set `max_capture_age_ms`;
-the application clock then rejects stale captures before comparison.
-
-## Limits and lifecycle behavior
-
-Scenarios bound output bytes, frames, files, file bytes, process observations,
-protocol/shim events, connections, filesystem depth, total runtime, idle time,
-and settlement time. Process trees are sampled rather than syscall-traced, and
-short-lived descendants may be missed. Filesystem observations are snapshots,
-not filesystem event streams.
-
-REA tags launched processes with a private run identifier. During cleanup it
-revalidates that identifier before signaling sampled process groups, including
-groups detached from the original parent. Temporary HOME, replay, shim,
-terminal, checkpoint, and sampling resources are released on success, failure,
-timeout, and cancellation.
-
-After root exit, REA checks token-owned process groups every 50 ms. Two
-consecutive empty observations establish `quiesced`; the settlement deadline
-otherwise produces `alive_at_deadline`, while inspection or identity failures
-produce `unverifiable`. Sampling can still miss short-lived or early-detached
-descendants, which remains a residual unknown.
+Each capture carries commitments for the selected scenario, executable,
+comparison contract, and normalization rules. Comparison rejects captures
+whose comparison contracts differ. Captures are local Evidence files; keep
+their source artifacts and invocation context available when interpreting a
+difference.
diff --git a/docs/product-catalog.json b/docs/product-catalog.json
index 1da812877..df50bfea1 100644
--- a/docs/product-catalog.json
+++ b/docs/product-catalog.json
@@ -10,7 +10,7 @@
"skill_version": "23"
},
"tools": {
- "total": 122,
+ "total": 116,
"families": [
{
"id": "direct",
@@ -61,13 +61,12 @@
{
"id": "enhanced",
"surface": "enhanced",
- "count": 15,
+ "count": 14,
"tools": [
"analyze_function",
"analyze_swift_types",
"batch_decompile",
"binary_overview",
- "find_code_for_string",
"find_xrefs_to_name",
"get_call_graph",
"get_objc_classes",
@@ -109,14 +108,13 @@
{
"id": "managed",
"surface": "managed",
- "count": 8,
+ "count": 7,
"tools": [
"compare_managed_members",
"import_managed_reconstruction",
"inspect_managed_artifact",
"inspect_managed_members",
"inspect_managed_native_boundaries",
- "plan_managed_runtime_correlation",
"project_managed_application_graph",
"verify_managed_native_boundaries"
]
@@ -161,16 +159,13 @@
{
"id": "application",
"surface": "application-workflow",
- "count": 10,
+ "count": 7,
"tools": [
"build_reconstruction_obligation_ledger",
"compare_application_versions",
"compare_javascript_export_shapes",
"compare_source_to_bundle",
"evaluate_reconstruction_coverage",
- "execute_node_characterization",
- "prepare_node_characterization",
- "run_controlled_replay",
"trace_application_feature",
"trace_javascript_semantics"
]
@@ -178,7 +173,7 @@
{
"id": "session",
"surface": "session",
- "count": 22,
+ "count": 21,
"tools": [
"binary_session",
"build_call_path",
@@ -198,7 +193,6 @@
"list_unknowns",
"open_binary",
"record_unknown",
- "run_replay_machine",
"update_unknown",
"verify_reconstruction",
"verify_unknown_resolution"
@@ -344,7 +338,6 @@
"capabilities": [
"compare_managed_members",
"import_managed_reconstruction",
- "plan_managed_runtime_correlation",
"project_managed_application_graph",
"verify_managed_native_boundaries"
]
@@ -365,9 +358,6 @@
"compare_javascript_export_shapes",
"compare_source_to_bundle",
"evaluate_reconstruction_coverage",
- "execute_node_characterization",
- "prepare_node_characterization",
- "run_controlled_replay",
"trace_application_feature",
"trace_javascript_semantics"
]
@@ -445,7 +435,7 @@
}
],
"cli": {
- "primary_count": 74,
+ "primary_count": 69,
"commands": [
"analyze",
"analyze-javascript-application",
@@ -473,7 +463,6 @@
"evaluate-reconstruction-coverage",
"evidence-export",
"evidence-import",
- "execute-node-characterization",
"extract-artifact",
"function",
"import-managed-reconstruction",
@@ -502,14 +491,10 @@
"observe-javascript-runtime",
"observe-native-ui",
"observe-web-session",
- "plan-managed-runtime-correlation",
- "prepare-node-characterization",
"project-managed-application-graph",
"providers",
"reconcile-javascript-runtime",
"resolve-native-call-targets",
- "run-controlled-replay",
- "run-replay-machine",
"search",
"setup",
"trace",
@@ -531,15 +516,15 @@
},
"runtime_catalog": {
"counts": {
- "cli_commands": 74,
- "mcp_tools": 122,
+ "cli_commands": 69,
+ "mcp_tools": 116,
"mcp_prompts": 6
},
"digests": {
- "tools_sha256": "9f2b8a77ca7d972dedddf33294f5addb5fc7e00ce43ae5de21be989e0777871a",
+ "tools_sha256": "ff29fddb875d335c400902e846377990fa4276f04e4494a255dd3986a0fd7896",
"prompts_sha256": "4762db348821fec787a5b8c6423f2c48a71f6f0ec2afee97b16ad69030ec504b",
- "combined_sha256": "3a0e6d66df95b913797ce2e4548e97d409d6e8768023f8a7a23d8d85ae9d766f",
- "providers_sha256": "c5a88a49990fafc8244972ee8ff543fc19995eb79db960da55f6a311e15340d1"
+ "combined_sha256": "73a4cdf4fc83b1728426931ff89e2113f1c8beb1b3bf337c179f2c349ce3c0f2",
+ "providers_sha256": "2ae8b85b20be338739e500549d275e78e875aaa10235d2ca283f0ade7787da90"
}
}
}
diff --git a/docs/roadmap.md b/docs/roadmap.md
index f036a8055..342a7ccea 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -37,8 +37,8 @@ related-tool change; setup must never install Java.
Before enabling Windows Ghidra operations, add current-user-only DACL creation and
readback, handle-based reparse-point-safe path authority, a DACL-protected IPC
backend, and Job Object assignment before provider execution. Process capture,
-controlled replay, Hopper, and broad filesystem-sensitive workflows remain
-separate Windows projects rather than implied parity.
+Hopper, and broad filesystem-sensitive workflows remain separate Windows
+projects rather than implied parity.
## Capability-selective setup
diff --git a/docs/testing.md b/docs/testing.md
index 605411d0c..7756b6c10 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -45,10 +45,13 @@ The process-global Vitest configuration contract rejects new direct temporary-ro
creation outside the workspace seam and its narrowly documented boundary/package
exceptions.
-Real Hopper, Ghidra, browser, package, managed-code, and controlled-replay
-claims belong to their explicit `npm run verify:*` lanes. They are not inferred
-from mocks or folded into the deterministic local gate. Real model trials are
-manual; Vitest covers only deterministic evaluator logic.
+Real Hopper, Ghidra, browser, package, and managed-code claims belong to their
+explicit `npm run verify:*` lanes. The reconstruction-readiness lane also
+checks deterministic rerun, tamper, and stale-input handling; those checks do
+not execute extracted JavaScript modules. When application runtime behavior is
+needed, exercise the actual target through browser, Electron, or process
+capture. Real model trials are manual; Vitest covers deterministic evaluator
+logic.
## End-to-end, integration and golden evidence
diff --git a/docs/tool-design.md b/docs/tool-design.md
index 24bd55b31..965e4243c 100644
--- a/docs/tool-design.md
+++ b/docs/tool-design.md
@@ -8,14 +8,14 @@ protocol, authority, and resource-safety constraints, and report their effects.
## Choose the tool shape
-| Shape | Use it for | Contract should return |
-| -------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
-| `inspect` | Facts about one explicit target, address, object, or resource | Relevant fields, source locations, and facet-level availability |
-| `search` / `list` | Finding candidate targets or entities | Stable ordering and useful result context; expose pagination when the result format or caller's query needs it |
-| `trace` | Relationships across code, metadata, UI resources, or observations | Typed edges, supporting evidence, and unresolved paths; identify any genuine traversal boundary |
-| `compare` | Two explicitly identified artifacts, versions, or evidence sets | Paired identity, comparable coverage, and deltas with evidence |
-| `workflow` | A distinct analyst outcome that benefits from composition inside REA | A useful inline result, contributing evidence, and partial/unavailable facets |
-| `observe` / `capture` / `replay` | A question that requires runtime behavior | Required authority, launch/attach behavior, real operational constraints, lifecycle, and cleanup status |
+| Shape | Use it for | Contract should return |
+| --------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
+| `inspect` | Facts about one explicit target, address, object, or resource | Relevant fields, source locations, and facet-level availability |
+| `search` / `list` | Finding candidate targets or entities | Stable ordering and useful result context; expose pagination when the result format or caller's query needs it |
+| `trace` | Relationships across code, metadata, UI resources, or observations | Typed edges, supporting evidence, and unresolved paths; identify any genuine traversal boundary |
+| `compare` | Two explicitly identified artifacts, versions, or evidence sets | Paired identity, comparable coverage, and deltas with evidence |
+| `workflow` | A distinct analyst outcome that benefits from composition inside REA | A useful inline result, contributing evidence, and partial/unavailable facets |
+| `observe` / `capture` | A question that requires runtime behavior | Required authority, launch/attach behavior, real operational constraints, lifecycle, and cleanup status |
These are task shapes, not required prefixes. Name a tool for the action and
object agents reason about; keep the name distinct from nearby alternatives.
diff --git a/docs/verification/managed-conformance-ledger.json b/docs/verification/managed-conformance-ledger.json
index b56aa3898..0476f20a4 100644
--- a/docs/verification/managed-conformance-ledger.json
+++ b/docs/verification/managed-conformance-ledger.json
@@ -1,5 +1,5 @@
{
- "ledger_id": "ecl_b01c0b28362b48128765d176113471a5a82637fca0acfcd3182971155942e0d4",
+ "ledger_id": "ecl_132e857a8a93f0b366cd72f9cfa1c338ffd4100460a7834ef69abff936c84472",
"records": [
{
"claim_id": "managed.application-graph-projection",
@@ -78,13 +78,6 @@
"ev_f974f4b9d039cda841894c6173cae86427ecef48f4e7a4d6c73edf7b23af2b2a"
]
},
- {
- "claim_id": "managed.runtime-correlation-admission-plan",
- "status": "pass",
- "evidence_ids": [
- "ev_3e546c20493faf9696502171a5cfe9504569cffb22f0561ee3000f0027b9bbd8"
- ]
- },
{
"claim_id": "managed.unicode-obfuscated-identifiers",
"status": "pass",
@@ -101,8 +94,8 @@
}
],
"summary": {
- "total": 14,
- "pass": 12,
+ "total": 13,
+ "pass": 11,
"fail": 0,
"unsupported": 2,
"truncated": 0,
diff --git a/docs/verification/managed-conformance-manifest.json b/docs/verification/managed-conformance-manifest.json
index 9c72c7ee2..861486e9c 100644
--- a/docs/verification/managed-conformance-manifest.json
+++ b/docs/verification/managed-conformance-manifest.json
@@ -1,5 +1,5 @@
{
- "manifest_id": "ecm_d8e34c1218d199b3639830b184f02f87642c42aa9e8e8e2474f8daa60e1645ac",
+ "manifest_id": "ecm_c47e33ff69b099583ff6a3b19cb06b602040ecc735351cbac21b897501c4cffe",
"verifier": {
"id": "rea-managed-conformance",
"version": "1"
@@ -13,7 +13,7 @@
"skill_digests": [
{
"skill_id": "reverse-engineer-anything",
- "sha256": "7d767925a840318504e9f1712a6ecc4a985cd7a08f00bffe36105b75481a54c2"
+ "sha256": "54490266ef46ed8e57354dbca8cc1ad74ab6b7f21d38116bebfc87a04bfb55a5"
}
],
"claims": [
@@ -169,20 +169,6 @@
"version": "1"
}
},
- {
- "claim_id": "managed.runtime-correlation-admission-plan",
- "scenario": {
- "id": "runtime-correlation-admission-plan",
- "version": 1
- },
- "artifact_sha256s": [
- "cead4cc99733e4e56a5fd0cec8a0813bbc24e249aa72a10eca4c47ee3b0e129d"
- ],
- "provider": {
- "id": "rea-managed-conformance",
- "version": "1"
- }
- },
{
"claim_id": "managed.unicode-obfuscated-identifiers",
"scenario": {
diff --git a/docs/windows-ghidra-p0.md b/docs/windows-ghidra-p0.md
index 623c7c0f9..dff18699f 100644
--- a/docs/windows-ghidra-p0.md
+++ b/docs/windows-ghidra-p0.md
@@ -21,9 +21,9 @@ Once the required native controls are implemented, the intended P0 is limited to
- an explicit native, non-managed, non-DLL x86-64 PE application; and
- the 22 read-only Ghidra inventory and function-analysis operations.
-Hopper, Ghidra GUI state, mutation, controlled JavaScript replay, process
-capture, browser/Electron observation, artifact extraction, and general
-Windows feature parity are not implied by this boundary. Managed PE/CLI
+Hopper, Ghidra GUI state, mutation, process capture, browser/Electron
+observation, artifact extraction, and general Windows feature parity are not
+implied by this boundary. Managed PE/CLI
inspection remains a separate execution-free provider and is not routed
through Windows Ghidra P0.
diff --git a/package.json b/package.json
index 0e15ea0e4..0185f6252 100644
--- a/package.json
+++ b/package.json
@@ -132,7 +132,6 @@
"verify:javascript-runtime": "npm run build:cached && node scripts/verify-local-javascript-runtime.mjs",
"verify:application-workflows": "npm run build:cached && node scripts/verify-local-application-workflows.mjs",
"verify:managed": "npm run build:cached && node scripts/verify-managed-conformance.mjs",
- "verify:replay": "npm run build:cached && node scripts/verify-real-javascript-replay.mjs",
"verify:agent": "npm run build:cached && node scripts/verify-agent-experience.mjs",
"verify:hopper:linux": "npm run verify:fixtures && npm run build:cached && node scripts/verify-real-hopper-linux.mjs",
"verify:setup:linux": "npm run build:cached && node scripts/verify-linux-setup-e2e.mjs",
diff --git a/scripts/lib/browser-scenario-verifier.mjs b/scripts/lib/browser-scenario-verifier.mjs
index 6cc371fe3..6c7836e8d 100644
--- a/scripts/lib/browser-scenario-verifier.mjs
+++ b/scripts/lib/browser-scenario-verifier.mjs
@@ -53,7 +53,6 @@ export function browserScenario(browser, origin) {
},
],
},
- allowed_origins: [origin],
environment: {
viewport: { width: 1_280, height: 720, device_scale_factor: 1.25 },
locale: "en-US",
@@ -83,7 +82,6 @@ export function browserScenario(browser, origin) {
},
],
storage: duplicateOriginStorage(origin),
- request_replay: { mode: "disabled" },
secrets: [
{
secret_id: "verifier_url",
diff --git a/scripts/lib/browser-screenshot-e2e.mjs b/scripts/lib/browser-screenshot-e2e.mjs
index 10c74f049..e7da037ab 100644
--- a/scripts/lib/browser-screenshot-e2e.mjs
+++ b/scripts/lib/browser-screenshot-e2e.mjs
@@ -77,7 +77,6 @@ export async function verifyLargeScreenshotE2e(endpoint, origin) {
name: "capture_web_screenshot",
arguments: {
cdp_endpoint: endpoint,
- allowed_origins: [origin],
target_id: target.id,
},
},
diff --git a/scripts/lib/managed-completion-report.mjs b/scripts/lib/managed-completion-report.mjs
index 45b74f555..01124d604 100644
--- a/scripts/lib/managed-completion-report.mjs
+++ b/scripts/lib/managed-completion-report.mjs
@@ -93,11 +93,6 @@ const sourceOwnedClaims = ({
scenarioId: "decompiler-reconstruction-import",
targets: [obfuscated.target],
}),
- completionClaim({
- claimId: "managed.runtime-correlation-admission-plan",
- scenarioId: "runtime-correlation-admission-plan",
- targets: [obfuscated.target],
- }),
completionClaim({
claimId: "managed.mvid-and-token-drift",
scenarioId: "mvid-and-token-drift",
diff --git a/scripts/verify-agent-experience.mjs b/scripts/verify-agent-experience.mjs
index 2cfdc1b30..62ee3ffeb 100644
--- a/scripts/verify-agent-experience.mjs
+++ b/scripts/verify-agent-experience.mjs
@@ -82,7 +82,7 @@ try {
"depth = 1",
],
["static", "inferred"],
- ["runtime", "replay"],
+ ["runtime", "behavioral probes"],
],
prompt: `Without directly reading target files, compare the default export in parser.mjs between ${targets.javascriptShapeLeft} and ${targets.javascriptShapeRight}. Use REA to analyze each shipped artifact and then compare its exact static export return shapes. State the exact heading-shape change, cite produced Evidence, and distinguish static inference from runtime semantics.`,
},
@@ -283,13 +283,13 @@ async function createTargets(root, includeManaged) {
mkdir(javascriptShapeRight, { recursive: true }),
]);
await Promise.all([
- cp(
- join(repositoryRoot, "tests/fixtures/replay/parser.mjs"),
+ writeFile(
join(javascriptShapeLeft, "parser.mjs"),
+ 'export default function parse() { return { heading: "Title" }; }\n',
),
- cp(
- join(repositoryRoot, "tests/fixtures/replay/parser-v2.mjs"),
+ writeFile(
join(javascriptShapeRight, "parser.mjs"),
+ 'export default function parse() { return { heading: { text: "Title", level: 1 } }; }\n',
),
]);
diff --git a/scripts/verify-managed-conformance.mjs b/scripts/verify-managed-conformance.mjs
index f0b15b6ff..ecb226c4b 100644
--- a/scripts/verify-managed-conformance.mjs
+++ b/scripts/verify-managed-conformance.mjs
@@ -1,4 +1,4 @@
-import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import assert from "node:assert/strict";
@@ -9,7 +9,6 @@ import { inspectManagedNativeBoundariesBytes } from "../dist/dotnet/ManagedNativ
import { compareManagedMemberPaths } from "../dist/application/ManagedMemberComparisonService.js";
import { verifyManagedNativeBoundariesEvidence } from "../dist/application/ManagedNativeVerificationService.js";
import { importManagedReconstructionEvidence } from "../dist/application/ManagedReconstructionService.js";
-import { planManagedRuntimeCorrelationEvidence } from "../dist/application/ManagedRuntimeCorrelationService.js";
import { projectManagedApplicationGraphEvidence } from "../dist/application/ManagedApplicationGraphService.js";
import { traceApplicationFeatureEvidence } from "../dist/application/JavaScriptApplicationWorkflowService.js";
import { MANAGED_STATIC_PROVIDER } from "../dist/application/InvestigationProviders.js";
@@ -249,9 +248,8 @@ try {
assert.equal(obfuscatedMembers.types[0]?.full_name, "Fixture.ꙮType");
assert.equal(obfuscatedMembers.methods[0]?.name, "λ⛧");
assert.equal(obfuscatedMembers.fields[0]?.name, "字段");
- const runtimeExecutable = process.execPath;
- const runtimeMethod = obfuscatedMembers.methods[0];
- assert.ok(runtimeMethod);
+ const reconstructionMethod = obfuscatedMembers.methods[0];
+ assert.ok(reconstructionMethod);
const obfuscatedMembersEvidence = createEvidence(
undefined,
MANAGED_STATIC_PROVIDER,
@@ -274,9 +272,9 @@ try {
},
methods: [
{
- token: runtimeMethod.token,
- signature_sha256: runtimeMethod.signature.raw_sha256,
- normalized_il_sha256: runtimeMethod.body.normalized_il_sha256,
+ token: reconstructionMethod.token,
+ signature_sha256: reconstructionMethod.signature.raw_sha256,
+ normalized_il_sha256: reconstructionMethod.body.normalized_il_sha256,
reconstruction: {
kind: "decompiled-csharp",
language: "csharp",
@@ -293,45 +291,6 @@ try {
false,
);
assert.equal(reconstructionImport.value.confidence, "inferred");
- const runtimePlan = await planManagedRuntimeCorrelationEvidence(
- { configuration: () => ({ executablePath: runtimeExecutable }) },
- {
- static_members: obfuscatedMembersEvidence,
- method: {
- token: runtimeMethod.token,
- signature_sha256: runtimeMethod.signature.raw_sha256,
- normalized_il_sha256: runtimeMethod.body.normalized_il_sha256,
- },
- requested_effect: "debugger",
- host: {
- os: "linux",
- clr_family: "dotnet",
- architecture: "x86_64",
- },
- bounds: {
- timeout_ms: 5_000,
- max_threads: 32,
- max_output_bytes: 65_536,
- allow_network: false,
- allow_ui: false,
- },
- },
- );
- assert.equal(runtimePlan.ok, true);
- assert.equal(runtimePlan.value.normalized_result.executed, false);
- assert.equal(
- runtimePlan.value.normalized_result.unsupported_until_executor_exists,
- true,
- );
- assert.equal(
- runtimePlan.value.normalized_result.effect_taxonomy.uses_debugger,
- true,
- );
- assert.equal(
- runtimePlan.value.normalized_result.requested_runtime.confinement,
- "not-established",
- );
-
const left = await fixture("token-drift-left.exe", {
methodName: "StableSemanticSlice",
});
@@ -410,7 +369,7 @@ try {
process.stdout.write(
`${JSON.stringify({
verified:
- 12 +
+ 11 +
(operatorManifest === null ? 0 : 1) +
(ilspyOracle === null ? 0 : 1),
managedSurfaces: [
@@ -420,7 +379,6 @@ try {
"compare_managed_members",
"verify_managed_native_boundaries",
"import_managed_reconstruction",
- "plan_managed_runtime_correlation",
"project_managed_application_graph",
],
coverage: [
@@ -431,7 +389,6 @@ try {
"managed-application-graph-projection",
"unicode-obfuscated-identifiers",
"decompiler-reconstruction-import",
- "runtime-correlation-admission-plan",
"mvid-and-token-drift",
"not-managed",
"malformed-metadata",
diff --git a/scripts/verify-package-managed.mjs b/scripts/verify-package-managed.mjs
index beebc7fe2..99a0be52a 100644
--- a/scripts/verify-package-managed.mjs
+++ b/scripts/verify-package-managed.mjs
@@ -115,69 +115,6 @@ const verifyManagedReconstruction = async ({
throw new Error("packaged managed reconstruction import CLI failed");
};
-const verifyManagedRuntimePlan = async ({
- cli,
- managedMembers,
- managedMethod,
- workspace,
- environment,
-}) => {
- const managedRuntimePlanInput = {
- static_members: managedMembers,
- method: {
- token: managedMethod.token,
- signature_sha256: managedMethod.signature.raw_sha256,
- normalized_il_sha256: managedMethod.body.normalized_il_sha256,
- },
- requested_effect: "attach",
- host: {
- os: "linux",
- clr_family: "dotnet",
- architecture: "x86_64",
- },
- bounds: {
- timeout_ms: 5_000,
- max_threads: 32,
- max_output_bytes: 65_536,
- allow_network: false,
- allow_ui: false,
- },
- };
- const managedRuntimePlan = json(
- await run(
- cli,
- [
- "plan-managed-runtime-correlation",
- JSON.stringify(managedRuntimePlanInput),
- "--json",
- ],
- {
- ...environment,
- REA_MANAGED_RUNTIME_EXECUTABLE_PATH: process.execPath,
- },
- ),
- );
- if (
- managedRuntimePlan.operation !== "plan_managed_runtime_correlation" ||
- managedRuntimePlan.provider?.id !== "rea-dotnet-workflows" ||
- managedRuntimePlan.normalized_result?.executed !== false ||
- managedRuntimePlan.normalized_result?.unsupported_until_executor_exists !==
- true ||
- managedRuntimePlan.normalized_result?.effect_taxonomy?.attaches_process !==
- true ||
- managedRuntimePlan.normalized_result?.effect_taxonomy
- ?.invokes_target_code !== false
- )
- throw new Error("packaged managed runtime-correlation CLI failed");
- if (
- managedRuntimePlan.normalized_result?.requested_runtime?.confinement !==
- "not-established"
- )
- throw new Error(
- "packaged managed runtime plan claimed unverified confinement",
- );
-};
-
const verifyManagedBoundaries = async ({ cli, managedPath, environment }) => {
const managedBoundaries = json(
await run(
@@ -363,13 +300,6 @@ export async function verifyManaged({ cli, workspace, environment }) {
managedMethod,
environment,
});
- await verifyManagedRuntimePlan({
- cli,
- managedMembers,
- managedMethod,
- workspace,
- environment,
- });
const managedBoundaries = await verifyManagedBoundaries({
cli,
managedPath,
diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs
index ab8e48b59..32937264e 100644
--- a/scripts/verify-package.mjs
+++ b/scripts/verify-package.mjs
@@ -117,7 +117,7 @@ try {
evidenceRoot,
});
process.stdout.write(
- `${JSON.stringify({ verifier_run: await completeVerifierRun(verifierRun), cli: true, analysisCli: true, artifactCli: true, managedCli: true, managedReconstructionCli: true, managedNativeVerificationCli: true, managedRuntimePlanCli: true, managedApplicationGraphCli: true, evidenceCli: true, incurMcpCommand: PRODUCT_IDENTITY.mcpCommand, lifecycleScriptsRequired: false, doctor: "platform-appropriate", setup: supportedSetupHost ? "planned-then-idempotent" : "unsupported-host-rejected", setupPlanReadOnly: supportedSetupHost, existingHopperPreserved: supportedSetupHost, clients: supportedSetupHost ? 3 : 0, backupReadback: supportedSetupHost, failureRecovery: supportedSetupHost, configSymlinkLifecycle: supportedSetupHost, skill: supportedSetupHost, skillReferences: supportedSetupHost, mcpTools: TOOL_CONTRACTS.length, mcpPrompts: prompts.names.length, promptCompletion: true, promptCompletionLifecycle: true, evidenceMcp: true, targetFree: true, targetLifecycle: true, boundedRegexBridge: true, mcpStartup, mcpModuleLoading })}\n`,
+ `${JSON.stringify({ verifier_run: await completeVerifierRun(verifierRun), cli: true, analysisCli: true, artifactCli: true, managedCli: true, managedReconstructionCli: true, managedNativeVerificationCli: true, managedApplicationGraphCli: true, evidenceCli: true, incurMcpCommand: PRODUCT_IDENTITY.mcpCommand, lifecycleScriptsRequired: false, doctor: "platform-appropriate", setup: supportedSetupHost ? "planned-then-idempotent" : "unsupported-host-rejected", setupPlanReadOnly: supportedSetupHost, existingHopperPreserved: supportedSetupHost, clients: supportedSetupHost ? 3 : 0, backupReadback: supportedSetupHost, failureRecovery: supportedSetupHost, configSymlinkLifecycle: supportedSetupHost, skill: supportedSetupHost, skillReferences: supportedSetupHost, mcpTools: TOOL_CONTRACTS.length, mcpPrompts: prompts.names.length, promptCompletion: true, promptCompletionLifecycle: true, evidenceMcp: true, targetFree: true, targetLifecycle: true, boundedRegexBridge: true, mcpStartup, mcpModuleLoading })}\n`,
);
} finally {
if (tarball) await rm(join(root, tarball), { force: true });
diff --git a/scripts/verify-real-browser.mjs b/scripts/verify-real-browser.mjs
index 502149e8f..8abb6534e 100644
--- a/scripts/verify-real-browser.mjs
+++ b/scripts/verify-real-browser.mjs
@@ -82,7 +82,7 @@ try {
});
const endpoint = `http://127.0.0.1:${String(port)}`;
const provider = new CdpBrowserProvider();
- const target = await pageTarget(provider, endpoint, site.origin);
+ const target = await pageTarget(provider, endpoint);
const observed = await provider.inspectPage(
inspectWebPageInputSchema.parse({
cdp_endpoint: endpoint,
@@ -355,7 +355,7 @@ try {
}
async function verifyPageScopedTransport(provider, proxy, origin) {
- const target = await pageTarget(provider, proxy.endpoint, origin);
+ const target = await pageTarget(provider, proxy.endpoint);
const input = inspectWebPageInputSchema.parse({
cdp_endpoint: proxy.endpoint,
allowed_origins: [origin],
@@ -432,12 +432,11 @@ async function browserExecutable() {
);
}
-async function pageTarget(provider, endpoint, origin) {
+async function pageTarget(provider, endpoint) {
for (let attempt = 0; attempt < 100; attempt += 1) {
const listed = await provider.listTargets(
listBrowserTargetsInputSchema.parse({
cdp_endpoint: endpoint,
- allowed_origins: [origin],
}),
);
if (listed.ok && listed.value.targets[0] !== undefined)
diff --git a/scripts/verify-real-javascript-replay.mjs b/scripts/verify-real-javascript-replay.mjs
deleted file mode 100644
index e533de2f2..000000000
--- a/scripts/verify-real-javascript-replay.mjs
+++ /dev/null
@@ -1,314 +0,0 @@
-#!/usr/bin/env node
-
-import { readFile, realpath } from "node:fs/promises";
-import { resolve } from "node:path";
-
-import { runControlledReplay } from "../dist/application/JavaScriptReplayService.js";
-import { LinuxJavaScriptReplayRunner } from "../dist/replay/LinuxJavaScriptReplayRunner.js";
-import { SystemJavaScriptReplayHost } from "../dist/replay/SystemJavaScriptReplayHost.js";
-import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
-
-const verifierRun = createVerifierRun();
-
-const suppliedInputPath = process.env.REA_REPLAY_INPUT_PATH;
-const suppliedInput =
- suppliedInputPath === undefined
- ? undefined
- : JSON.parse(await readFile(await realpath(suppliedInputPath), "utf8"));
-const canonicalSide = async (side) => ({
- ...side,
- modules: await Promise.all(
- side.modules.map(async (module) => ({
- ...module,
- path: await realpath(module.path),
- })),
- ),
-});
-const leftPath = await realpath(
- process.env.REA_REPLAY_LEFT_PATH ??
- resolve("tests/fixtures/replay/parser.mjs"),
-);
-const rightPath = await realpath(
- process.env.REA_REPLAY_RIGHT_PATH ??
- resolve("tests/fixtures/replay/parser-v2.mjs"),
-);
-const suppliedLeft =
- suppliedInput === undefined
- ? undefined
- : await canonicalSide(suppliedInput.left);
-const suppliedRight =
- suppliedInput?.right === undefined
- ? undefined
- : await canonicalSide(suppliedInput.right);
-const configuration = {
- nodePath: process.execPath,
- bubblewrapPath:
- process.env.REA_JAVASCRIPT_REPLAY_BWRAP_PATH ?? "/usr/bin/bwrap",
- systemdRunPath:
- process.env.REA_JAVASCRIPT_REPLAY_SYSTEMD_RUN_PATH ??
- "/usr/bin/systemd-run",
- systemctlPath:
- process.env.REA_JAVASCRIPT_REPLAY_SYSTEMCTL_PATH ?? "/usr/bin/systemctl",
- shellPath: process.env.REA_JAVASCRIPT_REPLAY_SHELL_PATH ?? "/usr/bin/bash",
-};
-const dependencies = {
- configuration: () => configuration,
- host: new SystemJavaScriptReplayHost(),
- runner: new LinuxJavaScriptReplayRunner(),
-};
-const defaultInput = {
- mode: "plan",
- left: {
- modules: [
- {
- alias: "parser",
- path: leftPath,
- format: "esm",
- role: "module",
- dependencies: {},
- },
- ],
- entry_alias: "parser",
- entry_export: "default",
- },
- right: {
- modules: [
- {
- alias: "parser",
- path: rightPath,
- format: "esm",
- role: "module",
- dependencies: {},
- },
- ],
- entry_alias: "parser",
- entry_export: "default",
- },
- cases: [
- { case_id: "heading", arguments: ["# Title"] },
- { case_id: "plain", arguments: ["plain"] },
- ],
- generator: { preset: "parser-boundaries", seed: 7, count: 4 },
-};
-const suppliedPlanInput =
- suppliedInput === undefined
- ? undefined
- : (({ approved: _approved, plan_digest: _planDigest, ...planInput }) =>
- planInput)(suppliedInput);
-const input =
- suppliedPlanInput === undefined
- ? defaultInput
- : {
- ...suppliedPlanInput,
- mode: "plan",
- left: suppliedLeft,
- ...(suppliedRight === undefined ? {} : { right: suppliedRight }),
- reproducer_export: undefined,
- };
-
-const planned = await runControlledReplay(dependencies, input);
-if (!planned.ok) {
- if (process.env.REA_REPLAY_ALLOW_UNAVAILABLE === "true") {
- process.stdout.write(
- `${JSON.stringify({ verifier_run: await completeVerifierRun(verifierRun), available: false, error_tag: planned.error._tag })}\n`,
- );
- process.exit(0);
- }
- throw planned.error;
-}
-const plan = planned.value.plan;
-if (plan === null || typeof plan.plan_digest !== "string")
- throw new Error("Real replay verifier did not receive a plan");
-const executed = await runControlledReplay(dependencies, {
- ...input,
- mode: "execute",
- plan_digest: plan.plan_digest,
-});
-if (!executed.ok) throw executed.error;
-const evidence = executed.value.evidence;
-if (
- evidence === null ||
- evidence.authority !== "controlled-replay" ||
- evidence.environment?.isolation !== "container" ||
- evidence.normalized_result.termination !== "completed" ||
- evidence.normalized_result.cleanup.state !== "complete" ||
- evidence.normalized_result.comparison?.some(
- ({ status }) => status === "unknown",
- ) === true
-)
- throw new Error(
- `Real replay verifier received incomplete Evidence: ${JSON.stringify({
- authority: evidence?.authority ?? null,
- isolation: evidence?.environment?.isolation ?? null,
- termination: evidence?.normalized_result?.termination ?? null,
- cleanup: evidence?.normalized_result?.cleanup?.state ?? null,
- outcomes:
- evidence?.normalized_result?.outcomes?.map(({ outcome }) => outcome) ??
- [],
- stderr: evidence?.normalized_result?.stderr ?? null,
- comparison_statuses:
- evidence?.normalized_result?.comparison?.map(({ status }) => status) ??
- [],
- })}`,
- );
-
-if (suppliedInput !== undefined) {
- process.stdout.write(
- `${JSON.stringify({
- verifier_run: await completeVerifierRun(verifierRun),
- available: true,
- supplied_manifest: true,
- plan_digest: plan.plan_digest,
- evidence_id: evidence.evidence_id,
- source_evidence_ids: executed.value.source_evidence.map(
- ({ evidence_id: id }) => id,
- ),
- left_sha256: plan.left.modules.map(({ sha256 }) => sha256),
- right_sha256: plan.right?.modules.map(({ sha256 }) => sha256) ?? [],
- cases: plan.cases.length,
- comparison:
- evidence.normalized_result.comparison?.map(({ case_id, status }) => ({
- case_id,
- status,
- })) ?? [],
- cleanup: evidence.normalized_result.cleanup.state,
- })}\n`,
- );
- process.exit(0);
-}
-
-const runSingle = async (path, limits = undefined) => {
- const manifest = {
- mode: "plan",
- left: {
- modules: [
- {
- alias: "entry",
- path: await realpath(path),
- format: "esm",
- role: "module",
- dependencies: {},
- },
- ],
- entry_alias: "entry",
- entry_export: "default",
- },
- cases: [{ case_id: "probe", arguments: [] }],
- ...(limits === undefined ? {} : { limits }),
- };
- const proposed = await runControlledReplay(dependencies, manifest);
- if (!proposed.ok || proposed.value.plan === null)
- throw new Error("Replay conformance plan failed");
- const observed = await runControlledReplay(dependencies, {
- ...manifest,
- mode: "execute",
- plan_digest: proposed.value.plan.plan_digest,
- });
- if (!observed.ok || observed.value.evidence === null)
- throw new Error("Replay conformance execution failed");
- return observed.value.evidence.normalized_result;
-};
-
-const sideEffects = await runSingle(
- resolve("tests/fixtures/replay/side-effect-attempt.mjs"),
-);
-if (
- sideEffects.outcomes[0]?.outcome !== "return" ||
- Object.values(sideEffects.outcomes[0].value).some(
- (value) => value !== "undefined",
- )
-)
- throw new Error("Ambient side-effect surface was exposed to replay code");
-
-const timeout = await runSingle(
- resolve("tests/fixtures/replay/busy-loop.mjs"),
- {
- wall_time_ms: 100,
- },
-);
-if (timeout.termination !== "timeout" || timeout.cleanup.state !== "complete")
- throw new Error("Replay timeout did not kill and clean the complete sandbox");
-
-const differentialTimeoutInput = {
- mode: "plan",
- left: {
- modules: [
- {
- alias: "entry",
- path: await realpath(resolve("tests/fixtures/replay/busy-loop.mjs")),
- format: "esm",
- role: "module",
- dependencies: {},
- },
- ],
- entry_alias: "entry",
- entry_export: "default",
- },
- right: {
- modules: [
- {
- alias: "entry",
- path: await realpath(resolve("tests/fixtures/replay/parser.mjs")),
- format: "esm",
- role: "module",
- dependencies: {},
- },
- ],
- entry_alias: "entry",
- entry_export: "default",
- },
- cases: [{ case_id: "probe", arguments: [] }],
- limits: { wall_time_ms: 100 },
-};
-const differentialTimeoutPlan = await runControlledReplay(
- dependencies,
- differentialTimeoutInput,
-);
-if (!differentialTimeoutPlan.ok || differentialTimeoutPlan.value.plan === null)
- throw new Error("Differential timeout plan failed");
-const differentialTimeout = await runControlledReplay(dependencies, {
- ...differentialTimeoutInput,
- mode: "execute",
- plan_digest: differentialTimeoutPlan.value.plan.plan_digest,
-});
-if (
- !differentialTimeout.ok ||
- differentialTimeout.value.evidence === null ||
- differentialTimeout.value.source_evidence.length !== 2 ||
- differentialTimeout.value.evidence.normalized_result.comparison?.[0]
- ?.status !== "unknown" ||
- differentialTimeout.value.source_evidence.some(
- (item) => item.normalized_result.outcomes[0]?.outcome !== "timeout",
- )
-)
- throw new Error("Differential termination did not retain both observations");
-
-const memory = await runSingle(
- resolve("tests/fixtures/replay/memory-pressure.mjs"),
- { wall_time_ms: 3000, memory_bytes: 32 * 1024 * 1024 },
-);
-if (memory.termination !== "oom" || memory.cleanup.state !== "complete")
- throw new Error(
- `Replay OOM did not report and clean the cgroup limit: ${JSON.stringify(memory)}`,
- );
-process.stdout.write(
- `${JSON.stringify({
- verifier_run: await completeVerifierRun(verifierRun),
- available: true,
- plan_digest: plan.plan_digest,
- evidence_id: evidence.evidence_id,
- left_sha256: plan.left.modules[0]?.sha256,
- right_sha256: plan.right?.modules[0]?.sha256,
- cases: plan.cases.length,
- comparison: evidence.normalized_result.comparison?.map(
- ({ case_id, status }) => ({ case_id, status }),
- ),
- cleanup: evidence.normalized_result.cleanup.state,
- side_effect_surface: "unavailable",
- timeout: timeout.termination,
- differential_timeout:
- differentialTimeout.value.evidence.normalized_result.comparison[0]
- ?.status,
- memory: memory.termination,
- })}\n`,
-);
diff --git a/skills/reverse-engineer-anything/SKILL.md b/skills/reverse-engineer-anything/SKILL.md
index 5466ef601..7784418ce 100644
--- a/skills/reverse-engineer-anything/SKILL.md
+++ b/skills/reverse-engineer-anything/SKILL.md
@@ -3,14 +3,14 @@ name: reverse-engineer-anything
description: Reverse engineer native, managed, Electron/JavaScript, packaged, and browser applications with REA. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
metadata:
version: "23"
- tool_count: 122
- catalog_digest: "3a0e6d66df95b913797ce2e4548e97d409d6e8768023f8a7a23d8d85ae9d766f"
+ tool_count: 116
+ catalog_digest: "73a4cdf4fc83b1728426931ff89e2113f1c8beb1b3bf337c179f2c349ce3c0f2"
---
# REA
Use REA when a claim depends on a shipped binary or package, decompilation,
-passive application runtime evidence, controlled replay, or comparison with
+passive application runtime evidence, or comparison with
behavior not established by available source. For ordinary analysis of a
complete source repository, use normal repository tools and do not run REA
readiness or provider commands.
@@ -71,8 +71,10 @@ For requests that span multiple features or subsystems, use a staged workflow:
they affect the question. Use format-aware inventory and parsers; do not
infer behavior from filenames, strings, or layout alone.
5. Corroborate a conclusion with the evidence type it requires. Use runtime
- observation or controlled replay only when static evidence cannot answer the
- question and the required host runtime and OS access are available.
+ observation only when static evidence cannot answer the question and the
+ required host runtime and OS access are available. For JavaScript behavior,
+ run probes against the actual app through the available browser, Electron,
+ or process capture workflow.
6. Decompose work into independent questions. When parallel workers are
available and the questions do not depend on one another, assign distinct
scopes, point workers to existing evidence, and ask them to return sources,
@@ -96,8 +98,6 @@ as complete while required questions remain open.
[references/runtime-observation.md](references/runtime-observation.md)
- Evidence paging, comparisons, residual unknowns, and verification:
[references/evidence-workflows.md](references/evidence-workflows.md)
-- Controlled JavaScript replay:
- [references/controlled-replay.md](references/controlled-replay.md)
## Readiness and setup
diff --git a/skills/reverse-engineer-anything/references/controlled-replay.md b/skills/reverse-engineer-anything/references/controlled-replay.md
deleted file mode 100644
index 8fb493faa..000000000
--- a/skills/reverse-engineer-anything/references/controlled-replay.md
+++ /dev/null
@@ -1,12 +0,0 @@
-# Controlled JavaScript replay
-
-Use `run_controlled_replay` only for operator-selected extracted modules when
-the separate replay policy is enabled. Call `mode: plan` first. Review the exact
-module, stub, runtime, sandbox, case, limits, and policy commitments. Execute
-only with `approved: true` and that exact plan digest.
-
-Treat return values, exceptions, denials, limits, and crashes as observations of
-the isolated experiment with controlled-replay authority, not facts about the
-real application. Never substitute browser/Electron permission, process
-capture, or an in-process `vm` run. Reproducer export requires separate literal
-approval and evidence-write authority after sandbox cleanup.
diff --git a/skills/reverse-engineer-anything/references/javascript-applications.md b/skills/reverse-engineer-anything/references/javascript-applications.md
index 195e05f1b..d85128277 100644
--- a/skills/reverse-engineer-anything/references/javascript-applications.md
+++ b/skills/reverse-engineer-anything/references/javascript-applications.md
@@ -31,5 +31,6 @@ Include the complete Evidence records from both analysis calls. Accept variant
pairing only through the tool's unique exact literal discriminant. Cite the
comparison Evidence and report JSON Pointer changes; dynamic values, ambiguous
variants, and incomplete parent-property coverage stay unknown. This is static
-inference, not runtime behavior. Use `run_controlled_replay` separately only
-when the user needs approved runtime semantics.
+inference, not runtime behavior. When runtime semantics are needed, run
+behavioral probes against the relevant application versions and capture them
+through the available browser, Electron, or process workflows.
diff --git a/src/application/BrowserScenarioEvidence.ts b/src/application/BrowserScenarioEvidence.ts
index a41c4b70e..47eb0b597 100644
--- a/src/application/BrowserScenarioEvidence.ts
+++ b/src/application/BrowserScenarioEvidence.ts
@@ -8,18 +8,17 @@ import {
type EvidenceObservation,
} from "../domain/evidence.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
-import { digestJson } from "./JavaScriptReplayPlanning.js";
+import { digestCanonicalJson } from "../domain/browserScenarioNormalization.js";
const browserScenarioParameters = (
scenario: BrowserScenario,
): EvidenceObservation["parameters"] => ({
- scenario_sha256: digestJson(scenario),
+ scenario_sha256: digestCanonicalJson(scenario),
browser_mode: scenario.browser.mode,
...(scenario.browser.mode === "launch"
? { browser_headless: scenario.browser.headless }
: {}),
start_url: sanitizeBrowserUrl(scenario.start_url.url),
- allowed_origins: scenario.allowed_origins,
environment: scenario.environment,
actions: scenario.actions.map(({ step_id, action }) => ({
step_id,
diff --git a/src/application/CapabilityInventory.test.ts b/src/application/CapabilityInventory.test.ts
index 83ba864a8..43b3cff01 100644
--- a/src/application/CapabilityInventory.test.ts
+++ b/src/application/CapabilityInventory.test.ts
@@ -4,7 +4,6 @@ import { buildCapabilityInventory } from "./CapabilityInventory.js";
const enabledPolicy: Parameters[1] = {
processCaptureEnabled: true,
- controlledReplayEnabled: true,
browserObservationEnabled: true,
browserScenarioEnabled: true,
electronObservationEnabled: true,
@@ -159,26 +158,6 @@ describe("capability inventory: provider status", () => {
available: false,
reason: "provider_missing",
});
- expect(byName.get("run_controlled_replay")).toMatchObject({
- available: true,
- reason: "available",
- });
- expect(byName.get("plan_managed_runtime_correlation")).toMatchObject({
- available: true,
- reason: "available",
- });
- });
-
- it("reports controlled replay as unsupported on other host targets", () => {
- const replay = entry("run_controlled_replay", status(), {
- ...enabledPolicy,
- controlledReplayEnabled: false,
- });
- expect(replay).toMatchObject({
- available: false,
- reason: "unsupported_host",
- remediation: "Run controlled replay on a Linux x86_64 host.",
- });
});
});
diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts
index 0f497edfa..fc8c09d1c 100644
--- a/src/application/CapabilityInventory.ts
+++ b/src/application/CapabilityInventory.ts
@@ -11,7 +11,6 @@ import {
import type { ToolKind } from "../contracts/toolContractTypes.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { GENERATED_MCP_TOOL_CATALOG } from "../generatedMcpToolCatalog.js";
-import { isSupportedControlledReplayHost } from "./ControlledReplayHostSupport.js";
import {
clientRequirementsFor,
NO_CLIENT_FEATURES,
@@ -29,7 +28,6 @@ type ToolAvailabilityReason = "available" | ToolUnavailabilityReason;
type ProviderDescriptor = ProviderCapability;
export type AvailabilityPolicy = {
readonly processCaptureEnabled: boolean;
- readonly controlledReplayEnabled?: boolean;
readonly browserObservationEnabled?: boolean;
readonly browserScenarioEnabled?: boolean;
readonly electronObservationEnabled?: boolean;
@@ -214,16 +212,6 @@ const workflowAvailabilityFor = ({
reason: "unsupported_host",
remediation: "Run process capture on a supported Linux or macOS host.",
};
- if (name === "run_controlled_replay")
- return (policy.controlledReplayEnabled ??
- isSupportedControlledReplayHost(process.platform, process.arch))
- ? { reason: "available", remediation: null }
- : {
- reason: "unsupported_host",
- remediation: "Run controlled replay on a Linux x86_64 host.",
- };
- if (name === "plan_managed_runtime_correlation")
- return { reason: "available", remediation: null };
if (kind === "application") return { reason: "available", remediation: null };
const electron = electronProviderAvailability(name, kind, policy);
if (electron !== null) return electron;
diff --git a/src/application/CommandShimReplay.ts b/src/application/CommandShimReplay.ts
deleted file mode 100644
index 9fa430803..000000000
--- a/src/application/CommandShimReplay.ts
+++ /dev/null
@@ -1,202 +0,0 @@
-import { createServer, type Server } from "node:http";
-import { chmod, mkdir, writeFile } from "node:fs/promises";
-import { join } from "node:path";
-
-import type {
- ProcessScenario,
- RecordProcessCaptureEvent,
- ShimEvent,
-} from "../domain/processCapture.js";
-
-interface Invocation {
- readonly command: string;
- readonly arguments: readonly string[];
- readonly working_directory: string;
-}
-
-/** Owned command-shim replay resources for one process scenario. */
-export interface CommandShimReplay {
- readonly binPath: string;
- readonly url: string;
- readonly events: readonly ShimEvent[];
- readonly truncated: boolean;
- close(): Promise;
-}
-
-const RUNNER = `const command = process.argv[2];
-const maximumTimerDelay = 2147483647;
-const delay = milliseconds => new Promise(resolve => {
- let remaining = milliseconds;
- const schedule = () => {
- const interval = Math.min(remaining, maximumTimerDelay);
- setTimeout(() => {
- remaining -= interval;
- if (remaining === 0) resolve();
- else schedule();
- }, interval);
- };
- schedule();
-});
-const response = await fetch(process.env.REA_SHIM_LEDGER_URL, {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ command, arguments: process.argv.slice(3), working_directory: process.cwd() }),
-});
-if (!response.ok) process.exit(127);
-const route = await response.json();
-let elapsed = 0;
-for (const output of route.outputs) {
- const wait = Math.max(0, output.at_ms - elapsed);
- if (wait > 0) await delay(wait);
- elapsed = output.at_ms;
- (output.stream === "stdout" ? process.stdout : process.stderr).write(output.data);
-}
-if (route.termination.type === "signal") process.kill(process.pid, route.termination.signal);
-else process.exit(route.termination.code);
-`;
-
-const shellQuote = (value: string): string =>
- `'${value.replaceAll("'", `'\\''`)}'`;
-
-/**
- * Start bounded executable replay wrappers and their private invocation ledger.
- *
- * Exact argument matching is deliberate: a fallback to the host executable
- * would make an unmatched dependency call look authoritative. Unmatched and
- * exhausted routes therefore fail visibly and remain part of the capture.
- */
-export const startCommandShimReplay = async (
- scenario: ProcessScenario,
- temporaryRoot: string,
- started: number,
- recordEvent: RecordProcessCaptureEvent = () => undefined,
-): Promise => {
- const binPath = join(temporaryRoot, "shims");
- await mkdir(binPath);
- const runnerPath = join(temporaryRoot, "shim-runner.mjs");
- await writeFile(runnerPath, RUNNER, { mode: 0o600, flag: "wx" });
- for (const shim of scenario.command_shims) {
- const path = join(binPath, shim.name);
- const wrapper = `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(runnerPath)} ${shellQuote(shim.name)} "$@"\n`;
- await writeFile(path, wrapper, { mode: 0o700, flag: "wx" });
- await chmod(path, 0o700);
- }
-
- const events: ShimEvent[] = [];
- let truncated = false;
- const calls = new Map();
- const server = createServer((request, response) => {
- if (request.method !== "POST" || request.url !== "/invoke") {
- response.writeHead(404).end();
- return;
- }
- readInvocation(request)
- .then((invocation) => {
- const shim = scenario.command_shims.find(
- ({ name }) => name === invocation.command,
- );
- const routeIndex = shim?.routes.findIndex(
- ({ arguments: expected }) =>
- JSON.stringify(expected) === JSON.stringify(invocation.arguments),
- );
- const key = `${invocation.command}:${String(routeIndex ?? -1)}`;
- const used = calls.get(key) ?? 0;
- const route =
- shim !== undefined && routeIndex !== undefined && routeIndex >= 0
- ? shim.routes[routeIndex]
- : undefined;
- const outcome =
- route === undefined
- ? "unmatched"
- : used >= route.max_calls
- ? "exhausted"
- : "matched";
- if (events.length >= scenario.limits.protocol_events) truncated = true;
- else {
- const index = events.length;
- events.push({
- sequence: index,
- at_ms: Math.max(0, Date.now() - started),
- command: invocation.command,
- route_index:
- routeIndex !== undefined && routeIndex >= 0 ? routeIndex : null,
- arguments: invocation.arguments,
- working_directory: invocation.working_directory,
- outcome,
- });
- recordEvent("shim_events", index);
- }
- if (outcome !== "matched" || route === undefined) {
- response.writeHead(409).end();
- return;
- }
- calls.set(key, used + 1);
- response
- .writeHead(200, { "content-type": "application/json" })
- .end(JSON.stringify(route));
- })
- .catch(() => response.writeHead(400).end());
- });
- await listen(server);
- const address = server.address();
- if (address === null || typeof address === "string") {
- await closeServer(server);
- throw new Error("command shim ledger did not bind an IPv4 port");
- }
- return {
- binPath,
- url: `http://127.0.0.1:${String(address.port)}/invoke`,
- events,
- get truncated() {
- return truncated;
- },
- close: () => closeServer(server),
- };
-};
-
-const readInvocation = async (
- request: import("node:http").IncomingMessage,
-): Promise => {
- const chunks: Buffer[] = [];
- let bytes = 0;
- for await (const chunk of request) {
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
- bytes += buffer.length;
- if (bytes > 64 * 1024) throw new Error("shim invocation exceeds limit");
- chunks.push(buffer);
- }
- const value: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
- if (
- typeof value !== "object" ||
- value === null ||
- !("command" in value) ||
- typeof value.command !== "string" ||
- !("arguments" in value) ||
- !Array.isArray(value.arguments) ||
- !value.arguments.every((item) => typeof item === "string") ||
- !("working_directory" in value) ||
- typeof value.working_directory !== "string"
- )
- throw new Error("invalid shim invocation");
- return {
- command: value.command,
- arguments: value.arguments,
- working_directory: value.working_directory,
- };
-};
-
-const listen = (server: Server): Promise =>
- new Promise((resolveListen, rejectListen) => {
- server.once("error", rejectListen);
- server.listen(0, "127.0.0.1", () => {
- server.off("error", rejectListen);
- resolveListen();
- });
- });
-
-const closeServer = (server: Server): Promise =>
- new Promise((resolveClose, rejectClose) =>
- server.close((cause) =>
- cause === undefined ? resolveClose() : rejectClose(cause),
- ),
- );
diff --git a/src/application/ControlledReplayHostSupport.test.ts b/src/application/ControlledReplayHostSupport.test.ts
deleted file mode 100644
index 14465b77b..000000000
--- a/src/application/ControlledReplayHostSupport.test.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { describe, expect, it } from "vitest";
-
-import { isSupportedControlledReplayHost } from "./ControlledReplayHostSupport.js";
-
-describe("controlled replay host support", () => {
- it("admits only Linux x86_64", () => {
- expect(isSupportedControlledReplayHost("linux", "x64")).toBe(true);
- expect(isSupportedControlledReplayHost("linux", "arm64")).toBe(false);
- expect(isSupportedControlledReplayHost("darwin", "x64")).toBe(false);
- expect(isSupportedControlledReplayHost("win32", "x64")).toBe(false);
- });
-});
diff --git a/src/application/ControlledReplayHostSupport.ts b/src/application/ControlledReplayHostSupport.ts
deleted file mode 100644
index fe230a922..000000000
--- a/src/application/ControlledReplayHostSupport.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-/** Return whether this host has the platform boundary required by replay v1. */
-export const isSupportedControlledReplayHost = (
- platform: string,
- architecture: string,
-): boolean => platform === "linux" && architecture === "x64";
diff --git a/src/application/Doctor.runtimeExecutables.test.ts b/src/application/Doctor.runtimeExecutables.test.ts
deleted file mode 100644
index 01ba18dd6..000000000
--- a/src/application/Doctor.runtimeExecutables.test.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import { describe, expect, it } from "vitest";
-
-import { createDoctorHostFixture as host } from "./Doctor.fixture.js";
-import { runDoctor } from "./Doctor.js";
-
-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/src/application/Doctor.test.ts b/src/application/Doctor.test.ts
index b4fd7d712..b5d35ea12 100644
--- a/src/application/Doctor.test.ts
+++ b/src/application/Doctor.test.ts
@@ -3,33 +3,6 @@ import type { ClientRegistrationStatus } from "./ClientRegistrationStatus.js";
import { createDoctorHostFixture as host } from "./Doctor.fixture.js";
import { runDoctor } from "./Doctor.js";
-it("reports unavailable replay without blocking installation or Hopper readiness", async () => {
- const replayCheck = {
- name: "javascript-replay",
- ok: false,
- classification: "unsupported_host" as const,
- detail: "Controlled replay requires Linux x86_64",
- };
- const doctorHost = host({
- javascriptReplayCheck: () => Promise.resolve(replayCheck),
- });
- for (const scope of [undefined, { providers: ["hopper"] }]) {
- const report = await runDoctor(undefined, doctorHost, scope);
- expect(report.healthy).toBe(true);
- expect(report.checks).toContainEqual(replayCheck);
- expect(report.informational_checks).toContainEqual(replayCheck);
- expect(report.scope_checks).not.toContainEqual(replayCheck);
- }
- const unhealthy = await runDoctor(
- undefined,
- host({
- nodeVersion: "20.0.0",
- javascriptReplayCheck: () => Promise.resolve(replayCheck),
- }),
- );
- expect(unhealthy.healthy).toBe(false);
-});
-
describe("doctor", () => {
it("returns exact recovery for every failed diagnostic", async () => {
const result = await runDoctor(
diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts
index ce7fd9f4b..0331676b2 100644
--- a/src/application/Doctor.ts
+++ b/src/application/Doctor.ts
@@ -20,14 +20,9 @@ import {
type ClientRegistrationStatus,
type UnhealthyClientRegistrationStatus,
} from "./ClientRegistrationStatus.js";
-import {
- inspectRuntimeExecutables,
- type RuntimeExecutableInventory,
-} from "./RuntimeExecutableDiagnostics.js";
import {
collectDoctorDiagnostics,
doctorHealthy,
- isOptionalDoctorCheck,
} from "./DoctorDiagnostics.js";
import {
normalizeDoctorScope,
@@ -91,9 +86,7 @@ export interface DoctorHost {
installationPaths?(): Promise;
installedSkillIdentity?(): Promise;
clientRegistrations?(): Promise;
- javascriptReplayCheck?(): Promise;
ilspyCmdVersion?(path: string): Promise;
- runtimeExecutables?(): Promise;
}
/** Parsed identity committed by an installed REA skill. */
@@ -145,7 +138,6 @@ interface DoctorIdentity {
readonly remediation: string | null;
};
readonly registrations: readonly ClientRegistrationStatus[];
- readonly runtime_executables: RuntimeExecutableInventory | null;
}
/**
@@ -162,9 +154,8 @@ export const runDoctor = async (
checks: diagnosticChecks,
hopperPath,
providerInspections,
- runtimeExecutables,
} = await collectDoctorDiagnostics(target, host);
- const identity = await collectDoctorIdentity(host, runtimeExecutables);
+ const identity = await collectDoctorIdentity(host);
const checks = [...diagnosticChecks, ...identity.checks];
const providers = {
hopperAvailable:
@@ -188,14 +179,9 @@ export const runDoctor = async (
healthy: scope.mode === "audit-wide" ? environmentHealthy : scoped.healthy,
environment_healthy: environmentHealthy,
scope,
- scope_checks:
- scope.mode === "audit-wide"
- ? checks.filter(({ name }) => !isOptionalDoctorCheck(name))
- : scoped.scopeChecks,
+ scope_checks: scope.mode === "audit-wide" ? checks : scoped.scopeChecks,
informational_checks:
- scope.mode === "audit-wide"
- ? checks.filter(({ name }) => isOptionalDoctorCheck(name))
- : scoped.informationalChecks,
+ scope.mode === "audit-wide" ? [] : scoped.informationalChecks,
...(hopperPath === undefined ? {} : { hopperPath }),
...(providerInspections === undefined ? {} : { providerInspections }),
checks,
@@ -205,7 +191,6 @@ export const runDoctor = async (
const collectDoctorIdentity = async (
host: DoctorHost,
- runtimeExecutables: RuntimeExecutableInventory | undefined,
): Promise<{
readonly checks: readonly DoctorCheck[];
readonly value: DoctorIdentity;
@@ -253,7 +238,6 @@ const collectDoctorIdentity = async (
: "Run rea setup to update the installed REA skill.",
},
registrations,
- runtime_executables: runtimeExecutables ?? null,
},
};
};
@@ -298,7 +282,6 @@ export interface SystemDoctorHostOptions {
readonly providerInspections?: () => Promise<
readonly DoctorProviderInspection[]
>;
- readonly javascriptReplayCheck?: () => Promise;
readonly linuxDemoRuntimeCheck?: () => Promise;
}
@@ -344,9 +327,6 @@ export const systemDoctorHost = (
...(options.providerInspections === undefined
? {}
: { providerInspections: options.providerInspections }),
- ...(options.javascriptReplayCheck === undefined
- ? {}
- : { javascriptReplayCheck: options.javascriptReplayCheck }),
async ilspyCmdVersion(path) {
try {
await access(path, constants.X_OK);
@@ -357,15 +337,6 @@ export const systemDoctorHost = (
return undefined;
}
},
- runtimeExecutables: () =>
- inspectRuntimeExecutables({
- platform: process.platform,
- path: process.env.PATH ?? "",
- launcherNode: process.execPath,
- ...(process.env.PATHEXT === undefined
- ? {}
- : { pathExtensions: process.env.PATHEXT.split(delimiter) }),
- }),
async installationPaths() {
try {
const command = process.platform === "win32" ? "where" : "which";
diff --git a/src/application/DoctorDiagnostics.ts b/src/application/DoctorDiagnostics.ts
index 0b5b576da..542d663d1 100644
--- a/src/application/DoctorDiagnostics.ts
+++ b/src/application/DoctorDiagnostics.ts
@@ -7,7 +7,6 @@ import type {
DoctorHost,
DoctorProviderInspection,
} from "./Doctor.js";
-import type { RuntimeExecutableInventory } from "./RuntimeExecutableDiagnostics.js";
const DEFAULT_HOPPER =
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper";
@@ -18,7 +17,6 @@ export interface DoctorDiagnostics {
readonly checks: readonly DoctorCheck[];
readonly hopperPath?: string;
readonly providerInspections?: readonly DoctorProviderInspection[];
- readonly runtimeExecutables?: RuntimeExecutableInventory;
}
/** Collect host, provider, and optional-target diagnostics without mutation. */
@@ -26,11 +24,7 @@ export const collectDoctorDiagnostics = async (
target: string | undefined,
host: DoctorHost,
): Promise => {
- const runtimeExecutables = await host.runtimeExecutables?.();
- const checks: DoctorCheck[] = [
- nodeCheck(host),
- ...runtimeChecks(runtimeExecutables),
- ];
+ const checks: DoctorCheck[] = [nodeCheck(host)];
checks.push(await hostCheck(host));
const hopperPath = await findHopper(host);
@@ -42,8 +36,6 @@ export const collectDoctorDiagnostics = async (
if (providerInspections !== undefined)
checks.push(...providerInspections.flatMap(providerDoctorChecks));
- const javascriptReplayCheck = await host.javascriptReplayCheck?.();
- if (javascriptReplayCheck !== undefined) checks.push(javascriptReplayCheck);
const ilspyCheck = await optionalIlspyCheck(host);
if (ilspyCheck !== undefined) checks.push(ilspyCheck);
const hopperDemoCheck = await optionalHopperDemoCheck(host, hopperPath);
@@ -55,7 +47,6 @@ export const collectDoctorDiagnostics = async (
checks,
...(hopperPath === undefined ? {} : { hopperPath }),
...(providerInspections === undefined ? {} : { providerInspections }),
- ...(runtimeExecutables === undefined ? {} : { runtimeExecutables }),
};
};
@@ -70,19 +61,16 @@ export const doctorHealthy = (
| undefined;
},
): boolean => {
- const requiredChecks = checks.filter(
- ({ name }) => !isOptionalDoctorCheck(name),
- );
if (providers.providerInspections === undefined)
- return requiredChecks.every(({ ok }) => ok);
- const providerChecks = requiredChecks.filter(
+ return checks.every(({ ok }) => ok);
+ const providerChecks = checks.filter(
({ name }) =>
name.startsWith("hopper") ||
providers.providerInspections?.some((inspection) =>
providerCheckName(inspection.id, name),
) === true,
);
- const coreHealthy = requiredChecks
+ const coreHealthy = checks
.filter((candidate) => !providerChecks.includes(candidate))
.every(({ ok }) => ok);
const configuredProvidersHealthy = providerChecks
@@ -102,36 +90,12 @@ export const doctorHealthy = (
);
};
-/** Identify workflow diagnostics that do not gate core installation readiness. */
-export const isOptionalDoctorCheck = (name: string): boolean =>
- name === "javascript-replay";
-
const nodeCheck = (host: DoctorHost): DoctorCheck =>
check("node", supportsNodeVersion(host.nodeVersion), host.nodeVersion, {
remediation: "Install Node.js 22.19+ or 24.11+.",
classification: "missing_dependency",
});
-const runtimeChecks = (
- inventory: RuntimeExecutableInventory | undefined,
-): readonly DoctorCheck[] => {
- if (inventory === undefined) return [];
- const broken = inventory.candidates.filter(({ healthy }) => !healthy);
- const detail =
- broken.length === 0
- ? `${String(inventory.candidates.length)} runtime executable candidates passed bounded version probes.`
- : `${String(broken.length)} of ${String(inventory.candidates.length)} runtime executable candidates failed bounded version probes: ${broken
- .map(({ lexical_path: path }) => path)
- .join(", ")}`;
- return [
- check("node-toolchains", broken.length === 0, detail, {
- remediation:
- "Select a verified healthy Node toolchain or repair the failing installation, then rerun rea doctor. Do not create compatibility-library symlinks.",
- classification: "missing_dependency",
- }),
- ];
-};
-
const hostCheck = async (host: DoctorHost): Promise => {
const macosVersion =
host.platform === "darwin" ? await host.macosVersion() : undefined;
diff --git a/src/application/DoctorScope.ts b/src/application/DoctorScope.ts
index 1caffe18d..2af795e8c 100644
--- a/src/application/DoctorScope.ts
+++ b/src/application/DoctorScope.ts
@@ -7,7 +7,7 @@ import type {
DoctorProviderInspection,
DoctorScope,
} from "./Doctor.js";
-import { doctorHealthy, isOptionalDoctorCheck } from "./DoctorDiagnostics.js";
+import { doctorHealthy } from "./DoctorDiagnostics.js";
/** Normalized readiness boundary included in every doctor response. */
export interface DoctorScopeReport {
@@ -58,7 +58,6 @@ export const scopeDoctorChecks = (
const coreChecks = input.checks.filter(
({ name }) =>
!isProviderCheck(name, input.providers.providerInspections) &&
- !isOptionalDoctorCheck(name) &&
name !== "skill:identity" &&
!name.startsWith("registration:"),
);
diff --git a/src/application/ElectronActiveEvidence.ts b/src/application/ElectronActiveEvidence.ts
index 847051ea7..05e9192bd 100644
--- a/src/application/ElectronActiveEvidence.ts
+++ b/src/application/ElectronActiveEvidence.ts
@@ -6,7 +6,7 @@ import type {
ElectronActiveObservationResult,
} from "../domain/electronActiveObservation.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
-import { digestJson } from "./JavaScriptReplayPlanning.js";
+import { digestCanonicalJson } from "../domain/browserScenarioNormalization.js";
type CanonicalElectronActiveObservationInput =
ElectronActiveObservationInput & {
@@ -49,5 +49,5 @@ const parameters = (
input: CanonicalElectronActiveObservationInput,
): EvidenceObservation["parameters"] => ({
...scenarioProjection(input),
- scenario_sha256: digestJson(scenarioProjection(input)),
+ scenario_sha256: digestCanonicalJson(scenarioProjection(input)),
});
diff --git a/src/application/EnhancedLiteralTracing.test.ts b/src/application/EnhancedLiteralTracing.test.ts
index c51d82065..42e61974e 100644
--- a/src/application/EnhancedLiteralTracing.test.ts
+++ b/src/application/EnhancedLiteralTracing.test.ts
@@ -16,9 +16,7 @@ describe("literal feature tracing", () => {
throw new Error(`Unexpected analysis call: ${name}`);
},
{ query: "launch feature", case_sensitive: false },
- "feature",
);
-
expect(result).toMatchObject({
ok: true,
value: {
@@ -30,26 +28,4 @@ describe("literal feature tracing", () => {
},
});
});
-
- it("keeps string-code tracing scoped to string labels", async () => {
- const result = await traceLiteralFeature(
- async (name) => {
- if (name === "search_strings")
- return ok([{ address: "0x1000", value: "launch feature" }]);
- if (name === "xrefs") return ok([]);
- throw new Error(`Unexpected analysis call: ${name}`);
- },
- { query: "launch", case_sensitive: false },
- "string",
- );
- expect(result).toMatchObject({
- ok: true,
- value: {
- matches: [
- { type: "string", address: "0x1000", value: "launch feature" },
- ],
- references: [],
- },
- });
- });
});
diff --git a/src/application/EnhancedLiteralTracing.ts b/src/application/EnhancedLiteralTracing.ts
index 497d704ac..e1ea1fdb7 100644
--- a/src/application/EnhancedLiteralTracing.ts
+++ b/src/application/EnhancedLiteralTracing.ts
@@ -25,29 +25,28 @@ interface ReferenceTraceRequest {
readonly signal?: AbortSignal;
}
-const FEATURE_SEARCH_SOURCES: readonly LiteralSearchSource[] = [
+const LITERAL_SEARCH_SOURCES: readonly LiteralSearchSource[] = [
["search_strings", "string"],
["search_procedures", "procedure"],
];
-const STRING_SEARCH_SOURCES: readonly LiteralSearchSource[] = [
- ["search_strings", "string"],
-];
export interface LiteralTraceInput {
readonly query: string;
readonly case_sensitive: boolean;
}
-/** Trace every matching feature or string literal through provider xrefs. */
+/** Trace matching strings and procedures through provider xrefs. */
export const traceLiteralFeature = async (
call: AnalysisCall,
input: LiteralTraceInput,
- scope: "feature" | "string",
signal?: AbortSignal,
): EnhancedResult => {
- const sources =
- scope === "feature" ? FEATURE_SEARCH_SOURCES : STRING_SEARCH_SOURCES;
- const searched = await literalMatches(call, input, sources, signal);
+ const searched = await literalMatches(
+ call,
+ input,
+ LITERAL_SEARCH_SOURCES,
+ signal,
+ );
if (!searched.ok) return searched;
const traced = await traceReferences(call, {
matches: searched.value.matches,
diff --git a/src/application/EnhancedTools.ts b/src/application/EnhancedTools.ts
index 2bfdda9d3..b4e774cd8 100644
--- a/src/application/EnhancedTools.ts
+++ b/src/application/EnhancedTools.ts
@@ -56,11 +56,7 @@ export class EnhancedTools {
input: unknown,
signal?: AbortSignal,
): EnhancedResult {
- if (
- name === "trace_feature" ||
- name === "find_code_for_string" ||
- name === "trace_call_path"
- )
+ if (name === "trace_feature" || name === "trace_call_path")
return this.#executeTracing(name, input, signal);
if (name === "trace_native_values") {
const parsed = enhancedInputSchemas.trace_native_values.safeParse(input);
@@ -163,15 +159,6 @@ export class EnhancedTools {
(name, arguments_, operationSignal) =>
this.#call(name, arguments_, operationSignal),
call.input,
- "feature",
- signal,
- );
- case "find_code_for_string":
- return traceLiteralFeature(
- (name, arguments_, operationSignal) =>
- this.#call(name, arguments_, operationSignal),
- call.input,
- "string",
signal,
);
case "trace_call_path":
@@ -190,7 +177,7 @@ export class EnhancedTools {
}
#executeTracing(
- name: "trace_feature" | "find_code_for_string" | "trace_call_path",
+ name: "trace_feature" | "trace_call_path",
input: unknown,
signal?: AbortSignal,
): EnhancedResult {
@@ -199,12 +186,7 @@ export class EnhancedTools {
if (!parsed.success) return invalidEnhancedInput(name, parsed.error);
return this.executeValidated({ name, input: parsed.data }, signal);
}
- if (name === "trace_feature") {
- const parsed = enhancedInputSchemas.trace_feature.safeParse(input);
- if (!parsed.success) return invalidEnhancedInput(name, parsed.error);
- return this.executeValidated({ name, input: parsed.data }, signal);
- }
- const parsed = enhancedInputSchemas.find_code_for_string.safeParse(input);
+ const parsed = enhancedInputSchemas.trace_feature.safeParse(input);
if (!parsed.success) return invalidEnhancedInput(name, parsed.error);
return this.executeValidated({ name, input: parsed.data }, signal);
}
diff --git a/src/application/InstrumentedJavaScriptReplayHost.ts b/src/application/InstrumentedJavaScriptReplayHost.ts
deleted file mode 100644
index 3da3ffe3a..000000000
--- a/src/application/InstrumentedJavaScriptReplayHost.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import {
- instrumentJavaScriptExport,
- type JavaScriptExportInstrumentation,
-} from "../domain/javascriptExportInstrumentation.js";
-import type { NodeCharacterizationPreparationInput } from "../domain/nodeRuntimeCharacterization.js";
-import type {
- JavaScriptReplayHost,
- ReplayExecutableIdentity,
- ReplayRuntimeFileIdentity,
- ReplaySourceBytes,
-} from "./JavaScriptReplayPlanning.js";
-
-/** Decorate one exact replay source with deterministic in-memory instrumentation. */
-export class InstrumentedJavaScriptReplayHost implements JavaScriptReplayHost {
- instrumentation: JavaScriptExportInstrumentation | null = null;
-
- constructor(
- private readonly delegate: JavaScriptReplayHost,
- private readonly input: NodeCharacterizationPreparationInput["instrumentation"],
- ) {}
-
- async readSource(
- path: string,
- maximumBytes: number,
- ): Promise {
- const source = await this.delegate.readSource(path, maximumBytes);
- if (path !== this.input.artifact_path) return source;
- const instrumented = instrumentJavaScriptExport(source.bytes, this.input);
- if (instrumented.bytes.byteLength > maximumBytes)
- throw new RangeError(
- "Instrumented JavaScript exceeds the replay module byte limit",
- );
- this.instrumentation = instrumented;
- return { canonicalPath: source.canonicalPath, bytes: instrumented.bytes };
- }
-
- identifyExecutable(
- path: string,
- versionArguments: readonly string[],
- ): Promise {
- return this.delegate.identifyExecutable(path, versionArguments);
- }
-
- identifyWorker(): Promise {
- return this.delegate.identifyWorker();
- }
-
- identifyRuntimeClosure(
- nodePath: string,
- ): Promise {
- return this.delegate.identifyRuntimeClosure(nodePath);
- }
-
- seccompDigest(): string {
- return this.delegate.seccompDigest();
- }
-
- probe(
- configuration: Parameters[0],
- ): Promise {
- return this.delegate.probe(configuration);
- }
-}
diff --git a/src/application/JavaScriptReplayPlanning.test.ts b/src/application/JavaScriptReplayPlanning.test.ts
deleted file mode 100644
index 7a2e8a9a4..000000000
--- a/src/application/JavaScriptReplayPlanning.test.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-
-import {
- prepareReplayPlan,
- type JavaScriptReplayConfiguration,
- type JavaScriptReplayHost,
-} from "./JavaScriptReplayPlanning.js";
-import { controlledReplayInputSchema } from "../domain/javascriptReplay.js";
-
-const root = "/replay-fixture";
-const configuration: JavaScriptReplayConfiguration = {
- nodePath: "/runtime/node",
- bubblewrapPath: "/runtime/bwrap",
- systemdRunPath: "/runtime/systemd-run",
- systemctlPath: "/runtime/systemctl",
- shellPath: "/runtime/sh",
-};
-
-const inputWithExpandedManifest = () => {
- const modules = Array.from({ length: 130 }, (_, index) => ({
- alias: `module-${index}`,
- path: `${root}/module.mjs`,
- format: "esm" as const,
- role: "module" as const,
- dependencies: {},
- }));
- const first = modules[0];
- if (first === undefined) throw new TypeError("Missing fixture module");
- modules[0] = {
- ...first,
- dependencies: Object.fromEntries(
- Array.from({ length: 129 }, (_, index) => [
- `dependency-${index}`,
- `module-${index + 1}`,
- ]),
- ),
- };
- return controlledReplayInputSchema.parse({
- mode: "plan",
- left: {
- modules,
- entry_alias: "module-0",
- entry_export: "default",
- },
- cases: Array.from({ length: 129 }, (_, index) => ({
- case_id: `case-${index}`,
- arguments: Array.from({ length: 17 }, () => "value"),
- })),
- });
-};
-
-describe("controlled replay planning input bounds", () => {
- it("accepts module, dependency, case, and argument counts beyond old quotas", () => {
- const input = inputWithExpandedManifest();
-
- expect(input.left.modules).toHaveLength(130);
- expect(Object.keys(input.left.modules[0]?.dependencies ?? {})).toHaveLength(
- 129,
- );
- expect(input.cases).toHaveLength(129);
- expect(input.cases[0]?.arguments).toHaveLength(17);
- });
-
- it("rejects an oversized protocol manifest before probing or reading", async () => {
- const probe = vi.fn(async () => undefined);
- const readSource = vi.fn(async () => ({
- canonicalPath: `${root}/module.mjs`,
- bytes: new Uint8Array(),
- }));
- const host: JavaScriptReplayHost = {
- readSource,
- identifyExecutable: vi.fn(async (path) => ({
- path,
- version: "1",
- sha256: "0".repeat(64),
- })),
- identifyWorker: vi.fn(async () => ({
- path: "/worker.js",
- version: "1",
- sha256: "0".repeat(64),
- })),
- identifyRuntimeClosure: vi.fn(async () => []),
- seccompDigest: () => "0".repeat(64),
- probe,
- };
- const input = controlledReplayInputSchema.parse({
- mode: "plan",
- left: {
- modules: Array.from({ length: 300 }, (_, index) => ({
- alias: `module-${index}`,
- path: `/${"p".repeat(4_095)}`,
- format: "esm",
- dependencies: {},
- })),
- entry_alias: "module-0",
- },
- cases: [{ case_id: "empty", arguments: [] }],
- limits: { protocol_bytes: 1024 * 1024 },
- });
-
- await expect(prepareReplayPlan(input, configuration, host)).rejects.toThrow(
- "Replay plan input exceeds its protocol byte limit",
- );
- expect(probe).not.toHaveBeenCalled();
- expect(readSource).not.toHaveBeenCalled();
- });
-});
diff --git a/src/application/JavaScriptReplayPlanning.ts b/src/application/JavaScriptReplayPlanning.ts
deleted file mode 100644
index 16b24dba1..000000000
--- a/src/application/JavaScriptReplayPlanning.ts
+++ /dev/null
@@ -1,325 +0,0 @@
-import canonicalize from "canonicalize";
-import { createHash } from "node:crypto";
-
-import type { JsonValue } from "../domain/jsonValue.js";
-import {
- createSeededRandomUint,
- NON_DEGENERATE_XORSHIFT32_SEED,
-} from "../replay/seededRandom.js";
-import type {
- ControlledReplayInput,
- ReplayExecutionResult,
- ReplayPlan,
-} from "../domain/javascriptReplay.js";
-
-const REPLAY_POLICY_VERSION = "linux-bwrap-systemd-v1" as const;
-const REPLAY_POLICY_DOCUMENT = Object.freeze({
- version: REPLAY_POLICY_VERSION,
- namespaces: ["user", "pid", "network", "ipc", "uts", "cgroup"],
- capabilities: "drop-all",
- network: "none",
- filesystem: "minimal-read-only-runtime-plus-private-tmpfs",
- seccomp: "rea-linux-x86_64-v1",
- resource_manager: "systemd-user-cgroup-v2",
- nested_user_namespaces: false,
-});
-
-export interface ReplaySourceBytes {
- readonly canonicalPath: string;
- readonly bytes: Uint8Array;
-}
-
-export interface ReplayExecutableIdentity {
- readonly path: string;
- readonly version: string;
- readonly sha256: string;
-}
-
-export interface ReplayRuntimeFileIdentity {
- readonly sourcePath: string;
- readonly destinationPath: string;
- readonly sha256: string;
-}
-
-/** Executable paths required to establish isolated Linux JavaScript replay. */
-export interface JavaScriptReplayConfiguration {
- readonly nodePath: string;
- readonly bubblewrapPath: string;
- readonly systemdRunPath: string;
- readonly systemctlPath: string;
- readonly shellPath: string;
-}
-
-export interface JavaScriptReplayHost {
- readonly readSource: (
- path: string,
- maximumBytes: number,
- ) => Promise;
- readonly identifyExecutable: (
- path: string,
- versionArguments: readonly string[],
- ) => Promise;
- readonly identifyWorker: () => Promise;
- readonly identifyRuntimeClosure: (
- nodePath: string,
- ) => Promise;
- readonly seccompDigest: () => string;
- readonly probe: (
- configuration: JavaScriptReplayConfiguration,
- ) => Promise;
-}
-
-export interface PreparedReplayPlan {
- readonly publicPlan: ReplayPlan;
- readonly leftSources: Readonly>;
- readonly rightSources?: Readonly>;
-}
-
-export interface JavaScriptReplayRunner {
- readonly execute: (
- prepared: PreparedReplayPlan,
- configuration: JavaScriptReplayConfiguration,
- signal?: AbortSignal,
- ) => Promise;
-}
-
-const assertReplayManifestWithinProtocolLimit = (
- input: ControlledReplayInput,
-): void => {
- if (
- new TextEncoder().encode(JSON.stringify(input)).byteLength >
- input.limits.protocol_bytes
- )
- throw new RangeError("Replay plan input exceeds its protocol byte limit");
-};
-
-/** Read, normalize, and content-address every input to one replay experiment. */
-export const prepareReplayPlan = async (
- input: ControlledReplayInput,
- configuration: JavaScriptReplayConfiguration,
- host: JavaScriptReplayHost,
-): Promise => {
- assertReplayManifestWithinProtocolLimit(input);
- const cases = replayCases(input).map((item) => ({
- ...item,
- sha256: digestJson(item.arguments),
- }));
- if (
- new TextEncoder().encode(
- JSON.stringify(cases.map(({ arguments: args }) => args)),
- ).byteLength > input.limits.input_bytes
- )
- throw new RangeError("Replay case inputs exceed the aggregate byte limit");
- await host.probe(configuration);
- const [
- runtime,
- bubblewrap,
- systemdRun,
- systemctl,
- shell,
- worker,
- runtimeFiles,
- left,
- right,
- ] = await Promise.all([
- host.identifyExecutable(configuration.nodePath, ["--version"]),
- host.identifyExecutable(configuration.bubblewrapPath, ["--version"]),
- host.identifyExecutable(configuration.systemdRunPath, ["--version"]),
- host.identifyExecutable(configuration.systemctlPath, ["--version"]),
- host.identifyExecutable(configuration.shellPath, ["--version"]),
- host.identifyWorker(),
- host.identifyRuntimeClosure(configuration.nodePath),
- prepareSide(input.left, input.limits.module_bytes, host),
- input.right === undefined
- ? Promise.resolve(undefined)
- : prepareSide(input.right, input.limits.module_bytes, host),
- ]);
- const withoutDigest = {
- policy_version: REPLAY_POLICY_VERSION,
- policy_sha256: digestJson(REPLAY_POLICY_DOCUMENT),
- network: "none" as const,
- filesystem: {
- host_writes: false as const,
- private_tmpfs_bytes: input.limits.tmpfs_bytes,
- },
- runtime: {
- executable: runtime,
- worker,
- read_only_files: runtimeFiles.map((item) => ({
- source_path: item.sourcePath,
- destination_path: item.destinationPath,
- sha256: item.sha256,
- })),
- },
- sandbox: {
- bubblewrap,
- systemd_run: systemdRun,
- systemctl,
- shell,
- seccomp_sha256: host.seccompDigest(),
- },
- left: left.commitment,
- ...(right === undefined ? {} : { right: right.commitment }),
- cases,
- determinism: input.determinism,
- limits: input.limits,
- effects: [
- "Execute selected extracted modules in a disposable Linux OS sandbox",
- "Create and remove one private cgroup and mount namespace",
- "Retain bounded return, exception, termination, and diagnostic evidence",
- "Perform no host filesystem writes and expose no host or external network",
- ...(input.reproducer_export === undefined
- ? []
- : ["Write one reproducer after sandbox cleanup"]),
- ],
- reproducer_export:
- input.reproducer_export === undefined
- ? null
- : {
- path: input.reproducer_export.path,
- include_sources: input.reproducer_export.include_sources,
- },
- };
- const publicPlan: ReplayPlan = {
- ...withoutDigest,
- plan_digest: digestJson(withoutDigest),
- };
- return {
- publicPlan,
- leftSources: left.sources,
- ...(right === undefined ? {} : { rightSources: right.sources }),
- };
-};
-
-const prepareSide = async (
- side: ControlledReplayInput["left"],
- maximumBytes: number,
- host: JavaScriptReplayHost,
-) => {
- const aliases = new Set(side.modules.map(({ alias }) => alias));
- if (aliases.size !== side.modules.length || !aliases.has(side.entry_alias))
- throw new TypeError("Replay manifest aliases or entry alias are invalid");
- for (const module of side.modules)
- for (const dependency of Object.values(module.dependencies))
- if (!aliases.has(dependency))
- throw new TypeError(
- `Undeclared replay dependency alias: ${dependency}`,
- );
- const loaded: {
- readonly module: (typeof side.modules)[number];
- readonly source: ReplaySourceBytes;
- }[] = [];
- let totalBytes = 0;
- for (const module of side.modules) {
- const remaining = maximumBytes - totalBytes;
- if (remaining <= 0)
- throw new RangeError("Replay module bytes exceed the aggregate limit");
- const source = await host.readSource(module.path, remaining);
- if (source.bytes.byteLength > remaining)
- throw new RangeError("Replay module bytes exceed the aggregate limit");
- totalBytes += source.bytes.byteLength;
- loaded.push({ module, source });
- }
- return {
- commitment: {
- modules: loaded.map(({ module, source }) => ({
- alias: module.alias,
- canonical_path: source.canonicalPath,
- format: module.format,
- role: module.role,
- byte_count: source.bytes.byteLength,
- sha256: digestBytes(source.bytes),
- dependencies: module.dependencies,
- })),
- entry_alias: side.entry_alias,
- entry_export: side.entry_export,
- },
- sources: Object.fromEntries(
- loaded.map(({ module, source }) => [
- module.alias,
- new TextDecoder("utf-8", { fatal: true }).decode(source.bytes),
- ]),
- ),
- };
-};
-
-const replayCases = (
- input: ControlledReplayInput,
-): readonly { readonly case_id: string; readonly arguments: JsonValue[] }[] => {
- const explicit = input.cases.map((item) => ({
- case_id: item.case_id,
- arguments: item.arguments,
- }));
- if (input.generator === undefined) return uniqueCases(explicit);
- const next = createSeededRandomUint(
- input.generator.seed || NON_DEGENERATE_XORSHIFT32_SEED,
- );
- const corpus = boundaryCorpus(input.generator.preset);
- const generated: {
- readonly case_id: string;
- readonly arguments: JsonValue[];
- }[] = Array.from({ length: input.generator.count }, (_, index) => ({
- case_id: `generated-${input.generator?.preset ?? "boundary"}-${String(index)}`,
- arguments: [corpus[next() % corpus.length] ?? ""],
- }));
- return uniqueCases([...explicit, ...generated]);
-};
-
-const uniqueCases = (
- cases: readonly Case[],
-): readonly Case[] => {
- const ids = new Set();
- for (const item of cases) {
- if (ids.has(item.case_id))
- throw new TypeError(`Duplicate replay case ID: ${item.case_id}`);
- ids.add(item.case_id);
- }
- return cases;
-};
-
-const boundaryCorpus = (
- preset: NonNullable["preset"],
-): readonly JsonValue[] => {
- if (preset === "sanitizer-boundaries")
- return [
- "",
- "",
- "
",
- "javascript:alert(1)",
- "ok",
- "\u0000",
- ];
- if (preset === "clipboard-boundaries")
- return [
- "",
- "plain",
- "a\r\nb",
- "a\nb",
- "\t",
- "😀",
- "text/html",
- "text/plain",
- ];
- return [
- "",
- " ",
- "\n",
- "# heading",
- "**bold**",
- "[x](javascript:y)",
- "`code`",
- "😀",
- "\u0000",
- "a".repeat(1024),
- ];
-};
-
-export const digestBytes = (value: Uint8Array): string =>
- createHash("sha256").update(value).digest("hex");
-
-export const digestJson = (value: unknown): string => {
- const encoded = canonicalize(value);
- if (encoded === undefined)
- throw new TypeError("Replay commitment is not canonical JSON");
- return createHash("sha256").update(encoded).digest("hex");
-};
diff --git a/src/application/JavaScriptReplayService.ts b/src/application/JavaScriptReplayService.ts
deleted file mode 100644
index a9e9075a6..000000000
--- a/src/application/JavaScriptReplayService.ts
+++ /dev/null
@@ -1,430 +0,0 @@
-import { z } from "zod";
-import writeFileAtomic from "write-file-atomic";
-
-import {
- controlledReplayInputSchema,
- replayExecutionResultSchema,
- controlledReplayOutputSchema,
- replayEvidenceSchema,
- type ControlledReplayExecutionInput,
- type ControlledReplayInput,
-} from "../domain/javascriptReplay.js";
-import {
- AnalysisCapabilityUnavailableError,
- AnalysisCancelledError,
- AnalysisInputError,
- AnalysisProtocolError,
- ReplayPlanStaleError,
- type AnalysisError,
-} from "../domain/errors.js";
-import { createEvidence } from "../domain/evidence.js";
-import { projectInputIssues } from "../domain/inputIssueProjection.js";
-import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
-import { err, ok, type Result } from "../domain/result.js";
-import type { ExecutionOptions } from "./AnalysisProvider.js";
-import {
- digestBytes,
- prepareReplayPlan,
- type JavaScriptReplayConfiguration,
- type JavaScriptReplayHost,
- type JavaScriptReplayRunner,
-} from "./JavaScriptReplayPlanning.js";
-
-const OPERATION = "run_controlled_replay" as const;
-const isAborted = (signal: AbortSignal | undefined): boolean =>
- signal?.aborted === true;
-
-export interface JavaScriptReplayDependencies {
- readonly configuration: () => JavaScriptReplayConfiguration;
- readonly host: JavaScriptReplayHost;
- readonly runner: JavaScriptReplayRunner;
-}
-
-/** Plan or execute one content-bound extracted-module replay experiment. */
-export const runControlledReplay = async (
- dependencies: JavaScriptReplayDependencies,
- rawInput: unknown,
- options: ExecutionOptions = {},
-): Promise> => {
- const parsed = controlledReplayInputSchema.safeParse(rawInput);
- if (!parsed.success)
- return err(
- new AnalysisInputError(
- OPERATION,
- { cause: parsed.error },
- projectInputIssues(parsed.error.issues, rawInput),
- ),
- );
- return runControlledReplayValidated(dependencies, parsed.data, options);
-};
-
-/** Plan or execute replay input already parsed by a trusted adapter. */
-export const runControlledReplayValidated = async (
- dependencies: JavaScriptReplayDependencies,
- input: ControlledReplayInput,
- options: ExecutionOptions = {},
-): Promise> => {
- if (isAborted(options.signal))
- return err(new AnalysisCancelledError(OPERATION));
- const configuration = dependencies.configuration();
-
- const prepared = await prepareValidatedReplay(
- dependencies,
- configuration,
- input,
- options.signal,
- );
- if (!prepared.ok) return prepared;
- if (isAborted(options.signal))
- return err(new AnalysisCancelledError(OPERATION));
-
- if (input.mode === "plan")
- return ok(
- asJson({
- phase: "plan",
- plan: prepared.value.publicPlan,
- source_evidence: [],
- evidence: null,
- }),
- );
-
- if (input.plan_digest !== prepared.value.publicPlan.plan_digest)
- return err(
- new ReplayPlanStaleError(
- input.plan_digest,
- prepared.value.publicPlan.plan_digest,
- ),
- );
-
- const exportContext = prepareReproducerExport(input);
- if (!exportContext.ok) return exportContext;
-
- const executed = await executeValidatedReplay({
- runner: dependencies.runner,
- configuration,
- prepared: prepared.value,
- exportContext: exportContext.value,
- options,
- });
- if (!executed.ok) return executed;
-
- try {
- return ok(
- buildReplayOutput(
- prepared.value,
- executed.value.executed,
- executed.value.sourceEvidence,
- ),
- );
- } catch (cause: unknown) {
- return err(
- new AnalysisProtocolError(
- cause instanceof z.ZodError
- ? "Controlled replay produced an invalid bounded result"
- : "Controlled replay could not establish or clean up its sandbox",
- { cause },
- ),
- );
- }
-};
-
-const prepareValidatedReplay = async (
- dependencies: JavaScriptReplayDependencies,
- configuration: JavaScriptReplayConfiguration,
- input: ControlledReplayInput,
- signal: AbortSignal | undefined,
-): Promise<
- Result>, AnalysisError>
-> => {
- if (isAborted(signal)) return err(new AnalysisCancelledError(OPERATION));
- try {
- return ok(await prepareReplayPlan(input, configuration, dependencies.host));
- } catch (cause: unknown) {
- return err(
- new AnalysisCapabilityUnavailableError(
- "rea-javascript-replay",
- OPERATION,
- cause instanceof Error ? cause.message : "replay planning failed",
- ),
- );
- }
-};
-
-interface ReproducerExportContext {
- readonly path: string;
- readonly includeSources: boolean;
-}
-
-const prepareReproducerExport = (
- input: ControlledReplayExecutionInput,
-): Result => {
- if (input.reproducer_export === undefined) return ok(undefined);
- return ok({
- path: input.reproducer_export.path,
- includeSources: input.reproducer_export.include_sources === true,
- });
-};
-
-interface ReplayExecutionContext {
- readonly runner: JavaScriptReplayRunner;
- readonly configuration: JavaScriptReplayConfiguration;
- readonly prepared: Awaited>;
- readonly exportContext: ReproducerExportContext | undefined;
- readonly options: ExecutionOptions;
-}
-
-const executeValidatedReplay = async (
- context: ReplayExecutionContext,
-): Promise<
- Result<
- {
- readonly executed: z.infer;
- readonly sourceEvidence: ReturnType[];
- },
- AnalysisError
- >
-> => {
- const { runner, configuration, prepared, exportContext, options } = context;
- await options.progress?.report({
- phase: OPERATION,
- completed: 0,
- total: 1,
- message: "Starting the isolated replay worker",
- });
- if (isAborted(options.signal))
- return err(new AnalysisCancelledError(OPERATION));
-
- try {
- let executed = replayExecutionResultSchema.parse(
- await runner.execute(prepared, configuration, options.signal),
- );
- await options.progress?.report({
- phase: OPERATION,
- completed: 1,
- total: 1,
- message: "Controlled replay stopped and sandbox cleanup was observed",
- terminal: true,
- });
- if (executed.termination === "cancelled")
- return err(new AnalysisCancelledError(OPERATION));
- executed = await applyReproducerExport(executed, exportContext, prepared);
- const sourceEvidence = buildSourceEvidence(prepared, executed);
- return ok({ executed, sourceEvidence });
- } catch (cause: unknown) {
- return err(
- new AnalysisProtocolError(
- cause instanceof z.ZodError
- ? "Controlled replay produced an invalid bounded result"
- : "Controlled replay could not establish or clean up its sandbox",
- { cause },
- ),
- );
- }
-};
-
-const applyReproducerExport = async (
- executed: z.infer,
- exportContext: ReproducerExportContext | undefined,
- prepared: Awaited>,
-): Promise> => {
- if (exportContext === undefined || executed.cleanup.state !== "complete")
- return executed;
-
- const manifest = {
- plan: prepared.publicPlan,
- result: executed,
- sources: exportContext.includeSources
- ? {
- left: prepared.leftSources,
- ...(prepared.rightSources === undefined
- ? {}
- : { right: prepared.rightSources }),
- }
- : null,
- };
- const encoded = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
- try {
- await writeFileAtomic(exportContext.path, encoded, {
- mode: 0o600,
- });
- return replayExecutionResultSchema.parse({
- ...executed,
- reproducer: {
- state: "written",
- path: exportContext.path,
- sha256: digestBytes(encoded),
- },
- });
- } catch (cause: unknown) {
- return replayExecutionResultSchema.parse({
- ...executed,
- limitations: [
- ...executed.limitations,
- "Replay completed, but the requested reproducer export failed.",
- ],
- reproducer: {
- state: "failed",
- path: exportContext.path,
- error:
- cause instanceof Error
- ? cause.message
- : "unknown reproducer export failure",
- },
- });
- }
-};
-
-const buildSourceEvidence = (
- prepared: Awaited>,
- executed: z.infer,
-): ReturnType[] => {
- const leftCount = prepared.publicPlan.cases.length;
- return [
- createReplayEvidence(
- prepared,
- executed,
- "left",
- executed.outcomes.slice(0, leftCount),
- ),
- ...(prepared.publicPlan.right === undefined
- ? []
- : [
- createReplayEvidence(
- prepared,
- executed,
- "right",
- executed.outcomes.slice(leftCount),
- ),
- ]),
- ];
-};
-
-const buildReplayOutput = (
- prepared: Awaited>,
- executed: z.infer,
- sourceEvidence: ReturnType[],
-): JsonValue => {
- const subject = prepared.publicPlan.left.modules[0];
- const evidence = createEvidence(
- subject === undefined
- ? undefined
- : {
- path: subject.canonical_path,
- sha256: subject.sha256,
- format: "javascript",
- },
- {
- id: "rea-javascript-replay",
- name: "REA isolated JavaScript replay",
- version: "1",
- },
- {
- predicateType: "javascript-controlled-replay",
- operation: OPERATION,
- parameters: {
- replay_plan: jsonValueSchema.parse(
- JSON.parse(JSON.stringify(prepared.publicPlan)),
- ),
- },
- result: jsonValueSchema.parse(JSON.parse(JSON.stringify(executed))),
- rawResult: jsonValueSchema.parse(
- JSON.parse(
- JSON.stringify({
- cases: prepared.publicPlan.cases,
- outcomes: executed.outcomes,
- stderr: executed.stderr,
- }),
- ),
- ),
- confidence: executed.comparison === undefined ? "observed" : "derived",
- authority: "controlled-replay",
- environment: {
- id: prepared.publicPlan.plan_digest,
- platform: process.platform,
- architecture: process.arch,
- isolation: "container",
- },
- limitations: executed.limitations,
- locations: prepared.publicPlan.left.modules.map((module) => ({
- kind: "artifact-path" as const,
- path: module.canonical_path,
- })),
- evidenceLinks: sourceEvidence.map(({ evidence_id: id }) => id),
- },
- );
- return asJson({
- phase: "execute",
- plan: null,
- source_evidence: sourceEvidence.map((item) =>
- replayEvidenceSchema.parse(item),
- ),
- evidence: replayEvidenceSchema.parse(evidence),
- });
-};
-
-const createReplayEvidence = (
- prepared: Awaited>,
- executed: z.infer,
- side: "left" | "right",
- outcomes: z.infer["outcomes"],
-) => {
- const manifest =
- side === "left" ? prepared.publicPlan.left : prepared.publicPlan.right;
- const subject = manifest?.modules[0];
- const result = replayExecutionResultSchema.parse({
- ...executed,
- outcomes,
- comparison: undefined,
- reproducer: null,
- });
- return createEvidence(
- subject === undefined
- ? undefined
- : {
- path: subject.canonical_path,
- sha256: subject.sha256,
- format: "javascript",
- },
- {
- id: "rea-javascript-replay",
- name: "REA isolated JavaScript replay",
- version: "1",
- },
- {
- predicateType: "javascript-controlled-replay-observation",
- operation: OPERATION,
- parameters: {
- replay_plan: jsonValueSchema.parse(
- JSON.parse(JSON.stringify(prepared.publicPlan)),
- ),
- side,
- },
- result: jsonValueSchema.parse(JSON.parse(JSON.stringify(result))),
- rawResult: jsonValueSchema.parse(
- JSON.parse(
- JSON.stringify({ cases: prepared.publicPlan.cases, outcomes }),
- ),
- ),
- confidence: "observed",
- authority: "controlled-replay",
- environment: {
- id: prepared.publicPlan.plan_digest,
- platform: process.platform,
- architecture: process.arch,
- isolation: "container",
- },
- limitations: executed.limitations,
- locations: (manifest?.modules ?? []).map((module) => ({
- kind: "artifact-path" as const,
- path: module.canonical_path,
- })),
- },
- );
-};
-
-const asJson = (value: unknown): JsonValue => {
- const serialized: unknown = JSON.parse(
- JSON.stringify(controlledReplayOutputSchema.parse(value)),
- );
- return jsonValueSchema.parse(serialized);
-};
diff --git a/src/application/LoopbackReplay.ts b/src/application/LoopbackReplay.ts
deleted file mode 100644
index 11f64ea0c..000000000
--- a/src/application/LoopbackReplay.ts
+++ /dev/null
@@ -1,531 +0,0 @@
-import {
- createServer,
- type IncomingMessage,
- type Server,
- type ServerResponse,
-} from "node:http";
-import { WebSocketServer, type WebSocket } from "ws";
-import type {
- ProcessScenario,
- ProtocolEvent,
- RecordProcessCaptureEvent,
-} from "../domain/processCapture.js";
-import {
- type ReplayMachineDecision,
- type ReplayTransitionRecord,
-} from "../domain/replayMachineRuntime.js";
-import {
- closeReplayServer,
- listenOnLoopback,
- recordMachineEvent,
- readReplayRequestBody,
- ReplayRecorder,
- ReplayDelayScheduler,
- requestHeaders,
-} from "./LoopbackReplayRecorder.js";
-
-/** Owned loopback replay endpoints and their bounded protocol observations. */
-export interface LoopbackReplay {
- readonly httpUrl: string;
- readonly websocketUrl: string;
- readonly events: ProtocolEvent[];
- readonly transitions: readonly ReplayTransitionRecord[];
- readonly truncated: boolean;
- close(): Promise;
-}
-
-interface HttpReplayRequest {
- readonly request: IncomingMessage;
- readonly response: ServerResponse;
- readonly method: string;
- readonly path: string;
- readonly body: string;
- readonly rawAtMs: number;
- readonly recordedAtMs: number;
-}
-
-const recordHttpResponse = (
- recorder: ReplayRecorder,
- request: Pick,
- data: string,
- outcome: ProtocolEvent["outcome"],
-): void =>
- recorder.record({
- at_ms: recorder.atMs(),
- protocol: "http",
- direction: "response",
- method: request.method,
- path: request.path,
- data,
- outcome,
- });
-
-const handleMachineHttp = async (
- recorder: ReplayRecorder,
- request: HttpReplayRequest,
- delays: ReplayDelayScheduler,
-): Promise => {
- const machine = recorder.machine;
- if (machine === undefined) return;
- const decision = recorder.dispatchMachine({
- protocol: "http",
- connection: "not_applicable",
- at_ms: request.rawAtMs,
- recorded_at_ms: request.recordedAtMs,
- method: request.method,
- path: request.path,
- headers: requestHeaders(request.request),
- body: request.body,
- });
- recordMachineEvent(
- recorder,
- {
- at_ms: request.recordedAtMs,
- protocol: "http",
- direction: "request",
- method: request.method,
- path: request.path,
- data: request.body,
- },
- decision,
- );
- if (decision.outcome !== "matched") {
- request.response.statusCode = decision.outcome === "unmatched" ? 404 : 409;
- request.response.end();
- return;
- }
- for (const action of decision.actions) {
- if (action.type === "delay") await delays.wait(action.duration_ms);
- else if (action.type === "disconnect") {
- request.response.destroy();
- recordHttpResponse(recorder, request, "", "disconnected");
- return;
- } else if (action.type === "http_response") {
- request.response.statusCode = action.status;
- for (const [name, value] of Object.entries(action.headers))
- request.response.setHeader(name, value);
- request.response.end(action.body);
- recordHttpResponse(
- recorder,
- request,
- machine.redact(action.body),
- "matched",
- );
- }
- }
-};
-
-const handleStaticHttp = async (
- recorder: ReplayRecorder,
- request: HttpReplayRequest,
- calls: Map,
- delays: ReplayDelayScheduler,
-): Promise => {
- const index = recorder.scenario.replay.http.findIndex((route) =>
- route.method !== request.method || route.path !== request.path
- ? false
- : (route.request_body === undefined ||
- route.request_body === request.body) &&
- Object.entries(route.request_headers).every(
- ([name, value]) =>
- request.request.headers[name.toLowerCase()] === value,
- ),
- );
- const route = recorder.scenario.replay.http[index];
- const used = index < 0 ? 0 : (calls.get(index) ?? 0);
- const outcome =
- route === undefined
- ? "unmatched"
- : used >= route.max_calls
- ? "script_exhausted"
- : "matched";
- recorder.record({
- at_ms: recorder.atMs(),
- protocol: "http",
- direction: "request",
- method: request.method,
- path: request.path,
- data: request.body.length === 0 ? "" : "",
- outcome,
- });
- if (route === undefined || outcome === "script_exhausted") {
- request.response.statusCode = route === undefined ? 404 : 409;
- request.response.end();
- return;
- }
- calls.set(index, used + 1);
- if (route.delay_ms > 0) await delays.wait(route.delay_ms);
- if (route.disconnect) {
- request.response.destroy();
- recordHttpResponse(recorder, request, "", "disconnected");
- return;
- }
- request.response.statusCode = route.status;
- for (const [name, value] of Object.entries(route.response_headers))
- request.response.setHeader(name, value);
- request.response.end(route.body);
- recordHttpResponse(recorder, request, route.body, "matched");
-};
-
-const handleHttpRequest = async (
- recorder: ReplayRecorder,
- request: IncomingMessage,
- response: ServerResponse,
- calls: Map,
- delays: ReplayDelayScheduler,
-): Promise => {
- const body = await readReplayRequestBody(
- request,
- recorder.scenario.limits.protocol_body_bytes,
- );
- const method = request.method ?? "GET";
- const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1");
- const path = `${requestUrl.pathname}${requestUrl.search}`;
- if (body === undefined) {
- recorder.truncated = true;
- response.statusCode = 413;
- response.end();
- recorder.record({
- at_ms: recorder.atMs(),
- protocol: "http",
- direction: "request",
- method,
- path,
- data: "",
- outcome: "unmatched",
- });
- return;
- }
- const replayRequest = {
- request,
- response,
- method,
- path,
- body,
- rawAtMs: recorder.rawAtMs(),
- recordedAtMs: recorder.atMs(),
- };
- await (recorder.machine === undefined
- ? handleStaticHttp(recorder, replayRequest, calls, delays)
- : recorder.enqueueMachine(() =>
- handleMachineHttp(recorder, replayRequest, delays),
- ));
-};
-
-const createHttpHandler = (
- recorder: ReplayRecorder,
- delays: ReplayDelayScheduler,
-): ((request: IncomingMessage, response: ServerResponse) => void) => {
- const calls = new Map();
- return (request, response) => {
- void handleHttpRequest(recorder, request, response, calls, delays).catch(
- () => {
- recorder.truncated = true;
- if (response.headersSent) response.destroy();
- else {
- response.statusCode = 500;
- response.end();
- }
- },
- );
- };
-};
-
-const sendWebSocketScript = async (
- client: WebSocket,
- connection: number,
- recorder: ReplayRecorder,
- delays: ReplayDelayScheduler,
-): Promise => {
- const scripts = recorder.scenario.replay.websocket_connections;
- const script = scripts[connection - 1];
- if (scripts.length > 0 && script === undefined) {
- recorder.record({
- at_ms: recorder.atMs(),
- protocol: "websocket",
- direction: "sent",
- method: null,
- path: "/ws",
- data: "",
- outcome: "script_exhausted",
- });
- client.terminate();
- return;
- }
- const messages =
- script?.messages ??
- recorder.scenario.replay.websocket_messages.map((data) => ({
- data,
- delay_ms: 0,
- }));
- for (const message of messages) {
- if (message.delay_ms > 0) await delays.wait(message.delay_ms);
- if (client.readyState !== 1) return;
- client.send(message.data);
- recorder.record({
- at_ms: recorder.atMs(),
- protocol: "websocket",
- direction: "sent",
- method: null,
- path: "/ws",
- data: message.data,
- outcome: "matched",
- });
- }
- if (script?.disconnect_after === true) {
- client.close();
- recorder.record({
- at_ms: recorder.atMs(),
- protocol: "websocket",
- direction: "sent",
- method: null,
- path: "/ws",
- data: "",
- outcome: "disconnected",
- });
- }
-};
-
-const runWebSocketActions = async (
- client: WebSocket,
- recorder: ReplayRecorder,
- decision: Extract,
- path: string,
- delays: ReplayDelayScheduler,
-): Promise => {
- for (const action of decision.actions) {
- if (action.type === "delay") {
- await delays.wait(action.duration_ms);
- continue;
- }
- if (action.type === "disconnect") {
- client.close();
- recorder.record({
- at_ms: recorder.atMs(),
- protocol: "websocket",
- direction: "sent",
- method: null,
- path,
- data: "",
- outcome: "disconnected",
- });
- return;
- }
- if (action.type === "websocket_send" && client.readyState === 1) {
- client.send(action.data);
- recorder.record({
- at_ms: recorder.atMs(),
- protocol: "websocket",
- direction: "sent",
- method: null,
- path,
- data: recorder.machine?.redact(action.data) ?? action.data,
- outcome: "matched",
- });
- }
- }
-};
-
-const handleStaticWebSocket = (
- client: WebSocket,
- recorder: ReplayRecorder,
- connection: number,
- path: string,
- delays: ReplayDelayScheduler,
-): void => {
- client.on("message", (value) =>
- recorder.record({
- at_ms: recorder.atMs(),
- protocol: "websocket",
- direction: "received",
- method: null,
- path,
- data: value.toString(),
- outcome: "matched",
- }),
- );
- void sendWebSocketScript(client, connection, recorder, delays);
-};
-
-const handleMachineWebSocket = (options: {
- readonly client: WebSocket;
- readonly request: IncomingMessage;
- readonly recorder: ReplayRecorder;
- readonly connection: number;
- readonly path: string;
- readonly delays: ReplayDelayScheduler;
-}): void => {
- const { client, request, recorder, connection, path, delays } = options;
- const machine = recorder.machine;
- if (machine === undefined) return;
- void recorder
- .enqueueMachine(async () => {
- const connectedAt = recorder.atMs();
- const connectionDecision = recorder.dispatchMachine({
- protocol: "websocket_connect",
- connection: connection === 1 ? "initial" : "reconnect",
- at_ms: recorder.rawAtMs(),
- recorded_at_ms: connectedAt,
- method: null,
- path,
- headers: requestHeaders(request),
- body: "",
- });
- recordMachineEvent(
- recorder,
- {
- at_ms: connectedAt,
- protocol: "websocket",
- direction: "received",
- method: null,
- path,
- data: "",
- },
- connectionDecision,
- );
- if (connectionDecision.outcome === "matched")
- await runWebSocketActions(
- client,
- recorder,
- connectionDecision,
- path,
- delays,
- );
- else client.close();
- })
- .catch(() => {
- recorder.truncated = true;
- client.terminate();
- });
- client.on("message", (value) => {
- const body = value.toString();
- const rawAtMs = recorder.rawAtMs();
- const recordedAtMs = recorder.atMs();
- void recorder
- .enqueueMachine(async () => {
- const decision = recorder.dispatchMachine({
- protocol: "websocket_message",
- connection: connection === 1 ? "initial" : "reconnect",
- at_ms: rawAtMs,
- recorded_at_ms: recordedAtMs,
- method: null,
- path,
- headers: {},
- body,
- });
- recordMachineEvent(
- recorder,
- {
- at_ms: recordedAtMs,
- protocol: "websocket",
- direction: "received",
- method: null,
- path,
- data: body,
- },
- decision,
- );
- if (decision.outcome === "matched")
- await runWebSocketActions(client, recorder, decision, path, delays);
- })
- .catch(() => {
- recorder.truncated = true;
- client.terminate();
- });
- });
-};
-
-const createWebSocketReplay = (
- server: Server,
- recorder: ReplayRecorder,
- delays: ReplayDelayScheduler,
-): WebSocketServer => {
- let connections = 0;
- const websocket = new WebSocketServer({
- noServer: true,
- maxPayload: recorder.scenario.limits.protocol_body_bytes,
- clientTracking: true,
- });
- server.on("upgrade", (request, socket, head) => {
- if (
- recorder.machine === undefined &&
- new URL(request.url ?? "/", "http://127.0.0.1").pathname !== "/ws"
- ) {
- socket.destroy();
- return;
- }
- websocket.handleUpgrade(request, socket, head, (client) =>
- websocket.emit("connection", client, request),
- );
- });
- websocket.on("connection", (client: WebSocket, request: IncomingMessage) => {
- connections += 1;
- if (connections > recorder.scenario.limits.connections) {
- recorder.truncated = true;
- client.terminate();
- return;
- }
- client.on("error", () => undefined);
- const path = new URL(request.url ?? "/", "http://127.0.0.1").pathname;
- if (recorder.machine === undefined)
- handleStaticWebSocket(client, recorder, connections, path, delays);
- else
- handleMachineWebSocket({
- client,
- request,
- recorder,
- connection: connections,
- path,
- delays,
- });
- });
- return websocket;
-};
-
-/** Start bounded HTTP and WebSocket replay endpoints on IPv4 loopback only. */
-export const startLoopbackReplay = async (
- scenario: ProcessScenario,
- recordEvent: RecordProcessCaptureEvent = () => undefined,
-): Promise => {
- const recorder = new ReplayRecorder(scenario, recordEvent);
- const delays = new ReplayDelayScheduler();
- const server = createServer(createHttpHandler(recorder, delays));
- const websocket = createWebSocketReplay(server, recorder, delays);
- let closePromise: Promise | undefined;
- let port: number;
- try {
- port = await listenOnLoopback(server);
- } catch (cause: unknown) {
- await closeReplayServer(server).catch(() => undefined);
- throw cause;
- }
- return {
- httpUrl: `http://127.0.0.1:${String(port)}`,
- websocketUrl: `ws://127.0.0.1:${String(port)}/ws`,
- get events() {
- return recorder.events.map((event) => ({ ...event }));
- },
- get transitions() {
- return recorder.transitions.map((transition) => ({
- ...transition,
- sensitive_aliases: [...transition.sensitive_aliases],
- }));
- },
- get truncated() {
- return recorder.truncated;
- },
- async close() {
- closePromise ??= (async () => {
- delays.close();
- recorder.stopMachineAdmission();
- await recorder.drainMachine();
- for (const client of websocket.clients) client.terminate();
- await new Promise((resolveClose) =>
- websocket.close(() => resolveClose()),
- );
- await closeReplayServer(server);
- })();
- await closePromise;
- },
- };
-};
diff --git a/src/application/LoopbackReplayRecorder.ts b/src/application/LoopbackReplayRecorder.ts
deleted file mode 100644
index ec8086b00..000000000
--- a/src/application/LoopbackReplayRecorder.ts
+++ /dev/null
@@ -1,180 +0,0 @@
-import type { IncomingMessage, Server } from "node:http";
-import type {
- ProcessScenario,
- ProtocolEvent,
- RecordProcessCaptureEvent,
-} from "../domain/processCapture.js";
-import {
- ReplayMachineRuntime,
- type ReplayMachineDecision,
- type ReplayTransitionRecord,
-} from "../domain/replayMachineRuntime.js";
-import { scheduleProcessDelay, type ProcessTimer } from "./ProcessTimer.js";
-
-/** Bounded protocol and replay-machine observations for one process run. */
-export class ReplayRecorder {
- readonly events: ProtocolEvent[] = [];
- readonly started = Date.now();
- readonly machine: ReplayMachineRuntime | undefined;
- #machineQueue: Promise = Promise.resolve();
- #machineAdmissionOpen = true;
- truncated = false;
-
- constructor(
- readonly scenario: ProcessScenario,
- private readonly recordEvent: RecordProcessCaptureEvent = () => undefined,
- ) {
- this.machine =
- scenario.replay.machine === null
- ? undefined
- : new ReplayMachineRuntime(scenario.replay.machine);
- }
-
- get transitions(): readonly ReplayTransitionRecord[] {
- return this.machine?.timeline ?? [];
- }
-
- atMs(): number {
- return (
- Math.floor(
- (Date.now() - this.started) /
- this.scenario.normalization.time_bucket_ms,
- ) * this.scenario.normalization.time_bucket_ms
- );
- }
-
- rawAtMs(): number {
- return Date.now() - this.started;
- }
-
- enqueueMachine(operation: () => Promise): Promise {
- if (!this.#machineAdmissionOpen)
- return Promise.reject(new Error("replay machine is closing"));
- const result = this.#machineQueue.then(operation);
- this.#machineQueue = result.then(
- () => undefined,
- () => undefined,
- );
- return result;
- }
-
- stopMachineAdmission(): void {
- this.#machineAdmissionOpen = false;
- }
-
- async drainMachine(): Promise {
- await this.#machineQueue;
- }
-
- dispatchMachine(
- event: Parameters[0],
- ): ReplayMachineDecision {
- if (this.machine === undefined)
- throw new Error("replay machine is not configured");
- return this.machine.dispatch(event);
- }
-
- recordTransition(decision: ReplayMachineDecision): void {
- if (decision.transition !== null)
- this.recordEvent("replay_transitions", decision.transition.sequence);
- }
-
- record(event: Omit): void {
- if (this.events.length >= this.scenario.limits.protocol_events) {
- this.truncated = true;
- return;
- }
- const sequence = this.events.length;
- this.events.push({ sequence, ...event });
- this.recordEvent("protocol_events", sequence);
- }
-}
-
-export const requestHeaders = (
- request: IncomingMessage,
-): Readonly> =>
- Object.fromEntries(
- Object.entries(request.headers).flatMap(([name, value]) =>
- value === undefined
- ? []
- : [[name, Array.isArray(value) ? value.join(", ") : value]],
- ),
- );
-
-export const readReplayRequestBody = async (
- request: IncomingMessage,
- limit: number,
-): Promise => {
- const chunks: Buffer[] = [];
- let bytes = 0;
- for await (const chunk of request) {
- const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
- bytes += value.byteLength;
- if (bytes > limit) return undefined;
- chunks.push(value);
- }
- return Buffer.concat(chunks).toString("utf8");
-};
-
-export const recordMachineEvent = (
- recorder: ReplayRecorder,
- event: Omit,
- decision: ReplayMachineDecision,
-): void => {
- recorder.record({
- ...event,
- data: recorder.machine?.redact(event.data) ?? event.data,
- outcome: decision.outcome,
- });
- recorder.recordTransition(decision);
-};
-
-/** Owns replay delays so endpoint shutdown can release every waiting handler. */
-export class ReplayDelayScheduler {
- readonly #pending = new Map void>();
- #closed = false;
-
- wait(durationMs: number): Promise {
- if (this.#closed) return Promise.resolve();
- return new Promise((resolveDelay) => {
- let timer: ProcessTimer;
- const finish = (): void => {
- this.#pending.delete(timer);
- resolveDelay();
- };
- timer = scheduleProcessDelay(durationMs, finish);
- this.#pending.set(timer, finish);
- });
- }
-
- close(): void {
- if (this.#closed) return;
- this.#closed = true;
- for (const [timer, finish] of this.#pending) {
- timer.cancel();
- finish();
- }
- this.#pending.clear();
- }
-}
-
-export const listenOnLoopback = async (server: Server): Promise => {
- await new Promise((resolveListen, rejectListen) => {
- server.once("error", rejectListen);
- server.listen(0, "127.0.0.1", () => {
- server.off("error", rejectListen);
- resolveListen();
- });
- });
- const address = server.address();
- if (address === null || typeof address === "string")
- throw new Error("loopback replay did not acquire a TCP port");
- return address.port;
-};
-
-export const closeReplayServer = (server: Server): Promise =>
- new Promise((resolveClose, rejectClose) => {
- server.close((error) =>
- error === undefined ? resolveClose() : rejectClose(error),
- );
- });
diff --git a/src/application/ManagedRuntimeCorrelationService.ts b/src/application/ManagedRuntimeCorrelationService.ts
deleted file mode 100644
index 73ccc0a80..000000000
--- a/src/application/ManagedRuntimeCorrelationService.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-import {
- AnalysisInputError,
- AnalysisProtocolError,
- type AnalysisError,
-} from "../domain/errors.js";
-import { createEvidence, type Evidence } from "../domain/evidence.js";
-import { jsonValueSchema } from "../domain/jsonValue.js";
-import {
- managedRuntimeCorrelationInputSchema,
- planManagedRuntimeCorrelation,
- type ManagedRuntimeCorrelationInput,
-} from "../domain/managedRuntimeCorrelation.js";
-import { err, ok, type Result } from "../domain/result.js";
-import { MANAGED_WORKFLOW_PROVIDER } from "./InvestigationProviders.js";
-
-const OPERATION = "plan_managed_runtime_correlation" as const;
-
-/** Configured local runtime executable used when preparing a correlation plan. */
-export interface ManagedRuntimeConfiguration {
- readonly executablePath: string | undefined;
-}
-
-/** Live managed runtime configuration source, evaluated for each request. */
-export interface ManagedRuntimeCorrelationDependencies {
- readonly configuration: () => ManagedRuntimeConfiguration;
-}
-
-/** Create a non-executing managed runtime correlation plan Evidence record. */
-export const planManagedRuntimeCorrelationEvidence = async (
- dependencies: ManagedRuntimeCorrelationDependencies,
- rawInput: unknown,
-): Promise> => {
- const parsed = managedRuntimeCorrelationInputSchema.safeParse(rawInput);
- if (!parsed.success)
- return err(new AnalysisInputError(OPERATION, { cause: parsed.error }));
- return planManagedRuntimeCorrelationEvidenceValidated(
- dependencies,
- parsed.data,
- );
-};
-
-/** Plan runtime correlation from input parsed by a trusted adapter. */
-export const planManagedRuntimeCorrelationEvidenceValidated = async (
- dependencies: ManagedRuntimeCorrelationDependencies,
- input: ManagedRuntimeCorrelationInput,
-): Promise> => {
- const executablePath =
- dependencies.configuration().executablePath ?? "dotnet";
- try {
- const result = planManagedRuntimeCorrelation(input, executablePath);
- return ok(
- createEvidence(
- {
- path: result.static_observation.artifact_path,
- sha256: result.static_observation.artifact_sha256,
- format: "pe",
- },
- MANAGED_WORKFLOW_PROVIDER,
- {
- predicateType: "rea.managed-runtime-correlation-plan",
- operation: OPERATION,
- parameters: {
- static_members_evidence_id: result.static_observation.evidence_id,
- method: jsonValueSchema.parse(input.method),
- requested_effect: input.requested_effect,
- host: jsonValueSchema.parse(input.host),
- bounds: jsonValueSchema.parse(input.bounds),
- },
- result: jsonValueSchema.parse(result),
- rawResult: null,
- confidence: "derived",
- authority: "analyst-inference",
- environment: null,
- limitations: result.limitations,
- locations: [
- {
- kind: "artifact-path",
- path: result.static_observation.artifact_path,
- },
- ],
- evidenceLinks: result.evidence_links,
- },
- ),
- );
- } catch (cause: unknown) {
- return err(
- cause instanceof TypeError
- ? new AnalysisInputError(OPERATION, { cause })
- : new AnalysisProtocolError(
- "Managed runtime correlation planning produced an invalid result",
- { cause },
- ),
- );
- }
-};
diff --git a/src/application/NodeRuntimeCharacterizationService.ts b/src/application/NodeRuntimeCharacterizationService.ts
deleted file mode 100644
index c5628671c..000000000
--- a/src/application/NodeRuntimeCharacterizationService.ts
+++ /dev/null
@@ -1,342 +0,0 @@
-import { createHash } from "node:crypto";
-
-import { z } from "zod";
-
-import {
- nodeCharacterizationExecutionInputSchema,
- nodeCharacterizationExecutionOutputSchema,
- nodeCharacterizationPreparationInputSchema,
- nodeCharacterizationPreparationOutputSchema,
- type NodeCharacterizationPreparationInput,
-} from "../domain/nodeRuntimeCharacterization.js";
-import {
- AnalysisInputError,
- AnalysisProtocolError,
- ReplayPlanStaleError,
- type AnalysisError,
-} from "../domain/errors.js";
-import { createEvidence, type Evidence } from "../domain/evidence.js";
-import {
- controlledReplayOutputSchema,
- type ControlledReplayExecutionOutput,
-} from "../domain/javascriptReplay.js";
-import type { ReplayPlan } from "../domain/javascriptReplay.js";
-import type { JavaScriptExportInstrumentation } from "../domain/javascriptExportInstrumentation.js";
-import {
- createRuntimeCharacterizationPlan,
- type RuntimeCharacterizationPlan,
-} from "../domain/runtimeCharacterization.js";
-import { err, ok, type Result } from "../domain/result.js";
-import type { JsonValue } from "../domain/jsonValue.js";
-import { jsonValueSchema } from "../domain/jsonValue.js";
-import type { ExecutionOptions } from "./AnalysisProvider.js";
-import { InstrumentedJavaScriptReplayHost } from "./InstrumentedJavaScriptReplayHost.js";
-import {
- runControlledReplayValidated,
- type JavaScriptReplayDependencies,
-} from "./JavaScriptReplayService.js";
-
-const PREPARE_OPERATION = "prepare_node_characterization" as const;
-const EXECUTE_OPERATION = "execute_node_characterization" as const;
-const PROVIDER = {
- id: "rea-node-characterization",
- name: "REA Node runtime characterization",
- version: "1",
-} as const;
-
-/** Prepare exact Node/JavaScript characterization without executing target code. */
-export const prepareNodeCharacterization = async (
- dependencies: JavaScriptReplayDependencies,
- rawInput: unknown,
- options: ExecutionOptions = {},
-): Promise> => {
- const parsed = nodeCharacterizationPreparationInputSchema.safeParse(rawInput);
- if (!parsed.success)
- return err(
- new AnalysisInputError(PREPARE_OPERATION, { cause: parsed.error }),
- );
- const prepared = await prepareValidated(dependencies, parsed.data, options);
- return prepared.ok
- ? ok(
- jsonValueSchema.parse(
- nodeCharacterizationPreparationOutputSchema.parse(prepared.value),
- ),
- )
- : prepared;
-};
-
-/** Execute only when the supplied plan hash matches freshly prepared content. */
-export const executeNodeCharacterization = async (
- dependencies: JavaScriptReplayDependencies,
- rawInput: unknown,
- options: ExecutionOptions = {},
-): Promise> => {
- const parsed = nodeCharacterizationExecutionInputSchema.safeParse(rawInput);
- if (!parsed.success)
- return err(
- new AnalysisInputError(EXECUTE_OPERATION, { cause: parsed.error }),
- );
- const prepared = await prepareValidated(
- dependencies,
- parsed.data.preparation,
- options,
- );
- if (!prepared.ok) return prepared;
- if (prepared.value.plan.plan_sha256 !== parsed.data.plan_sha256)
- return err(
- new ReplayPlanStaleError(
- parsed.data.plan_sha256,
- prepared.value.plan.plan_sha256,
- ),
- );
- const plannedReplay = controlledReplayOutputSchema.parse(
- prepared.value.replay,
- );
- if (plannedReplay.phase !== "plan")
- return err(
- new AnalysisProtocolError(
- "Node characterization preparation did not produce a replay plan",
- ),
- );
- const host = new InstrumentedJavaScriptReplayHost(
- dependencies.host,
- parsed.data.preparation.instrumentation,
- );
- const executed = await runControlledReplayValidated(
- { ...dependencies, host },
- {
- ...parsed.data.preparation.replay,
- mode: "execute",
- plan_digest: plannedReplay.plan.plan_digest,
- },
- options,
- );
- if (!executed.ok) return executed;
- const replayOutput = controlledReplayOutputSchema.parse(executed.value);
- if (replayOutput.phase !== "execute")
- return err(
- new AnalysisProtocolError(
- "Node characterization execution omitted replay Evidence",
- ),
- );
- if (host.instrumentation === null)
- return err(
- new AnalysisProtocolError(
- "Node characterization execution omitted instrumentation evidence",
- ),
- );
- return ok(
- jsonValueSchema.parse(
- nodeCharacterizationExecutionOutputSchema.parse({
- phase: "execution",
- plan: prepared.value.plan,
- transformation: host.instrumentation.manifest,
- transformation_evidence: prepared.value.transformation_evidence,
- evidence: createCharacterizationEvidence(prepared.value, replayOutput),
- replay: replayOutput,
- }),
- ),
- );
-};
-
-const prepareValidated = async (
- dependencies: JavaScriptReplayDependencies,
- input: NodeCharacterizationPreparationInput,
- options: ExecutionOptions,
-): Promise<
- Result<
- {
- readonly phase: "preparation";
- readonly plan: RuntimeCharacterizationPlan;
- readonly transformation: NonNullable<
- InstrumentedJavaScriptReplayHost["instrumentation"]
- >["manifest"];
- readonly transformation_evidence: Evidence;
- readonly replay: z.output;
- },
- AnalysisError
- >
-> => {
- const host = new InstrumentedJavaScriptReplayHost(
- dependencies.host,
- input.instrumentation,
- );
- const replay = await runControlledReplayValidated(
- { ...dependencies, host },
- input.replay,
- options,
- );
- if (!replay.ok) return replay;
- const output = controlledReplayOutputSchema.parse(replay.value);
- if (output.phase !== "plan" || host.instrumentation === null)
- return err(
- new AnalysisProtocolError(
- "Node characterization preparation omitted committed plan evidence",
- ),
- );
- const module = output.plan.left.modules.find(
- ({ alias }) => alias === input.selected_alias,
- );
- if (module === undefined)
- return err(
- new AnalysisProtocolError(
- "Node characterization replay plan omitted selected module",
- ),
- );
- const plan = createCharacterizationPlan(
- input,
- host.instrumentation,
- output.plan,
- );
- return ok({
- phase: "preparation",
- plan,
- transformation: host.instrumentation.manifest,
- transformation_evidence: createTransformationEvidence(
- input,
- host.instrumentation,
- ),
- replay: output,
- });
-};
-
-const createTransformationEvidence = (
- input: NodeCharacterizationPreparationInput,
- instrumentation: JavaScriptExportInstrumentation,
-): Evidence =>
- createEvidence(
- {
- path: input.instrumentation.artifact_path,
- sha256: input.instrumentation.artifact_sha256,
- format: "javascript-bundle",
- },
- PROVIDER,
- {
- predicateType: "rea.javascript-export-transformation",
- operation: PREPARE_OPERATION,
- parameters: {
- selected_alias: input.selected_alias,
- selected_sha256: input.instrumentation.selection.selected_sha256,
- },
- result: jsonValueSchema.parse(instrumentation.manifest),
- confidence: "derived",
- authority: "shipped-artifact",
- locations: [
- {
- kind: "file-offset-range",
- start: input.instrumentation.selection.byte_start,
- end: input.instrumentation.selection.byte_end,
- },
- ],
- limitations: [
- "The transformation is deterministic derived evidence; execution results are limited to the characterization plan whose content hash matched.",
- ],
- },
- );
-
-const createCharacterizationEvidence = (
- prepared: {
- readonly plan: RuntimeCharacterizationPlan;
- readonly transformation_evidence: Evidence;
- },
- replay: ControlledReplayExecutionOutput,
-): Evidence => {
- return createEvidence(
- {
- path: prepared.plan.artifact.path,
- sha256: prepared.plan.artifact.sha256,
- format: "javascript-bundle",
- },
- PROVIDER,
- {
- predicateType: "rea.runtime-characterization",
- operation: EXECUTE_OPERATION,
- parameters: {
- plan_sha256: prepared.plan.plan_sha256,
- replay_evidence_id: replay.evidence.evidence_id,
- },
- result: jsonValueSchema.parse({
- plan: prepared.plan,
- transformation_evidence_id:
- prepared.transformation_evidence.evidence_id,
- replay_evidence_id: replay.evidence.evidence_id,
- execution: replay.evidence.normalized_result,
- }),
- confidence: "observed",
- authority: "controlled-replay",
- environment: replay.evidence.environment,
- limitations: [
- "This Evidence proves only the finite hash-matched characterization plan and transformed controlled-replay result, not unmodified shipped-runtime equivalence.",
- ...replay.evidence.limitations,
- ],
- evidenceLinks: [
- prepared.transformation_evidence.evidence_id,
- replay.evidence.evidence_id,
- ],
- },
- );
-};
-
-const createCharacterizationPlan = (
- input: NodeCharacterizationPreparationInput,
- instrumentation: JavaScriptExportInstrumentation,
- replayPlan: ReplayPlan,
-): RuntimeCharacterizationPlan =>
- createRuntimeCharacterizationPlan({
- preparation_sha256: digestPreparation(
- instrumentation.manifest.instrumented_sha256,
- replayPlan.plan_digest,
- ),
- artifact: {
- path: input.instrumentation.artifact_path,
- sha256: input.instrumentation.artifact_sha256,
- byte_length: instrumentation.manifest.original_byte_length,
- },
- runtime: {
- family: "ecmascript",
- provider_id: "node-javascript",
- executable_path: replayPlan.runtime.executable.path,
- executable_sha256: replayPlan.runtime.executable.sha256,
- version: replayPlan.runtime.executable.version,
- profile_sha256: replayPlan.policy_sha256,
- },
- callable: {
- callable_id: `callable/${input.instrumentation.selection.selected_sha256}`,
- module_id: stableModuleId(input.selected_alias),
- export_name: input.instrumentation.selection.export_name,
- semantic_evidence_id: null,
- selector_sha256: input.instrumentation.selection.selected_sha256,
- },
- working_directory: "/work",
- isolated_home: "/work",
- expected_effect: input.expected_effect,
- allowed_boundaries: [],
- limits: {
- max_calls: replayPlan.cases.length,
- max_processes: 0,
- max_files: 0,
- max_bytes: replayPlan.limits.output_bytes,
- max_handles: replayPlan.limits.tasks,
- timeout_ms: replayPlan.limits.wall_time_ms,
- idle_timeout_ms: replayPlan.limits.wall_time_ms,
- },
- determinism: {
- clock: "fixed",
- randomness: "seeded",
- identifiers: "deterministic",
- seed: replayPlan.determinism.random_seed,
- },
- });
-
-const digestPreparation = (
- instrumentedSha256: string,
- replayPlanDigest: string,
-): string =>
- createHash("sha256")
- .update(
- `rea.node-characterization-preparation\0${instrumentedSha256}\0${replayPlanDigest}`,
- )
- .digest("hex");
-
-const stableModuleId = (alias: string): string =>
- `module/${createHash("sha256").update(alias).digest("hex")}`;
diff --git a/src/application/ProcessCaptureEnvironment.ts b/src/application/ProcessCaptureEnvironment.ts
index d27029673..1414349cd 100644
--- a/src/application/ProcessCaptureEnvironment.ts
+++ b/src/application/ProcessCaptureEnvironment.ts
@@ -1,38 +1,24 @@
-import { delimiter } from "node:path";
-
import type { ProcessScenario } from "../domain/processCapture.js";
-import type { LoopbackReplay } from "./LoopbackReplay.js";
-import type { CommandShimReplay } from "./CommandShimReplay.js";
interface ProcessCaptureEnvironmentOptions {
readonly scenario: ProcessScenario;
- readonly replay: LoopbackReplay;
- readonly shimReplay: CommandShimReplay;
readonly runId: string;
readonly hostEnvironment: Readonly>;
}
-/** Build the inherited environment with scenario overrides and replay instrumentation. */
+/** Build the child environment from the host plus caller-selected overrides. */
export const makeProcessCaptureEnvironment = (
options: ProcessCaptureEnvironmentOptions,
-): Record => {
- const { scenario, replay, shimReplay, runId, hostEnvironment } = options;
- const environment: Record = {
- ...Object.fromEntries(
- Object.entries(hostEnvironment).filter(
- (entry): entry is [string, string] => entry[1] !== undefined,
- ),
+): Record => ({
+ ...Object.fromEntries(
+ Object.entries(options.hostEnvironment).filter(
+ (entry): entry is [string, string] => entry[1] !== undefined,
),
- ...scenario.environment,
- TERM: scenario.environment.TERM ?? hostEnvironment.TERM ?? "xterm-256color",
- REA_PROCESS_RUN_ID: runId,
- };
- environment.REA_REPLAY_HTTP_URL = replay.httpUrl;
- environment.REA_REPLAY_WEBSOCKET_URL = replay.websocketUrl;
- environment.REA_SHIM_LEDGER_URL = shimReplay.url;
- // Shims are instrumentation; all remaining executable lookup uses host PATH.
- environment.PATH = [shimReplay.binPath, environment.PATH ?? ""]
- .filter((part) => part.length > 0)
- .join(delimiter);
- return environment;
-};
+ ),
+ ...options.scenario.environment,
+ TERM:
+ options.scenario.environment.TERM ??
+ options.hostEnvironment.TERM ??
+ "xterm-256color",
+ REA_PROCESS_RUN_ID: options.runId,
+});
diff --git a/src/application/ProcessCaptureJournal.test.ts b/src/application/ProcessCaptureJournal.test.ts
index eca7af52d..24232c445 100644
--- a/src/application/ProcessCaptureJournal.test.ts
+++ b/src/application/ProcessCaptureJournal.test.ts
@@ -1,35 +1,18 @@
import { expect, it } from "vitest";
-import type { ProcessCaptureEventJournalEntry } from "../domain/processCapture.js";
import { createProcessCaptureJournal } from "./ProcessCaptureJournal.js";
-it("publishes journal entries in capture order and supports nested recording", () => {
+it("records capture events in monotonic observation order", () => {
const journal = createProcessCaptureJournal();
- const observed: ProcessCaptureEventJournalEntry[] = [];
- const unsubscribe = journal.subscribe((entry) => observed.push(entry));
+ journal.recordEvent("filesystem_checkpoints", 0);
journal.recordEvent("lifecycle", 0);
- const settlementEntry = journal.record("lifecycle", 1);
- unsubscribe();
journal.recordEvent("frames", 0);
+ journal.recordEvent("filesystem_checkpoints", 1);
expect(journal.entries).toEqual([
- { capture_order: 0, collection: "lifecycle", index: 0 },
- { capture_order: 1, collection: "lifecycle", index: 1 },
+ { capture_order: 0, collection: "filesystem_checkpoints", index: 0 },
+ { capture_order: 1, collection: "lifecycle", index: 0 },
{ capture_order: 2, collection: "frames", index: 0 },
+ { capture_order: 3, collection: "filesystem_checkpoints", index: 1 },
]);
- 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"]);
});
diff --git a/src/application/ProcessCaptureJournal.ts b/src/application/ProcessCaptureJournal.ts
index 2553e49e5..619902210 100644
--- a/src/application/ProcessCaptureJournal.ts
+++ b/src/application/ProcessCaptureJournal.ts
@@ -17,63 +17,20 @@ import { scheduleProcessDelay, type ProcessTimer } from "./ProcessTimer.js";
export interface ProcessCaptureJournal {
readonly entries: readonly ProcessCaptureEventJournalEntry[];
readonly recordEvent: RecordProcessCaptureEvent;
- readonly record: (
- collection: ProcessCaptureEventJournalEntry["collection"],
- index: number,
- ) => ProcessCaptureEventJournalEntry;
- readonly subscribe: (
- listener: (entry: ProcessCaptureEventJournalEntry) => void,
- ) => () => void;
}
/** Create one monotonic event journal for a process capture. */
export const createProcessCaptureJournal = (): ProcessCaptureJournal => {
const entries: ProcessCaptureEventJournalEntry[] = [];
- const listeners = new Set<(entry: ProcessCaptureEventJournalEntry) => void>();
- const notifications: ProcessCaptureEventJournalEntry[] = [];
- let delivering = false;
- const deliverNotifications = (): void => {
- if (delivering) return;
- delivering = true;
- const failures: unknown[] = [];
- try {
- for (;;) {
- const notification = notifications.shift();
- if (notification === undefined) break;
- for (const listener of Array.from(listeners))
- try {
- listener(notification);
- } catch (cause: unknown) {
- failures.push(cause);
- }
- }
- } finally {
- delivering = false;
- }
- if (failures.length > 0)
- throw new AggregateError(
- failures,
- "process capture journal subscriber failed",
- );
- };
- const record = (
+ const recordEvent: RecordProcessCaptureEvent = (
collection: ProcessCaptureEventJournalEntry["collection"],
index: number,
- ): ProcessCaptureEventJournalEntry => {
- const entry = { capture_order: entries.length, collection, index };
- entries.push(entry);
- notifications.push(entry);
- deliverNotifications();
- return entry;
+ ): void => {
+ entries.push({ capture_order: entries.length, collection, index });
};
return {
entries,
- record,
- recordEvent: (collection, index) => void record(collection, index),
- subscribe: (listener) => {
- listeners.add(listener);
- return () => listeners.delete(listener);
- },
+ recordEvent,
};
};
diff --git a/src/application/ProcessCaptureLifecycle.ts b/src/application/ProcessCaptureLifecycle.ts
index d948eda4a..f136a017d 100644
--- a/src/application/ProcessCaptureLifecycle.ts
+++ b/src/application/ProcessCaptureLifecycle.ts
@@ -25,24 +25,18 @@ import {
import { PRODUCT_IDENTITY } from "../identity.js";
import type { SnapshotResult } from "./FilesystemSnapshot.js";
import { snapshotRoots } from "./FilesystemSnapshot.js";
-import type { LoopbackReplay } from "./LoopbackReplay.js";
+import { classifyFilesystemEffects } from "./ProcessFilesystemEffects.js";
import {
cleanupOwnedProcessGroup,
observeOwnedProcessGroup,
} from "../process/ProcessOwnership.js";
-import {
- classifyFilesystemEffects,
- ProcessCheckpoints,
-} from "./ProcessCheckpoints.js";
import { ProcessCaptureError } from "./ProcessCaptureError.js";
import { assertNotCancelled } from "./ProcessScenarioRuntimeValidation.js";
import {
normalizeProcessElapsedTime,
normalizeProcessSamples,
normalizeProcessText,
- normalizeProtocolEvents,
} from "./ProcessNormalization.js";
-import type { CommandShimReplay } from "./CommandShimReplay.js";
import { PROCESS_PROVIDER } from "./ProcessEvidence.js";
import { TerminalRenderer } from "./TerminalRenderer.js";
import { scheduleProcessInterval, type ProcessTimer } from "./ProcessTimer.js";
@@ -67,7 +61,6 @@ interface CaptureResultOptions {
readonly reason: "exited" | "timeout" | "idle_timeout";
};
readonly samples: readonly ProcessSample[];
- readonly replay: LoopbackReplay;
readonly before: SnapshotResult;
readonly after: SnapshotResult;
readonly truncated: boolean;
@@ -77,7 +70,6 @@ interface CaptureResultOptions {
readonly renderedFrames: UnverifiedProcessCapture["rendered_frames"];
readonly interactions: readonly InteractionEvent[];
readonly checkpoints: readonly FilesystemCheckpoint[];
- readonly shimEvents: UnverifiedProcessCapture["shim_events"];
readonly settlement: ObservedProcessSettlement;
readonly manifest: UnverifiedProcessCapture["manifest"];
readonly eventJournal: readonly ProcessCaptureEventJournalEntry[];
@@ -94,14 +86,32 @@ export type ObservedProcessSettlement =
"state" | "elapsed_ms"
>;
-/** Capture observations that cannot yet claim final cleanup or reactive state. */
+/** Capture observations before owned-resource cleanup has been verified. */
export type PendingProcessCapture = Omit<
UnverifiedProcessCapture,
- "cleanup" | "reactive_run" | "settlement"
+ "cleanup" | "settlement"
> & {
readonly settlement: ObservedProcessSettlement;
};
+/** Wait for trailing PTY callbacks before the capture becomes immutable. */
+export const settleProcessCaptureJournal = async (
+ journal: readonly ProcessCaptureEventJournalEntry[],
+ quietMs = 25,
+ maxWaitMs = 500,
+): Promise => {
+ const deadline = Date.now() + maxWaitMs;
+ let observed = journal.length;
+ let lastChangeAt = Date.now();
+ while (Date.now() < deadline) {
+ await new Promise((resolve) => setTimeout(resolve, 4));
+ if (journal.length !== observed) {
+ observed = journal.length;
+ lastChangeAt = Date.now();
+ } else if (Date.now() - lastChangeAt >= quietMs) return;
+ }
+};
+
export const buildCaptureResult = (
options: CaptureResultOptions,
): PendingProcessCapture => {
@@ -112,10 +122,6 @@ export const buildCaptureResult = (
"Sensitive scripted input values are redacted from process capture Evidence.";
const filesystemObservationUnknown =
"No filesystem observation paths were selected; filesystem effects remain unknown.";
- const protocolEvents = normalizeProtocolEvents(
- options.replay.events,
- options.scenario,
- );
const hasFilesystemObservations =
options.scenario.filesystem_observation_paths.length > 0;
return {
@@ -139,9 +145,6 @@ export const buildCaptureResult = (
options.rootPid,
),
filesystem_checkpoints: options.checkpoints,
- shim_events: options.shimEvents,
- protocol_events: protocolEvents,
- replay_transitions: options.replay.transitions,
event_journal: options.eventJournal,
files_before: options.before.files,
files_after: options.after.files,
@@ -156,7 +159,6 @@ export const buildCaptureResult = (
? ["Process-tree sampling ended with an incomplete observation."]
: []),
"Filesystem observations are before/after snapshots, not syscall traces.",
- "The harness does not enforce external network isolation.",
"Inherited host environment variables are not recorded and may affect results.",
...(!hasFilesystemObservations ? [filesystemObservationUnknown] : []),
...(hasSensitiveScriptedInput ? [sensitiveInputUnknown] : []),
@@ -167,14 +169,14 @@ export const buildCaptureResult = (
reason:
"Process trees are sampled and may omit short-lived descendants.",
},
- {
- scope: "network",
- reason: "External network isolation is not enforced by this adapter.",
- },
{
scope: "environment",
reason: "Inherited host environment variables are not recorded.",
},
+ {
+ scope: "network",
+ reason: "Network activity is not observed by this capture.",
+ },
...(!hasFilesystemObservations
? [
{
@@ -217,14 +219,10 @@ export const createRunManifest = async (
completed_at: completedAt.toISOString(),
scenario: scenarioCommitment,
comparison_contract: comparisonContract,
- shim_plan: scenario.command_shims,
- replay_plan: scenario.replay,
full_scenario_sha256: digestProcessCommitment(scenarioCommitment),
comparison_contract_sha256: digestProcessCommitment(comparisonContract),
executable_sha256: executableSha256,
normalization_sha256: digestProcessCommitment(scenario.normalization),
- shim_plan_sha256: digestProcessCommitment(scenario.command_shims),
- replay_plan_sha256: digestProcessCommitment(scenario.replay),
};
};
@@ -346,11 +344,8 @@ const processCaptureCleanupHost: ProcessCaptureCleanupHost = {
export const releaseProcessResources = async (options: {
readonly timers: ReadonlySet;
- readonly replay: LoopbackReplay | undefined;
readonly terminal: IPty | undefined;
readonly renderer: TerminalRenderer | undefined;
- readonly shimReplay: CommandShimReplay | undefined;
- readonly checkpoints: ProcessCheckpoints | undefined;
readonly runId: string;
readonly temporaryRoot: string;
readonly capturedProcessGroupIds: readonly number[];
@@ -359,26 +354,11 @@ export const releaseProcessResources = async (options: {
const host = options.host ?? processCaptureCleanupHost;
for (const timer of options.timers) timer.cancel();
let failure: string | undefined;
- try {
- await options.replay?.close();
- } catch {
- failure = "loopback replay cleanup failed";
- }
- try {
- await options.shimReplay?.close();
- } catch {
- failure ??= "command shim replay cleanup failed";
- }
try {
await options.renderer?.dispose();
} catch {
failure ??= "terminal renderer cleanup failed";
}
- try {
- await options.checkpoints?.dispose();
- } catch {
- failure ??= "filesystem checkpoint cleanup failed";
- }
if (options.terminal !== undefined && host.platform !== "win32") {
for (const processGroupId of new Set(options.capturedProcessGroupIds)) {
const cleaned = await host.cleanupProcessGroup({
@@ -405,7 +385,6 @@ export const captureTerminalFrames = (options: {
readonly temporaryRoot: string;
readonly onOutput: () => void;
readonly renderer: TerminalRenderer;
- readonly checkpoints: ProcessCheckpoints;
readonly recordEvent: RecordProcessCaptureEvent;
}): (() => boolean) => {
let outputBytes = 0;
@@ -434,11 +413,8 @@ export const captureTerminalFrames = (options: {
at_ms: atMs,
data: normalized,
});
- // Queue the observed output before publishing it: reactive subscribers may
- // synchronously resize the terminal in response to this journal entry.
options.renderer.write(data, atMs);
options.recordEvent("frames", sequence);
- options.checkpoints.observeTerminal(normalized);
});
return () => truncated;
};
diff --git a/src/application/ProcessCaptureObservation.test.ts b/src/application/ProcessCaptureObservation.test.ts
index e4b2899aa..119b153cc 100644
--- a/src/application/ProcessCaptureObservation.test.ts
+++ b/src/application/ProcessCaptureObservation.test.ts
@@ -2,7 +2,6 @@ import { expect, it } from "vitest";
import { snapshotRoots } from "./FilesystemSnapshot.js";
import { buildCaptureResult } from "./ProcessCaptureLifecycle.js";
-import { ProcessCheckpoints } from "./ProcessCheckpoints.js";
import { normalizeProcessSamples } from "./ProcessNormalization.js";
import { isInitializedPtyRoot, readLinuxChildren } from "./ProcessSampling.js";
import { TerminalRenderer } from "./TerminalRenderer.js";
@@ -19,7 +18,7 @@ const base = {
working_directory: "/tmp",
};
-it("returns detached terminal and filesystem checkpoint observations", async () => {
+it("returns detached terminal observations", async () => {
const observed: string[] = [];
const renderer = new TerminalRenderer({
columns: 40,
@@ -46,21 +45,6 @@ it("returns detached terminal and filesystem checkpoint observations", async ()
expect(reread).toHaveLength(frames.length);
expect(reread[0]?.cursor_x).toBe(originalCursor);
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 });
- const checkpoint = first[0];
- if (checkpoint === undefined) throw new Error("expected checkpoint");
- Reflect.set(checkpoint, "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", () => {
@@ -93,14 +77,6 @@ it("preserves rendered observation order instead of timestamp sorting", () => {
frames: [],
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,
@@ -110,7 +86,6 @@ it("preserves rendered observation order instead of timestamp sorting", () => {
renderedFrames,
interactions: [],
checkpoints: capture.filesystem_checkpoints,
- shimEvents: [],
settlement: {
state: capture.settlement.state,
elapsed_ms: capture.settlement.elapsed_ms,
@@ -128,14 +103,6 @@ it("marks redacted scripted input as an interaction unknown", () => {
frames: [],
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,
@@ -157,7 +124,6 @@ it("marks redacted scripted input as an interaction unknown", () => {
},
],
checkpoints: capture.filesystem_checkpoints,
- shimEvents: [],
settlement: {
state: capture.settlement.state,
elapsed_ms: capture.settlement.elapsed_ms,
@@ -259,7 +225,7 @@ it("admits PTY samples only after stable session and token setup", () => {
).toBe(true);
});
-it("keeps interaction and shim residual uncertainty in separate scopes", () => {
+it("keeps interaction and process residual uncertainty in separate scopes", () => {
const baseCapture = emptyCapture();
const interaction = parseProcessCapture({
...baseCapture,
@@ -267,22 +233,24 @@ it("keeps interaction and shim residual uncertainty in separate scopes", () => {
{ scope: "interaction", reason: "Interaction capture was partial." },
],
});
- const shim = parseProcessCapture({
+ const process = parseProcessCapture({
...baseCapture,
- residual_unknowns: [{ scope: "shim", reason: "Shim capture was partial." }],
+ residual_unknowns: [
+ { scope: "process", reason: "Process sampling was partial." },
+ ],
});
expect(compareProcessCaptures(interaction, baseCapture)).toMatchObject({
status: "unknown",
terminal: "unchanged",
interaction: "unknown",
- shim: "unchanged",
+ process: "unchanged",
});
- expect(compareProcessCaptures(shim, baseCapture)).toMatchObject({
+ expect(compareProcessCaptures(process, baseCapture)).toMatchObject({
status: "unknown",
terminal: "unchanged",
interaction: "unchanged",
- shim: "unknown",
+ process: "unknown",
});
});
diff --git a/src/application/ProcessCheckpoints.ts b/src/application/ProcessCheckpoints.ts
deleted file mode 100644
index 1eb648c85..000000000
--- a/src/application/ProcessCheckpoints.ts
+++ /dev/null
@@ -1,216 +0,0 @@
-import type {
- FileState,
- FilesystemCheckpoint,
- ProcessScenario,
- RecordProcessCaptureEvent,
-} from "../domain/processCapture.js";
-import { snapshotRoots, type SnapshotResult } from "./FilesystemSnapshot.js";
-import { scheduleProcessDelay, type ProcessTimer } from "./ProcessTimer.js";
-
-/** Classify path-stable filesystem effects between two bounded states. */
-export const classifyFilesystemEffects = (
- before: readonly FileState[],
- after: readonly FileState[],
-): FilesystemCheckpoint["effects"] => {
- const beforeByPath = new Map(before.map((file) => [file.path, file]));
- const afterByPath = new Map(after.map((file) => [file.path, file]));
- return [...new Set([...beforeByPath.keys(), ...afterByPath.keys()])]
- .sort()
- .map((path) => {
- const beforeFile = beforeByPath.get(path) ?? null;
- const afterFile = afterByPath.get(path) ?? null;
- if (beforeFile === null) {
- if (afterFile === null)
- throw new TypeError(`Filesystem effect ${path} has no file state`);
- return {
- path,
- status: "created",
- before: null,
- after: afterFile,
- } as const;
- }
- if (afterFile === null)
- return {
- path,
- status: "deleted",
- before: beforeFile,
- after: null,
- } as const;
- return {
- path,
- status:
- JSON.stringify(beforeFile) === JSON.stringify(afterFile)
- ? "unchanged"
- : "modified",
- before: beforeFile,
- after: afterFile,
- } as const;
- });
-};
-
-/** Coordinates named lifecycle filesystem observations without overlapping scans. */
-export class ProcessCheckpoints {
- readonly #captures: FilesystemCheckpoint[] = [];
- readonly #captured = new Set();
- readonly #terminalCounts = new Map();
- readonly #terminalTails = new Map();
- readonly #timers = new Set();
- readonly #signal: AbortSignal | undefined;
- readonly #recordEvent: RecordProcessCaptureEvent;
- #pending: Promise = Promise.resolve();
-
- constructor(
- private readonly scenario: ProcessScenario,
- private readonly started: number,
- before: SnapshotResult,
- options: {
- readonly signal: AbortSignal | undefined;
- readonly recordEvent?: RecordProcessCaptureEvent;
- } = { signal: undefined },
- ) {
- this.#signal = options.signal;
- this.#recordEvent = options.recordEvent ?? (() => undefined);
- this.#captures.push({
- name: "before",
- at_ms: 0,
- files: before.files,
- effects: [],
- truncated: before.truncated,
- });
- this.#recordEvent("filesystem_checkpoints", 0);
- this.#captured.add("before");
- for (const checkpoint of scenario.checkpoints) {
- if (checkpoint.trigger.type !== "time") continue;
- const timer = scheduleProcessDelay(checkpoint.trigger.at_ms, () =>
- this.capture(checkpoint.name),
- );
- this.#timers.add(timer);
- }
- }
-
- /** Observe normalized terminal text and fire matching literal checkpoints once. */
- observeTerminal(data: string): void {
- for (const checkpoint of this.scenario.checkpoints) {
- const trigger = checkpoint.trigger;
- if (
- trigger.type !== "terminal_literal" ||
- this.#captured.has(checkpoint.name)
- )
- continue;
- const combined = `${this.#terminalTails.get(checkpoint.name) ?? ""}${data}`;
- const count =
- (this.#terminalCounts.get(checkpoint.name) ?? 0) +
- countOccurrences(combined, trigger.value);
- this.#terminalCounts.set(checkpoint.name, count);
- this.#terminalTails.set(
- checkpoint.name,
- trigger.value.length === 1
- ? ""
- : combined.slice(-(trigger.value.length - 1)),
- );
- if (count >= trigger.occurrence) this.capture(checkpoint.name);
- }
- }
-
- /** Capture every checkpoint assigned to the specified lifecycle trigger. */
- trigger(type: "root_exit" | "settled"): void {
- for (const checkpoint of this.scenario.checkpoints)
- if (checkpoint.trigger.type === type) this.capture(checkpoint.name);
- }
-
- /** Queue one idempotent named snapshot. */
- capture(name: string): void {
- this.#scheduleCapture(name, this.#signal);
- }
-
- /** Read one committed checkpoint for synchronous journal projection. */
- at(index: number): FilesystemCheckpoint | undefined {
- const checkpoint = this.#captures[index];
- return checkpoint === undefined ? undefined : structuredClone(checkpoint);
- }
-
- #scheduleCapture(name: string, signal: AbortSignal | undefined): void {
- if (this.#captured.has(name)) return;
- this.#captured.add(name);
- // Serialize scans even when time, terminal, and lifecycle triggers fire
- // together. Each effect set is intentionally relative to the immediately
- // preceding checkpoint, which preserves transient create/delete behavior.
- this.#pending = this.#pending.then(async () => {
- const snapshot = await snapshotRoots(this.scenario, signal);
- const previous = this.#captures.at(-1)?.files ?? [];
- const index = this.#captures.length;
- this.#captures.push({
- name,
- at_ms: Math.max(0, Date.now() - this.started),
- files: snapshot.files,
- effects: classifyFilesystemEffects(previous, snapshot.files),
- truncated: snapshot.truncated,
- });
- this.#recordEvent("filesystem_checkpoints", index);
- });
- }
-
- /** Queue one named snapshot and return its exact captured record. */
- async captureAndRead(
- name: string,
- signal?: AbortSignal,
- ): Promise<{
- readonly index: number;
- readonly checkpoint: FilesystemCheckpoint;
- }> {
- const captureSignal =
- signal === undefined
- ? this.#signal
- : this.#signal === undefined
- ? signal
- : AbortSignal.any([this.#signal, signal]);
- this.#scheduleCapture(name, captureSignal);
- const scheduled = this.#pending;
- await scheduled;
- const index = this.#captures.findIndex(
- ({ name: capturedName }) => capturedName === name,
- );
- const checkpoint = this.#captures[index];
- if (index < 0 || checkpoint === undefined)
- throw new Error(`filesystem checkpoint was not captured: ${name}`);
- return { index, checkpoint: structuredClone(checkpoint) };
- }
-
- /** Finish pending snapshots and return them in observation order. */
- async finish(
- after: SnapshotResult,
- ): Promise {
- for (const timer of this.#timers) timer.cancel();
- await this.#pending;
- const previous = this.#captures.at(-1)?.files ?? [];
- const index = this.#captures.length;
- this.#captures.push({
- name: "after_settlement",
- at_ms: Math.max(0, Date.now() - this.started),
- files: after.files,
- effects: classifyFilesystemEffects(previous, after.files),
- truncated: after.truncated,
- });
- this.#recordEvent("filesystem_checkpoints", index);
- return structuredClone(this.#captures);
- }
-
- /** Cancel scheduled captures and await any snapshot already in progress. */
- async dispose(): Promise {
- for (const timer of this.#timers) timer.cancel();
- this.#timers.clear();
- await this.#pending;
- }
-}
-
-const countOccurrences = (text: string, value: string): number => {
- let count = 0;
- let offset = 0;
- while (offset <= text.length - value.length) {
- const found = text.indexOf(value, offset);
- if (found < 0) break;
- count += 1;
- offset = found + value.length;
- }
- return count;
-};
diff --git a/src/application/ProcessEvidence.test.ts b/src/application/ProcessEvidence.test.ts
index 463cb9298..b286cdcf0 100644
--- a/src/application/ProcessEvidence.test.ts
+++ b/src/application/ProcessEvidence.test.ts
@@ -2,7 +2,6 @@ import { expect, it } from "vitest";
import { createProcessCaptureEvidence } from "./ProcessEvidence.js";
import { createRunManifest } from "./ProcessCaptureLifecycle.js";
-import { normalizeProtocolEvents } from "./ProcessNormalization.js";
import { emptyUnverifiedProcessCapture } from "../domain/processCapture.fixture.js";
import {
compareProcessCaptures,
@@ -61,28 +60,6 @@ const captureEvidenceForEnvironmentSecret = async (value: string) => {
working_directory: "/tmp",
environment: { API_TOKEN: value },
events: [{ type: "input", at_ms: 0, data: value }],
- command_shims: [
- {
- name: "echo",
- routes: [
- {
- arguments: [],
- outputs: [{ at_ms: 0, stream: "stdout", data: value }],
- termination: { type: "exit", code: 0 },
- },
- ],
- },
- ],
- replay: {
- http: [
- {
- method: "GET",
- path: `/token?token=${value}`,
- status: 200,
- body: value,
- },
- ],
- },
});
const base = emptyUnverifiedProcessCapture();
const manifest = await createRunManifest(scenario, new Date(0), new Date(1));
@@ -94,21 +71,6 @@ const captureEvidenceForEnvironmentSecret = async (value: string) => {
...base,
normalization: scenario.normalization,
frames: [{ sequence: 0, at_ms: 0, data: "observed-output" }],
- protocol_events: normalizeProtocolEvents(
- [
- {
- sequence: 0,
- at_ms: 0,
- protocol: "http",
- direction: "request",
- method: "GET",
- path: `/token?token=${value}`,
- data: "",
- outcome: "matched",
- },
- ],
- scenario,
- ),
residual_unknowns: [
{
scope: "environment",
@@ -143,7 +105,7 @@ it("does not treat captures with different redacted inputs as equivalent", () =>
).toMatchObject({ status: "unknown", interaction: "unknown" });
});
-it("preserves selected local environment, shim, replay, and protocol values", async () => {
+it("preserves selected local environment and input values", async () => {
const left = await captureEvidenceForEnvironmentSecret("secret-left");
const right = await captureEvidenceForEnvironmentSecret("secret-right");
const serialized = JSON.stringify(left);
@@ -151,7 +113,6 @@ it("preserves selected local environment, shim, replay, and protocol values", as
const rightCapture = parseProcessCapture(right.normalized_result);
expect(serialized).toContain("secret-left");
- expect(serialized).toContain("/token?token=secret-left");
expect(leftCapture.manifest.scenario).toMatchObject({
environment: { API_TOKEN: "secret-left" },
});
diff --git a/src/application/ProcessEvidence.ts b/src/application/ProcessEvidence.ts
index bab243fa9..da9f7d04e 100644
--- a/src/application/ProcessEvidence.ts
+++ b/src/application/ProcessEvidence.ts
@@ -9,7 +9,7 @@ import type {
/** Provider identity for controlled process capture evidence. */
export const PROCESS_PROVIDER = {
id: "rea-process",
- name: "REA deterministic process harness",
+ name: "REA process capture",
version: "3",
} as const;
@@ -22,8 +22,6 @@ const processEvidenceParameters = (
event_count: scenario.events.length,
filesystem_observation_path_count:
scenario.filesystem_observation_paths.length,
- checkpoint_count: scenario.checkpoints.length,
- command_shim_count: scenario.command_shims.length,
normalization: scenario.normalization,
});
diff --git a/src/application/ProcessFilesystemEffects.ts b/src/application/ProcessFilesystemEffects.ts
new file mode 100644
index 000000000..a4c7112c1
--- /dev/null
+++ b/src/application/ProcessFilesystemEffects.ts
@@ -0,0 +1,45 @@
+import type {
+ FileState,
+ FilesystemCheckpoint,
+} from "../domain/processCapture.js";
+
+/** Classify path-stable filesystem effects between two bounded states. */
+export const classifyFilesystemEffects = (
+ before: readonly FileState[],
+ after: readonly FileState[],
+): FilesystemCheckpoint["effects"] => {
+ const beforeByPath = new Map(before.map((file) => [file.path, file]));
+ const afterByPath = new Map(after.map((file) => [file.path, file]));
+ return [...new Set([...beforeByPath.keys(), ...afterByPath.keys()])]
+ .sort()
+ .map((path) => {
+ const beforeFile = beforeByPath.get(path) ?? null;
+ const afterFile = afterByPath.get(path) ?? null;
+ if (beforeFile === null) {
+ if (afterFile === null)
+ throw new TypeError(`Filesystem effect ${path} has no file state`);
+ return {
+ path,
+ status: "created",
+ before: null,
+ after: afterFile,
+ } as const;
+ }
+ if (afterFile === null)
+ return {
+ path,
+ status: "deleted",
+ before: beforeFile,
+ after: null,
+ } as const;
+ return {
+ path,
+ status:
+ JSON.stringify(beforeFile) === JSON.stringify(afterFile)
+ ? "unchanged"
+ : "modified",
+ before: beforeFile,
+ after: afterFile,
+ } as const;
+ });
+};
diff --git a/src/application/ProcessHarness.test.ts b/src/application/ProcessHarness.test.ts
index 136c161dc..fe96c8194 100644
--- a/src/application/ProcessHarness.test.ts
+++ b/src/application/ProcessHarness.test.ts
@@ -2,6 +2,43 @@ import { expect, it } from "vitest";
import { processScenarioSchema } from "../domain/processScenario.js";
import { AnalysisCapabilityUnavailableError } from "../domain/errors.js";
import { captureProcessScenario } from "./ProcessHarness.js";
+import { settleProcessCaptureJournal } from "./ProcessCaptureLifecycle.js";
+import { processCaptureSchema } from "../domain/processCapture.js";
+import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../domain/processCapture.fixture.js";
+
+it("rejects legacy replay output instead of silently discarding it", () => {
+ expect(
+ processCaptureSchema.safeParse({
+ ...EMPTY_PROCESS_CAPTURE_EXAMPLE,
+ protocol_events: [],
+ }).success,
+ ).toBe(false);
+});
+
+it("waits for terminal observations delivered after the exit callback", async () => {
+ const journal: Array<{
+ capture_order: number;
+ collection: "frames";
+ index: number;
+ }> = [{ capture_order: 0, collection: "frames", index: 0 }];
+ const lateFrame = new Promise((resolve) => {
+ setTimeout(() => {
+ journal.push({ capture_order: 1, collection: "frames", index: 1 });
+ resolve();
+ }, 12);
+ });
+
+ let settled = false;
+ const wait = settleProcessCaptureJournal(journal, 20, 200).then(() => {
+ settled = true;
+ });
+ await lateFrame;
+ expect(settled).toBe(false);
+ await wait;
+
+ expect(journal).toHaveLength(2);
+ expect(journal[1]).toMatchObject({ collection: "frames", index: 1 });
+});
it("fails closed on Windows before resolving or launching scenario paths", async () => {
const scenario = processScenarioSchema.parse({
diff --git a/src/application/ProcessHarness.ts b/src/application/ProcessHarness.ts
index d7098bca5..faea7d0ae 100644
--- a/src/application/ProcessHarness.ts
+++ b/src/application/ProcessHarness.ts
@@ -20,20 +20,11 @@ import {
processCaptureCancelled,
} from "./ProcessCaptureError.js";
export { ProcessCaptureError } from "./ProcessCaptureError.js";
-import { startLoopbackReplay, type LoopbackReplay } from "./LoopbackReplay.js";
import { startProcessSampler } from "./ProcessSampling.js";
import { snapshotRoots } from "./FilesystemSnapshot.js";
-import { ProcessCheckpoints } from "./ProcessCheckpoints.js";
import { TerminalRenderer } from "./TerminalRenderer.js";
import type { ProcessTimer } from "./ProcessTimer.js";
-import {
- startCommandShimReplay,
- type CommandShimReplay,
-} from "./CommandShimReplay.js";
-import {
- normalizeProcessShimEvent,
- normalizeProcessText,
-} from "./ProcessNormalization.js";
+import { normalizeProcessText } from "./ProcessNormalization.js";
import { selectCapturedProcessGroupIds } from "../process/ProcessOwnership.js";
import {
awaitTerminalExit,
@@ -44,6 +35,7 @@ import {
prepareProcessCapture,
releaseProcessResources,
resolveProcessResult,
+ settleProcessCaptureJournal,
type PendingProcessCapture,
} from "./ProcessCaptureLifecycle.js";
import {
@@ -53,40 +45,27 @@ import {
import {
createProcessCaptureJournal,
scheduleScenarioInteractions,
- type ProcessCaptureJournal,
} from "./ProcessCaptureJournal.js";
-import {
- assertSupportedReactiveScenario,
- projectProcessReactiveRun,
- startProcessReactiveHarness,
- type ProcessReactiveHarness,
-} from "./ProcessReactiveHarness.js";
import { makeProcessCaptureEnvironment } from "./ProcessCaptureEnvironment.js";
+import { classifyFilesystemEffects } from "./ProcessFilesystemEffects.js";
import { processCaptureOwnershipUnavailableReason } from "./ProcessCaptureCapability.js";
export { probeProcessCaptureCapability } from "./ProcessCaptureCapability.js";
interface StartedCaptureRuntime {
- readonly replay: LoopbackReplay;
readonly renderer: TerminalRenderer;
- readonly checkpoints: ProcessCheckpoints;
- readonly shimReplay: CommandShimReplay;
readonly terminal: IPty;
readonly started: number;
readonly startedAt: Date;
readonly lastOutput: () => number;
readonly framesTruncated: () => boolean;
readonly stopSampler: () => Promise<{ readonly partial: boolean }>;
- readonly reactive: ProcessReactiveHarness | undefined;
}
const cleanupFailedStartup = async (options: {
readonly cause: unknown;
readonly timers: Set;
- readonly replay: LoopbackReplay | undefined;
readonly terminal: IPty | undefined;
readonly renderer: TerminalRenderer | undefined;
- readonly shimReplay: CommandShimReplay | undefined;
- readonly checkpoints: ProcessCheckpoints | undefined;
readonly runId: string;
readonly temporaryRoot: string;
}): Promise => {
@@ -125,67 +104,23 @@ interface StartCaptureRuntimeOptions {
readonly hostEnvironment: Readonly>;
readonly temporaryRoot: string;
readonly runId: string;
- readonly before: Awaited>;
readonly frames: TerminalFrame[];
readonly samples: ProcessSample[];
readonly interactions: InteractionEvent[];
readonly timers: Set;
readonly dispatchedEventIndexes: Set;
readonly recordEvent: RecordProcessCaptureEvent;
- readonly journal: ProcessCaptureJournal;
readonly signal?: AbortSignal;
}
-/**
- * Wait until the capture journal stops growing.
- *
- * node-pty reports buffered output through `onData` callbacks rather than an
- * awaitable read, so chunks can still be in flight after the child exits. The
- * quiet interval is measured in elapsed time rather than scheduler turns:
- * consecutive `setImmediate` turns elapse in microseconds, so any fixed number
- * of them finishes long before a delayed pty callback under host contention,
- * which is exactly when the trailing chunk matters. `maxWaitMs` bounds the
- * total wait so a pathological stream cannot stall exit.
- */
-const settleTrailingJournal = async (
- journal: readonly ProcessCaptureEventJournalEntry[],
- quietMs = 25,
- maxWaitMs = 500,
-): Promise => {
- const deadline = Date.now() + maxWaitMs;
- let observed = journal.length;
- let lastChangeAt = Date.now();
- while (Date.now() < deadline) {
- await new Promise((resolve) => setTimeout(resolve, 4));
- if (journal.length !== observed) {
- observed = journal.length;
- lastChangeAt = Date.now();
- } else if (Date.now() - lastChangeAt >= quietMs) return;
- }
-};
-
-const reactiveCapture = (
- options: StartCaptureRuntimeOptions,
- protocolEvents: () => LoopbackReplay["events"],
-): Parameters[0]["capture"] => ({
- ...options,
- processSamples: options.samples,
- protocolEvents,
-});
-
const startCaptureRuntime = async (
options: StartCaptureRuntimeOptions,
): Promise => {
const { scenario } = options;
- let replay: LoopbackReplay | undefined;
let renderer: TerminalRenderer | undefined;
- let checkpoints: ProcessCheckpoints | undefined;
- let shimReplay: CommandShimReplay | undefined;
let terminal: IPty | undefined;
- let reactive: ProcessReactiveHarness | undefined;
try {
const { spawn } = await import("@lydell/node-pty");
- replay = await startLoopbackReplay(scenario, options.recordEvent);
const startedAt = new Date();
const started = Date.now();
let lastOutput = started;
@@ -195,19 +130,9 @@ const startCaptureRuntime = async (
() => terminal?.pid ?? -1,
options.recordEvent,
);
- checkpoints = new ProcessCheckpoints(scenario, started, options.before, {
- signal: options.signal,
- recordEvent: options.recordEvent,
- });
- shimReplay = await startCommandShimReplay(
- scenario,
- options.temporaryRoot,
- started,
- options.recordEvent,
- );
terminal = spawn(scenario.executable, [...scenario.arguments], {
cwd: scenario.working_directory,
- env: makeProcessCaptureEnvironment({ ...options, replay, shimReplay }),
+ env: makeProcessCaptureEnvironment(options),
cols: scenario.terminal.columns,
rows: scenario.terminal.rows,
name: "xterm-256color",
@@ -218,16 +143,6 @@ const startCaptureRuntime = async (
started,
onOutput: () => (lastOutput = Date.now()),
renderer,
- checkpoints,
- });
- reactive = startProcessReactiveHarness({
- capture: reactiveCapture(options, () => replay?.events ?? []),
- scenario,
- terminal: () => terminal,
- renderer,
- checkpoints,
- shimReplay,
- started,
});
scheduleScenarioInteractions({
...options,
@@ -244,49 +159,26 @@ const startCaptureRuntime = async (
recordEvent: options.recordEvent,
});
return {
- replay,
renderer,
- checkpoints,
- shimReplay,
terminal,
started,
startedAt,
lastOutput: () => lastOutput,
framesTruncated,
stopSampler,
- reactive,
};
} catch (cause: unknown) {
- reactive?.unsubscribe();
- await reactive?.coordinator.close();
return cleanupFailedStartup({
cause,
timers: options.timers,
- replay,
terminal,
renderer,
- shimReplay,
- checkpoints,
runId: options.runId,
temporaryRoot: options.temporaryRoot,
});
}
};
-const normalizedShimEvents = (
- runtime: StartedCaptureRuntime,
- scenario: ProcessScenario,
- temporaryRoot: string,
-): UnverifiedProcessCapture["shim_events"] =>
- runtime.shimReplay.events.map((event) =>
- normalizeProcessShimEvent(
- event,
- scenario,
- temporaryRoot,
- runtime.terminal.pid,
- ),
- );
-
const finishProcessRun = async (options: {
readonly runtime: StartedCaptureRuntime | undefined;
readonly timers: Set;
@@ -297,15 +189,11 @@ const finishProcessRun = async (options: {
readonly capture: PendingProcessCapture | undefined;
readonly executionFailure: unknown;
}): Promise => {
- options.runtime?.reactive?.unsubscribe();
await options.stopSampler();
const cleanupFailure = await releaseProcessResources({
timers: options.timers,
- replay: options.runtime?.replay,
terminal: options.runtime?.terminal,
renderer: options.runtime?.renderer,
- shimReplay: options.runtime?.shimReplay,
- checkpoints: options.runtime?.checkpoints,
runId: options.runId,
temporaryRoot: options.temporaryRoot,
capturedProcessGroupIds:
@@ -316,11 +204,6 @@ const finishProcessRun = async (options: {
options.samples,
),
});
- if (cleanupFailure !== undefined)
- await options.runtime?.reactive?.coordinator.submit({
- kind: "cleanup_failed",
- });
- await options.runtime?.reactive?.coordinator.close();
let { capture, executionFailure } = options;
let verifiedCapture: ProcessCapture | undefined;
if (capture !== undefined && cleanupFailure === undefined) {
@@ -330,7 +213,6 @@ const finishProcessRun = async (options: {
: { ...capture.settlement, cleanup_outcome: "cleaned" };
const completedCapture: UnverifiedProcessCapture = {
...capture,
- reactive_run: projectProcessReactiveRun(options.runtime?.reactive),
settlement,
cleanup: {
owned_process_group: "verified",
@@ -369,45 +251,36 @@ const completeCapture = async (options: {
readonly recordEvent: RecordProcessCaptureEvent;
}): Promise => {
const { runtime, scenario } = options;
- runtime.checkpoints.trigger("root_exit");
const { reason } = options.exit;
- if (reason === "cancelled") {
- if (runtime.reactive !== undefined) {
- await runtime.reactive.coordinator.submit({ kind: "cancelled" });
- runtime.reactive.unsubscribe();
- }
- throw processCaptureCancelled();
- }
+ if (reason === "cancelled") throw processCaptureCancelled();
const settlement = await observeSettlement(
options.runId,
selectCapturedProcessGroupIds(runtime.terminal.pid, options.samples),
scenario.settle_ms,
options.recordEvent,
);
- runtime.checkpoints.trigger("settled");
const samplingPartial = (await runtime.stopSampler()).partial;
- if (runtime.reactive !== undefined) {
- // Process, filesystem, shim, and protocol observations can be recorded
- // during settlement after the PTY exits. Make target loss terminal only
- // after that journal has drained, otherwise the coordinator's control
- // priority discards a valid multi-source completion predicate.
- //
- // Draining the coordinator is not sufficient on its own: node-pty delivers
- // buffered output through onData callbacks, which run outside the
- // coordinator's queue. A trailing chunk that has not been delivered yet has
- // produced no journal entry, so the drain has nothing to wait for and the
- // target is declared lost while a terminal trigger is still satisfiable.
- // Let any trailing chunks land first.
- await settleTrailingJournal(options.eventJournal);
- await runtime.reactive.coordinator.drain();
- await runtime.reactive.coordinator.submit({ kind: "target_lost" });
- runtime.reactive.unsubscribe();
- }
+ await settleProcessCaptureJournal(options.eventJournal);
assertNotCancelled(options.signal);
const after = await snapshotRoots(scenario, options.signal);
+ options.recordEvent("filesystem_checkpoints", 1);
const renderedFrames = await runtime.renderer.frames();
- const checkpoints = await runtime.checkpoints.finish(after);
- await runtime.replay.close();
+ const checkpoints: UnverifiedProcessCapture["filesystem_checkpoints"] = [
+ {
+ name: "before",
+ at_ms: 0,
+ files: options.before.files,
+ effects: [],
+ truncated: options.before.truncated,
+ },
+ {
+ name: "after_settlement",
+ at_ms: Math.max(0, Date.now() - runtime.started),
+ files: after.files,
+ effects: classifyFilesystemEffects(options.before.files, after.files),
+ truncated: after.truncated,
+ },
+ ];
const manifest = await createRunManifest(
scenario,
runtime.startedAt,
@@ -416,8 +289,6 @@ const completeCapture = async (options: {
const truncated =
options.initiallyTruncated ||
after.truncated ||
- runtime.replay.truncated ||
- runtime.shimReplay.truncated ||
runtime.framesTruncated() ||
checkpoints.some(({ truncated: partial }) => partial) ||
samplingPartial ||
@@ -426,7 +297,6 @@ const completeCapture = async (options: {
frames: options.frames,
exit: { ...options.exit, reason },
samples: options.samples,
- replay: runtime.replay,
before: options.before,
after,
truncated,
@@ -436,7 +306,6 @@ const completeCapture = async (options: {
renderedFrames,
interactions: options.interactions,
checkpoints,
- shimEvents: normalizedShimEvents(runtime, scenario, options.temporaryRoot),
settlement,
manifest,
eventJournal: options.eventJournal,
@@ -449,7 +318,6 @@ const runProcessScenario = async (
signal?: AbortSignal,
hostEnvironment: Readonly> = process.env,
): Promise => {
- assertSupportedReactiveScenario(scenario);
const { temporaryRoot, runId, before } = await prepareProcessCapture(
scenario,
signal,
@@ -458,6 +326,7 @@ const runProcessScenario = async (
const samples: ProcessSample[] = [];
const journal = createProcessCaptureJournal();
const { entries: eventJournal, recordEvent } = journal;
+ recordEvent("filesystem_checkpoints", 0);
let runtime: StartedCaptureRuntime | undefined;
const timers = new Set();
let capture: PendingProcessCapture | undefined;
@@ -472,14 +341,12 @@ const runProcessScenario = async (
hostEnvironment,
temporaryRoot,
runId,
- before,
frames,
samples,
interactions,
timers,
dispatchedEventIndexes,
recordEvent,
- journal,
...(signal === undefined ? {} : { signal }),
});
stopSampler = runtime.stopSampler;
diff --git a/src/application/ProcessNormalization.ts b/src/application/ProcessNormalization.ts
index 58cc5bb01..2c3c1236f 100644
--- a/src/application/ProcessNormalization.ts
+++ b/src/application/ProcessNormalization.ts
@@ -1,8 +1,6 @@
import type {
- ProcessCapture,
ProcessSample,
ProcessScenario,
- ShimEvent,
} from "../domain/processCapture.js";
/** Bucket one elapsed process-capture timestamp under scenario normalization. */
@@ -88,25 +86,6 @@ export const normalizeProcessSamples = (
}));
};
-/** Normalize one shim observation identically for live matching and capture. */
-export const normalizeProcessShimEvent = (
- event: ShimEvent,
- scenario: ProcessScenario,
- temporaryRoot: string,
- rootPid: number,
-): ShimEvent => ({
- ...event,
- arguments: event.arguments.map((argument) =>
- normalizeProcessText(argument, scenario, temporaryRoot, rootPid),
- ),
- working_directory: normalizeProcessText(
- event.working_directory,
- scenario,
- temporaryRoot,
- rootPid,
- ),
-});
-
const normalizePidTokens = (
value: string,
identifiers: readonly number[],
@@ -121,12 +100,3 @@ const normalizePidTokens = (
"",
);
};
-
-export const normalizeProtocolEvents = (
- events: readonly ProcessCapture["protocol_events"][number][],
- scenario: ProcessScenario,
-): readonly ProcessCapture["protocol_events"][number][] =>
- events.map((event) => ({
- ...event,
- data: normalizeProcessText(event.data, scenario, "", -1),
- }));
diff --git a/src/application/ProcessReactiveCoordinator.test.ts b/src/application/ProcessReactiveCoordinator.test.ts
deleted file mode 100644
index 6a9c7602c..000000000
--- a/src/application/ProcessReactiveCoordinator.test.ts
+++ /dev/null
@@ -1,301 +0,0 @@
-import { describe, expect, it } from "vitest";
-import {
- ProcessReactiveCoordinator,
- type ProcessReactiveTimerHost,
-} from "./ProcessReactiveCoordinator.js";
-import { executeProcessReactiveEffects } from "./ProcessReactiveEffects.js";
-import { createProcessCaptureJournal } from "./ProcessCaptureJournal.js";
-import { subscribeProcessReactiveObservations } from "./ProcessReactiveObservations.js";
-import type {
- FilesystemCheckpoint,
- InteractionEvent,
- TerminalFrame,
-} from "../domain/processCapture.js";
-import { parseProcessScenario } from "../domain/processScenario.js";
-import {
- processReactiveScenarioSchema,
- type ProcessReactiveScenario,
-} from "../domain/processReactiveScenario.js";
-const terminalTrigger = () => ({
- kind: "terminal_text" as const,
- literal: "Ready",
- 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({
- 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" }],
- 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" },
- },
- ],
- },
- ],
- });
-const createHarness = (scenario: ProcessReactiveScenario) => {
- const journal = createProcessCaptureJournal();
- const frames: TerminalFrame[] = [];
- const interactions: InteractionEvent[] = [];
- const checkpoints: FilesystemCheckpoint[] = [];
- const terminalCalls: string[] = [];
- const timers = timerHost();
- let now = 100;
- const effectHost = {
- scenario: parseProcessScenario({ executable: "node" }),
- terminal: () => ({
- write: (data: string) => terminalCalls.push(`write:${data}`),
- resize: (columns: number, rows: number) =>
- terminalCalls.push(`resize:${String(columns)}x${String(rows)}`),
- closeInput: () => terminalCalls.push("close-input"),
- kill: (signal: "SIGINT" | "SIGTERM" | "SIGKILL") =>
- terminalCalls.push(`kill:${signal}`),
- }),
- renderer: {
- resize: (columns: number, rows: number) =>
- terminalCalls.push(`render:${String(columns)}x${String(rows)}`),
- },
- checkpoints: {
- captureAndRead: async (name: string) => {
- const index = checkpoints.length;
- const checkpoint: FilesystemCheckpoint = {
- name,
- at_ms: now - 100,
- files: [],
- effects: [],
- truncated: false,
- };
- checkpoints.push(checkpoint);
- journal.record("filesystem_checkpoints", index);
- return { index, checkpoint };
- },
- },
- interactions,
- journal,
- startedAtMs: 100,
- now: () => now,
- // Matches the processScenario default; rendered frames are normalized with
- // this so they share a clock with captured frames.
- timeBucketMs: 10,
- };
- const coordinator = new ProcessReactiveCoordinator({
- scenario,
- executor: {
- execute: (actions, signal) =>
- executeProcessReactiveEffects(effectHost, actions, signal),
- },
- timerHost: timers.host,
- });
- const unsubscribe = subscribeProcessReactiveObservations({
- journal,
- frames,
- interactions,
- checkpointAt: (index) => checkpoints[index],
- processSampleAt: () => undefined,
- protocolEventAt: () => undefined,
- shimEventAt: () => undefined,
- sink: coordinator,
- });
- return {
- coordinator,
- frames,
- interactions,
- checkpoints,
- journal,
- terminalCalls,
- timers: timers.scheduled,
- setNow: (value: number) => (now = value),
- unsubscribe,
- };
-};
-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",
- active_state: "sent",
- });
- expect(
- harness.coordinator.snapshot.transitions.map(
- ({ transition_id }) => transition_id,
- ),
- ).toEqual(["send", "confirm"]);
- expect(harness.terminalCalls).toEqual(["write:go"]);
- expect(harness.journal.entries).toEqual([
- { collection: "frames", index: 0, capture_order: 0 },
- { collection: "interaction_events", index: 0, capture_order: 1 },
- ]);
- expect(harness.interactions[0]).toMatchObject({
- type: "input",
- data: "go",
- outcome: "dispatched",
- });
- harness.unsubscribe();
- await harness.coordinator.close();
- });
- it("executes input, resize, root signal, and checkpoint in journal order", async () => {
- const scenario = processReactiveScenarioSchema.parse({
- initial_state: "ready",
- deadline_ms: 30000,
- states: [
- {
- id: "ready",
- max_visits: 1,
- deadline_ms: 5000,
- on: [
- {
- id: "act",
- priority: 1,
- max_uses: 1,
- when: terminalTrigger(),
- actions: [
- { type: "send_input", data: "secret", sensitive: true },
- { type: "resize", columns: 100, rows: 40 },
- { type: "close_stdin" },
- {
- type: "send_signal",
- target: { kind: "root" },
- signal: "SIGINT",
- },
- { type: "checkpoint", name: "stopped" },
- ],
- target: { kind: "finish" },
- },
- ],
- },
- ],
- });
- const harness = createHarness(scenario);
- 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",
- "render:100x40",
- "close-input",
- "kill:SIGINT",
- ]);
- expect(harness.interactions.map(({ data }) => data)).toEqual([
- "",
- "100x40",
- "",
- "SIGINT",
- ]);
- expect(harness.checkpoints).toHaveLength(1);
- expect(
- harness.coordinator.snapshot.transitions[0]?.action_event_ids,
- ).toEqual([
- "obs.interaction_events.0",
- "obs.interaction_events.1",
- "obs.interaction_events.2",
- "obs.interaction_events.3",
- "obs.filesystem_checkpoints.0",
- ]);
- await harness.coordinator.close();
- });
- it("ignores a stale state timer after a transition", async () => {
- const base = twoStateScenario();
- const first = base.states[0];
- const second = base.states[1];
- if (first === undefined || second === undefined)
- throw new Error("two-state fixture is incomplete");
- const scenario = processReactiveScenarioSchema.parse({
- ...base,
- states: [
- {
- ...first,
- on: first.on.map((transition) => ({
- ...transition,
- actions: [],
- })),
- },
- {
- ...second,
- on: second.on.map((transition) => ({
- ...transition,
- when: { ...terminalTrigger(), literal: "Later" },
- })),
- },
- ],
- });
- const harness = createHarness(scenario);
- const oldStateTimer = harness.timers[1];
- 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",
- outcome: null,
- });
- const currentStateTimer = harness.timers[2];
- currentStateTimer?.callback();
- await harness.coordinator.drain();
- expect(harness.coordinator.snapshot.outcome).toBe("predicate_timeout");
- await harness.coordinator.close();
- });
-});
diff --git a/src/application/ProcessReactiveCoordinator.ts b/src/application/ProcessReactiveCoordinator.ts
deleted file mode 100644
index d767d9ef3..000000000
--- a/src/application/ProcessReactiveCoordinator.ts
+++ /dev/null
@@ -1,300 +0,0 @@
-import {
- commitProcessReactiveProposal,
- createProcessReactiveSnapshot,
- reduceProcessReactiveScenario,
- type ProcessReactiveDecision,
- type ProcessReactiveEffectResult,
- type ProcessReactiveInput,
- type ProcessReactiveSnapshot,
-} from "../domain/processReactiveRuntime.js";
-import type {
- ProcessReactiveAction,
- ProcessReactiveScenario,
-} from "../domain/processReactiveScenario.js";
-import { scheduleProcessDelay } from "./ProcessTimer.js";
-
-/** Effect boundary owned by the serialized reactive coordinator. */
-export interface ProcessReactiveExecutor {
- execute(
- actions: readonly ProcessReactiveAction[],
- signal: AbortSignal,
- ): Promise;
-}
-
-/** Cancel handle for one coordinator-owned deadline. */
-export type ProcessReactiveTimer = ReturnType;
-
-/** Injectable deadline scheduler used by deterministic coordinator tests. */
-export interface ProcessReactiveTimerHost {
- readonly schedule: (
- callback: () => void,
- delayMs: number,
- ) => ProcessReactiveTimer;
-}
-
-const systemTimerHost: ProcessReactiveTimerHost = {
- schedule: (callback, delayMs) => scheduleProcessDelay(delayMs, callback),
-};
-
-/**
- * Serializes live observations, deadlines, and effect commits for one run.
- * Producer callbacks enqueue work without awaiting it, preventing reentrancy.
- */
-export class ProcessReactiveCoordinator {
- readonly #scenario: ProcessReactiveScenario;
- readonly #executor: ProcessReactiveExecutor;
- readonly #timerHost: ProcessReactiveTimerHost;
- readonly #now: () => number;
- readonly #scenarioDeadlineAt: number;
- readonly #onDecision:
- | ((decision: ProcessReactiveDecision, input: ProcessReactiveInput) => void)
- | undefined;
- #snapshot: ProcessReactiveSnapshot;
- readonly #pendingInputs: ProcessReactiveInput[] = [];
- #tail: Promise = Promise.resolve();
- #running = false;
- #failure: unknown;
- #stateTimer: ProcessReactiveTimer | undefined;
- #scenarioTimer: ProcessReactiveTimer | undefined;
- #stateDeadlineAt = 0;
- #effectAbort: AbortController | undefined;
- #accepting = true;
- #closed = false;
-
- constructor(options: {
- readonly scenario: ProcessReactiveScenario;
- readonly executor: ProcessReactiveExecutor;
- readonly timerHost?: ProcessReactiveTimerHost;
- readonly now?: () => number;
- readonly onDecision?: (
- decision: ProcessReactiveDecision,
- input: ProcessReactiveInput,
- ) => void;
- }) {
- this.#scenario = options.scenario;
- this.#executor = options.executor;
- this.#timerHost = options.timerHost ?? systemTimerHost;
- this.#now = options.now ?? Date.now;
- this.#scenarioDeadlineAt = this.#now() + options.scenario.deadline_ms;
- this.#onDecision = options.onDecision;
- this.#snapshot = createProcessReactiveSnapshot(options.scenario);
- this.#scheduleScenarioDeadline();
- this.#scheduleStateDeadline();
- }
-
- /** Current immutable reducer snapshot after all completed turns. */
- get snapshot(): ProcessReactiveSnapshot {
- return this.#snapshot;
- }
-
- /** Enqueue producer work without creating a floating promise. */
- enqueue(input: ProcessReactiveInput): void {
- if (!this.#accepting) return;
- if (this.#interruptsEffects(input)) this.#effectAbort?.abort();
- this.#enqueueOwned(input);
- }
-
- /** Submit one input and wait until every turn queued before it completes. */
- async submit(input: ProcessReactiveInput): Promise {
- this.enqueue(input);
- await this.drain();
- return this.#snapshot;
- }
-
- /** Wait for currently queued reducer/effect work and surface host failures. */
- async drain(): Promise {
- for (;;) {
- const observedTail = this.#tail;
- await observedTail;
- if (observedTail === this.#tail) break;
- }
- if (this.#failure !== undefined) throw this.#failure;
- }
-
- /** Stop owned deadlines after queued work settles. */
- async close(): Promise {
- this.#accepting = false;
- this.#clearTimers();
- await this.drain();
- this.#closed = true;
- }
-
- #enqueueOwned(input: ProcessReactiveInput): void {
- if (this.#closed || this.#failure !== undefined) return;
- this.#pendingInputs.push(input);
- this.#startWorker();
- }
-
- #startWorker(): void {
- if (this.#running || this.#pendingInputs.length === 0) return;
- this.#running = true;
- this.#tail = this.#runQueue()
- .catch((cause: unknown) => this.#fail(cause))
- .finally(() => {
- this.#running = false;
- if (this.#pendingInputs.length > 0) this.#startWorker();
- });
- }
-
- async #runQueue(): Promise {
- for (;;) {
- const input = this.#nextInput();
- if (input === undefined) return;
- await this.#advance(input);
- }
- }
-
- #nextInput(): ProcessReactiveInput | undefined {
- const terminalIndex = this.#pendingInputs.findIndex(
- ({ kind }) => kind === "cancelled" || kind === "cleanup_failed",
- );
- if (terminalIndex >= 0)
- return this.#pendingInputs.splice(terminalIndex, 1)[0];
- let observationIndex = -1;
- let captureOrder = Number.POSITIVE_INFINITY;
- for (const [index, input] of this.#pendingInputs.entries())
- if (
- input.kind === "observation" &&
- input.observation.capture_order < captureOrder
- ) {
- observationIndex = index;
- captureOrder = input.observation.capture_order;
- }
- return observationIndex >= 0
- ? this.#pendingInputs.splice(observationIndex, 1)[0]
- : this.#pendingInputs.shift();
- }
-
- #fail(cause: unknown): void {
- this.#failure ??= cause;
- this.#accepting = false;
- this.#closed = true;
- this.#effectAbort?.abort();
- this.#pendingInputs.length = 0;
- this.#clearTimers();
- }
-
- async #advance(input: ProcessReactiveInput): Promise {
- const before = this.#snapshot;
- let decision = reduceProcessReactiveScenario(this.#scenario, before, input);
- let effectResults: readonly ProcessReactiveEffectResult[] = [];
- if (decision.kind === "proposal") {
- const effectAbort = new AbortController();
- this.#effectAbort = effectAbort;
- try {
- effectResults = await this.#executor.execute(
- decision.effects,
- effectAbort.signal,
- );
- } catch (cause: unknown) {
- if (effectAbort.signal.aborted) return;
- throw cause;
- } finally {
- if (this.#effectAbort === effectAbort) this.#effectAbort = undefined;
- }
- if (effectAbort.signal.aborted) return;
- const expired = this.#expiredDeadlineInput();
- if (expired !== undefined) {
- this.#enqueueOwned(expired);
- return;
- }
- decision = commitProcessReactiveProposal(
- this.#scenario,
- decision,
- effectResults,
- );
- }
- this.#snapshot = decision.snapshot;
- this.#onDecision?.(decision, input);
- if (this.#snapshot.status === "finished") this.#clearTimers();
- else if (
- this.#accepting &&
- (before.active_state !== this.#snapshot.active_state ||
- before.state_entry_capture_order !==
- this.#snapshot.state_entry_capture_order)
- )
- this.#scheduleStateDeadline();
- for (const result of effectResults)
- if (
- result.status === "succeeded" &&
- !this.#hasObservation(result.observation.event_id)
- )
- this.#enqueueOwned({
- kind: "observation",
- observation: result.observation,
- });
- }
-
- #hasObservation(eventId: string): boolean {
- return (
- this.#snapshot.observations.some(
- (observation) => observation.event_id === eventId,
- ) ||
- this.#pendingInputs.some(
- (input) =>
- input.kind === "observation" &&
- input.observation.event_id === eventId,
- )
- );
- }
-
- #interruptsEffects(input: ProcessReactiveInput): boolean {
- if (
- input.kind === "cancelled" ||
- input.kind === "cleanup_failed" ||
- input.kind === "scenario_deadline" ||
- input.kind === "target_lost"
- )
- return true;
- return (
- input.kind === "state_deadline" &&
- input.state_id === this.#snapshot.active_state &&
- input.state_entry_capture_order ===
- this.#snapshot.state_entry_capture_order
- );
- }
-
- #expiredDeadlineInput(): ProcessReactiveInput | undefined {
- const now = this.#now();
- if (now >= this.#scenarioDeadlineAt) return { kind: "scenario_deadline" };
- if (now < this.#stateDeadlineAt) return undefined;
- return {
- kind: "state_deadline",
- state_id: this.#snapshot.active_state,
- state_entry_capture_order: this.#snapshot.state_entry_capture_order,
- };
- }
-
- #scheduleScenarioDeadline(): void {
- if (!this.#accepting) return;
- this.#scenarioTimer = this.#timerHost.schedule(
- () => this.enqueue({ kind: "scenario_deadline" }),
- this.#scenario.deadline_ms,
- );
- }
-
- #scheduleStateDeadline(): void {
- if (!this.#accepting) return;
- this.#stateTimer?.cancel();
- const state = this.#scenario.states.find(
- ({ id }) => id === this.#snapshot.active_state,
- );
- if (state === undefined) return;
- this.#stateDeadlineAt = this.#now() + state.deadline_ms;
- const identity = {
- state_id: state.id,
- state_entry_capture_order: this.#snapshot.state_entry_capture_order,
- };
- this.#stateTimer = this.#timerHost.schedule(
- () => this.enqueue({ kind: "state_deadline", ...identity }),
- state.deadline_ms,
- );
- }
-
- #clearTimers(): void {
- this.#stateTimer?.cancel();
- this.#scenarioTimer?.cancel();
- this.#stateTimer = undefined;
- this.#scenarioTimer = undefined;
- }
-}
diff --git a/src/application/ProcessReactiveCoordinatorFailures.fixture.ts b/src/application/ProcessReactiveCoordinatorFailures.fixture.ts
deleted file mode 100644
index 22d8a828f..000000000
--- a/src/application/ProcessReactiveCoordinatorFailures.fixture.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import type { ProcessReactiveTimerHost } from "./ProcessReactiveCoordinator.js";
-import {
- processReactiveScenarioSchema,
- type ProcessReactiveScenario,
-} from "../domain/processReactiveScenario.js";
-
-export const terminalTrigger = () => ({
- kind: "terminal_text" as const,
- literal: "Ready",
- occurrence: 1,
- since: { kind: "scenario_start" as const },
- consume: true,
-});
-export 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 };
-};
-export const twoStateScenario = (): ProcessReactiveScenario =>
- processReactiveScenarioSchema.parse({
- 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" }],
- 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" },
- },
- ],
- },
- ],
- });
diff --git a/src/application/ProcessReactiveCoordinatorFailures.test.ts b/src/application/ProcessReactiveCoordinatorFailures.test.ts
deleted file mode 100644
index 578fbd7cf..000000000
--- a/src/application/ProcessReactiveCoordinatorFailures.test.ts
+++ /dev/null
@@ -1,198 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { ProcessReactiveCoordinator } from "./ProcessReactiveCoordinator.js";
-import { unsupportedProcessReactiveFeatures } from "./ProcessReactiveEffects.js";
-import { createProcessObservation } from "../domain/processObservation.js";
-import type { ProcessReactiveEffectResult } from "../domain/processReactiveRuntime.js";
-import { processReactiveScenarioSchema } from "../domain/processReactiveScenario.js";
-import {
- timerHost,
- twoStateScenario,
-} from "./ProcessReactiveCoordinatorFailures.fixture.js";
-
-describe("process reactive coordinator failures: host preflight and ordering", () => {
- 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);
- });
-});
-
-describe("process reactive coordinator failures: effect deadlines", () => {
- 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/src/application/ProcessReactiveEffects.ts b/src/application/ProcessReactiveEffects.ts
deleted file mode 100644
index 8f6360629..000000000
--- a/src/application/ProcessReactiveEffects.ts
+++ /dev/null
@@ -1,254 +0,0 @@
-import type {
- FilesystemCheckpoint,
- InteractionEvent,
-} from "../domain/processCapture.js";
-import { createProcessObservation } from "../domain/processObservation.js";
-import type { ProcessReactiveEffectResult } from "../domain/processReactiveRuntime.js";
-import type {
- ProcessReactiveAction,
- ProcessReactiveScenario,
-} from "../domain/processReactiveScenario.js";
-import type { ProcessScenario } from "../domain/processScenario.js";
-import type { ProcessCaptureJournal } from "./ProcessCaptureJournal.js";
-import {
- normalizeProcessElapsedTime,
- normalizeProcessText,
-} from "./ProcessNormalization.js";
-
-/** Minimal PTY authority used by the admitted reactive action slice. */
-export interface ProcessReactiveTerminal {
- write(data: string): void;
- resize(columns: number, rows: number): void;
- closeInput(): void;
- kill(signal: "SIGINT" | "SIGTERM" | "SIGKILL"): void;
-}
-
-/** Rendered-terminal resize seam kept in lockstep with the PTY. */
-export interface ProcessReactiveRenderer {
- resize(columns: number, rows: number, atMs: number): void;
-}
-
-/** Awaited filesystem checkpoint seam used by reactive effects. */
-export interface ProcessReactiveCheckpoints {
- captureAndRead(
- name: string,
- signal?: AbortSignal,
- ): Promise<{
- readonly index: number;
- readonly checkpoint: FilesystemCheckpoint;
- }>;
-}
-
-/** Host dependencies for journal-backed reactive process actions. */
-export interface ProcessReactiveEffectHost {
- readonly scenario: ProcessScenario;
- readonly terminal: () => ProcessReactiveTerminal | undefined;
- readonly renderer: ProcessReactiveRenderer;
- readonly checkpoints: ProcessReactiveCheckpoints;
- readonly interactions: InteractionEvent[];
- readonly journal: ProcessCaptureJournal;
- readonly startedAtMs: number;
- readonly now: () => number;
- /** Time bucket the scenario normalizes rendered-frame timestamps with. */
- readonly timeBucketMs: number;
-}
-
-const elapsed = (host: ProcessReactiveEffectHost): number =>
- Math.max(0, host.now() - host.startedAtMs);
-
-const interactionData = (
- action: Exclude,
- scenario: ProcessScenario,
-): string => {
- if (action.type === "send_input")
- return action.sensitive
- ? ``
- : normalizeProcessText(action.data, scenario, "", -1);
- if (action.type === "resize")
- return `${String(action.columns)}x${String(action.rows)}`;
- if (action.type === "close_stdin") return "";
- return action.signal;
-};
-
-const interactionType = (
- action: Exclude,
-): InteractionEvent["type"] => {
- if (action.type === "send_input") return "input";
- if (action.type === "resize") return "resize";
- if (action.type === "close_stdin") return "stdin_close";
- return "signal";
-};
-
-const performTerminalAction = (
- host: Pick,
- terminal: ProcessReactiveTerminal,
- action: Exclude,
- atMs: number,
-): void => {
- if (action.type === "send_input") terminal.write(action.data);
- else if (action.type === "resize") {
- terminal.resize(action.columns, action.rows);
- // Rendered frames share the normalized clock used by captured terminal
- // frames and by the scheduled-event resize path. Passing the raw elapsed
- // time here stamped this frame on a different clock, so the next bucketed
- // frame could appear to move backwards and fail capture validation with
- // "rendered_frames: timestamps must be ordered".
- host.renderer.resize(
- action.columns,
- action.rows,
- normalizeProcessElapsedTime(atMs, host.timeBucketMs),
- );
- } else if (action.type === "close_stdin") terminal.closeInput();
- else terminal.kill(action.signal);
-};
-
-const recordInteraction = (
- host: ProcessReactiveEffectHost,
- action: Exclude,
- atMs: number,
- outcome: InteractionEvent["outcome"],
-): ProcessReactiveEffectResult => {
- const index = host.interactions.length;
- const event: InteractionEvent = {
- sequence: index,
- scheduled_at_ms: atMs,
- dispatched_at_ms: atMs,
- type: interactionType(action),
- data: interactionData(action, host.scenario),
- outcome,
- };
- host.interactions.push(event);
- const location = host.journal.record("interaction_events", index);
- if (outcome !== "dispatched") return { status: "rejected" };
- return {
- status: "succeeded",
- observation: createProcessObservation({
- source: "interaction",
- source_sequence: index,
- captured_at_ms: atMs,
- subject_id: null,
- location,
- payload: event,
- }),
- };
-};
-
-const executeTerminalAction = (
- host: ProcessReactiveEffectHost,
- action: Exclude,
-): ProcessReactiveEffectResult => {
- if (action.type === "send_signal" && action.target.kind !== "root")
- return { status: "rejected" };
- const terminal = host.terminal();
- if (terminal === undefined) return { status: "target_lost" };
- const atMs = elapsed(host);
- let outcome: InteractionEvent["outcome"] = "dispatched";
- try {
- performTerminalAction(host, terminal, action, atMs);
- } catch {
- outcome = "failed";
- }
- return recordInteraction(host, action, atMs, outcome);
-};
-
-const executeCheckpoint = async (
- host: ProcessReactiveEffectHost,
- action: Extract,
- signal: AbortSignal,
-): Promise => {
- try {
- const { index, checkpoint } = await host.checkpoints.captureAndRead(
- action.name,
- signal,
- );
- const location = host.journal.entries.findLast(
- (entry) =>
- entry.collection === "filesystem_checkpoints" && entry.index === index,
- );
- if (location === undefined) return { status: "rejected" };
- return {
- status: "succeeded",
- observation: createProcessObservation({
- source: "filesystem",
- source_sequence: index,
- captured_at_ms: checkpoint.at_ms,
- subject_id: `checkpoint:${checkpoint.name}`,
- location,
- payload: checkpoint,
- }),
- };
- } catch {
- return { status: "rejected" };
- }
-};
-
-/** Execute proposed effects in order and stop after the first failure. */
-export const executeProcessReactiveEffects = async (
- host: ProcessReactiveEffectHost,
- actions: readonly ProcessReactiveAction[],
- signal: AbortSignal,
-): Promise => {
- const results: ProcessReactiveEffectResult[] = [];
- for (const action of actions) {
- if (signal.aborted) return results;
- const result =
- action.type === "checkpoint"
- ? await executeCheckpoint(host, action, signal)
- : executeTerminalAction(host, action);
- results.push(result);
- if (result.status !== "succeeded") break;
- }
- return results;
-};
-
-const triggerSources = (
- trigger: ProcessReactiveScenario["states"][number]["on"][number]["when"],
-): readonly string[] => {
- if (trigger.kind === "terminal_text") return [];
- if (trigger.kind === "event")
- return [
- "terminal_raw",
- "interaction",
- "process",
- "filesystem",
- "http",
- "websocket",
- "shim",
- ].includes(trigger.source)
- ? []
- : [trigger.source];
- return trigger.kind === "repeat"
- ? triggerSources(trigger.trigger)
- : trigger.triggers.flatMap(triggerSources);
-};
-
-/** Return unsupported sources, selectors, and checkpoint collisions pre-launch. */
-export const unsupportedProcessReactiveFeatures = (
- scenario: ProcessReactiveScenario,
- reservedCheckpointNames: ReadonlySet = new Set([
- "before",
- "after_settlement",
- ]),
-): readonly string[] => [
- ...scenario.states.flatMap((state) =>
- state.on.flatMap((transition) =>
- triggerSources(transition.when).map(
- (source) => `${transition.id}:source:${source}`,
- ),
- ),
- ),
- ...scenario.states.flatMap((state) =>
- state.on.flatMap((transition) =>
- transition.actions.flatMap((action) => {
- if (action.type === "send_signal" && action.target.kind !== "root")
- return [`${transition.id}:target:${action.target.kind}`];
- if (
- action.type === "checkpoint" &&
- reservedCheckpointNames.has(action.name)
- )
- return [`${transition.id}:checkpoint:${action.name}`];
- return [];
- }),
- ),
- ),
-];
diff --git a/src/application/ProcessReactiveHarness.ts b/src/application/ProcessReactiveHarness.ts
deleted file mode 100644
index 6271a27d4..000000000
--- a/src/application/ProcessReactiveHarness.ts
+++ /dev/null
@@ -1,185 +0,0 @@
-import type { IPty } from "@lydell/node-pty";
-
-import type {
- InteractionEvent,
- ProcessCapture,
- ProcessReactiveControlRecord,
- ProcessSample,
- ProcessScenario,
- ProtocolEvent,
- TerminalFrame,
-} from "../domain/processCapture.js";
-import type { ProcessReactiveInput } from "../domain/processReactiveRuntime.js";
-import { ProcessCaptureError } from "./ProcessCaptureError.js";
-import type { ProcessCaptureJournal } from "./ProcessCaptureJournal.js";
-import type { ProcessCheckpoints } from "./ProcessCheckpoints.js";
-import type { CommandShimReplay } from "./CommandShimReplay.js";
-import { ProcessReactiveCoordinator } from "./ProcessReactiveCoordinator.js";
-import {
- executeProcessReactiveEffects,
- type ProcessReactiveTerminal,
- unsupportedProcessReactiveFeatures,
-} from "./ProcessReactiveEffects.js";
-import { subscribeProcessReactiveObservations } from "./ProcessReactiveObservations.js";
-import {
- normalizeProcessSamples,
- normalizeProcessShimEvent,
- normalizeProtocolEvents,
-} from "./ProcessNormalization.js";
-import type { TerminalRenderer } from "./TerminalRenderer.js";
-
-/** Coordinator and subscription owned by one live process capture. */
-export interface ProcessReactiveHarness {
- readonly coordinator: ProcessReactiveCoordinator;
- readonly controls: readonly ProcessReactiveControlRecord[];
- readonly unsubscribe: () => void;
-}
-
-const reactiveTerminalFor = (
- terminal: IPty | undefined,
-): ProcessReactiveTerminal | undefined => {
- if (terminal === undefined) return undefined;
- return {
- write: (data) => terminal.write(data),
- resize: (columns, rows) => terminal.resize(columns, rows),
- closeInput: () => {
- const end: unknown = Reflect.get(terminal, "end");
- if (typeof end !== "function")
- throw new ProcessCaptureError(
- "PTY backend does not expose an stdin-close operation",
- );
- Reflect.apply(end, terminal, []);
- },
- kill: (signal) => terminal.kill(signal),
- };
-};
-
-const controlKind = (
- input: ProcessReactiveInput,
-): ProcessReactiveControlRecord["kind"] | undefined =>
- input.kind === "observation" ? undefined : input.kind;
-
-/** Reject reactive declarations the current process adapter cannot execute. */
-export const assertSupportedReactiveScenario = (
- scenario: ProcessScenario,
-): void => {
- if (scenario.reactive === null) return;
- const unsupported = unsupportedProcessReactiveFeatures(scenario.reactive);
- if (unsupported.length === 0) return;
- throw new ProcessCaptureError(
- `reactive scenario requests unsupported features: ${unsupported.join(", ")}`,
- );
-};
-
-/**
- * Attach the serialized reactive runtime once the target PID is available.
- *
- * Subscription replays journal entries admitted during launcher startup before
- * observing new records, so PID-dependent normalization cannot drop them.
- */
-export const startProcessReactiveHarness = (options: {
- readonly scenario: ProcessScenario;
- readonly terminal: () => IPty | undefined;
- readonly renderer: TerminalRenderer;
- readonly checkpoints: ProcessCheckpoints;
- readonly shimReplay: CommandShimReplay;
- readonly started: number;
- readonly capture: {
- readonly interactions: InteractionEvent[];
- readonly journal: ProcessCaptureJournal;
- readonly frames: readonly TerminalFrame[];
- readonly processSamples: readonly ProcessSample[];
- readonly protocolEvents: () => readonly ProtocolEvent[];
- readonly temporaryRoot: string;
- };
-}): ProcessReactiveHarness | undefined => {
- if (options.scenario.reactive === null) return undefined;
- const controls: ProcessReactiveControlRecord[] = [];
- const coordinator = new ProcessReactiveCoordinator({
- scenario: options.scenario.reactive,
- executor: {
- execute: (actions, signal) =>
- executeProcessReactiveEffects(
- {
- scenario: options.scenario,
- terminal: () => reactiveTerminalFor(options.terminal()),
- renderer: options.renderer,
- checkpoints: options.checkpoints,
- interactions: options.capture.interactions,
- journal: options.capture.journal,
- startedAtMs: options.started,
- now: Date.now,
- timeBucketMs: options.scenario.normalization.time_bucket_ms,
- },
- actions,
- signal,
- ),
- },
- onDecision: (decision, input) => {
- const kind = controlKind(input);
- if (kind === undefined || decision.kind !== "finished") return;
- controls.push({
- sequence: controls.length,
- kind,
- after_capture_order:
- decision.snapshot.observations.at(-1)?.capture_order ?? -1,
- });
- },
- });
- return {
- coordinator,
- controls,
- unsubscribe: subscribeProcessReactiveObservations({
- journal: options.capture.journal,
- frames: options.capture.frames,
- interactions: options.capture.interactions,
- checkpointAt: (index) => options.checkpoints.at(index),
- processSampleAt: (index) => {
- const rootPid = options.terminal()?.pid;
- return rootPid === undefined
- ? undefined
- : normalizeProcessSamples(
- options.capture.processSamples,
- options.scenario,
- rootPid,
- )[index];
- },
- protocolEventAt: (index) =>
- normalizeProtocolEvents(
- options.capture.protocolEvents().slice(index, index + 1),
- options.scenario,
- )[0],
- shimEventAt: (index) => {
- const event = options.shimReplay.events[index];
- const rootPid = options.terminal()?.pid;
- return event === undefined || rootPid === undefined
- ? undefined
- : normalizeProcessShimEvent(
- event,
- options.scenario,
- options.capture.temporaryRoot,
- rootPid,
- );
- },
- sink: coordinator,
- }),
- };
-};
-
-/** Project internal reducer state into the stable Process Capture shape. */
-export const projectProcessReactiveRun = (
- harness: ProcessReactiveHarness | undefined,
-): ProcessCapture["reactive_run"] => {
- if (harness === undefined) return null;
- const snapshot = harness.coordinator.snapshot;
- const completion =
- snapshot.status === "running"
- ? { status: snapshot.status, outcome: null }
- : { status: snapshot.status, outcome: snapshot.outcome };
- return {
- ...completion,
- active_state: snapshot.active_state,
- transitions: snapshot.transitions,
- controls: harness.controls,
- };
-};
diff --git a/src/application/ProcessReactiveObservations.ts b/src/application/ProcessReactiveObservations.ts
deleted file mode 100644
index d2c4520da..000000000
--- a/src/application/ProcessReactiveObservations.ts
+++ /dev/null
@@ -1,164 +0,0 @@
-import type {
- InteractionEvent,
- FilesystemCheckpoint,
- ProcessCaptureEventJournalEntry,
- ProcessSample,
- ProtocolEvent,
- ShimEvent,
- TerminalFrame,
-} from "../domain/processCapture.js";
-import {
- createProcessObservation,
- processObservationSubject,
- type ProcessObservation,
-} from "../domain/processObservation.js";
-import type { ProcessReactiveInput } from "../domain/processReactiveRuntime.js";
-import type { ProcessCaptureJournal } from "./ProcessCaptureJournal.js";
-
-/** Non-blocking input seam implemented by the reactive coordinator. */
-export interface ProcessReactiveObservationSink {
- enqueue(input: ProcessReactiveInput): void;
-}
-
-interface ProcessReactiveObservationOptions {
- readonly journal: ProcessCaptureJournal;
- readonly frames: readonly TerminalFrame[];
- readonly interactions: readonly InteractionEvent[];
- readonly checkpointAt: (index: number) => FilesystemCheckpoint | undefined;
- readonly processSampleAt: (index: number) => ProcessSample | undefined;
- readonly protocolEventAt: (index: number) => ProtocolEvent | undefined;
- readonly shimEventAt: (index: number) => ShimEvent | undefined;
- readonly sink: ProcessReactiveObservationSink;
-}
-
-const terminalObservation = (
- options: ProcessReactiveObservationOptions,
- entry: ProcessCaptureEventJournalEntry,
-): ProcessObservation | undefined => {
- const value = options.frames[entry.index];
- return value === undefined
- ? undefined
- : createProcessObservation({
- source: "terminal_raw",
- source_sequence: value.sequence,
- captured_at_ms: value.at_ms,
- subject_id: null,
- location: entry,
- payload: value,
- });
-};
-
-const interactionObservation = (
- options: ProcessReactiveObservationOptions,
- entry: ProcessCaptureEventJournalEntry,
-): ProcessObservation | undefined => {
- const value = options.interactions[entry.index];
- return value === undefined
- ? undefined
- : createProcessObservation({
- source: "interaction",
- source_sequence: value.sequence,
- captured_at_ms: value.dispatched_at_ms,
- subject_id: null,
- location: entry,
- payload: value,
- });
-};
-
-const filesystemObservation = (
- options: ProcessReactiveObservationOptions,
- entry: ProcessCaptureEventJournalEntry,
-): ProcessObservation | undefined => {
- const value = options.checkpointAt(entry.index);
- return value === undefined
- ? undefined
- : createProcessObservation({
- source: "filesystem",
- source_sequence: entry.index,
- captured_at_ms: value.at_ms,
- subject_id: processObservationSubject("filesystem", value),
- location: entry,
- payload: value,
- });
-};
-
-const processObservation = (
- options: ProcessReactiveObservationOptions,
- entry: ProcessCaptureEventJournalEntry,
-): ProcessObservation | undefined => {
- const value = options.processSampleAt(entry.index);
- return value === undefined
- ? undefined
- : createProcessObservation({
- source: "process",
- source_sequence: entry.index,
- captured_at_ms: value.at_ms,
- subject_id: processObservationSubject("process", value),
- location: entry,
- payload: value,
- });
-};
-
-const protocolObservation = (
- options: ProcessReactiveObservationOptions,
- entry: ProcessCaptureEventJournalEntry,
-): ProcessObservation | undefined => {
- const value = options.protocolEventAt(entry.index);
- return value === undefined
- ? undefined
- : createProcessObservation({
- source: value.protocol,
- source_sequence: value.sequence,
- captured_at_ms: value.at_ms,
- subject_id: processObservationSubject(value.protocol, value),
- location: entry,
- payload: value,
- });
-};
-
-const shimObservation = (
- options: ProcessReactiveObservationOptions,
- entry: ProcessCaptureEventJournalEntry,
-): ProcessObservation | undefined => {
- const normalized = options.shimEventAt(entry.index);
- if (normalized === undefined) return undefined;
- return createProcessObservation({
- source: "shim",
- source_sequence: normalized.sequence,
- captured_at_ms: normalized.at_ms,
- subject_id: processObservationSubject("shim", normalized),
- location: entry,
- payload: normalized,
- });
-};
-
-const observationFor = (
- options: ProcessReactiveObservationOptions,
- entry: ProcessCaptureEventJournalEntry,
-): ProcessObservation | undefined => {
- if (entry.collection === "frames") return terminalObservation(options, entry);
- if (entry.collection === "interaction_events")
- return interactionObservation(options, entry);
- if (entry.collection === "filesystem_checkpoints")
- return filesystemObservation(options, entry);
- if (entry.collection === "process_samples")
- return processObservation(options, entry);
- if (entry.collection === "protocol_events")
- return protocolObservation(options, entry);
- if (entry.collection === "shim_events")
- return shimObservation(options, entry);
- return undefined;
-};
-
-/** Subscribe the reactive coordinator to admitted normalized journal records. */
-export const subscribeProcessReactiveObservations = (
- options: ProcessReactiveObservationOptions,
-): (() => void) => {
- const enqueue = (entry: ProcessCaptureEventJournalEntry): void => {
- const observation = observationFor(options, entry);
- if (observation !== undefined)
- options.sink.enqueue({ kind: "observation", observation });
- };
- for (const entry of options.journal.entries) enqueue(entry);
- return options.journal.subscribe(enqueue);
-};
diff --git a/src/application/ProcessTimer.test.ts b/src/application/ProcessTimer.test.ts
index fe6700a6c..3e2224e46 100644
--- a/src/application/ProcessTimer.test.ts
+++ b/src/application/ProcessTimer.test.ts
@@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest";
-import { ReplayDelayScheduler } from "./LoopbackReplayRecorder.js";
import { scheduleProcessDelay } from "./ProcessTimer.js";
describe("process delay scheduling", () => {
@@ -36,15 +35,4 @@ describe("process delay scheduling", () => {
scheduled[1]?.callback();
expect(fired).toBe(false);
});
-
- it("releases owned replay delays when the endpoint closes", async () => {
- const scheduler = new ReplayDelayScheduler();
- const waiting = scheduler.wait(Number.MAX_SAFE_INTEGER);
-
- scheduler.close();
- scheduler.close();
- await waiting;
-
- await scheduler.wait(Number.MAX_SAFE_INTEGER);
- });
});
diff --git a/src/application/RuntimeExecutableDiagnostics.ts b/src/application/RuntimeExecutableDiagnostics.ts
deleted file mode 100644
index fcc1fb3b7..000000000
--- a/src/application/RuntimeExecutableDiagnostics.ts
+++ /dev/null
@@ -1,390 +0,0 @@
-import { spawn, type ChildProcess } from "node:child_process";
-import { constants } from "node:fs";
-import { access, realpath } from "node:fs/promises";
-import { delimiter, join } from "node:path";
-
-const TOOL_NAMES = ["node", "npm", "npx"] as const;
-const PROBE_CONCURRENCY = 4;
-
-type RuntimeToolName = (typeof TOOL_NAMES)[number];
-type RuntimeProbeFailureCode =
- | "runtime_dynamic_library_missing"
- | "runtime_invalid_version_output"
- | "runtime_timeout"
- | "runtime_signal"
- | "runtime_spawn_failed"
- | "runtime_nonzero_exit";
-
-/** Classification of one failed executable probe with its complete stderr. */
-interface RuntimeProbeFailure {
- readonly code: RuntimeProbeFailureCode;
- readonly exit_code: number | null;
- readonly signal: NodeJS.Signals | null;
- readonly dependency: string | null;
- readonly stderr: string;
-}
-
-interface RuntimeExecutableIdentity {
- readonly tool: RuntimeToolName;
- readonly lexical_path: string;
- readonly canonical_path: string;
- readonly path_index: number | null;
- readonly selection: "rea-launcher" | "path-primary" | "path-shadowed";
-}
-
-/** One lexical PATH candidate and its canonical executable probe result. */
-type RuntimeExecutableDiagnostic = RuntimeExecutableIdentity &
- (
- | {
- readonly version: string;
- readonly healthy: true;
- readonly failure: null;
- }
- | {
- readonly version: null;
- readonly healthy: false;
- readonly failure: RuntimeProbeFailure;
- }
- );
-
-/** Runtime identities observed under one exact effective environment. */
-export interface RuntimeExecutableInventory {
- readonly launcher_node: string;
- readonly candidates: readonly RuntimeExecutableDiagnostic[];
-}
-
-/** Host inputs that determine executable discovery and shebang resolution. */
-export interface RuntimeExecutableInventoryOptions {
- readonly platform: NodeJS.Platform;
- readonly path: string;
- readonly launcherNode: string;
- readonly pathExtensions?: readonly string[];
- readonly timeoutMs?: number;
-}
-
-/** Inventory and probe Node toolchain candidates under one exact PATH. */
-export const inspectRuntimeExecutables = async (
- options: RuntimeExecutableInventoryOptions,
-): Promise => {
- const pathEntries = unique(options.path.split(delimiter).filter(Boolean));
- const candidates: Array<{
- readonly tool: RuntimeToolName;
- readonly path: string;
- readonly pathIndex: number | null;
- readonly selection: RuntimeExecutableDiagnostic["selection"];
- }> = [
- {
- tool: "node",
- path: options.launcherNode,
- pathIndex: null,
- selection: "rea-launcher",
- },
- ];
- for (const tool of TOOL_NAMES) {
- const discovered = await discoverToolCandidates(
- tool,
- pathEntries,
- options.platform,
- options.pathExtensions,
- );
- for (const [index, candidate] of discovered.entries())
- candidates.push({
- tool,
- path: candidate.path,
- pathIndex: candidate.pathIndex,
- selection: index === 0 ? "path-primary" : "path-shadowed",
- });
- }
- const seen = new Set();
- const uniqueCandidates: typeof candidates = [];
- for (const candidate of candidates) {
- const key = `${candidate.tool}\0${candidate.path}`;
- if (seen.has(key)) continue;
- seen.add(key);
- uniqueCandidates.push(candidate);
- }
- const diagnostics = await mapConcurrentBounded(
- uniqueCandidates,
- PROBE_CONCURRENCY,
- (candidate) =>
- probeCandidate(
- candidate,
- options.timeoutMs,
- options.path,
- options.platform,
- ),
- );
- return {
- launcher_node: await canonicalPath(options.launcherNode),
- candidates: diagnostics,
- };
-};
-
-const discoverToolCandidates = async (
- tool: RuntimeToolName,
- pathEntries: readonly string[],
- platform: NodeJS.Platform,
- configuredExtensions: readonly string[] | undefined,
-): Promise<
- readonly { readonly path: string; readonly pathIndex: number }[]
-> => {
- const extensions =
- platform === "win32"
- ? (configuredExtensions ?? [".COM", ".EXE", ".BAT", ".CMD"])
- : [""];
- const candidates: Array<{
- readonly path: string;
- readonly pathIndex: number;
- }> = [];
- for (const [pathIndex, directory] of pathEntries.entries()) {
- for (const extension of extensions) {
- const path = join(directory, `${tool}${extension}`);
- if (await isExecutable(path, platform)) {
- candidates.push({ path, pathIndex });
- break;
- }
- }
- }
- return candidates;
-};
-
-const probeCandidate = async (
- candidate: {
- readonly tool: RuntimeToolName;
- readonly path: string;
- readonly pathIndex: number | null;
- readonly selection: RuntimeExecutableDiagnostic["selection"];
- },
- timeoutMs: number | undefined,
- effectivePath: string,
- platform: NodeJS.Platform,
-): Promise => {
- const canonical = await canonicalPath(candidate.path);
- const result = await executeVersion(
- candidate.path,
- timeoutMs,
- effectivePath,
- platform,
- );
- const identity = {
- tool: candidate.tool,
- lexical_path: candidate.path,
- canonical_path: canonical,
- path_index: candidate.pathIndex,
- selection: candidate.selection,
- };
- return result.ok
- ? { ...identity, version: result.version, healthy: true, failure: null }
- : {
- ...identity,
- version: null,
- healthy: false,
- failure: result.failure,
- };
-};
-
-type VersionResult =
- | { readonly ok: true; readonly version: string }
- | { readonly ok: false; readonly failure: RuntimeProbeFailure };
-
-const executeVersion = (
- path: string,
- timeoutMs: number | undefined,
- effectivePath: string,
- platform: NodeJS.Platform = process.platform,
-): Promise =>
- new Promise((resolve) => {
- // Node >= 18.20 refuses to spawn .cmd/.bat files without a shell
- // (CVE-2024-27980 mitigation) and throws spawn EINVAL. On Windows, npm and
- // npx PATH shims are .CMD files, so every probe of them failed. Route
- // batch-file candidates through cmd.exe instead.
- const command = windowsBatchCommand(path, platform);
- const child = command
- ? spawn(command.executable, command.arguments, {
- windowsHide: true,
- windowsVerbatimArguments: true,
- env: environmentWithPath(effectivePath),
- stdio: ["ignore", "pipe", "pipe"],
- })
- : spawn(path, ["--version"], {
- windowsHide: true,
- env: environmentWithPath(effectivePath),
- stdio: ["ignore", "pipe", "pipe"],
- });
- let stdout = "";
- let stderr = "";
- let spawnError: Error | undefined;
- let timeout: NodeJS.Timeout | undefined;
- let timedOut = false;
- child.stdout?.setEncoding("utf8");
- child.stderr?.setEncoding("utf8");
- child.stdout?.on("data", (chunk: string) => {
- stdout += chunk;
- });
- child.stderr?.on("data", (chunk: string) => {
- stderr += chunk;
- });
- child.once("error", (cause: Error) => {
- spawnError = cause;
- });
- if (timeoutMs !== undefined && timeoutMs > 0) {
- timeout = setTimeout(() => {
- timedOut = true;
- child.kill();
- }, timeoutMs);
- timeout.unref();
- }
- child.once("close", () => {
- if (timeout !== undefined) clearTimeout(timeout);
- if (spawnError === undefined && child.exitCode === 0) {
- const version = firstNonemptyLine(stdout) ?? firstNonemptyLine(stderr);
- if (version !== undefined) {
- resolve({ ok: true, version });
- return;
- }
- resolve({
- ok: false,
- failure: failure("runtime_invalid_version_output", child, stderr),
- });
- return;
- }
- const cause =
- spawnError ??
- new Error("Runtime version probe did not exit successfully");
- resolve({
- ok: false,
- failure: timedOut
- ? failure("runtime_timeout", child, stderr)
- : classifyProbeFailure(cause, child, stderr),
- });
- });
- });
-
-/**
- * Build the cmd.exe invocation for a Windows batch-file candidate
- * (.cmd/.bat/.CMD/.BAT). Returns null for anything else, including on
- * non-Windows platforms. The whole command line is passed as one
- * /c argument so paths with spaces survive; `windowsVerbatimArguments`
- * keeps Node from re-quoting it. Embedded double quotes are doubled,
- * since a bare quote would terminate the /c string and allow argument
- * injection via a crafted PATH entry.
- */
-const windowsBatchCommand = (
- path: string,
- platform: NodeJS.Platform = process.platform,
- systemRoot: string = process.env.SystemRoot ?? "C:\\Windows",
-): { executable: string; arguments: string[] } | null => {
- if (platform !== "win32") return null;
- if (!/\.(cmd|bat)$/iu.test(path)) return null;
- return {
- executable: `${systemRoot}\\System32\\cmd.exe`,
- arguments: ["/d", "/s", "/c", `"${path.replaceAll('"', '""')}" --version`],
- };
-};
-
-/**
- * Testing-only re-export of the batch-command builder. Pure and
- * platform-injected, so it is fully exercisable on non-Windows hosts.
- */
-export const windowsBatchCommandForTesting = windowsBatchCommand;
-
-/** Testing-only re-export of the version probe. */
-export const executeVersionForTesting = executeVersion;
-
-const classifyProbeFailure = (
- cause: Error,
- child: ChildProcess,
- stderr: string,
-): RuntimeProbeFailure => {
- const dependency = missingDynamicLibrary(stderr);
- if (dependency !== null)
- return failure(
- "runtime_dynamic_library_missing",
- child,
- stderr,
- dependency,
- );
- if ("killed" in cause && cause.killed === true)
- return failure("runtime_timeout", child, stderr);
- if (child.signalCode !== null)
- return failure("runtime_signal", child, stderr);
- if ("code" in cause && typeof cause.code === "string")
- return failure("runtime_spawn_failed", child, stderr);
- return failure("runtime_nonzero_exit", child, stderr);
-};
-
-const failure = (
- code: RuntimeProbeFailureCode,
- child: ChildProcess,
- stderr: string,
- dependency: string | null = null,
-): RuntimeProbeFailure => ({
- code,
- exit_code: child.exitCode,
- signal: child.signalCode,
- dependency,
- stderr: stderr.trim(),
-});
-
-const missingDynamicLibrary = (stderr: string): string | null =>
- /(?:dyld(?:\[[^\]]+\])?: )?Library not loaded:\s*([^\r\n]+)/u
- .exec(stderr)?.[1]
- ?.trim() ?? null;
-
-const isExecutable = async (
- path: string,
- platform: NodeJS.Platform,
-): Promise => {
- try {
- await access(path, platform === "win32" ? constants.F_OK : constants.X_OK);
- return true;
- } catch {
- return false;
- }
-};
-
-const canonicalPath = async (path: string): Promise => {
- try {
- return await realpath(path);
- } catch {
- return path;
- }
-};
-
-const firstNonemptyLine = (value: string): string | undefined =>
- value
- .split(/\r?\n/u)
- .find((line) => line.trim().length > 0)
- ?.trim();
-
-const unique = (values: readonly string[]): string[] => [...new Set(values)];
-
-const environmentWithPath = (path: string): NodeJS.ProcessEnv => {
- const environment: NodeJS.ProcessEnv = {};
- for (const [name, value] of Object.entries(process.env))
- if (name.toUpperCase() !== "PATH") environment[name] = value;
- environment.PATH = path;
- return environment;
-};
-
-const mapConcurrentBounded = async (
- inputs: readonly Input[],
- concurrency: number,
- operation: (input: Input) => Promise