Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
161377a505 | ||
|
|
732d606609 | ||
|
|
c9bd91d7c3 | ||
|
|
e698c977ed | ||
|
|
31a3cfa7e9 | ||
|
|
829d9a0933 | ||
|
|
9da179425e | ||
|
|
b906defbff | ||
|
|
6e6cce1bf3 | ||
|
|
69761e95b6 |
No files matched your search
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "2.3.0"
|
||||
".": "2.4.0"
|
||||
}
|
||||
@@ -1,5 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## [2.4.0](https://github.com/morluto/rea/compare/rea-agents-2.3.0...rea-agents-2.4.0) (2026-07-23)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **artifacts:** support MSIX and AppX packages ([6e6cce1](https://github.com/morluto/rea/commit/6e6cce1bf3cbd9c48799bee9969c53d66c42162e))
|
||||
* expand reactive capture and package analysis ([9da1794](https://github.com/morluto/rea/commit/9da179425e83df9f255597437320c1ab6ff5c5c2))
|
||||
* **process:** add reactive capture scenarios ([69761e9](https://github.com/morluto/rea/commit/69761e95b63ec7617aa161f7a1b4e3c8847a6175))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **capabilities:** expose typed availability codes ([b906def](https://github.com/morluto/rea/commit/b906defbff28885a1d39f78da7c2b7170a7108c6))
|
||||
|
||||
## [2.3.0](https://github.com/morluto/rea/compare/rea-agents-2.2.0...rea-agents-2.3.0) (2026-07-23)
|
||||
|
||||
|
||||
|
||||
@@ -376,7 +376,7 @@ REA handles the app analysis in steps 1–5. The agent performs step 6 with its
|
||||
| Native inspection | 33 | procedures, pseudocode, assembly, strings, names, segments, callers, callees, xrefs, annotations |
|
||||
| Investigation workflows | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `trace_feature`, call graphs, Swift and Objective-C discovery |
|
||||
| Native macOS utilities | 5 | Mach-O metadata, code signatures, plists, architectures, Swift demangling; Hopper-free and provenance-bearing |
|
||||
| Artifact graph | 2 | deterministic directory, ZIP/APK/IPA, and ASAR inventory; explicitly selected extraction into an absent owned tree |
|
||||
| Artifact graph | 2 | deterministic directory, ZIP/APK/IPA/MSIX/AppX, and ASAR inventory; explicitly selected extraction into an absent owned tree |
|
||||
| Managed PE/CLI | 8 | PE/CLI identity, metadata members, CIL hashes, P/Invoke/native-boundary declarations and verification, application-graph projection, decompiler reconstruction import, token remapping, runtime-correlation plans, and version comparison |
|
||||
| Browser observation | 8 | exact-origin CDP capture, bundle and source-map analysis, WebMCP discovery, session timelines, capture diff, and visual evidence |
|
||||
| Electron analysis | 4 | passive root-confined observation, bounded static application mapping, and evidence-backed static/runtime reconciliation |
|
||||
@@ -479,7 +479,7 @@ Setup detects Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf,
|
||||
"mcpServers": {
|
||||
"rea": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "rea-agents@2.3.0", "mcp"]
|
||||
"args": ["-y", "rea-agents@2.4.0", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -182,7 +182,7 @@ rea uninstall --purge-data
|
||||
"mcpServers": {
|
||||
"rea": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "rea-agents@2.3.0", "mcp"]
|
||||
"args": ["-y", "rea-agents@2.4.0", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -172,7 +172,7 @@ Setup は Claude Code、Claude Desktop、Codex、Cursor、Gemini CLI、Windsurf
|
||||
"mcpServers": {
|
||||
"rea": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "rea-agents@2.3.0", "mcp"]
|
||||
"args": ["-y", "rea-agents@2.4.0", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -172,7 +172,7 @@ Setup은 Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf, Devin
|
||||
"mcpServers": {
|
||||
"rea": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "rea-agents@2.3.0", "mcp"]
|
||||
"args": ["-y", "rea-agents@2.4.0", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -172,7 +172,7 @@ Setup 会检测 Claude Code、Claude Desktop、Codex、Cursor、Gemini CLI、Win
|
||||
"mcpServers": {
|
||||
"rea": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "rea-agents@2.3.0", "mcp"]
|
||||
"args": ["-y", "rea-agents@2.4.0", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,37 @@ redacted from recorded text. The command-shim directory is placed first in the
|
||||
captured process `PATH`; any remaining `PATH` must be explicitly supplied or
|
||||
inherited under operator policy.
|
||||
|
||||
## Reactive process scenarios
|
||||
|
||||
Set `reactive` to a Process Reactive Scenario v1 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, bounded uses and visits, trigger predicates, ordered actions, and
|
||||
either a next state or `passed` outcome.
|
||||
|
||||
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
|
||||
bounded `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.
|
||||
|
||||
Reactive results are recorded in `reactive_run`, including the terminal outcome,
|
||||
active state, matched event IDs, action evidence IDs, and deterministic
|
||||
transition journal. 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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"catalog_schema_version": 1,
|
||||
"package": {
|
||||
"name": "rea-agents",
|
||||
"version": "2.3.0",
|
||||
"version": "2.4.0",
|
||||
"sdk": {
|
||||
"server": "2.0.0-beta.4",
|
||||
"client": "2.0.0-beta.4",
|
||||
@@ -594,10 +594,10 @@
|
||||
"mcp_resource_templates": 9
|
||||
},
|
||||
"digests": {
|
||||
"tools_sha256": "501d405efbf8fc3537d13791f87ac81f3b97f3f328b62710ca0885de7213a62f",
|
||||
"tools_sha256": "15544cf838c730784a082d0ff9e0feccbe35acd1675d7dfbe99d63296b72b3e3",
|
||||
"prompts_sha256": "f6da3ad38a1000c11364d1710f122ac4990551f04efd8c53ffb2e819b5cf5707",
|
||||
"resources_sha256": "94bebbab1a1b3cf47644c38d3de6469de568caa7bd7b842f529ead8ad320a912",
|
||||
"combined_sha256": "71de8a7174176823fd82c2666130233b5a1b4a1613583270d268774775b410bc"
|
||||
"combined_sha256": "aa413204625f3aeb62732d0f732b9b8b24832eea4520214b1e6bd204e599bcd6"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"manifest_id": "ecm_e04a25d07a540df3bec2fd5ba6d8a158e1f582dc94fc59225c7ba329cbabbbf6",
|
||||
"manifest_id": "ecm_e2ed4ec5ff3bf3b3c25678a0c1e69971eb03d0fe6bbaebb1bf26a13b0151b17b",
|
||||
"verifier": {
|
||||
"id": "rea-managed-conformance",
|
||||
"version": "1"
|
||||
@@ -14,7 +14,7 @@
|
||||
"skill_digests": [
|
||||
{
|
||||
"skill_id": "reverse-engineer-anything",
|
||||
"sha256": "a8ffb68f043fc9dfd0eb401caa6e548adce991d1e228b4539333cf90be17a755"
|
||||
"sha256": "f330e6b677d1d68cdf369d852e61c390eb3369503fead732be560e2a1f0fbdc7"
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "rea-agents",
|
||||
"version": "2.3.0",
|
||||
"version": "2.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "rea-agents",
|
||||
"version": "2.3.0",
|
||||
"version": "2.4.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "8.0.4",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "rea-agents",
|
||||
"version": "2.3.0",
|
||||
"version": "2.4.0",
|
||||
"description": "Reverse engineer anything from your terminal or agent with one CLI and MCP server.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -4,7 +4,7 @@ description: Reverse engineer native, managed, Electron/JavaScript, packaged, an
|
||||
metadata:
|
||||
version: "23"
|
||||
tool_count: 98
|
||||
catalog_digest: "71de8a7174176823fd82c2666130233b5a1b4a1613583270d268774775b410bc"
|
||||
catalog_digest: "aa413204625f3aeb62732d0f732b9b8b24832eea4520214b1e6bd204e599bcd6"
|
||||
---
|
||||
|
||||
# REA
|
||||
@@ -22,7 +22,8 @@ Choose the first tool from the target the user supplied. Do not call
|
||||
|
||||
- ASAR or extracted JavaScript/Electron tree:
|
||||
`analyze_javascript_application`.
|
||||
- Archive, application package, ZIP/APK/IPA, or DMG: `inventory_artifact`.
|
||||
- Archive, application package, ZIP/APK/IPA/MSIX/AppX, or DMG:
|
||||
`inventory_artifact`.
|
||||
- Managed PE/CLI assembly: `inspect_managed_artifact`.
|
||||
- User-owned browser page already open: `list_browser_targets`.
|
||||
- User-owned Electron runtime already open: `list_electron_targets`.
|
||||
|
||||
@@ -24,8 +24,8 @@ parser and must not become an implicit setup dependency.
|
||||
## Packages and extraction
|
||||
|
||||
Call `inventory_artifact` before extraction for application bundles, archives,
|
||||
ZIP/APK/IPA, ASAR, or DMG inputs. Continue deterministic occurrence pages from
|
||||
the returned offset only when needed. Cite graph manifest IDs.
|
||||
ZIP/APK/IPA/MSIX/AppX, ASAR, or DMG inputs. Continue deterministic occurrence
|
||||
pages from the returned offset only when needed. Cite graph manifest IDs.
|
||||
|
||||
`extract_artifact` requires explicit approval, an absent absolute output root,
|
||||
and selected occurrence IDs. Never extract every entry implicitly. Symlinks and
|
||||
|
||||
@@ -127,8 +127,16 @@ interface CapabilityEffects {
|
||||
}
|
||||
|
||||
type CapabilityAvailability =
|
||||
| { readonly available: true; readonly reason: null }
|
||||
| { readonly available: false; readonly reason: string };
|
||||
| {
|
||||
readonly available: true;
|
||||
readonly reason: null;
|
||||
readonly availabilityCode?: null;
|
||||
}
|
||||
| {
|
||||
readonly available: false;
|
||||
readonly reason: string;
|
||||
readonly availabilityCode?: ProviderRejectionCode;
|
||||
};
|
||||
|
||||
interface CapabilityLimits {
|
||||
readonly maxResults: number | null;
|
||||
|
||||
@@ -269,7 +269,13 @@ const createReader = async (
|
||||
if ((await lstat(path)).isDirectory())
|
||||
return new DirectoryArtifactReader(path);
|
||||
if (format === "asar") return new AsarArtifactReader(path);
|
||||
if (format === "ipa" || format === "apk" || format === "zip")
|
||||
if (
|
||||
format === "ipa" ||
|
||||
format === "apk" ||
|
||||
format === "msix" ||
|
||||
format === "appx" ||
|
||||
format === "zip"
|
||||
)
|
||||
return new ZipArtifactReader(path, format);
|
||||
if (format === "mach-o") return new MachOSliceArtifactReader(path);
|
||||
throw new ArtifactReaderFailure(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { extname } from "node:path";
|
||||
|
||||
import canonicalize from "canonicalize";
|
||||
|
||||
@@ -13,6 +12,7 @@ import type {
|
||||
ArtifactNode,
|
||||
ArtifactOccurrence,
|
||||
} from "../domain/artifactGraph.js";
|
||||
import { zipPackageFormatForPath } from "../domain/zipPackageFormat.js";
|
||||
|
||||
/** Mutable internal occurrence used until root-bound IDs are known. */
|
||||
export interface MutableOccurrence {
|
||||
@@ -130,7 +130,9 @@ export const createRootNode = (input: {
|
||||
size: input.digest?.bytes ?? 0,
|
||||
kind:
|
||||
input.directory ||
|
||||
["zip", "ipa", "apk", "asar", "dmg", "pkg"].includes(input.format)
|
||||
["zip", "ipa", "apk", "msix", "appx", "asar", "dmg", "pkg"].includes(
|
||||
input.format,
|
||||
)
|
||||
? "container"
|
||||
: classifyArtifactPath(input.path).kind,
|
||||
format: input.format,
|
||||
@@ -264,7 +266,7 @@ export const classifyArtifactPath = (
|
||||
if (/\.(?:m?js|cjs)$/u.test(lower))
|
||||
return { kind: "javascript", format: "javascript-bundle" };
|
||||
if (lower.endsWith(".asar")) return { kind: "container", format: "asar" };
|
||||
const archiveFormat = archiveFormatForExtension(extname(lower));
|
||||
const archiveFormat = zipPackageFormatForPath(lower);
|
||||
if (archiveFormat !== undefined)
|
||||
return { kind: "container", format: archiveFormat };
|
||||
if (/\.framework(?:\/|$)/u.test(lower))
|
||||
@@ -351,19 +353,4 @@ const relationFor = (path: string): ArtifactEdge["relation"] => {
|
||||
return "contains";
|
||||
};
|
||||
|
||||
const archiveFormatForExtension = (
|
||||
extension: string,
|
||||
): "zip" | "ipa" | "apk" | undefined => {
|
||||
switch (extension) {
|
||||
case ".zip":
|
||||
return "zip";
|
||||
case ".ipa":
|
||||
return "ipa";
|
||||
case ".apk":
|
||||
return "apk";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const depth = (path: string): number => path.split("/").length;
|
||||
@@ -2,12 +2,15 @@ import { open } from "node:fs/promises";
|
||||
|
||||
import { classifyArtifactPath } from "../ArtifactGraphConstruction.js";
|
||||
import type { ArtifactNode } from "../../domain/artifactGraph.js";
|
||||
import { zipPackageFormatForPath } from "../../domain/zipPackageFormat.js";
|
||||
|
||||
const classifyContainerExtension = (
|
||||
path: string,
|
||||
): ArtifactNode["format"] | undefined => {
|
||||
const lower = path.toLowerCase();
|
||||
for (const format of ["asar", "ipa", "apk", "zip", "dmg", "pkg"] as const)
|
||||
const zipPackage = zipPackageFormatForPath(lower);
|
||||
if (zipPackage !== undefined) return zipPackage;
|
||||
for (const format of ["asar", "dmg", "pkg"] as const)
|
||||
if (lower.endsWith(`.${format}`)) return format;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -22,6 +22,8 @@ export const createReader = async (
|
||||
case "zip":
|
||||
case "ipa":
|
||||
case "apk":
|
||||
case "msix":
|
||||
case "appx":
|
||||
return new ZipArtifactReader(path, format);
|
||||
case "asar":
|
||||
return new AsarArtifactReader(path);
|
||||
|
||||
@@ -147,6 +147,7 @@ const capabilityStatus = (
|
||||
operation: descriptor.operation,
|
||||
available: descriptor.available,
|
||||
reason: descriptor.reason,
|
||||
availability_code: descriptor.availabilityCode ?? null,
|
||||
input_contract_version: descriptor.inputContractVersion,
|
||||
output_contract_version: descriptor.outputContractVersion,
|
||||
pagination: descriptor.pagination,
|
||||
|
||||
@@ -12,6 +12,7 @@ const statusSchema = z.object({
|
||||
operation: z.string(),
|
||||
available: z.boolean(),
|
||||
reason: z.string().nullable(),
|
||||
availability_code: z.string().nullable().optional(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
@@ -29,6 +30,7 @@ type ToolKind = (typeof TOOL_CONTRACTS)[number]["kind"];
|
||||
type ProviderDescriptor = {
|
||||
readonly available: boolean;
|
||||
readonly reason: string | null;
|
||||
readonly availability_code?: string | null;
|
||||
};
|
||||
type AvailabilityPolicy = {
|
||||
readonly processCaptureEnabled: boolean;
|
||||
@@ -86,8 +88,15 @@ export const buildCapabilityInventory = (
|
||||
policy: AvailabilityPolicy,
|
||||
) => {
|
||||
const status = statusSchema.parse(sessionStatus);
|
||||
const descriptors = new Map(
|
||||
status.capabilities.map((descriptor) => [descriptor.operation, descriptor]),
|
||||
const descriptors = new Map<string, ProviderDescriptor>(
|
||||
status.capabilities.map((descriptor) => [
|
||||
descriptor.operation,
|
||||
{
|
||||
available: descriptor.available,
|
||||
reason: descriptor.reason,
|
||||
availability_code: descriptor.availability_code ?? null,
|
||||
},
|
||||
]),
|
||||
);
|
||||
return TOOL_CONTRACTS.map((contract) => {
|
||||
const availability = availabilityFor({
|
||||
@@ -237,7 +246,7 @@ const providerAvailability = ({
|
||||
};
|
||||
if (
|
||||
!descriptor.available &&
|
||||
descriptor.reason?.toLowerCase().includes("require macos") === true
|
||||
descriptor.availability_code === "unsupported_host"
|
||||
)
|
||||
return {
|
||||
reason: "unsupported_host",
|
||||
@@ -254,10 +263,7 @@ const providerAvailability = ({
|
||||
|
||||
const composedAvailability = (
|
||||
name: string,
|
||||
descriptors: ReadonlyMap<
|
||||
string,
|
||||
{ readonly available: boolean; readonly reason: string | null }
|
||||
>,
|
||||
descriptors: ReadonlyMap<string, ProviderDescriptor>,
|
||||
): {
|
||||
readonly reason: ToolAvailabilityReason;
|
||||
readonly remediation: string | null;
|
||||
@@ -280,7 +286,7 @@ const composedAvailability = (
|
||||
if (unavailable !== undefined)
|
||||
return {
|
||||
reason:
|
||||
unavailable.reason?.toLowerCase().includes("require macos") === true
|
||||
unavailable.availability_code === "unsupported_host"
|
||||
? "unsupported_host"
|
||||
: "provider_unavailable",
|
||||
remediation:
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
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 home: string;
|
||||
readonly replay: LoopbackReplay;
|
||||
readonly shimReplay: CommandShimReplay;
|
||||
readonly runId: string;
|
||||
}
|
||||
|
||||
/** Build the explicitly admitted environment for a captured process. */
|
||||
export const makeProcessCaptureEnvironment = (
|
||||
options: ProcessCaptureEnvironmentOptions,
|
||||
): Record<string, string> => {
|
||||
const { scenario, home, replay, shimReplay, runId } = options;
|
||||
const environment: Record<string, string> = {
|
||||
...scenario.environment,
|
||||
HOME: home,
|
||||
TERM: "xterm-256color",
|
||||
REA_PROCESS_RUN_ID: runId,
|
||||
};
|
||||
for (const name of scenario.inherit_environment) {
|
||||
const value = process.env[name];
|
||||
if (value !== undefined) environment[name] = value;
|
||||
}
|
||||
environment.REA_REPLAY_HTTP_URL = replay.httpUrl;
|
||||
environment.REA_REPLAY_WEBSOCKET_URL = replay.websocketUrl;
|
||||
environment.REA_SHIM_LEDGER_URL = shimReplay.url;
|
||||
// PATH is evidence-producing input. Inheriting the host PATH here would let
|
||||
// the authority and reconstruction probe different tools without recording
|
||||
// that difference. Scenarios must opt in through environment or
|
||||
// inherit_environment; deterministic shims always take precedence.
|
||||
environment.PATH = [shimReplay.binPath, environment.PATH ?? ""]
|
||||
.filter((part) => part.length > 0)
|
||||
.join(":");
|
||||
return environment;
|
||||
};
|
||||
@@ -71,6 +71,7 @@ interface CaptureResultOptions {
|
||||
};
|
||||
readonly samples: readonly ProcessSample[];
|
||||
readonly replay: LoopbackReplay;
|
||||
readonly reactiveRun: ProcessCapture["reactive_run"];
|
||||
readonly before: SnapshotResult;
|
||||
readonly after: SnapshotResult;
|
||||
readonly truncated: boolean;
|
||||
@@ -116,6 +117,7 @@ export const buildCaptureResult = (
|
||||
options.scenario,
|
||||
),
|
||||
replay_transitions: options.replay.transitions,
|
||||
reactive_run: options.reactiveRun,
|
||||
event_journal: options.eventJournal,
|
||||
files_before: options.before.files,
|
||||
files_after: options.after.files,
|
||||
|
||||
@@ -106,6 +106,12 @@ export class ProcessCheckpoints {
|
||||
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);
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
ProcessCaptureError,
|
||||
processCaptureCancelled,
|
||||
} from "./ProcessCaptureError.js";
|
||||
|
||||
export { ProcessCaptureError } from "./ProcessCaptureError.js";
|
||||
import { startLoopbackReplay, type LoopbackReplay } from "./LoopbackReplay.js";
|
||||
import { startProcessSampler } from "./ProcessSampling.js";
|
||||
@@ -26,7 +25,10 @@ import {
|
||||
startCommandShimReplay,
|
||||
type CommandShimReplay,
|
||||
} from "./CommandShimReplay.js";
|
||||
import { normalizeProcessText } from "./ProcessNormalization.js";
|
||||
import {
|
||||
normalizeProcessShimEvent,
|
||||
normalizeProcessText,
|
||||
} from "./ProcessNormalization.js";
|
||||
import { selectCapturedProcessGroupIds } from "../process/ProcessOwnership.js";
|
||||
import {
|
||||
awaitTerminalExit,
|
||||
@@ -42,45 +44,17 @@ import { assertNotCancelled } from "./ProcessCaptureAuthority.js";
|
||||
import {
|
||||
createProcessCaptureJournal,
|
||||
scheduleScenarioInteractions,
|
||||
type ProcessCaptureJournal,
|
||||
} from "./ProcessCaptureJournal.js";
|
||||
|
||||
import {
|
||||
assertSupportedReactiveScenario,
|
||||
projectProcessReactiveRun,
|
||||
startProcessReactiveHarness,
|
||||
type ProcessReactiveHarness,
|
||||
} from "./ProcessReactiveHarness.js";
|
||||
import { makeProcessCaptureEnvironment } from "./ProcessCaptureEnvironment.js";
|
||||
export { probeProcessCaptureCapability } from "./ProcessCaptureCapability.js";
|
||||
|
||||
interface EnvironmentOptions {
|
||||
readonly scenario: ProcessScenario;
|
||||
readonly home: string;
|
||||
readonly replay: LoopbackReplay;
|
||||
readonly shimReplay: CommandShimReplay;
|
||||
readonly runId: string;
|
||||
}
|
||||
|
||||
const makeEnvironment = (
|
||||
options: EnvironmentOptions,
|
||||
): Record<string, string> => {
|
||||
const { scenario, home, replay, shimReplay, runId } = options;
|
||||
const environment: Record<string, string> = {
|
||||
...scenario.environment,
|
||||
HOME: home,
|
||||
TERM: "xterm-256color",
|
||||
REA_PROCESS_RUN_ID: runId,
|
||||
};
|
||||
for (const name of scenario.inherit_environment) {
|
||||
const value = process.env[name];
|
||||
if (value !== undefined) environment[name] = value;
|
||||
}
|
||||
environment.REA_REPLAY_HTTP_URL = replay.httpUrl;
|
||||
environment.REA_REPLAY_WEBSOCKET_URL = replay.websocketUrl;
|
||||
environment.REA_SHIM_LEDGER_URL = shimReplay.url;
|
||||
// PATH is evidence-producing input. Inheriting the host PATH here would let
|
||||
// the authority and reconstruction probe different tools without recording
|
||||
// that difference. Scenarios must opt in through environment or
|
||||
// inherit_environment; deterministic shims always take precedence.
|
||||
environment.PATH = [shimReplay.binPath, environment.PATH ?? ""]
|
||||
.filter((part) => part.length > 0)
|
||||
.join(":");
|
||||
return environment;
|
||||
};
|
||||
|
||||
interface StartedCaptureRuntime {
|
||||
readonly replay: LoopbackReplay;
|
||||
readonly renderer: TerminalRenderer;
|
||||
@@ -92,6 +66,7 @@ interface StartedCaptureRuntime {
|
||||
readonly lastOutput: () => number;
|
||||
readonly framesTruncated: () => boolean;
|
||||
readonly stopSampler: () => Promise<{ readonly partial: boolean }>;
|
||||
readonly reactive: ProcessReactiveHarness | undefined;
|
||||
}
|
||||
|
||||
const cleanupFailedStartup = async (options: {
|
||||
@@ -136,7 +111,7 @@ const createTerminalRenderer = (
|
||||
recordEvent,
|
||||
});
|
||||
|
||||
const startCaptureRuntime = async (options: {
|
||||
interface StartCaptureRuntimeOptions {
|
||||
readonly scenario: ProcessScenario;
|
||||
readonly home: string;
|
||||
readonly temporaryRoot: string;
|
||||
@@ -148,14 +123,29 @@ const startCaptureRuntime = async (options: {
|
||||
readonly timers: Set<NodeJS.Timeout>;
|
||||
readonly dispatchedEventIndexes: Set<number>;
|
||||
readonly recordEvent: RecordProcessCaptureEvent;
|
||||
readonly journal: ProcessCaptureJournal;
|
||||
readonly signal?: AbortSignal;
|
||||
}): Promise<StartedCaptureRuntime> => {
|
||||
}
|
||||
|
||||
const reactiveCapture = (
|
||||
options: StartCaptureRuntimeOptions,
|
||||
protocolEvents: () => LoopbackReplay["events"],
|
||||
): Parameters<typeof startProcessReactiveHarness>[0]["capture"] => ({
|
||||
...options,
|
||||
processSamples: options.samples,
|
||||
protocolEvents,
|
||||
});
|
||||
|
||||
const startCaptureRuntime = async (
|
||||
options: StartCaptureRuntimeOptions,
|
||||
): Promise<StartedCaptureRuntime> => {
|
||||
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);
|
||||
@@ -180,7 +170,7 @@ const startCaptureRuntime = async (options: {
|
||||
);
|
||||
terminal = spawn(scenario.executable, [...scenario.arguments], {
|
||||
cwd: scenario.working_directory,
|
||||
env: makeEnvironment({ ...options, replay, shimReplay }),
|
||||
env: makeProcessCaptureEnvironment({ ...options, replay, shimReplay }),
|
||||
cols: scenario.terminal.columns,
|
||||
rows: scenario.terminal.rows,
|
||||
name: "xterm-256color",
|
||||
@@ -193,6 +183,15 @@ const startCaptureRuntime = async (options: {
|
||||
renderer,
|
||||
checkpoints,
|
||||
});
|
||||
reactive = startProcessReactiveHarness({
|
||||
capture: reactiveCapture(options, () => replay?.events ?? []),
|
||||
scenario,
|
||||
terminal: () => terminal,
|
||||
renderer,
|
||||
checkpoints,
|
||||
shimReplay,
|
||||
started,
|
||||
});
|
||||
scheduleScenarioInteractions({
|
||||
...options,
|
||||
getTerminal: () => terminal,
|
||||
@@ -218,8 +217,11 @@ const startCaptureRuntime = async (options: {
|
||||
lastOutput: () => lastOutput,
|
||||
framesTruncated,
|
||||
stopSampler,
|
||||
reactive,
|
||||
};
|
||||
} catch (cause: unknown) {
|
||||
reactive?.unsubscribe();
|
||||
await reactive?.coordinator.close();
|
||||
return cleanupFailedStartup({
|
||||
cause,
|
||||
timers: options.timers,
|
||||
@@ -239,23 +241,14 @@ const normalizedShimEvents = (
|
||||
scenario: ProcessScenario,
|
||||
temporaryRoot: string,
|
||||
): ProcessCapture["shim_events"] =>
|
||||
runtime.shimReplay.events.map((event) => ({
|
||||
...event,
|
||||
arguments: event.arguments.map((argument) =>
|
||||
normalizeProcessText(
|
||||
argument,
|
||||
scenario,
|
||||
temporaryRoot,
|
||||
runtime.terminal.pid,
|
||||
),
|
||||
),
|
||||
working_directory: normalizeProcessText(
|
||||
event.working_directory,
|
||||
runtime.shimReplay.events.map((event) =>
|
||||
normalizeProcessShimEvent(
|
||||
event,
|
||||
scenario,
|
||||
temporaryRoot,
|
||||
runtime.terminal.pid,
|
||||
),
|
||||
}));
|
||||
);
|
||||
|
||||
const finishProcessRun = async (options: {
|
||||
readonly runtime: StartedCaptureRuntime | undefined;
|
||||
@@ -267,6 +260,7 @@ const finishProcessRun = async (options: {
|
||||
readonly capture: ProcessCapture | undefined;
|
||||
readonly executionFailure: unknown;
|
||||
}): Promise<ProcessCapture> => {
|
||||
options.runtime?.reactive?.unsubscribe();
|
||||
await options.stopSampler();
|
||||
const cleanupFailure = await releaseProcessResources({
|
||||
timers: options.timers,
|
||||
@@ -285,10 +279,16 @@ 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;
|
||||
if (capture !== undefined) {
|
||||
capture = {
|
||||
...capture,
|
||||
reactive_run: projectProcessReactiveRun(options.runtime?.reactive),
|
||||
settlement: {
|
||||
...capture.settlement,
|
||||
cleanup_outcome:
|
||||
@@ -326,7 +326,17 @@ const completeCapture = async (options: {
|
||||
const { runtime, scenario } = options;
|
||||
runtime.checkpoints.trigger("root_exit");
|
||||
const { reason } = options.exit;
|
||||
if (reason === "cancelled") throw processCaptureCancelled();
|
||||
if (reason === "cancelled") {
|
||||
if (runtime.reactive !== undefined) {
|
||||
await runtime.reactive.coordinator.submit({ kind: "cancelled" });
|
||||
runtime.reactive.unsubscribe();
|
||||
}
|
||||
throw processCaptureCancelled();
|
||||
}
|
||||
if (runtime.reactive !== undefined) {
|
||||
await runtime.reactive.coordinator.submit({ kind: "target_lost" });
|
||||
runtime.reactive.unsubscribe();
|
||||
}
|
||||
const settlement = await observeSettlement(
|
||||
options.runId,
|
||||
selectCapturedProcessGroupIds(runtime.terminal.pid, options.samples),
|
||||
@@ -359,6 +369,7 @@ const completeCapture = async (options: {
|
||||
exit: { ...options.exit, reason },
|
||||
samples: options.samples,
|
||||
replay: runtime.replay,
|
||||
reactiveRun: projectProcessReactiveRun(runtime.reactive),
|
||||
before: options.before,
|
||||
after,
|
||||
truncated,
|
||||
@@ -381,6 +392,7 @@ const runProcessScenario = async (
|
||||
policy: ProcessExecutionPolicy,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ProcessCapture> => {
|
||||
assertSupportedReactiveScenario(scenario);
|
||||
const { temporaryRoot, runId, home, before } = await prepareProcessCapture(
|
||||
scenario,
|
||||
policy,
|
||||
@@ -388,7 +400,8 @@ const runProcessScenario = async (
|
||||
);
|
||||
const frames: TerminalFrame[] = [];
|
||||
const samples: ProcessSample[] = [];
|
||||
const { entries: eventJournal, recordEvent } = createProcessCaptureJournal();
|
||||
const journal = createProcessCaptureJournal();
|
||||
const { entries: eventJournal, recordEvent } = journal;
|
||||
let runtime: StartedCaptureRuntime | undefined;
|
||||
const timers = new Set<NodeJS.Timeout>();
|
||||
let capture: ProcessCapture | undefined;
|
||||
@@ -410,6 +423,7 @@ const runProcessScenario = async (
|
||||
timers,
|
||||
dispatchedEventIndexes,
|
||||
recordEvent,
|
||||
journal,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
stopSampler = runtime.stopSampler;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
ProcessCapture,
|
||||
ProcessSample,
|
||||
ProcessScenario,
|
||||
ShimEvent,
|
||||
} from "../domain/processCapture.js";
|
||||
|
||||
/** Bucket one elapsed process-capture timestamp under scenario normalization. */
|
||||
@@ -91,6 +92,25 @@ 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[],
|
||||
|
||||
@@ -51,7 +51,7 @@ export class ProcessReactiveCoordinator {
|
||||
readonly #now: () => number;
|
||||
readonly #scenarioDeadlineAt: number;
|
||||
readonly #onDecision:
|
||||
| ((decision: ProcessReactiveDecision) => void)
|
||||
| ((decision: ProcessReactiveDecision, input: ProcessReactiveInput) => void)
|
||||
| undefined;
|
||||
#snapshot: ProcessReactiveSnapshot;
|
||||
readonly #pendingInputs: ProcessReactiveInput[] = [];
|
||||
@@ -70,7 +70,10 @@ export class ProcessReactiveCoordinator {
|
||||
readonly executor: ProcessReactiveExecutor;
|
||||
readonly timerHost?: ProcessReactiveTimerHost;
|
||||
readonly now?: () => number;
|
||||
readonly onDecision?: (decision: ProcessReactiveDecision) => void;
|
||||
readonly onDecision?: (
|
||||
decision: ProcessReactiveDecision,
|
||||
input: ProcessReactiveInput,
|
||||
) => void;
|
||||
}) {
|
||||
this.#scenario = options.scenario;
|
||||
this.#executor = options.executor;
|
||||
@@ -206,7 +209,7 @@ export class ProcessReactiveCoordinator {
|
||||
);
|
||||
}
|
||||
this.#snapshot = decision.snapshot;
|
||||
this.#onDecision?.(decision);
|
||||
this.#onDecision?.(decision, input);
|
||||
if (this.#snapshot.status === "finished") this.#clearTimers();
|
||||
else if (
|
||||
this.#accepting &&
|
||||
@@ -216,18 +219,35 @@ export class ProcessReactiveCoordinator {
|
||||
)
|
||||
this.#scheduleStateDeadline();
|
||||
for (const result of effectResults)
|
||||
if (result.status === "succeeded")
|
||||
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 === "scenario_deadline" ||
|
||||
input.kind === "target_lost"
|
||||
)
|
||||
return true;
|
||||
return (
|
||||
|
||||
@@ -184,9 +184,15 @@ const triggerSources = (
|
||||
): readonly string[] => {
|
||||
if (trigger.kind === "terminal_text") return [];
|
||||
if (trigger.kind === "event")
|
||||
return ["terminal_raw", "interaction", "filesystem"].includes(
|
||||
trigger.source,
|
||||
)
|
||||
return [
|
||||
"terminal_raw",
|
||||
"interaction",
|
||||
"process",
|
||||
"filesystem",
|
||||
"http",
|
||||
"websocket",
|
||||
"shim",
|
||||
].includes(trigger.source)
|
||||
? []
|
||||
: [trigger.source];
|
||||
return trigger.kind === "repeat"
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
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,
|
||||
unsupportedProcessReactiveFeatures,
|
||||
} from "./ProcessReactiveEffects.js";
|
||||
import { subscribeProcessReactiveObservations } from "./ProcessReactiveObservations.js";
|
||||
import {
|
||||
normalizeProcessSamples,
|
||||
normalizeProcessShimEvent,
|
||||
redactProtocolEvents,
|
||||
} 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 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(
|
||||
{
|
||||
terminal: options.terminal,
|
||||
renderer: options.renderer,
|
||||
checkpoints: options.checkpoints,
|
||||
interactions: options.capture.interactions,
|
||||
journal: options.capture.journal,
|
||||
startedAtMs: options.started,
|
||||
now: Date.now,
|
||||
},
|
||||
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) =>
|
||||
redactProtocolEvents(
|
||||
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"] =>
|
||||
harness === undefined
|
||||
? null
|
||||
: {
|
||||
status: harness.coordinator.snapshot.status,
|
||||
outcome: harness.coordinator.snapshot.outcome,
|
||||
active_state: harness.coordinator.snapshot.active_state,
|
||||
transitions: harness.coordinator.snapshot.transitions,
|
||||
controls: harness.controls,
|
||||
};
|
||||
@@ -1,8 +1,17 @@
|
||||
import type {
|
||||
InteractionEvent,
|
||||
FilesystemCheckpoint,
|
||||
ProcessCaptureEventJournalEntry,
|
||||
ProcessSample,
|
||||
ProtocolEvent,
|
||||
ShimEvent,
|
||||
TerminalFrame,
|
||||
} from "../domain/processCapture.js";
|
||||
import { createProcessObservation } from "../domain/processObservation.js";
|
||||
import {
|
||||
createProcessObservation,
|
||||
processObservationSubject,
|
||||
type ProcessObservation,
|
||||
} from "../domain/processObservation.js";
|
||||
import type { ProcessReactiveInput } from "../domain/processReactiveRuntime.js";
|
||||
import type { ProcessCaptureJournal } from "./ProcessCaptureJournal.js";
|
||||
|
||||
@@ -11,25 +20,145 @@ export interface ProcessReactiveObservationSink {
|
||||
enqueue(input: ProcessReactiveInput): void;
|
||||
}
|
||||
|
||||
/** Subscribe a reactive coordinator to normalized raw PTY journal records. */
|
||||
export const subscribeProcessReactiveTerminalObservations = (options: {
|
||||
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;
|
||||
}): (() => void) =>
|
||||
options.journal.subscribe((entry: ProcessCaptureEventJournalEntry) => {
|
||||
if (entry.collection !== "frames") return;
|
||||
const frame = options.frames[entry.index];
|
||||
if (frame === undefined) return;
|
||||
options.sink.enqueue({
|
||||
kind: "observation",
|
||||
observation: createProcessObservation({
|
||||
}
|
||||
|
||||
const terminalObservation = (
|
||||
options: ProcessReactiveObservationOptions,
|
||||
entry: ProcessCaptureEventJournalEntry,
|
||||
): ProcessObservation | undefined => {
|
||||
const value = options.frames[entry.index];
|
||||
return value === undefined
|
||||
? undefined
|
||||
: createProcessObservation({
|
||||
source: "terminal_raw",
|
||||
source_sequence: frame.sequence,
|
||||
captured_at_ms: frame.at_ms,
|
||||
source_sequence: value.sequence,
|
||||
captured_at_ms: value.at_ms,
|
||||
subject_id: null,
|
||||
location: entry,
|
||||
payload: frame,
|
||||
}),
|
||||
});
|
||||
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);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Readable } from "node:stream";
|
||||
import type { ArtifactCommand } from "../domain/artifactGraph.js";
|
||||
import type { ZipPackageFormat } from "../domain/zipPackageFormat.js";
|
||||
|
||||
/** Archive-neutral entry metadata. Reader adapters never choose output paths. */
|
||||
export interface ArtifactEntry {
|
||||
@@ -23,7 +24,7 @@ export interface ArtifactEntry {
|
||||
|
||||
/** Read-only adapter over one directory, archive, or virtual container. */
|
||||
export interface ArtifactReader {
|
||||
readonly format: "directory" | "zip" | "ipa" | "apk" | "asar" | "file";
|
||||
readonly format: "directory" | ZipPackageFormat | "asar" | "file";
|
||||
entries(signal?: AbortSignal): AsyncIterable<ArtifactEntry>;
|
||||
open(entry: ArtifactEntry, signal?: AbortSignal): Promise<Readable>;
|
||||
provenance(): readonly ArtifactCommand[];
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type ArtifactEntry,
|
||||
type ArtifactReader,
|
||||
} from "./ArtifactReader.js";
|
||||
import type { ZipPackageFormat } from "../domain/zipPackageFormat.js";
|
||||
|
||||
class NodeFileReader extends Reader<string> {
|
||||
#handle: FileHandle | undefined;
|
||||
@@ -41,12 +42,12 @@ class NodeFileReader extends Reader<string> {
|
||||
|
||||
/** Lazy Zip64-capable reader with CRC and overlap verification on every read. */
|
||||
export class ZipArtifactReader implements ArtifactReader {
|
||||
readonly format: "zip" | "ipa" | "apk";
|
||||
readonly format: ZipPackageFormat;
|
||||
readonly #source: NodeFileReader;
|
||||
readonly #reader: ZipReader<string>;
|
||||
readonly #entries = new Map<string, Entry>();
|
||||
|
||||
constructor(path: string, format: "zip" | "ipa" | "apk") {
|
||||
constructor(path: string, format: ZipPackageFormat) {
|
||||
this.format = format;
|
||||
this.#source = new NodeFileReader(path);
|
||||
this.#reader = new ZipReader(this.#source, {
|
||||
|
||||
@@ -45,6 +45,8 @@ export const targetFormatSchema = z.enum([
|
||||
"zip",
|
||||
"ipa",
|
||||
"apk",
|
||||
"msix",
|
||||
"appx",
|
||||
"asar",
|
||||
"dmg",
|
||||
"pkg",
|
||||
@@ -64,6 +66,7 @@ const providerCapability = z.object({
|
||||
operation: z.string(),
|
||||
available: z.boolean(),
|
||||
reason: z.string().nullable(),
|
||||
availability_code: z.enum(PROVIDER_REJECTION_CODES).nullable(),
|
||||
input_contract_version: z.number().int().min(1),
|
||||
output_contract_version: z.number().int().min(1),
|
||||
pagination: z.enum(["none", "offset", "cursor"]),
|
||||
|
||||
@@ -37,6 +37,8 @@ const formatSchema = z.enum([
|
||||
"zip",
|
||||
"ipa",
|
||||
"apk",
|
||||
"msix",
|
||||
"appx",
|
||||
"asar",
|
||||
"dmg",
|
||||
"pkg",
|
||||
|
||||
@@ -52,6 +52,8 @@ const artifactFormatSchema = z.enum([
|
||||
"analysis-database",
|
||||
"ipa",
|
||||
"apk",
|
||||
"msix",
|
||||
"appx",
|
||||
"plist",
|
||||
"entitlements",
|
||||
"directory",
|
||||
|
||||
@@ -14,6 +14,7 @@ import { promisify } from "node:util";
|
||||
|
||||
import { BinaryTargetError } from "./errors.js";
|
||||
import { err, ok, type Result } from "./result.js";
|
||||
import { zipPackageFormatForPath } from "./zipPackageFormat.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -36,6 +37,8 @@ export interface BinaryTarget {
|
||||
| "zip"
|
||||
| "ipa"
|
||||
| "apk"
|
||||
| "msix"
|
||||
| "appx"
|
||||
| "asar"
|
||||
| "dmg"
|
||||
| "pkg"
|
||||
@@ -131,11 +134,7 @@ const detectArtifactFormat = async (
|
||||
[0x03, 0x05, 0x07].includes(magic[2] ?? -1) &&
|
||||
[0x04, 0x06, 0x08].includes(magic[3] ?? -1)
|
||||
) {
|
||||
return lower.endsWith(".ipa")
|
||||
? "ipa"
|
||||
: lower.endsWith(".apk")
|
||||
? "apk"
|
||||
: "zip";
|
||||
return zipPackageFormatForPath(lower) ?? "zip";
|
||||
}
|
||||
const named = namedArtifactFormat(lower);
|
||||
if (named !== undefined) return named;
|
||||
@@ -167,7 +166,7 @@ const namedArtifactFormat = (
|
||||
};
|
||||
|
||||
const isArchiveFormat = (format: BinaryTarget["format"]): boolean =>
|
||||
["zip", "ipa", "apk", "asar", "dmg", "pkg"].includes(format);
|
||||
["zip", "ipa", "apk", "msix", "appx", "asar", "dmg", "pkg"].includes(format);
|
||||
|
||||
const sha256Handle = async (handle: FileHandle): Promise<string> => {
|
||||
const hash = createHash("sha256");
|
||||
|
||||
@@ -29,6 +29,8 @@ const subjectSchema = z.object({
|
||||
"zip",
|
||||
"ipa",
|
||||
"apk",
|
||||
"msix",
|
||||
"appx",
|
||||
"asar",
|
||||
"dmg",
|
||||
"pkg",
|
||||
|
||||
@@ -5,6 +5,11 @@ import {
|
||||
LEGACY_PROCESS_CAPTURE_MESSAGE,
|
||||
normalizationSchema,
|
||||
} from "./processScenario.js";
|
||||
import type {
|
||||
ProcessReactiveOutcome,
|
||||
ProcessReactiveTransitionRecord,
|
||||
} from "./processReactiveRuntime.js";
|
||||
import { PROCESS_REACTIVE_LIMITS } from "./processReactiveScenario.js";
|
||||
import type { ReplayTransitionRecord } from "./replayMachineRuntime.js";
|
||||
|
||||
export * from "./processScenario.js";
|
||||
@@ -133,6 +138,18 @@ export interface ProcessCaptureEventJournalEntry {
|
||||
readonly index: number;
|
||||
}
|
||||
|
||||
/** Ordered control input that terminated or superseded a reactive run. */
|
||||
export interface ProcessReactiveControlRecord {
|
||||
readonly sequence: number;
|
||||
readonly kind:
|
||||
| "state_deadline"
|
||||
| "scenario_deadline"
|
||||
| "target_lost"
|
||||
| "cancelled"
|
||||
| "cleanup_failed";
|
||||
readonly after_capture_order: number;
|
||||
}
|
||||
|
||||
/** Shared observation callback used by every process-capture producer. */
|
||||
export type RecordProcessCaptureEvent = (
|
||||
collection: ProcessCaptureEventCollection,
|
||||
@@ -185,6 +202,13 @@ export interface ProcessCapture {
|
||||
readonly shim_events: readonly ShimEvent[];
|
||||
readonly protocol_events: readonly ProtocolEvent[];
|
||||
readonly replay_transitions: readonly ReplayTransitionRecord[];
|
||||
readonly reactive_run: {
|
||||
readonly status: "running" | "finished";
|
||||
readonly outcome: ProcessReactiveOutcome | null;
|
||||
readonly active_state: string;
|
||||
readonly transitions: readonly ProcessReactiveTransitionRecord[];
|
||||
readonly controls: readonly ProcessReactiveControlRecord[];
|
||||
} | null;
|
||||
/**
|
||||
* Global observation order across independently recorded collections.
|
||||
*
|
||||
@@ -363,6 +387,75 @@ export const processCaptureSchema: z.ZodType<ProcessCapture> = z.object({
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
reactive_run: z
|
||||
.object({
|
||||
status: z.enum(["running", "finished"]),
|
||||
outcome: z
|
||||
.enum([
|
||||
"passed",
|
||||
"predicate_timeout",
|
||||
"scenario_deadline",
|
||||
"ambiguous_match",
|
||||
"action_rejected",
|
||||
"target_lost",
|
||||
"capture_incomplete",
|
||||
"cancelled",
|
||||
"cleanup_failed",
|
||||
])
|
||||
.nullable(),
|
||||
active_state: z.string().min(1).max(64),
|
||||
controls: z
|
||||
.array(
|
||||
z.object({
|
||||
sequence: z.number().int().nonnegative(),
|
||||
kind: z.enum([
|
||||
"state_deadline",
|
||||
"scenario_deadline",
|
||||
"target_lost",
|
||||
"cancelled",
|
||||
"cleanup_failed",
|
||||
]),
|
||||
after_capture_order: z.number().int().min(-1),
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
transitions: z
|
||||
.array(
|
||||
z.object({
|
||||
sequence: z.number().int().nonnegative(),
|
||||
transition_id: z.string().min(1).max(64),
|
||||
state_before: z.string().min(1).max(64),
|
||||
state_after: z.string().min(1).max(64).nullable(),
|
||||
outcome: z
|
||||
.enum([
|
||||
"passed",
|
||||
"predicate_timeout",
|
||||
"scenario_deadline",
|
||||
"ambiguous_match",
|
||||
"action_rejected",
|
||||
"target_lost",
|
||||
"capture_incomplete",
|
||||
"cancelled",
|
||||
"cleanup_failed",
|
||||
])
|
||||
.nullable(),
|
||||
trigger_event_ids: z
|
||||
.array(z.string().min(1))
|
||||
.max(PROCESS_REACTIVE_LIMITS.predicates),
|
||||
action_event_ids: z
|
||||
.array(z.string().min(1))
|
||||
.max(PROCESS_REACTIVE_LIMITS.actionsPerTransition),
|
||||
action_types: z
|
||||
.array(
|
||||
z.enum(["send_input", "resize", "send_signal", "checkpoint"]),
|
||||
)
|
||||
.max(PROCESS_REACTIVE_LIMITS.actionsPerTransition),
|
||||
}),
|
||||
)
|
||||
.max(PROCESS_REACTIVE_LIMITS.transitions),
|
||||
})
|
||||
.nullable()
|
||||
.default(null),
|
||||
event_journal: z
|
||||
.array(
|
||||
z.object({
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import canonicalize from "canonicalize";
|
||||
|
||||
import type { ProcessCapture } from "./processCapture.js";
|
||||
import {
|
||||
projectProcessObservation,
|
||||
type ProcessObservation,
|
||||
} from "./processObservation.js";
|
||||
import {
|
||||
commitProcessReactiveProposal,
|
||||
createProcessReactiveSnapshot,
|
||||
reduceProcessReactiveScenario,
|
||||
type ProcessReactiveDecision,
|
||||
type ProcessReactiveInput,
|
||||
type ProcessReactiveSnapshot,
|
||||
} from "./processReactiveRuntime.js";
|
||||
import {
|
||||
processReactiveScenarioSchema,
|
||||
type ProcessReactiveScenario,
|
||||
} from "./processReactiveScenario.js";
|
||||
|
||||
type RequireInvariant = (
|
||||
condition: boolean,
|
||||
path: string,
|
||||
message: string,
|
||||
) => void;
|
||||
type ReactiveRun = NonNullable<ProcessCapture["reactive_run"]>;
|
||||
type ReactiveTransition = ReactiveRun["transitions"][number];
|
||||
|
||||
const admittedCollection = (
|
||||
collection: NonNullable<
|
||||
ProcessCapture["event_journal"]
|
||||
>[number]["collection"],
|
||||
): boolean =>
|
||||
[
|
||||
"frames",
|
||||
"interaction_events",
|
||||
"process_samples",
|
||||
"filesystem_checkpoints",
|
||||
"shim_events",
|
||||
"protocol_events",
|
||||
].includes(collection);
|
||||
|
||||
const reactiveObservations = (
|
||||
capture: ProcessCapture,
|
||||
): readonly ProcessObservation[] =>
|
||||
(capture.event_journal ?? []).flatMap((location) => {
|
||||
if (!admittedCollection(location.collection)) return [];
|
||||
const observation = projectProcessObservation(capture, location);
|
||||
return observation === null ? [] : [observation];
|
||||
});
|
||||
|
||||
const validateTransitionDeclaration = (
|
||||
transition: ReactiveTransition,
|
||||
next: ReactiveTransition | undefined,
|
||||
context: {
|
||||
readonly index: number;
|
||||
readonly scenario: ProcessReactiveScenario;
|
||||
readonly require: RequireInvariant;
|
||||
},
|
||||
): void => {
|
||||
const { index, scenario, require } = context;
|
||||
const path = `reactive_run.transitions[${String(index)}]`;
|
||||
const declared = scenario.states
|
||||
.find(({ id }) => id === transition.state_before)
|
||||
?.on.find(({ id }) => id === transition.transition_id);
|
||||
require(declared !==
|
||||
undefined, path, "reactive transition is not declared by its recorded source state");
|
||||
if (declared !== undefined) {
|
||||
const stateAfter =
|
||||
declared.target.kind === "goto" ? declared.target.state : null;
|
||||
const outcome =
|
||||
declared.target.kind === "finish" ? declared.target.outcome : null;
|
||||
require(transition.state_after === stateAfter &&
|
||||
transition.outcome ===
|
||||
outcome, path, "reactive transition target or outcome differs from the committed graph");
|
||||
require(canonicalize(transition.action_types) ===
|
||||
canonicalize(
|
||||
declared.actions.map(({ type }) => type),
|
||||
), `${path}.action_types`, "reactive transition actions differ from the committed graph");
|
||||
}
|
||||
require(transition.sequence ===
|
||||
index, `${path}.sequence`, "reactive transition sequence must be contiguous from zero");
|
||||
if (next !== undefined)
|
||||
require(transition.state_after ===
|
||||
next.state_before, `reactive_run.transitions[${String(index + 1)}].state_before`, "reactive transition states must be contiguous");
|
||||
};
|
||||
|
||||
const validateTransitionDeclarations = (
|
||||
run: ReactiveRun,
|
||||
scenario: ProcessReactiveScenario,
|
||||
require: RequireInvariant,
|
||||
): void => {
|
||||
require(run.transitions[0]?.state_before === scenario.initial_state ||
|
||||
run.transitions.length ===
|
||||
0, "reactive_run.transitions[0].state_before", "reactive transition journal must start at the declared initial state");
|
||||
for (const [index, transition] of run.transitions.entries())
|
||||
validateTransitionDeclaration(transition, run.transitions[index + 1], {
|
||||
index,
|
||||
scenario,
|
||||
require,
|
||||
});
|
||||
};
|
||||
|
||||
const commitRecordedEffects = (options: {
|
||||
readonly scenario: ProcessReactiveScenario;
|
||||
readonly proposal: Extract<
|
||||
ProcessReactiveDecision,
|
||||
{ readonly kind: "proposal" }
|
||||
>;
|
||||
readonly expected: ReactiveTransition | undefined;
|
||||
readonly observations: ReadonlyMap<string, ProcessObservation>;
|
||||
readonly recordedFailure: "action_rejected" | "target_lost" | undefined;
|
||||
}): ProcessReactiveDecision =>
|
||||
commitProcessReactiveProposal(
|
||||
options.scenario,
|
||||
options.proposal,
|
||||
options.recordedFailure === undefined
|
||||
? options.proposal.effects.map((_, index) => {
|
||||
const eventId = options.expected?.action_event_ids[index];
|
||||
const observation =
|
||||
eventId === undefined
|
||||
? undefined
|
||||
: options.observations.get(eventId);
|
||||
return observation === undefined
|
||||
? { status: "rejected" as const }
|
||||
: { status: "succeeded" as const, observation };
|
||||
})
|
||||
: [
|
||||
options.recordedFailure === "target_lost"
|
||||
? { status: "target_lost" as const }
|
||||
: { status: "rejected" as const },
|
||||
],
|
||||
{ committedSensitiveInputs: true },
|
||||
);
|
||||
|
||||
const replayTransitions = (
|
||||
scenario: ProcessReactiveScenario,
|
||||
run: ReactiveRun,
|
||||
observations: readonly ProcessObservation[],
|
||||
require: RequireInvariant,
|
||||
): ProcessReactiveSnapshot => {
|
||||
const observationsById = new Map(
|
||||
observations.map((observation) => [observation.event_id, observation]),
|
||||
);
|
||||
let snapshot = createProcessReactiveSnapshot(scenario);
|
||||
let transitionIndex = 0;
|
||||
const advance = (input: ProcessReactiveInput): void => {
|
||||
let decision = reduceProcessReactiveScenario(scenario, snapshot, input);
|
||||
if (decision.kind === "proposal") {
|
||||
const proposal = decision;
|
||||
decision = commitRecordedEffects({
|
||||
scenario,
|
||||
proposal: decision,
|
||||
expected: run.transitions[transitionIndex],
|
||||
observations: observationsById,
|
||||
recordedFailure:
|
||||
run.transitions[transitionIndex] === undefined &&
|
||||
decision.effects.length > 0 &&
|
||||
(run.outcome === "action_rejected" || run.outcome === "target_lost")
|
||||
? run.outcome
|
||||
: undefined,
|
||||
});
|
||||
require(decision.kind !== "finished" ||
|
||||
decision.outcome !== "action_rejected" ||
|
||||
run.outcome ===
|
||||
"action_rejected", `reactive_run.transitions[${String(transitionIndex)}].action_event_ids`, `recorded action observations do not match proposed effects: ${canonicalize(
|
||||
{
|
||||
effects: proposal.effects,
|
||||
observations: run.transitions[transitionIndex]?.action_event_ids.map(
|
||||
(eventId) => observationsById.get(eventId),
|
||||
),
|
||||
},
|
||||
)}`);
|
||||
}
|
||||
if (decision.kind === "transition") {
|
||||
const expected = run.transitions[transitionIndex];
|
||||
require(expected !== undefined &&
|
||||
canonicalize(decision.record) ===
|
||||
canonicalize(
|
||||
expected,
|
||||
), `reactive_run.transitions[${String(transitionIndex)}]`, "reactive transition differs from deterministic journal replay");
|
||||
transitionIndex += 1;
|
||||
}
|
||||
snapshot = decision.snapshot;
|
||||
};
|
||||
const advanceControl = (
|
||||
control: ReactiveRun["controls"][number],
|
||||
index: number,
|
||||
): void => {
|
||||
require(snapshot.status !== "finished" ||
|
||||
control.kind ===
|
||||
"cleanup_failed", `reactive_run.controls[${String(index)}]`, "reactive control cannot follow a finished run");
|
||||
advance(
|
||||
control.kind === "state_deadline"
|
||||
? {
|
||||
kind: control.kind,
|
||||
state_id: snapshot.active_state,
|
||||
state_entry_capture_order: snapshot.state_entry_capture_order,
|
||||
}
|
||||
: { kind: control.kind },
|
||||
);
|
||||
};
|
||||
let controlIndex = 0;
|
||||
for (const observation of observations) {
|
||||
while (
|
||||
(run.controls[controlIndex]?.after_capture_order ??
|
||||
Number.MAX_SAFE_INTEGER) < observation.capture_order
|
||||
) {
|
||||
const control = run.controls[controlIndex]!;
|
||||
advanceControl(control, controlIndex);
|
||||
controlIndex += 1;
|
||||
}
|
||||
advance({ kind: "observation", observation });
|
||||
}
|
||||
for (; controlIndex < run.controls.length; controlIndex += 1) {
|
||||
const control = run.controls[controlIndex]!;
|
||||
advanceControl(control, controlIndex);
|
||||
}
|
||||
require(transitionIndex ===
|
||||
run.transitions
|
||||
.length, "reactive_run.transitions", `reactive transition journal is not reproduced by deterministic replay (replayed ${String(transitionIndex)} of ${String(run.transitions.length)}; outcome ${snapshot.outcome})`);
|
||||
return snapshot;
|
||||
};
|
||||
|
||||
const validateControls = (
|
||||
run: ReactiveRun,
|
||||
observations: readonly ProcessObservation[],
|
||||
require: RequireInvariant,
|
||||
): void => {
|
||||
const admittedOrders = new Set([
|
||||
-1,
|
||||
...observations.map(({ capture_order }) => capture_order),
|
||||
]);
|
||||
for (const [index, control] of run.controls.entries()) {
|
||||
require(control.sequence ===
|
||||
index, `reactive_run.controls[${String(index)}].sequence`, "reactive control sequence must be contiguous");
|
||||
require(admittedOrders.has(
|
||||
control.after_capture_order,
|
||||
), `reactive_run.controls[${String(index)}].after_capture_order`, "reactive control cutoff must identify the last admitted observation");
|
||||
require(index === 0 ||
|
||||
control.after_capture_order >=
|
||||
run.controls[index - 1]!
|
||||
.after_capture_order, `reactive_run.controls[${String(index)}].after_capture_order`, "reactive control cutoffs must be ordered");
|
||||
}
|
||||
};
|
||||
|
||||
/** Validate one capture's reactive journal by replaying the production reducer. */
|
||||
export const validateProcessCaptureReactiveRun = (
|
||||
capture: ProcessCapture,
|
||||
require: RequireInvariant,
|
||||
): void => {
|
||||
const declared = capture.manifest.scenario["reactive"];
|
||||
const run = capture.reactive_run;
|
||||
const declaredReactive = declared !== undefined && declared !== null;
|
||||
require(declaredReactive ===
|
||||
(run !==
|
||||
null), "reactive_run", "reactive result presence must match the committed scenario");
|
||||
if (run === null) return;
|
||||
const parsed = processReactiveScenarioSchema.safeParse(declared);
|
||||
require(parsed.success, "manifest.scenario.reactive", "committed reactive scenario does not satisfy its declared schema");
|
||||
if (!parsed.success) return;
|
||||
require(run.status === "finished" &&
|
||||
run.outcome !==
|
||||
null, "reactive_run.outcome", "completed capture requires a finished reactive outcome");
|
||||
validateTransitionDeclarations(run, parsed.data, require);
|
||||
const observations = reactiveObservations(capture);
|
||||
validateControls(run, observations, require);
|
||||
const snapshot = replayTransitions(parsed.data, run, observations, require);
|
||||
require(snapshot.outcome ===
|
||||
run.outcome, "reactive_run.outcome", "reactive outcome differs from deterministic journal replay");
|
||||
require(snapshot.active_state ===
|
||||
run.active_state, "reactive_run.active_state", "reactive active state differs from deterministic journal replay");
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ProcessCapture } from "./processCapture.js";
|
||||
import { validateProcessCaptureReactiveRun } from "./processCaptureReactiveValidation.js";
|
||||
import { replayMachineSchema, type ReplayMachine } from "./replayMachine.js";
|
||||
import { digestProcessCommitment } from "./processScenario.js";
|
||||
|
||||
@@ -314,5 +315,6 @@ export const validateProcessCapture = (
|
||||
validateLifecycle(capture, require);
|
||||
validateShimEvents(capture, require);
|
||||
validateReplayEvents(capture, require);
|
||||
validateProcessCaptureReactiveRun(capture, require);
|
||||
return issues;
|
||||
};
|
||||
@@ -109,16 +109,29 @@ const atMilliseconds = (value: unknown): number | null => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const subjectFor = (
|
||||
/** Derive the stable capture-local identity for one normalized observation. */
|
||||
export const processObservationSubject = (
|
||||
source: ProcessObservationSource,
|
||||
value: JsonValue,
|
||||
value: unknown,
|
||||
): string | null => {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value))
|
||||
return null;
|
||||
if (source === "process" && typeof value["pid"] === "number")
|
||||
return `process:${String(value["pid"])}`;
|
||||
if (source === "filesystem" && typeof value["name"] === "string")
|
||||
return `checkpoint:${value["name"]}`;
|
||||
if (source === "process" && "pid" in value && typeof value.pid === "number")
|
||||
return `process:${String(value.pid)}`;
|
||||
if (
|
||||
source === "filesystem" &&
|
||||
"name" in value &&
|
||||
typeof value.name === "string"
|
||||
)
|
||||
return `checkpoint:${value.name}`;
|
||||
if (
|
||||
source === "shim" &&
|
||||
"command" in value &&
|
||||
typeof value.command === "string"
|
||||
)
|
||||
return "route_index" in value && typeof value.route_index === "number"
|
||||
? `shim:${value.command}:route:${String(value.route_index)}`
|
||||
: `shim:${value.command}:unmatched`;
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -188,7 +201,7 @@ const projectedRecord = (
|
||||
return {
|
||||
source: projected.source,
|
||||
captured_at_ms: atMilliseconds(payload),
|
||||
subject_id: subjectFor(projected.source, payload),
|
||||
subject_id: processObservationSubject(projected.source, payload),
|
||||
payload,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -65,6 +65,7 @@ export type ProcessReactiveInput =
|
||||
readonly state_entry_capture_order: number;
|
||||
}
|
||||
| { readonly kind: "scenario_deadline" }
|
||||
| { readonly kind: "target_lost" }
|
||||
| { readonly kind: "cancelled" }
|
||||
| { readonly kind: "cleanup_failed" };
|
||||
|
||||
@@ -123,6 +124,33 @@ export type ProcessReactiveDecision =
|
||||
const counterValue = (counters: readonly Counter[], id: string): number =>
|
||||
counters.find((counter) => counter.id === id)?.count ?? 0;
|
||||
|
||||
type ObservationAdmission =
|
||||
| { readonly kind: "new"; readonly snapshot: ProcessReactiveSnapshot }
|
||||
| { readonly kind: "invalid" };
|
||||
|
||||
const observeInput = (
|
||||
snapshot: ProcessReactiveSnapshot,
|
||||
observation: ProcessObservation,
|
||||
): ObservationAdmission => {
|
||||
const duplicate = snapshot.observations.find(
|
||||
({ event_id }) => event_id === observation.event_id,
|
||||
);
|
||||
if (duplicate !== undefined) return { kind: "invalid" };
|
||||
const prior = snapshot.observations.at(-1);
|
||||
if (
|
||||
(prior !== undefined && observation.capture_order <= prior.capture_order) ||
|
||||
snapshot.observations.length >= PROCESS_REACTIVE_LIMITS.retainedObservations
|
||||
)
|
||||
return { kind: "invalid" };
|
||||
return {
|
||||
kind: "new",
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
observations: [...snapshot.observations, observation],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** Create the initial immutable reducer state for one validated scenario. */
|
||||
export const createProcessReactiveSnapshot = (
|
||||
scenario: ProcessReactiveScenario,
|
||||
@@ -166,6 +194,8 @@ const reduceControlInput = (
|
||||
: { kind: "waiting", snapshot, evaluations: [] };
|
||||
if (input.kind === "scenario_deadline")
|
||||
return finishedDecision(snapshot, "scenario_deadline");
|
||||
if (input.kind === "target_lost")
|
||||
return finishedDecision(snapshot, "target_lost");
|
||||
return finishedDecision(snapshot, "cancelled");
|
||||
};
|
||||
|
||||
@@ -179,23 +209,10 @@ export const reduceProcessReactiveScenario = (
|
||||
if (controlDecision !== null) return controlDecision;
|
||||
if (input.kind !== "observation")
|
||||
return finishedDecision(snapshot, "capture_incomplete");
|
||||
const prior = snapshot.observations.at(-1);
|
||||
if (
|
||||
(prior !== undefined &&
|
||||
input.observation.capture_order <= prior.capture_order) ||
|
||||
snapshot.observations.some(
|
||||
({ event_id }) => event_id === input.observation.event_id,
|
||||
)
|
||||
)
|
||||
const admitted = observeInput(snapshot, input.observation);
|
||||
if (admitted.kind === "invalid")
|
||||
return finishedDecision(snapshot, "capture_incomplete");
|
||||
if (
|
||||
snapshot.observations.length >= PROCESS_REACTIVE_LIMITS.retainedObservations
|
||||
)
|
||||
return finishedDecision(snapshot, "capture_incomplete");
|
||||
const observed = {
|
||||
...snapshot,
|
||||
observations: [...snapshot.observations, input.observation],
|
||||
};
|
||||
const observed = admitted.snapshot;
|
||||
const state = scenario.states.find(({ id }) => id === observed.active_state);
|
||||
if (state === undefined) return finishedDecision(observed, "target_lost");
|
||||
const budget = { remaining: PROCESS_REACTIVE_LIMITS.evaluationWork };
|
||||
|
||||
@@ -124,6 +124,7 @@ const applyProcessReactiveTransition = (input: {
|
||||
const effectObservationMatchesAction = (
|
||||
action: ProcessReactiveScenario["states"][number]["on"][number]["actions"][number],
|
||||
observation: ProcessObservation,
|
||||
committedSensitiveInputs: boolean,
|
||||
): boolean => {
|
||||
const payload =
|
||||
typeof observation.payload === "object" &&
|
||||
@@ -153,9 +154,11 @@ const effectObservationMatchesAction = (
|
||||
return (
|
||||
payload["type"] === "input" &&
|
||||
payload["data"] ===
|
||||
(action.sensitive
|
||||
? `<redacted-input:${String(Buffer.byteLength(action.data))}-bytes>`
|
||||
: action.data)
|
||||
expectedInputEvidence(
|
||||
action.data,
|
||||
action.sensitive,
|
||||
committedSensitiveInputs,
|
||||
)
|
||||
);
|
||||
if (action.type === "resize")
|
||||
return (
|
||||
@@ -165,6 +168,16 @@ const effectObservationMatchesAction = (
|
||||
return payload["type"] === "signal" && payload["data"] === action.signal;
|
||||
};
|
||||
|
||||
const expectedInputEvidence = (
|
||||
data: string,
|
||||
sensitive: boolean,
|
||||
committedSensitiveInput: boolean,
|
||||
): string =>
|
||||
!sensitive ||
|
||||
(committedSensitiveInput && /^<redacted-input:\d+-bytes>$/.test(data))
|
||||
? data
|
||||
: `<redacted-input:${String(Buffer.byteLength(data))}-bytes>`;
|
||||
|
||||
const failedProposal = (
|
||||
proposed: Extract<ProcessReactiveDecision, { readonly kind: "proposal" }>,
|
||||
outcome: "action_rejected" | "target_lost",
|
||||
@@ -180,6 +193,7 @@ export const commitProcessReactiveProposal = (
|
||||
scenario: ProcessReactiveScenario,
|
||||
proposed: Extract<ProcessReactiveDecision, { readonly kind: "proposal" }>,
|
||||
results: readonly ProcessReactiveEffectResult[],
|
||||
options: { readonly committedSensitiveInputs?: boolean } = {},
|
||||
): ProcessReactiveDecision => {
|
||||
if (results.some(({ status }) => status === "target_lost"))
|
||||
return failedProposal(proposed, "target_lost");
|
||||
@@ -227,7 +241,11 @@ export const commitProcessReactiveProposal = (
|
||||
const effectObservation = result.observation;
|
||||
if (
|
||||
action === undefined ||
|
||||
!effectObservationMatchesAction(action, effectObservation) ||
|
||||
!effectObservationMatchesAction(
|
||||
action,
|
||||
effectObservation,
|
||||
options.committedSensitiveInputs ?? false,
|
||||
) ||
|
||||
!Number.isSafeInteger(effectObservation.capture_order) ||
|
||||
effectObservation.capture_order <= previousOrder ||
|
||||
knownEventIds.has(effectObservation.event_id)
|
||||
|
||||
@@ -2,6 +2,10 @@ import { createHash } from "node:crypto";
|
||||
import { resolve } from "node:path";
|
||||
import canonicalize from "canonicalize";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
processReactiveScenarioSchema,
|
||||
type ProcessReactiveScenario,
|
||||
} from "./processReactiveScenario.js";
|
||||
import { replayMachineSchema } from "./replayMachine.js";
|
||||
|
||||
const positiveBudget = z.number().int().positive();
|
||||
@@ -51,6 +55,29 @@ export const digestProcessCommitment = (value: unknown): string => {
|
||||
return createHash("sha256").update(serialized).digest("hex");
|
||||
};
|
||||
|
||||
const reactiveScenarioCommitment = (
|
||||
scenario: ProcessReactiveScenario | null,
|
||||
): Readonly<Record<string, unknown>> | null =>
|
||||
scenario === null
|
||||
? null
|
||||
: {
|
||||
...scenario,
|
||||
states: scenario.states.map((state) => ({
|
||||
...state,
|
||||
on: state.on.map((transition) => ({
|
||||
...transition,
|
||||
actions: transition.actions.map((action) =>
|
||||
action.type === "send_input" && action.sensitive
|
||||
? {
|
||||
...action,
|
||||
data: `<redacted-input:${String(Buffer.byteLength(action.data))}-bytes>`,
|
||||
}
|
||||
: action,
|
||||
),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
|
||||
/** Build the secret-safe scenario projection committed by Process Capture v4. */
|
||||
export const processScenarioCommitment = (
|
||||
scenario: ProcessScenario,
|
||||
@@ -63,6 +90,7 @@ export const processScenarioCommitment = (
|
||||
scenario.secret_aliases.includes(name) ? "<redacted-secret>" : value,
|
||||
]),
|
||||
),
|
||||
reactive: reactiveScenarioCommitment(scenario.reactive),
|
||||
unknown_registry_approved: scenario.unknown_registry_approved === true,
|
||||
executable_sha256: executableSha256 ?? null,
|
||||
});
|
||||
@@ -86,6 +114,7 @@ export const processComparisonContract = (
|
||||
normalization: scenario.normalization,
|
||||
command_shims: scenario.command_shims,
|
||||
replay: scenario.replay,
|
||||
reactive: reactiveScenarioCommitment(scenario.reactive),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -292,6 +321,7 @@ export const processScenarioSchema = z
|
||||
websocket_messages: [],
|
||||
websocket_connections: [],
|
||||
}),
|
||||
reactive: processReactiveScenarioSchema.nullable().default(null),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((scenario, context) => {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/** ZIP-backed application package families that share the hardened archive reader. */
|
||||
export type ZipPackageFormat = "zip" | "ipa" | "apk" | "msix" | "appx";
|
||||
|
||||
/**
|
||||
* Classify a ZIP-backed package by its complete lower-cased path suffix.
|
||||
*
|
||||
* This is deliberately extension-only. Callers must separately verify ZIP
|
||||
* magic before trusting the classification for a root input.
|
||||
*/
|
||||
export const zipPackageFormatForPath = (
|
||||
path: string,
|
||||
): ZipPackageFormat | undefined => {
|
||||
const lower = path.toLowerCase();
|
||||
if (lower.endsWith(".zip")) return "zip";
|
||||
if (lower.endsWith(".ipa")) return "ipa";
|
||||
if (lower.endsWith(".apk") || lower.endsWith(".aab")) return "apk";
|
||||
if (lower.endsWith(".msix") || lower.endsWith(".msixbundle")) return "msix";
|
||||
if (lower.endsWith(".appx") || lower.endsWith(".appxbundle")) return "appx";
|
||||
return undefined;
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Generated from package.json and package-lock.json; do not edit. */
|
||||
export const PACKAGE_METADATA = {
|
||||
name: "rea-agents",
|
||||
version: "2.3.0", // x-release-please-version
|
||||
version: "2.4.0", // x-release-please-version
|
||||
serverSdkVersion: "2.0.0-beta.4",
|
||||
clientSdkVersion: "2.0.0-beta.4",
|
||||
coreSdkVersion: "2.0.0-beta.4",
|
||||
|
||||
@@ -75,6 +75,7 @@ export class NativeMacOSProvider implements AnalysisProvider {
|
||||
? ({ available: true, reason: null } as const)
|
||||
: ({
|
||||
available: false,
|
||||
availabilityCode: "unsupported_host",
|
||||
reason: "Native macOS utilities require macOS.",
|
||||
} as const);
|
||||
return Object.freeze({
|
||||
|
||||
@@ -179,6 +179,71 @@ describe("artifact graph provider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["fixture.msix", "msix"],
|
||||
["fixture.appxbundle", "appx"],
|
||||
] as const)(
|
||||
"reuses the hardened ZIP reader for %s package inventory",
|
||||
async (name, format) => {
|
||||
const root = await createTestTempDirectory("rea-windows-package-");
|
||||
const path = join(root, name);
|
||||
const writer = new ZipWriter(new Uint8ArrayWriter());
|
||||
await writer.add(
|
||||
"Assets/main.js",
|
||||
new TextReader("export default 'windows';"),
|
||||
);
|
||||
await writer.add(
|
||||
"VFS/ProgramFilesX64/App/addon.node",
|
||||
new TextReader("native"),
|
||||
);
|
||||
await writeFile(path, await writer.close());
|
||||
|
||||
const parsed = await parseBinaryTarget(path);
|
||||
expect(parsed.ok && parsed.value).toMatchObject({
|
||||
kind: "archive",
|
||||
format,
|
||||
});
|
||||
if (!parsed.ok) return;
|
||||
const result = await inventory(parsed.value);
|
||||
expect(result.manifest.root_format).toBe(format);
|
||||
expect(result.nodes.items.map(({ kind }) => kind)).toEqual(
|
||||
expect.arrayContaining(["javascript", "native-addon"]),
|
||||
);
|
||||
const selected = result.occurrences.items.find(
|
||||
({ logical_path: logicalPath }) => logicalPath === "Assets/main.js",
|
||||
);
|
||||
expect(selected).toBeDefined();
|
||||
if (selected === undefined) return;
|
||||
const output = join(root, "output");
|
||||
const extracted = await new ArtifactProvider()
|
||||
.createClient(parsed.value)
|
||||
.execute(
|
||||
"extract_artifact",
|
||||
artifactExtractionInputSchema.parse({
|
||||
approved: true,
|
||||
output_root: output,
|
||||
occurrence_ids: [selected.occurrence_id],
|
||||
}),
|
||||
);
|
||||
expect(extracted.ok).toBe(true);
|
||||
expect(await readFile(join(output, "Assets", "main.js"), "utf8")).toBe(
|
||||
"export default 'windows';",
|
||||
);
|
||||
expect(
|
||||
parseEvidence(
|
||||
await runProviderAnalysis(path, "inventory_artifact", {
|
||||
node_limit: 10,
|
||||
occurrence_limit: 10,
|
||||
edge_limit: 10,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
operation: "inventory_artifact",
|
||||
subject: { format },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects unsafe, colliding, and over-ratio entries", async () => {
|
||||
expect(() => normalizeArtifactPath("../escape", limits())).toThrow(
|
||||
/normalized/u,
|
||||
|
||||
@@ -95,18 +95,35 @@ describe("executable header parsing", () => {
|
||||
});
|
||||
|
||||
describe("binary target I/O", () => {
|
||||
it("classifies ZIP profiles and text artifacts without Hopper", async () => {
|
||||
it("classifies ZIP package families and text artifacts without Hopper", async () => {
|
||||
const directory = await createTestTempDirectory("rea-artifact-target-");
|
||||
const zip = join(directory, "fixture.apk");
|
||||
const msix = join(directory, "fixture.msixbundle");
|
||||
const appx = join(directory, "fixture.appx");
|
||||
const script = join(directory, "bundle.js");
|
||||
await writeFile(zip, Buffer.from([0x50, 0x4b, 0x05, 0x06, 0, 0, 0, 0]));
|
||||
const emptyZip = Buffer.from([0x50, 0x4b, 0x05, 0x06, 0, 0, 0, 0]);
|
||||
await Promise.all([
|
||||
writeFile(zip, emptyZip),
|
||||
writeFile(msix, emptyZip),
|
||||
writeFile(appx, emptyZip),
|
||||
]);
|
||||
await writeFile(script, "export default 1;\n");
|
||||
const archive = await parseBinaryTarget(zip);
|
||||
const msixArchive = await parseBinaryTarget(msix);
|
||||
const appxArchive = await parseBinaryTarget(appx);
|
||||
const javascript = await parseBinaryTarget(script);
|
||||
expect(archive.ok && archive.value).toMatchObject({
|
||||
kind: "archive",
|
||||
format: "apk",
|
||||
});
|
||||
expect(msixArchive.ok && msixArchive.value).toMatchObject({
|
||||
kind: "archive",
|
||||
format: "msix",
|
||||
});
|
||||
expect(appxArchive.ok && appxArchive.value).toMatchObject({
|
||||
kind: "archive",
|
||||
format: "appx",
|
||||
});
|
||||
expect(javascript.ok && javascript.value).toMatchObject({
|
||||
kind: "artifact",
|
||||
format: "javascript",
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildCapabilityInventory } from "../src/application/CapabilityInventory.js";
|
||||
|
||||
const enabledPolicy: Parameters<typeof buildCapabilityInventory>[1] = {
|
||||
processCaptureEnabled: true,
|
||||
evidenceFileRoots: 1,
|
||||
investigationInputRoots: 1,
|
||||
browserObservationEnabled: true,
|
||||
electronObservationEnabled: true,
|
||||
javascriptReplayEnabled: true,
|
||||
managedRuntimeEnabled: true,
|
||||
};
|
||||
|
||||
const status = (
|
||||
options: {
|
||||
readonly open?: boolean;
|
||||
readonly kind?: "executable" | "database" | "archive" | "artifact";
|
||||
readonly capabilities?: readonly {
|
||||
readonly operation: string;
|
||||
readonly available: boolean;
|
||||
readonly reason: string | null;
|
||||
readonly availability_code?: string;
|
||||
}[];
|
||||
} = {},
|
||||
) => ({
|
||||
open: options.open ?? false,
|
||||
...(options.kind === undefined ? {} : { kind: options.kind }),
|
||||
capabilities: [...(options.capabilities ?? [])],
|
||||
});
|
||||
|
||||
const entry = (
|
||||
name: string,
|
||||
sessionStatus: ReturnType<typeof status>,
|
||||
policy = enabledPolicy,
|
||||
) => {
|
||||
const found = buildCapabilityInventory(sessionStatus, policy).find(
|
||||
(candidate) => candidate.name === name,
|
||||
);
|
||||
if (found === undefined) throw new Error(`missing capability ${name}`);
|
||||
return found;
|
||||
};
|
||||
|
||||
describe("capability inventory", () => {
|
||||
it.each([
|
||||
{
|
||||
label: "requires a target",
|
||||
name: "current_address",
|
||||
sessionStatus: status(),
|
||||
reason: "target_required",
|
||||
remediation: "open_binary",
|
||||
},
|
||||
{
|
||||
label: "rejects an incompatible target family",
|
||||
name: "current_address",
|
||||
sessionStatus: status({ open: true, kind: "archive" }),
|
||||
reason: "target_unsupported",
|
||||
remediation: "native executable",
|
||||
},
|
||||
{
|
||||
label: "reports a missing provider operation",
|
||||
name: "current_address",
|
||||
sessionStatus: status({ open: true, kind: "executable" }),
|
||||
reason: "provider_missing",
|
||||
remediation: "provider",
|
||||
},
|
||||
{
|
||||
label: "distinguishes an unsupported host",
|
||||
name: "current_address",
|
||||
sessionStatus: status({
|
||||
open: true,
|
||||
kind: "executable",
|
||||
capabilities: [
|
||||
{
|
||||
operation: "current_address",
|
||||
available: false,
|
||||
availability_code: "unsupported_host",
|
||||
reason: "Operation requires macOS",
|
||||
},
|
||||
],
|
||||
}),
|
||||
reason: "unsupported_host",
|
||||
remediation: "macOS",
|
||||
},
|
||||
{
|
||||
label: "retains a provider failure",
|
||||
name: "current_address",
|
||||
sessionStatus: status({
|
||||
open: true,
|
||||
kind: "executable",
|
||||
capabilities: [
|
||||
{
|
||||
operation: "current_address",
|
||||
available: false,
|
||||
reason: "Provider session is unhealthy",
|
||||
},
|
||||
],
|
||||
}),
|
||||
reason: "provider_unavailable",
|
||||
remediation: "unhealthy",
|
||||
},
|
||||
{
|
||||
label: "reports an available provider operation",
|
||||
name: "current_address",
|
||||
sessionStatus: status({
|
||||
open: true,
|
||||
kind: "executable",
|
||||
capabilities: [
|
||||
{
|
||||
operation: "current_address",
|
||||
available: true,
|
||||
reason: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
reason: "available",
|
||||
remediation: null,
|
||||
},
|
||||
])("$label", ({ name, sessionStatus, reason, remediation }) => {
|
||||
const availability = entry(name, sessionStatus);
|
||||
expect(availability).toMatchObject({
|
||||
reason,
|
||||
available: reason === "available",
|
||||
});
|
||||
if (remediation === null) expect(availability.remediation).toBeNull();
|
||||
else expect(availability.remediation).toContain(remediation);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["capture_process_scenario", { processCaptureEnabled: false }],
|
||||
["inspect_web_page", { browserObservationEnabled: false }],
|
||||
["inspect_electron_page", { electronObservationEnabled: false }],
|
||||
["run_controlled_replay", { javascriptReplayEnabled: false }],
|
||||
["plan_managed_runtime_correlation", { managedRuntimeEnabled: false }],
|
||||
] as const)("reports stable policy denial for %s", (name, override) => {
|
||||
const availability = entry(name, status(), {
|
||||
...enabledPolicy,
|
||||
...override,
|
||||
});
|
||||
expect(availability).toMatchObject({
|
||||
available: false,
|
||||
reason: "policy_disabled",
|
||||
});
|
||||
expect(availability.remediation).toEqual(expect.any(String));
|
||||
expect(availability.remediation?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("gives every unavailable public tool an actionable remediation", () => {
|
||||
const inventory = buildCapabilityInventory(status(), {
|
||||
...enabledPolicy,
|
||||
processCaptureEnabled: false,
|
||||
evidenceFileRoots: 0,
|
||||
investigationInputRoots: 0,
|
||||
browserObservationEnabled: false,
|
||||
electronObservationEnabled: false,
|
||||
javascriptReplayEnabled: false,
|
||||
managedRuntimeEnabled: false,
|
||||
});
|
||||
for (const availability of inventory)
|
||||
if (!availability.available) {
|
||||
expect(availability.reason).not.toBe("available");
|
||||
expect(availability.remediation).toEqual(expect.any(String));
|
||||
expect(availability.remediation?.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -85,19 +85,8 @@ describe("tool contract surface", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("advertises complete typed schemas and annotations for all analysis tools", () => {
|
||||
const contracts = [
|
||||
...OFFICIAL_TOOL_CONTRACTS,
|
||||
...ENHANCED_TOOL_CONTRACTS,
|
||||
...NATIVE_TOOL_CONTRACTS,
|
||||
...ARTIFACT_TOOL_CONTRACTS,
|
||||
...MANAGED_TOOL_CONTRACTS,
|
||||
...MANAGED_WORKFLOW_TOOL_CONTRACTS,
|
||||
...BROWSER_TOOL_CONTRACTS,
|
||||
...ELECTRON_TOOL_CONTRACTS,
|
||||
...APPLICATION_TOOL_CONTRACTS,
|
||||
];
|
||||
expect(contracts).toHaveLength(79);
|
||||
it("advertises complete typed schemas and annotations for every public tool", () => {
|
||||
const contracts = TOOL_CONTRACTS;
|
||||
for (const contract of contracts) {
|
||||
const inputSchema = contractJsonSchema(contract.inputSchema);
|
||||
const outputSchema = contractJsonSchema(contract.outputSchema);
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
|
||||
import { WebSocket } from "ws";
|
||||
|
||||
process.stdout.write("Ready\n");
|
||||
const worker = spawn(
|
||||
process.execPath,
|
||||
["-e", 'process.title = "rea-reactive-worker"; setTimeout(() => {}, 750);'],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
process.stdout.write("Collecting\n");
|
||||
spawnSync(process.argv[2], ["probe"], { stdio: "ignore" });
|
||||
await writeFile("reactive-result.txt", "created");
|
||||
await fetch(`${process.env.REA_REPLAY_HTTP_URL}/reactive`);
|
||||
await new Promise((resolve, reject) => {
|
||||
const socket = new WebSocket(process.env.REA_REPLAY_WEBSOCKET_URL);
|
||||
socket.once("open", () => socket.send("reactive-client"));
|
||||
socket.once("message", () => socket.close());
|
||||
socket.once("close", resolve);
|
||||
socket.once("error", reject);
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
worker.once("exit", resolve);
|
||||
worker.once("error", reject);
|
||||
});
|
||||
@@ -103,6 +103,7 @@ const emptyCapture = (): ProcessCapture => {
|
||||
shim_events: [],
|
||||
protocol_events: [],
|
||||
replay_transitions: [],
|
||||
reactive_run: null,
|
||||
event_journal: [],
|
||||
files_before: [],
|
||||
files_after: [],
|
||||
@@ -187,6 +188,7 @@ describe("process capture domain", () => {
|
||||
];
|
||||
const result = buildCaptureResult({
|
||||
frames: [],
|
||||
reactiveRun: null,
|
||||
exit: { exitCode: 0, reason: "exited" },
|
||||
samples: [],
|
||||
replay: {
|
||||
@@ -452,6 +454,7 @@ describe("process capture domain", () => {
|
||||
shim_events: [],
|
||||
protocol_events: [],
|
||||
replay_transitions: [],
|
||||
reactive_run: null,
|
||||
files_before: [],
|
||||
files_after: [],
|
||||
filesystem_effects: [],
|
||||
@@ -696,6 +699,7 @@ describe("process capture domain", () => {
|
||||
shim_events: [],
|
||||
protocol_events: [],
|
||||
replay_transitions: [],
|
||||
reactive_run: null,
|
||||
files_before: [],
|
||||
files_after: [],
|
||||
filesystem_effects: [],
|
||||
@@ -756,6 +760,7 @@ describe("process capture domain", () => {
|
||||
shim_events: [],
|
||||
protocol_events: [],
|
||||
replay_transitions: [],
|
||||
reactive_run: null,
|
||||
files_before: [],
|
||||
files_after: [],
|
||||
filesystem_effects: [],
|
||||
@@ -800,6 +805,7 @@ describe("process capture domain", () => {
|
||||
shim_events: [],
|
||||
protocol_events: [],
|
||||
replay_transitions: [],
|
||||
reactive_run: null,
|
||||
files_before: [],
|
||||
files_after: [],
|
||||
filesystem_effects: [],
|
||||
@@ -873,6 +879,7 @@ describe("process capture domain", () => {
|
||||
shim_events: [],
|
||||
protocol_events: [],
|
||||
replay_transitions: [],
|
||||
reactive_run: null,
|
||||
files_before: [],
|
||||
files_after: [],
|
||||
filesystem_effects: [],
|
||||
@@ -1507,6 +1514,407 @@ describe("process capture adapter", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("drives a process from terminal observations and retains the reactive transition journal", async () => {
|
||||
const root = await createTestTempDirectory("rea-reactive-harness-test-");
|
||||
const script = join(root, "reactive.mjs");
|
||||
await writeFile(
|
||||
script,
|
||||
[
|
||||
'import { createInterface } from "node:readline";',
|
||||
"const input = createInterface({ input: process.stdin, terminal: false });",
|
||||
'process.stdout.write("Ready\\n");',
|
||||
'input.once("line", () => { process.stdout.write("Done\\n"); input.close(); });',
|
||||
].join("\n"),
|
||||
);
|
||||
const scenario = parseProcessScenario({
|
||||
approved: true,
|
||||
executable: process.execPath,
|
||||
arguments: [script],
|
||||
working_directory: root,
|
||||
reactive: {
|
||||
version: 1,
|
||||
initial_state: "waiting",
|
||||
deadline_ms: 5_000,
|
||||
states: [
|
||||
{
|
||||
id: "waiting",
|
||||
max_visits: 1,
|
||||
deadline_ms: 2_000,
|
||||
on: [
|
||||
{
|
||||
id: "answer",
|
||||
priority: 0,
|
||||
max_uses: 1,
|
||||
when: {
|
||||
kind: "terminal_text",
|
||||
view: "decoded",
|
||||
encoding: "utf8",
|
||||
literal: "Ready",
|
||||
case_sensitive: true,
|
||||
control_sequences: "include",
|
||||
occurrence: 1,
|
||||
since: { kind: "scenario_start" },
|
||||
consume: true,
|
||||
},
|
||||
actions: [
|
||||
{ type: "send_input", data: "accepted\n", sensitive: true },
|
||||
],
|
||||
target: { kind: "goto", state: "finishing" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "finishing",
|
||||
max_visits: 1,
|
||||
deadline_ms: 2_000,
|
||||
on: [
|
||||
{
|
||||
id: "complete",
|
||||
priority: 0,
|
||||
max_uses: 1,
|
||||
when: {
|
||||
kind: "terminal_text",
|
||||
view: "decoded",
|
||||
encoding: "utf8",
|
||||
literal: "Done",
|
||||
case_sensitive: true,
|
||||
control_sequences: "include",
|
||||
occurrence: 1,
|
||||
since: { kind: "state_entry" },
|
||||
consume: true,
|
||||
},
|
||||
actions: [],
|
||||
target: { kind: "finish", outcome: "passed" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
try {
|
||||
const capability = await probeProcessCaptureCapability();
|
||||
if (!capability.available) return;
|
||||
const result = await captureProcessScenario(scenario, {
|
||||
enabled: true,
|
||||
executableRoots: [dirname(process.execPath)],
|
||||
workingRoots: [root],
|
||||
allowedEnvironment: [],
|
||||
allowExternalNetwork: true,
|
||||
});
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.value.reactive_run).toMatchObject({
|
||||
status: "finished",
|
||||
outcome: "passed",
|
||||
active_state: "finishing",
|
||||
});
|
||||
expect(
|
||||
result.value.reactive_run?.transitions.map(
|
||||
({ transition_id }) => transition_id,
|
||||
),
|
||||
).toEqual(["answer", "complete"]);
|
||||
expect(result.value.interaction_events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "input",
|
||||
data: "<redacted-input:9-bytes>",
|
||||
outcome: "dispatched",
|
||||
}),
|
||||
);
|
||||
expect(result.value.frames.map(({ data }) => data).join("")).toContain(
|
||||
"Done",
|
||||
);
|
||||
expect(result.value.manifest.comparison_contract).toHaveProperty(
|
||||
"reactive",
|
||||
);
|
||||
expect(
|
||||
JSON.stringify(result.value.manifest.comparison_contract),
|
||||
).not.toContain("accepted");
|
||||
expect(JSON.stringify(result.value.manifest.scenario)).not.toContain(
|
||||
"accepted",
|
||||
);
|
||||
const reactiveRun = result.value.reactive_run;
|
||||
if (reactiveRun === null) throw new Error("reactive result missing");
|
||||
const lastReactiveOrder = result.value.event_journal
|
||||
?.filter(({ collection }) =>
|
||||
[
|
||||
"frames",
|
||||
"interaction_events",
|
||||
"filesystem_checkpoints",
|
||||
"shim_events",
|
||||
].includes(collection),
|
||||
)
|
||||
.at(-1)?.capture_order;
|
||||
if (lastReactiveOrder === undefined)
|
||||
throw new Error("reactive observation order missing");
|
||||
expect(
|
||||
validateProcessCapture({
|
||||
...result.value,
|
||||
reactive_run: {
|
||||
...reactiveRun,
|
||||
controls: [
|
||||
...reactiveRun.controls,
|
||||
{
|
||||
sequence: reactiveRun.controls.length,
|
||||
kind: "target_lost",
|
||||
after_capture_order: lastReactiveOrder,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toContainEqual(
|
||||
expect.objectContaining({
|
||||
path: `reactive_run.controls[${String(reactiveRun.controls.length)}]`,
|
||||
message: "reactive control cannot follow a finished run",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
validateProcessCapture({
|
||||
...result.value,
|
||||
reactive_run: {
|
||||
...reactiveRun,
|
||||
transitions: reactiveRun.transitions.map((transition, index) =>
|
||||
index === 0
|
||||
? { ...transition, transition_id: "fabricated" }
|
||||
: transition,
|
||||
),
|
||||
},
|
||||
}),
|
||||
).toContainEqual(
|
||||
expect.objectContaining({
|
||||
path: "reactive_run.transitions[0]",
|
||||
message:
|
||||
"reactive transition is not declared by its recorded source state",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
validateProcessCapture({
|
||||
...result.value,
|
||||
reactive_run: {
|
||||
status: "finished",
|
||||
outcome: "passed",
|
||||
active_state: "waiting",
|
||||
transitions: [],
|
||||
controls: [],
|
||||
},
|
||||
}),
|
||||
).toContainEqual(
|
||||
expect.objectContaining({
|
||||
path: "reactive_run.outcome",
|
||||
message: "reactive outcome differs from deterministic journal replay",
|
||||
}),
|
||||
);
|
||||
const suffix = reactiveRun.transitions
|
||||
.slice(1)
|
||||
.map((transition, index) => ({
|
||||
...transition,
|
||||
sequence: index,
|
||||
}));
|
||||
expect(
|
||||
validateProcessCapture({
|
||||
...result.value,
|
||||
reactive_run: { ...reactiveRun, transitions: suffix },
|
||||
}),
|
||||
).toContainEqual(
|
||||
expect.objectContaining({
|
||||
path: "reactive_run.transitions[0].state_before",
|
||||
message:
|
||||
"reactive transition journal must start at the declared initial state",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
validateProcessCapture({
|
||||
...result.value,
|
||||
reactive_run: {
|
||||
...reactiveRun,
|
||||
transitions: reactiveRun.transitions.map((transition, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...transition,
|
||||
trigger_event_ids: [],
|
||||
action_event_ids: [],
|
||||
}
|
||||
: transition,
|
||||
),
|
||||
},
|
||||
}),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: "reactive_run.transitions",
|
||||
message: expect.stringContaining(
|
||||
"reactive transition journal is not reproduced",
|
||||
),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
path: "reactive_run.transitions[0].action_event_ids",
|
||||
message: expect.stringContaining(
|
||||
"recorded action observations do not match proposed effects",
|
||||
),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
validateProcessCapture({
|
||||
...result.value,
|
||||
reactive_run: {
|
||||
...reactiveRun,
|
||||
status: "running",
|
||||
outcome: null,
|
||||
},
|
||||
}),
|
||||
).toContainEqual(
|
||||
expect.objectContaining({
|
||||
message: "completed capture requires a finished reactive outcome",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
validateProcessCapture({
|
||||
...result.value,
|
||||
reactive_run: {
|
||||
...reactiveRun,
|
||||
transitions: [
|
||||
reactiveRun.transitions[0]!,
|
||||
{ ...reactiveRun.transitions[0]!, sequence: 1 },
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
message: "reactive transition states must be contiguous",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
message:
|
||||
"reactive transition differs from deterministic journal replay",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("records target loss before post-exit settlement can win the deadline race", async () => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-reactive-target-loss-test-",
|
||||
);
|
||||
const script = join(root, "exit.mjs");
|
||||
await writeFile(script, 'process.stdout.write("exiting\\n");\n');
|
||||
const scenario = parseProcessScenario({
|
||||
approved: true,
|
||||
executable: process.execPath,
|
||||
arguments: [script],
|
||||
working_directory: root,
|
||||
settle_ms: 500,
|
||||
reactive: {
|
||||
version: 1,
|
||||
initial_state: "waiting",
|
||||
deadline_ms: 2_000,
|
||||
states: [
|
||||
{
|
||||
id: "waiting",
|
||||
max_visits: 1,
|
||||
deadline_ms: 300,
|
||||
on: [
|
||||
{
|
||||
id: "unreachable",
|
||||
priority: 0,
|
||||
max_uses: 1,
|
||||
when: {
|
||||
kind: "terminal_text",
|
||||
view: "decoded",
|
||||
encoding: "utf8",
|
||||
literal: "never-produced",
|
||||
case_sensitive: true,
|
||||
control_sequences: "include",
|
||||
occurrence: 1,
|
||||
since: { kind: "scenario_start" },
|
||||
consume: true,
|
||||
},
|
||||
actions: [],
|
||||
target: { kind: "finish", outcome: "passed" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
try {
|
||||
const capability = await probeProcessCaptureCapability();
|
||||
if (!capability.available) return;
|
||||
const result = await captureProcessScenario(scenario, {
|
||||
enabled: true,
|
||||
executableRoots: [dirname(process.execPath)],
|
||||
workingRoots: [root],
|
||||
allowedEnvironment: [],
|
||||
allowExternalNetwork: true,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.reactive_run).toMatchObject({
|
||||
status: "finished",
|
||||
outcome: "target_lost",
|
||||
active_state: "waiting",
|
||||
transitions: [],
|
||||
controls: [
|
||||
expect.objectContaining({
|
||||
kind: "target_lost",
|
||||
}),
|
||||
],
|
||||
});
|
||||
const frame = result.value.frames.at(-1);
|
||||
const journal = result.value.event_journal;
|
||||
const reactiveRun = result.value.reactive_run;
|
||||
if (frame === undefined || journal === undefined || reactiveRun === null)
|
||||
throw new Error("ordered reactive evidence missing");
|
||||
const laterCaptureOrder = journal.length;
|
||||
const withLaterMatch = {
|
||||
...result.value,
|
||||
frames: [
|
||||
...result.value.frames,
|
||||
{
|
||||
...frame,
|
||||
sequence: result.value.frames.length,
|
||||
at_ms: frame.at_ms + 1,
|
||||
data: "never-produced",
|
||||
},
|
||||
],
|
||||
event_journal: [
|
||||
...journal,
|
||||
{
|
||||
capture_order: laterCaptureOrder,
|
||||
collection: "frames" as const,
|
||||
index: result.value.frames.length,
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(
|
||||
validateProcessCapture(withLaterMatch).filter(({ path }) =>
|
||||
path.startsWith("reactive_run"),
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
validateProcessCapture({
|
||||
...withLaterMatch,
|
||||
reactive_run: {
|
||||
...reactiveRun,
|
||||
controls: reactiveRun.controls.map((control) => ({
|
||||
...control,
|
||||
after_capture_order: laterCaptureOrder,
|
||||
})),
|
||||
},
|
||||
}),
|
||||
).toContainEqual(
|
||||
expect.objectContaining({
|
||||
path: "reactive_run.outcome",
|
||||
message: "reactive outcome differs from deterministic journal replay",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("renders terminal state, records shim invocations, and captures literal checkpoints", async () => {
|
||||
const root = await createTestTempDirectory("rea-v3-test-");
|
||||
const script = join(root, "scenario.mjs");
|
||||
@@ -1529,7 +1937,6 @@ describe("process capture adapter", () => {
|
||||
executable: process.execPath,
|
||||
arguments: [script],
|
||||
working_directory: root,
|
||||
filesystem_roots: [root],
|
||||
checkpoints: [
|
||||
{
|
||||
name: "probe_seen",
|
||||
@@ -1562,6 +1969,75 @@ describe("process capture adapter", () => {
|
||||
],
|
||||
},
|
||||
],
|
||||
reactive: {
|
||||
version: 1,
|
||||
initial_state: "waiting_for_shim",
|
||||
deadline_ms: 5_000,
|
||||
states: [
|
||||
{
|
||||
id: "waiting_for_shim",
|
||||
max_visits: 1,
|
||||
deadline_ms: 2_000,
|
||||
on: [
|
||||
{
|
||||
id: "record_shim",
|
||||
priority: 0,
|
||||
max_uses: 1,
|
||||
when: {
|
||||
kind: "event",
|
||||
source: "shim",
|
||||
exact: {
|
||||
command: "codex",
|
||||
route_index: 0,
|
||||
arguments: ["--version"],
|
||||
working_directory: "<working-directory>",
|
||||
outcome: "matched",
|
||||
},
|
||||
ignore_fields: ["sequence", "at_ms"],
|
||||
since: { kind: "scenario_start" },
|
||||
consume: true,
|
||||
cardinality: { min: 1, max: 1 },
|
||||
},
|
||||
actions: [
|
||||
{ type: "checkpoint", name: "reactive_shim_seen" },
|
||||
],
|
||||
target: {
|
||||
kind: "goto",
|
||||
state: "waiting_for_checkpoint",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "waiting_for_checkpoint",
|
||||
max_visits: 1,
|
||||
deadline_ms: 2_000,
|
||||
on: [
|
||||
{
|
||||
id: "finish_checkpoint",
|
||||
priority: 0,
|
||||
max_uses: 1,
|
||||
when: {
|
||||
kind: "event",
|
||||
source: "filesystem",
|
||||
exact: {
|
||||
name: "reactive_shim_seen",
|
||||
files: [],
|
||||
effects: [],
|
||||
truncated: false,
|
||||
},
|
||||
ignore_fields: ["at_ms"],
|
||||
since: { kind: "scenario_start" },
|
||||
consume: true,
|
||||
cardinality: { min: 1, max: 1 },
|
||||
},
|
||||
actions: [],
|
||||
target: { kind: "finish", outcome: "passed" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{
|
||||
enabled: true,
|
||||
@@ -1591,12 +2067,300 @@ describe("process capture adapter", () => {
|
||||
]);
|
||||
expect(
|
||||
result.value.filesystem_checkpoints.map(({ name }) => name),
|
||||
).toEqual(["before", "probe_seen", "after_settlement"]);
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
"before",
|
||||
"reactive_shim_seen",
|
||||
"probe_seen",
|
||||
"after_settlement",
|
||||
]),
|
||||
);
|
||||
expect(result.value.reactive_run).toMatchObject({
|
||||
status: "finished",
|
||||
outcome: "passed",
|
||||
transitions: [
|
||||
expect.objectContaining({
|
||||
transition_id: "record_shim",
|
||||
trigger_event_ids: ["obs.shim_events.0"],
|
||||
action_event_ids: [
|
||||
expect.stringMatching(/^obs\.filesystem_checkpoints\./u),
|
||||
],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
transition_id: "finish_checkpoint",
|
||||
trigger_event_ids: [
|
||||
expect.stringMatching(/^obs\.filesystem_checkpoints\./u),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
it("runs the committed multi-source reactive fixture deterministically", async () => {
|
||||
const root = await createTestTempDirectory("rea-reactive-e2e-");
|
||||
const script = fileURLToPath(
|
||||
new URL("./fixtures/processReactiveScenario.mjs", import.meta.url),
|
||||
);
|
||||
const event = (
|
||||
source: "process" | "filesystem" | "http" | "websocket" | "shim",
|
||||
exact: Record<string, unknown>,
|
||||
ignoreFields: readonly string[],
|
||||
) => ({
|
||||
kind: "event" as const,
|
||||
source,
|
||||
exact,
|
||||
ignore_fields: ignoreFields,
|
||||
since: { kind: "scenario_start" as const },
|
||||
consume: true,
|
||||
cardinality: { min: 1, max: 1 },
|
||||
});
|
||||
const run = () =>
|
||||
captureProcessScenario(
|
||||
parseProcessScenario({
|
||||
approved: true,
|
||||
executable: process.execPath,
|
||||
arguments: [script, "codex"],
|
||||
working_directory: root,
|
||||
replay: {
|
||||
http: [
|
||||
{
|
||||
method: "GET",
|
||||
path: "/reactive",
|
||||
status: 200,
|
||||
body: "ok",
|
||||
},
|
||||
],
|
||||
websocket_messages: ["reactive-server"],
|
||||
},
|
||||
command_shims: [
|
||||
{
|
||||
name: "codex",
|
||||
routes: [
|
||||
{
|
||||
arguments: ["probe"],
|
||||
outputs: [],
|
||||
termination: { type: "exit", code: 0 },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
reactive: {
|
||||
version: 1,
|
||||
initial_state: "ready",
|
||||
deadline_ms: 8_000,
|
||||
states: [
|
||||
{
|
||||
id: "ready",
|
||||
max_visits: 1,
|
||||
deadline_ms: 3_000,
|
||||
on: [
|
||||
{
|
||||
id: "checkpoint_ready",
|
||||
priority: 0,
|
||||
max_uses: 1,
|
||||
when: {
|
||||
kind: "terminal_text",
|
||||
view: "decoded",
|
||||
encoding: "utf8",
|
||||
literal: "Ready",
|
||||
case_sensitive: true,
|
||||
control_sequences: "include",
|
||||
occurrence: 1,
|
||||
since: { kind: "scenario_start" },
|
||||
consume: true,
|
||||
},
|
||||
actions: [{ type: "checkpoint", name: "ready_snapshot" }],
|
||||
target: { kind: "goto", state: "collecting" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "collecting",
|
||||
max_visits: 1,
|
||||
deadline_ms: 5_000,
|
||||
on: [
|
||||
{
|
||||
id: "finish_all_sources",
|
||||
priority: 0,
|
||||
max_uses: 1,
|
||||
when: {
|
||||
kind: "all",
|
||||
triggers: [
|
||||
{
|
||||
kind: "any",
|
||||
triggers: [
|
||||
{
|
||||
kind: "terminal_text",
|
||||
view: "decoded",
|
||||
encoding: "utf8",
|
||||
literal: "Collecting",
|
||||
case_sensitive: true,
|
||||
control_sequences: "include",
|
||||
occurrence: 1,
|
||||
since: { kind: "scenario_start" },
|
||||
consume: false,
|
||||
},
|
||||
{
|
||||
kind: "terminal_text",
|
||||
view: "decoded",
|
||||
encoding: "utf8",
|
||||
literal: "unreachable-alternative",
|
||||
case_sensitive: true,
|
||||
control_sequences: "include",
|
||||
occurrence: 1,
|
||||
since: { kind: "scenario_start" },
|
||||
consume: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
event(
|
||||
"process",
|
||||
{
|
||||
pid: 3,
|
||||
parent_pid: 1,
|
||||
command: "rea-reactive-worker",
|
||||
process_group_id: 1,
|
||||
session_id: 1,
|
||||
},
|
||||
["at_ms"],
|
||||
),
|
||||
event(
|
||||
"filesystem",
|
||||
{
|
||||
name: "ready_snapshot",
|
||||
files: [],
|
||||
effects: [],
|
||||
truncated: false,
|
||||
},
|
||||
["at_ms"],
|
||||
),
|
||||
event(
|
||||
"shim",
|
||||
{
|
||||
command: "codex",
|
||||
route_index: 0,
|
||||
arguments: ["probe"],
|
||||
working_directory: "<working-directory>",
|
||||
outcome: "matched",
|
||||
},
|
||||
["sequence", "at_ms"],
|
||||
),
|
||||
{
|
||||
kind: "sequence",
|
||||
triggers: [
|
||||
event(
|
||||
"http",
|
||||
{
|
||||
protocol: "http",
|
||||
direction: "request",
|
||||
method: "GET",
|
||||
path: "/reactive",
|
||||
data: "",
|
||||
outcome: "matched",
|
||||
},
|
||||
["sequence", "at_ms"],
|
||||
),
|
||||
event(
|
||||
"websocket",
|
||||
{
|
||||
protocol: "websocket",
|
||||
direction: "received",
|
||||
method: null,
|
||||
path: "/ws",
|
||||
data: "reactive-client",
|
||||
outcome: "matched",
|
||||
},
|
||||
["sequence", "at_ms"],
|
||||
),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [],
|
||||
target: { kind: "finish", outcome: "passed" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{
|
||||
enabled: true,
|
||||
executableRoots: [dirname(process.execPath)],
|
||||
workingRoots: [root],
|
||||
allowedEnvironment: [],
|
||||
allowExternalNetwork: true,
|
||||
},
|
||||
);
|
||||
try {
|
||||
const capability = await probeProcessCaptureCapability();
|
||||
if (!capability.available) return;
|
||||
const first = await run();
|
||||
const second = await run();
|
||||
if (!first.ok) throw first.error;
|
||||
if (!second.ok) throw second.error;
|
||||
expect(first.ok).toBe(true);
|
||||
expect(second.ok).toBe(true);
|
||||
const trace = (capture: ProcessCapture) =>
|
||||
capture.reactive_run?.transitions.map((transition) => ({
|
||||
transition_id: transition.transition_id,
|
||||
from: transition.state_before,
|
||||
to: transition.state_after,
|
||||
outcome: transition.outcome,
|
||||
trigger_collections: transition.trigger_event_ids.map(
|
||||
(id) => id.split(".")[1],
|
||||
),
|
||||
}));
|
||||
expect(
|
||||
first.value.reactive_run?.outcome,
|
||||
JSON.stringify({
|
||||
run: first.value.reactive_run,
|
||||
processes: first.value.process_samples,
|
||||
checkpoints: first.value.filesystem_checkpoints,
|
||||
shims: first.value.shim_events,
|
||||
protocols: first.value.protocol_events,
|
||||
}),
|
||||
).toBe("passed");
|
||||
expect(trace(first.value)).toEqual(trace(second.value));
|
||||
expect(trace(first.value)).toEqual([
|
||||
expect.objectContaining({ transition_id: "checkpoint_ready" }),
|
||||
expect.objectContaining({
|
||||
transition_id: "finish_all_sources",
|
||||
trigger_collections: expect.arrayContaining([
|
||||
"frames",
|
||||
"process_samples",
|
||||
"filesystem_checkpoints",
|
||||
"shim_events",
|
||||
"protocol_events",
|
||||
]),
|
||||
}),
|
||||
]);
|
||||
for (const outcome of ["action_rejected", "target_lost"] as const) {
|
||||
const failedActionCapture = {
|
||||
...first.value,
|
||||
reactive_run: {
|
||||
status: "finished" as const,
|
||||
outcome,
|
||||
active_state: "ready",
|
||||
transitions: [],
|
||||
controls: [],
|
||||
},
|
||||
};
|
||||
expect(
|
||||
validateProcessCapture(failedActionCapture).filter(({ path }) =>
|
||||
path.startsWith("reactive_run"),
|
||||
),
|
||||
).toEqual([]);
|
||||
}
|
||||
expect(first.value.truncated).toBe(false);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("does not follow or disclose symlink targets outside declared roots", async () => {
|
||||
const root = await createTestTempDirectory("rea-symlink-test-");
|
||||
await symlink("/etc/passwd", join(root, "escape"));
|
||||
|
||||
@@ -3,7 +3,8 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js";
|
||||
|
||||
@@ -106,6 +107,130 @@ describe("process-capture MCP elicitation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["tampered", "expired"] as const)(
|
||||
"rejects %s signed state through the modern MCP client and server",
|
||||
async (failure) => {
|
||||
const startedAt = Date.parse("2026-07-23T00:00:00.000Z");
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(startedAt);
|
||||
const root = await createTestTempDirectory("rea-elicit-state-");
|
||||
const authority = new PermissionAuthority(
|
||||
createPermissionPolicy([
|
||||
{
|
||||
capability: "process_capture",
|
||||
roots: [root],
|
||||
executables: [process.execPath],
|
||||
environment_names: [],
|
||||
network: "external",
|
||||
mount: false,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const session = new BinarySession(() => ({
|
||||
execute: () => Promise.resolve(observed(null)),
|
||||
close: () => Promise.resolve(),
|
||||
}));
|
||||
const client = new Client(
|
||||
{ name: "process-elicit-state", version: "1" },
|
||||
{
|
||||
capabilities: { elicitation: { form: {} } },
|
||||
versionNegotiation: {
|
||||
mode: {
|
||||
pin: PROCESS_CAPTURE_ELICITATION_POLICY.protocolVersions[0],
|
||||
},
|
||||
},
|
||||
inputRequired: { autoFulfill: false, maxRounds: 3 },
|
||||
},
|
||||
);
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
const server = serveStdio(
|
||||
() =>
|
||||
createServer(session, session, {
|
||||
logger: silentLogger,
|
||||
permissionAuthority: authority,
|
||||
processPolicy: {
|
||||
enabled: true,
|
||||
executableRoots: [dirname(process.execPath)],
|
||||
workingRoots: [root],
|
||||
allowedEnvironment: [],
|
||||
allowExternalNetwork: true,
|
||||
},
|
||||
}),
|
||||
{ transport: serverTransport, legacy: "reject" },
|
||||
);
|
||||
const call = {
|
||||
name: "capture_process_scenario",
|
||||
arguments: {
|
||||
approved: true,
|
||||
executable: process.execPath,
|
||||
arguments: ["-e", "process.exit(0)"],
|
||||
working_directory: root,
|
||||
},
|
||||
};
|
||||
try {
|
||||
await client.connect(clientTransport);
|
||||
const required = await client.callTool(call, {
|
||||
allowInputRequired: true,
|
||||
});
|
||||
expect(isInputRequiredResult(required)).toBe(true);
|
||||
if (!isInputRequiredResult(required)) return;
|
||||
const requestState = required.requestState;
|
||||
expect(requestState).toEqual(expect.any(String));
|
||||
if (requestState === undefined) return;
|
||||
if (failure === "expired")
|
||||
vi.setSystemTime(
|
||||
startedAt +
|
||||
(PROCESS_CAPTURE_ELICITATION_POLICY.stateTtlSeconds + 1) * 1_000,
|
||||
);
|
||||
const echoedState =
|
||||
failure === "tampered"
|
||||
? `${requestState.slice(0, -1)}${requestState.endsWith("A") ? "B" : "A"}`
|
||||
: requestState;
|
||||
const retryMethod: string = "tools/call";
|
||||
await expect(
|
||||
client.request(
|
||||
{
|
||||
method: retryMethod,
|
||||
params: {
|
||||
...call,
|
||||
requestState: echoedState,
|
||||
inputResponses: {
|
||||
process_capture_grant: {
|
||||
action: "accept",
|
||||
content: { lifetime: "session" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
z.unknown(),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: -32_602,
|
||||
data: { reason: "invalid_request_state" },
|
||||
});
|
||||
expect(
|
||||
await authority.authorize(
|
||||
{
|
||||
capability: "process_capture",
|
||||
roots: [root],
|
||||
executables: [process.execPath],
|
||||
environment_names: [],
|
||||
network: "external",
|
||||
mount: false,
|
||||
operation_identity: `capture_process_scenario:${process.execPath}`,
|
||||
},
|
||||
"read",
|
||||
),
|
||||
).toMatchObject({ ok: false });
|
||||
} finally {
|
||||
await Promise.allSettled([client.close(), server.close()]);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
vi.useRealTimers();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("grants and reuses a signed session scope on the modern protocol", async () => {
|
||||
const authority = ceilingOnlyAuthority();
|
||||
let state: ProcessCaptureElicitationState | undefined;
|
||||
|
||||
@@ -122,6 +122,10 @@ describe("process observation vocabulary", () => {
|
||||
captured_at_ms: 4,
|
||||
subject_id: "process:2",
|
||||
});
|
||||
expect(observations[7]).toMatchObject({
|
||||
event_id: "obs.shim_events.0",
|
||||
subject_id: "shim:helper:route:0",
|
||||
});
|
||||
expect(observations[3]?.captured_at_ms).toBeNull();
|
||||
expect(observations[4]?.captured_at_ms).toBeNull();
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
unsupportedProcessReactiveFeatures,
|
||||
} from "../src/application/ProcessReactiveEffects.js";
|
||||
import { createProcessCaptureJournal } from "../src/application/ProcessCaptureJournal.js";
|
||||
import { subscribeProcessReactiveTerminalObservations } from "../src/application/ProcessReactiveObservations.js";
|
||||
import { subscribeProcessReactiveObservations } from "../src/application/ProcessReactiveObservations.js";
|
||||
import type {
|
||||
FilesystemCheckpoint,
|
||||
InteractionEvent,
|
||||
@@ -149,9 +149,14 @@ const createHarness = (scenario: ProcessReactiveScenario) => {
|
||||
},
|
||||
timerHost: timers.host,
|
||||
});
|
||||
const unsubscribe = subscribeProcessReactiveTerminalObservations({
|
||||
const unsubscribe = subscribeProcessReactiveObservations({
|
||||
journal,
|
||||
frames,
|
||||
interactions,
|
||||
checkpointAt: (index) => checkpoints[index],
|
||||
processSampleAt: () => undefined,
|
||||
protocolEventAt: () => undefined,
|
||||
shimEventAt: () => undefined,
|
||||
sink: coordinator,
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -194,6 +194,27 @@ describe("process reactive runtime", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("treats redaction-shaped live input as ordinary sensitive data", () => {
|
||||
const scenario = scenarioWith([
|
||||
finish("ready", terminalTrigger(), 100, [
|
||||
{
|
||||
type: "send_input",
|
||||
data: "<redacted-input:1-bytes>",
|
||||
sensitive: true,
|
||||
},
|
||||
]),
|
||||
]);
|
||||
const decision = offer(
|
||||
scenario,
|
||||
createProcessReactiveSnapshot(scenario),
|
||||
observation("terminal_raw", 0, { data: "Ready" }),
|
||||
);
|
||||
expect(decision).toMatchObject({
|
||||
kind: "transition",
|
||||
record: { outcome: "passed" },
|
||||
});
|
||||
});
|
||||
|
||||
it("reports only the minimal terminal chunk span containing the occurrence", () => {
|
||||
const scenario = scenarioWith([finish("ready", terminalTrigger())]);
|
||||
let snapshot = createProcessReactiveSnapshot(scenario);
|
||||
@@ -638,5 +659,16 @@ describe("process reactive runtime", () => {
|
||||
kind: "finished",
|
||||
outcome: "capture_incomplete",
|
||||
});
|
||||
const duplicate = observation("shim", 1, { name: "later" });
|
||||
expect(offer(scenario, first.snapshot, duplicate)).toMatchObject({
|
||||
kind: "finished",
|
||||
outcome: "capture_incomplete",
|
||||
});
|
||||
expect(
|
||||
offer(scenario, first.snapshot, {
|
||||
...duplicate,
|
||||
payload: { name: "changed" },
|
||||
}),
|
||||
).toMatchObject({ kind: "finished", outcome: "capture_incomplete" });
|
||||
});
|
||||
});
|
||||
@@ -115,6 +115,7 @@ describe("server and catalog identity", () => {
|
||||
{
|
||||
operation: "inspect_macho",
|
||||
available: false,
|
||||
availability_code: "unsupported_host",
|
||||
reason: "Native macOS utilities require macOS.",
|
||||
},
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user