401 lines
21 KiB
Markdown
401 lines
21 KiB
Markdown
# Process capture
|
||
|
||
Process Capture runs one caller-selected command and records bounded evidence
|
||
about its terminal behavior, scheduled interactions, process lifetime, and
|
||
selected filesystem state. Use it to compare two direct runs of the same
|
||
scenario, such as an authority build and a reconstruction. It does not replay a
|
||
previous execution or emulate dependencies.
|
||
|
||
The command runs with the current user's permissions and inherits the host
|
||
environment before applying the scenario's explicit overrides. This is not a
|
||
sandbox. Filesystem observation paths select what REA snapshots; they do not
|
||
restrict what the process can read or write. The inherited environment is not
|
||
recorded, so its influence may remain unknown.
|
||
|
||
## Host support
|
||
|
||
Capturing a new scenario currently requires Linux or macOS and a working
|
||
native PTY backend. Native Windows capture is unavailable because the PTY
|
||
adapter does not yet verify descendant cleanup. The native Job Object controls
|
||
used by other REA providers do not establish PTY capture support. Reinstalling
|
||
the Windows PTY binary does not enable this workflow.
|
||
|
||
For Linux commands, use Linux REA inside WSL. Adapt the scenario to that host;
|
||
this does not establish capture of a native Windows process tree. Comparing
|
||
existing capture Evidence through the CLI or MCP remains available on Windows
|
||
and does not launch a PTY or the captured target.
|
||
|
||
On supported capture hosts, a missing or incompatible native PTY binary has a
|
||
different recovery: reinstall REA for the active platform, architecture, and
|
||
Node.js version with optional dependencies enabled. Capability diagnostics
|
||
distinguish this from the Windows capture-adapter limitation.
|
||
|
||
macOS also requires Apple's Swift compiler through `xcrun` for process
|
||
ownership inspection. REA compiles its packaged, narrow process-inspection
|
||
helper into a private temporary directory before a capture or owned provider
|
||
process starts; the same prerequisite applies to owned provider-process
|
||
supervision on macOS. REA removes that directory when it exits. REA does not
|
||
install Xcode, Command Line Tools, or other software. Capability checks prepare
|
||
this helper before reporting macOS capture as available.
|
||
|
||
## Capture a command
|
||
|
||
Write a JSON scenario and pass its path to the CLI:
|
||
|
||
```sh
|
||
rea capture-process ./scenario.json --json > capture.json
|
||
```
|
||
|
||
`--json` is required when saving input for JSON consumers; the default terminal
|
||
format is TOON. The file contains the complete capture Evidence record.
|
||
Choose an output file distinct from the scenario input: shell redirection opens
|
||
and truncates the output before REA reads the scenario.
|
||
|
||
While the capture runs, REA writes live status to stderr as one JSON object per
|
||
line. Stdout stays the final Evidence document:
|
||
|
||
```json
|
||
{
|
||
"rea_progress": {
|
||
"phase": "running",
|
||
"completed": 3,
|
||
"total": null,
|
||
"message": "elapsed_ms=1200 frames=1 samples=2 interactions=0",
|
||
"sequence": 4
|
||
}
|
||
}
|
||
```
|
||
|
||
`phase` is the observed lifecycle stage: `prepare`, `running`, `settling`, or
|
||
`cleanup`. `completed` counts collected terminal frames, process samples, and
|
||
interaction events. `total` stays null; REA does not invent a unit total or a
|
||
percentage. `message` reports elapsed time and those counts. The final line uses
|
||
`phase` `cleanup`, sets `terminal` to true, and adds `disposition` (`exited`,
|
||
`timeout`, `idle_timeout`, `cancelled`, or `failed`) plus the owned-process,
|
||
renderer, and temporary-root cleanup states. Updates use the shared progress
|
||
boundary and are limited to one intermediate line per 100 ms; the terminal line
|
||
is always emitted. If a receiver is slow, intermediate observations are merged
|
||
into the latest status, and stale live observations are discarded before final
|
||
cleanup status. Progress never copies child output. Child output remains in the
|
||
capture Evidence on stdout.
|
||
|
||
For example:
|
||
|
||
```json
|
||
{
|
||
"executable": "node",
|
||
"arguments": ["./signup.mjs"],
|
||
"working_directory": ".",
|
||
"environment": { "APP_MODE": "test" },
|
||
"filesystem_observation_paths": ["./state"],
|
||
"terminal": { "columns": 80, "rows": 24, "scrollback": 1000 },
|
||
"events": [
|
||
{
|
||
"type": "input",
|
||
"at_ms": 250,
|
||
"data": "user@example.test\r",
|
||
"sensitive": true
|
||
},
|
||
{ "type": "resize", "at_ms": 500, "columns": 100, "rows": 30 },
|
||
{ "type": "signal", "at_ms": 1000, "signal": "SIGINT" }
|
||
],
|
||
"timeout_ms": 30000,
|
||
"idle_timeout_ms": 30000,
|
||
"settle_ms": 100
|
||
}
|
||
```
|
||
|
||
`executable` is required. `arguments`, `working_directory`, `environment`,
|
||
`filesystem_observation_paths`, terminal settings, timed `events`, timeouts,
|
||
resource limits, and normalization settings have defaults; see
|
||
`processScenarioSchema` for the exact contract. Event times are milliseconds
|
||
from launch, must be ordered, and must fall within `timeout_ms`. Inputs marked
|
||
`sensitive` are sent to the process but persisted as a byte-count placeholder.
|
||
Environment overrides are recorded in the scenario commitment; inherited
|
||
values are not copied into Evidence.
|
||
|
||
Terminal dimensions must satisfy `columns × (rows + scrollback) ≤ 1,000,000`.
|
||
Every scheduled resize uses the same selected scrollback and must fit this
|
||
working-buffer budget. REA rejects oversized scenarios before launching the
|
||
command; it never clamps the requested dimensions. This limit is independent
|
||
of `limits.output_bytes`, which bounds retained terminal observations.
|
||
|
||
The capture contains raw PTY output chunks and rendered terminal states,
|
||
interaction dispatch outcomes, exit reason, sampled process-tree observations,
|
||
and settlement status. When filesystem paths are selected, REA records
|
||
`files_before` and `files_after`, then classifies observed entries as created,
|
||
deleted, modified, or unchanged. These are bounded snapshots, not a syscall
|
||
trace; short-lived changes between snapshots may be missed. With no selected
|
||
paths, filesystem effects remain unknown.
|
||
|
||
A missing entry proves creation or deletion only when the corresponding
|
||
observation root's path enumeration was exhausted. Otherwise its effect has
|
||
`status: "unknown"`, the available before/after state, and a reason; a null
|
||
state in this variant means unobserved. Paths below symlinks that REA did not
|
||
follow also remain unknown. Partial enumeration of one root does not erase
|
||
known effects in another, and unavailable content hashes do not by themselves
|
||
make path absence unknown.
|
||
|
||
Terminal frame `data` is the comparison text after the selected normalization.
|
||
New captures also preserve `raw_data` when normalization changes a PTY chunk;
|
||
otherwise `data` is already the original text. Older captures may lack the
|
||
original text for changed chunks. Both values participate in Evidence identity,
|
||
while comparisons and trace assertions use the normalized text. Retaining the
|
||
original adds at most `limits.output_bytes` of text, because it comes from the
|
||
same admitted PTY chunks rather than a second unbounded stream.
|
||
|
||
Rendered frame `lines` hold the visible rows after normalization, with trailing
|
||
U+0020 spaces removed. Blank rows and internal spaces remain. `columns` records
|
||
terminal cell width; normalization and Unicode can make string lengths differ
|
||
from that width. `serialized_state` holds terminal serialization after the
|
||
selected normalization. Original admitted PTY chunks are available through
|
||
`raw_data ?? data`, as described above. Captures commit this line format in their
|
||
comparison contract, so a capture whose lines were padded to full width does not
|
||
compare as the same contract.
|
||
|
||
Custom `normalization.patterns` replace literal text with the caller's literal
|
||
replacement, including dollar sequences such as `$&` and `$$`. An empty pattern
|
||
also matches the first and last string positions. Captures with custom patterns
|
||
commit `pattern_normalization_version: "literal-replacements-v1"`. Legacy captures
|
||
remain readable. Cross-version comparison is rejected; capture both sides with
|
||
the same replacement semantics. Captures without custom patterns keep their
|
||
existing comparison contract.
|
||
|
||
Port normalization recognizes explicit `port`, `tcp_port`, `udp_port`, and
|
||
`listen` fields, URL authorities, IP endpoints, and `localhost` endpoints, with
|
||
ports from 0 through 65535. A port may end a sentence, as in `port: 8080.`;
|
||
a period followed by a letter or digit continues a decimal, version, or host
|
||
name instead. Each PTY chunk is normalized on its own, so a port whose period
|
||
ends the chunk is left unchanged because its continuation is not yet known. It preserves ordinary counters, dimensions, file line numbers,
|
||
and ambiguous bare host labels. Use explicit literal `patterns`
|
||
for a different application-specific spelling. Set `ports: false` to preserve
|
||
all endpoint numbers in comparison text. Captures commit the port-normalization
|
||
version so older broad numeric normalization cannot silently compare as the
|
||
same contract.
|
||
|
||
Output retention, file count, aggregate file-hashing bytes, process sampling, filesystem depth, total
|
||
runtime, idle time, and post-exit settlement are bounded by the scenario's
|
||
limits. The result marks truncated observations and residual unknowns rather
|
||
than treating missing data as proof of equivalence. Cancellation and timeout
|
||
run the same owned-process cleanup path. Settlement reports whether the
|
||
sampled process group quiesced or whether cleanup was needed or unverifiable;
|
||
sampling cannot prove that every short-lived or detached descendant was seen.
|
||
|
||
### Finalization interval
|
||
|
||
By default a deadline sends `SIGKILL` at once. Set `finalization_ms` to give the
|
||
target time to finish: when `timeout_ms` or `idle_timeout_ms` fires, REA sends
|
||
`SIGTERM`, keeps capturing output and selected files, and sends `SIGKILL` once
|
||
if the interval ends first. Cancellation is not delayed: it sends `SIGKILL`
|
||
immediately, also during finalization, and ends the run as cancelled.
|
||
|
||
```json
|
||
{
|
||
"executable": "node",
|
||
"arguments": ["./job.mjs"],
|
||
"filesystem_observation_paths": ["./reports"],
|
||
"timeout_ms": 30000,
|
||
"finalization_ms": 1500
|
||
}
|
||
```
|
||
|
||
REA binds the captured root's first start identity to its ownership token,
|
||
checking the identity before and after reading that token. A missing or
|
||
mismatched token, or an identity change during validation, leaves the identity
|
||
unverified rather than adopting a replacement process. Both finalization
|
||
signals recheck that retained identity immediately before the call; the check
|
||
and signal remain separate operating-system operations. REA does not fall back
|
||
to a bare PID kill for either finalization signal; failed identity or ownership
|
||
validation is recorded as `unverified` with its reason. Cancellation before any
|
||
deadline keeps the existing terminal kill. Descendants are not signalled during
|
||
finalization; owned-process cleanup handles them afterwards.
|
||
|
||
`exit.reason` keeps the initiating deadline (`timeout` or `idle_timeout`).
|
||
`exit.code` is `null` for every deadline exit; the code of an exit observed
|
||
during finalization is `exit.finalization.exit_code`, and the observed signal
|
||
stays in `exit.signal`. `exit.finalization` records what was observed and is
|
||
absent when no finalization was attempted:
|
||
|
||
```json
|
||
{
|
||
"code": null,
|
||
"signal": 0,
|
||
"reason": "timeout",
|
||
"finalization": {
|
||
"requested_ms": 1500,
|
||
"elapsed_ms": 12,
|
||
"exit_code": 0,
|
||
"signals": [
|
||
{ "signal": "SIGTERM", "sent_at_ms": 0, "delivery": "signaled" }
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
- `signals` lists each attempt in order: `SIGTERM` first, at most one `SIGKILL`.
|
||
`sent_at_ms` is the attempt time on a monotonic clock, relative to the start of
|
||
finalization, also for an attempt that could not be delivered.
|
||
- `delivery` is `signaled` (the identity matched and the signal call returned),
|
||
`gone` (no live process matched), `identity-changed` (the PID now belongs to a
|
||
different process, which was not signalled) or `unverified` (the identity could
|
||
not be established or the call failed). None of them proves the process
|
||
exited.
|
||
An `unverified` attempt may carry a `reason` string naming why: an unavailable
|
||
identity and its reason, an inspection or signal error, or delivery that did
|
||
not settle within the bound (1000 ms). An attempt recorded `unverified` after
|
||
that bound can still complete later; it stays identity-checked, and its late
|
||
result is not added to the record.
|
||
- `elapsed_ms` is when REA observed the exit, relative to the start of
|
||
finalization, or `null` when no exit was observed. The PTY layer can deliver the
|
||
exit slightly after the operating system ended the process.
|
||
- `exit_code` is the code the PTY reported when the exit was observed during
|
||
finalization and was not caused by a signal (`exit.signal` is `0`); a
|
||
finalizer that exits with `3` records `3`, one that exits cleanly records `0`.
|
||
It is `null` when no exit was observed or the target ended on a signal, and it
|
||
is never inferred. A target that exited on its own near the deadline is
|
||
recorded the same way.
|
||
- The observed signal stays in `exit.signal`; `exit.code` is `null` for every
|
||
deadline exit. The record does not attribute an exit to a particular signal,
|
||
and a `SIGKILL` from a scenario event next to the escalation cannot be told
|
||
apart from it.
|
||
- If the `SIGKILL` cannot be delivered, REA stops waiting for an exit, keeps every
|
||
observation collected so far with the delivery results, and releases the run
|
||
through owned-process cleanup.
|
||
- The `SIGKILL` is not sequenced after the `SIGTERM` delivery result: the two
|
||
deliveries are independent, because cancellation must not wait for a slow
|
||
identity check.
|
||
|
||
`timeout_ms + finalization_ms + settle_ms` must stay at or below
|
||
`Number.MAX_SAFE_INTEGER` (9007199254740991), the largest total exactly
|
||
representable as a JavaScript number; the scenario is rejected otherwise. This is
|
||
a representability limit, not a timer limit. The nominal lifecycle budget is that
|
||
sum; polling, snapshotting and cleanup lie outside it. Scenario `events` keep
|
||
being dispatched during finalization and are recorded as usual. `finalization_ms`
|
||
of `0`, the default, keeps today's behaviour and the committed identity of every
|
||
existing scenario. `elapsed_ms` and `sent_at_ms` are timing data and take part in
|
||
exit comparison like `settlement.elapsed_ms`.
|
||
|
||
Every capture requires `truncation_details`, with separate accounting for:
|
||
|
||
- `raw_terminal`: original UTF-8 PTY chunk bytes and observed/retained chunk
|
||
counts. A chunk that does not fit `limits.output_bytes` is omitted whole;
|
||
a later smaller chunk can still fit.
|
||
- `rendered_terminal`: cumulative UTF-8 serialized-state and visible-line
|
||
bytes, plus observed/retained frame counts. This is independent of the raw
|
||
chunk budget and does not count JSON encoding. Repeated screen snapshots can
|
||
exhaust it even when all raw output fits. Rendered states use retained PTY
|
||
input, so raw omissions also limit rendered coverage.
|
||
- `filesystem_before` and `filesystem_after`: file-count/depth limits,
|
||
enumeration failures, whole-file hash budget and bytes successfully hashed.
|
||
Directory names are read incrementally within the remaining entry capacity.
|
||
When a directory exceeds that capacity, the retained subset follows filesystem
|
||
enumeration order and is then sorted; it is explicitly incomplete and cannot
|
||
establish that an omitted path is absent.
|
||
Each retained regular file without a digest has an aliased path, size,
|
||
remaining budget and reason: `file_bytes_budget`,
|
||
`file_changed_or_short_read`, or `file_unavailable`. Its `system_code` is
|
||
null unless an OS file operation failed, in which case it preserves the
|
||
reported error code. A file too large for the remaining budget is
|
||
skipped; a later smaller file can still be hashed. Hash omissions do not
|
||
imply incomplete path enumeration.
|
||
- `process`: sampling limit and whether sampling ended partially. Coverage
|
||
remains `sampled`, including when that limit was not exhausted.
|
||
|
||
The aggregate `truncated` flag summarizes these observations. Comparisons retain
|
||
results for unaffected dimensions and mark affected dimensions unknown; an
|
||
incomplete capture cannot locate the first divergence across all dimensions.
|
||
Trace assertions use coverage for the sources they select, so an assertion
|
||
about complete raw terminal output need not fail because rendered snapshots
|
||
were omitted. Captures missing `truncation_details` are rejected on import;
|
||
preserve them as historical files and recapture for current comparisons.
|
||
`limits.file_bytes` is already the cumulative whole-file hashing budget,
|
||
independent of `limits.output_bytes`, which bounds retained terminal content.
|
||
Each filesystem checkpoint starts with a fresh `file_bytes` budget across all
|
||
selected roots; it is not a per-file allowance. File contents are read in
|
||
64 KiB chunks for hashing and are not embedded in capture Evidence.
|
||
|
||
When the host withholds an unrelated process’s ownership token, REA leaves that
|
||
process untouched and records its PID and reason in `cleanup.unverified_processes`
|
||
and process residual unknowns. Successful cleanup verifies the owned group;
|
||
it does not attribute those unrelated processes. Related or otherwise unexplained
|
||
unreadable processes still prevent successful cleanup.
|
||
|
||
On macOS, changing a Node process's `process.title` can make its run token
|
||
unreadable. Node documents that [setting the title overwrites argv memory](https://nodejs.org/download/release/v24.18.0/docs/api/process.html#processtitle).
|
||
With the pinned toolchain, a live child retained a readable start identity
|
||
while its token became unavailable after this change; npm changes its title
|
||
as well. A newly started unreadable process can prevent verified cleanup even
|
||
after the selected command exits. REA preserves this uncertainty and leaves
|
||
that process untouched.
|
||
|
||
## Hash a report independently of terminal output
|
||
|
||
Select the producer's report directory and give `file_bytes` enough room for
|
||
the complete files you need, including other files in those roots. For example,
|
||
save this scenario and run `rea capture-process ./scenario.json --json > capture.json`:
|
||
|
||
```json
|
||
{
|
||
"executable": "node",
|
||
"arguments": [
|
||
"-e",
|
||
"const fs=require('node:fs');fs.mkdirSync('reports',{recursive:true});fs.writeFileSync('reports/final.json',JSON.stringify({status:'complete',data:'X'.repeat(131072)}));console.log('x'.repeat(8192));"
|
||
],
|
||
"working_directory": ".",
|
||
"filesystem_observation_paths": ["./reports"],
|
||
"limits": { "output_bytes": 1024, "file_bytes": 262144 }
|
||
}
|
||
```
|
||
|
||
The report exceeds the terminal budget but fits the independent 256 KiB hash
|
||
budget. Its complete SHA-256 appears in `normalized_result.files_after` at
|
||
`root_0:final.json`; check `truncation_details.filesystem_after` for omissions
|
||
and actual bytes hashed. The verbose terminal output is truncated without
|
||
preventing the report digest. The same scenario object is the MCP
|
||
`capture_process_scenario` input. REA never returns a prefix hash as a full-file
|
||
digest: an insufficient remaining budget produces a null digest and an
|
||
explicit omission reason.
|
||
|
||
A digest binds the complete bytes observed at that checkpoint, not the
|
||
producer's notion of a finished report. A stable periodic or running report
|
||
can also have a complete digest. Choose the final file and verify the
|
||
producer's documented completion marker, exit outcome and settlement
|
||
observations before calling it final. `after_settlement` names a filesystem
|
||
checkpoint, not a guarantee that an application finished successfully or
|
||
that an unobserved writer will never change the file. REA checks opened-file
|
||
identity/state around hashing and reports observed changes or unavailable
|
||
files; cancellation stops the hash. If an external sealer produces a manifest,
|
||
keep its completion claims and digest separate from REA's checkpoint facts.
|
||
|
||
## Compare two captures
|
||
|
||
Compare saved capture Evidence with:
|
||
|
||
```sh
|
||
rea compare-process-captures authority.json reconstruction.json
|
||
```
|
||
|
||
The comparison checks terminal, interaction, exit, filesystem, and
|
||
process observations under a shared comparison contract. It reports observed
|
||
differences with their locations, while residual unknowns or truncated
|
||
observations prevent a complete-equivalence claim. Matching scenario
|
||
commitments do not reveal whether redacted sensitive inputs were equal.
|
||
|
||
For scenarios where independent scheduling can change event order, the CLI
|
||
also accepts an optional trace-specification JSON file as the third argument.
|
||
That specification must state the exact events and ordering constraints to
|
||
accept; it does not discard unmatched observations or make timestamps into
|
||
causal evidence. Trace comparison returns `unknown` when a capture lacks the
|
||
required complete event journal or contains relevant residual unknowns.
|
||
|
||
Each capture carries commitments for the selected scenario, executable,
|
||
comparison contract, and normalization rules. The comparison contract covers
|
||
the working directory, explicit environment, filesystem observation paths,
|
||
terminal size, scripted events, timeouts, limits, and normalization; the
|
||
executable and its arguments may differ. Comparison rejects captures whose
|
||
comparison contracts differ and names the differing fields, so run both
|
||
scenarios from the same absolute working directory and observation paths.
|
||
Captures are local Evidence files; keep
|
||
their source artifacts and invocation context available when interpreting a
|
||
difference.
|