rea-agents 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +94 -28
  2. package/bridge/hopper_bridge.py +161 -12
  3. package/dist/application/AnalysisSnapshotCache.js +131 -0
  4. package/dist/application/AnalysisSnapshotFiles.js +31 -0
  5. package/dist/application/ArtifactInventory.js +84 -44
  6. package/dist/application/AuthorizedArtifactInventory.js +16 -0
  7. package/dist/application/BinarySession.js +66 -36
  8. package/dist/application/BinarySessionPort.js +1 -0
  9. package/dist/application/BoundedJsonFiles.js +109 -0
  10. package/dist/application/CommandShimReplay.js +136 -0
  11. package/dist/application/CrossVersionInventory.js +98 -0
  12. package/dist/application/CrossVersionInvestigation.js +295 -0
  13. package/dist/application/DirectAnalysis.js +115 -22
  14. package/dist/application/Doctor.js +30 -1
  15. package/dist/application/EvidenceBundleFiles.js +16 -101
  16. package/dist/application/EvidenceLedger.js +3 -2
  17. package/dist/application/InvestigationProviders.js +48 -0
  18. package/dist/application/InvestigationWorkspaceStore.js +214 -0
  19. package/dist/application/LinuxHopper.js +72 -48
  20. package/dist/application/ProcessCaptureAuthority.js +25 -0
  21. package/dist/application/ProcessCaptureCapability.js +23 -0
  22. package/dist/application/ProcessCaptureError.js +17 -0
  23. package/dist/application/ProcessCaptureLifecycle.js +287 -0
  24. package/dist/application/ProcessCheckpoints.js +129 -0
  25. package/dist/application/ProcessCli.js +128 -0
  26. package/dist/application/ProcessEvidence.js +38 -0
  27. package/dist/application/ProcessHarness.js +252 -272
  28. package/dist/application/ProcessNormalization.js +10 -1
  29. package/dist/application/ProcessOwnership.js +39 -1
  30. package/dist/application/ProcessSampling.js +39 -23
  31. package/dist/application/ReferenceSourceImportEntries.js +18 -3
  32. package/dist/application/ReferenceSourceImportTypes.js +32 -0
  33. package/dist/application/Setup.js +44 -96
  34. package/dist/application/SetupClients.js +19 -0
  35. package/dist/application/SetupInstallFailure.js +53 -0
  36. package/dist/application/SetupPlan.js +31 -0
  37. package/dist/application/SetupSkill.js +44 -0
  38. package/dist/application/TerminalRenderer.js +92 -0
  39. package/dist/application/Uninstall.js +33 -23
  40. package/dist/application/UnknownEvidence.js +33 -0
  41. package/dist/application/Upgrade.js +1 -1
  42. package/dist/application/runtime.js +2 -2
  43. package/dist/artifacts/ArtifactProvider.js +20 -11
  44. package/dist/artifacts/ArtifactReader.js +3 -1
  45. package/dist/artifacts/AsarArtifactReader.js +8 -1
  46. package/dist/artifacts/DirectoryArtifactReader.js +1 -0
  47. package/dist/artifacts/MachOSliceArtifactReader.js +1 -0
  48. package/dist/artifacts/NativeDmgArtifactReader.js +151 -0
  49. package/dist/artifacts/ZipArtifactReader.js +1 -0
  50. package/dist/cli.js +57 -32
  51. package/dist/cliEvidenceCommands.js +11 -12
  52. package/dist/cliInvestigationCommands.js +86 -0
  53. package/dist/cliOutput.js +41 -0
  54. package/dist/cliProcessCommands.js +29 -0
  55. package/dist/config.js +41 -24
  56. package/dist/contracts/artifactToolContracts.js +1 -0
  57. package/dist/contracts/investigationExamples.js +3 -3
  58. package/dist/contracts/processCaptureExample.js +50 -7
  59. package/dist/contracts/promptContracts.js +256 -0
  60. package/dist/contracts/sessionLifecycleInputs.js +11 -0
  61. package/dist/contracts/toolContractTypes.js +1 -0
  62. package/dist/contracts/toolContracts.js +13 -8
  63. package/dist/contracts/toolOutputSchemas.js +33 -4
  64. package/dist/domain/analysisSnapshot.js +149 -0
  65. package/dist/domain/changedBehavior.js +32 -9
  66. package/dist/domain/errors.js +186 -39
  67. package/dist/domain/evidence.js +4 -2
  68. package/dist/domain/evidenceBundle.js +28 -0
  69. package/dist/domain/hopperStartupFailure.js +55 -0
  70. package/dist/domain/investigationWorkspace.js +214 -0
  71. package/dist/domain/jsonValue.js +2 -0
  72. package/dist/domain/nativeInspection.js +3 -2
  73. package/dist/domain/processCapture.js +100 -293
  74. package/dist/domain/processCaptureValidation.js +124 -0
  75. package/dist/domain/processComparison.js +177 -32
  76. package/dist/domain/processScenario.js +370 -0
  77. package/dist/domain/reconstructionVerification.js +3 -3
  78. package/dist/domain/staticRuntimeCorrelation.js +3 -2
  79. package/dist/hopper/BridgeLauncher.js +47 -20
  80. package/dist/hopper/HopperClient.js +17 -2
  81. package/dist/hopper/HopperProvider.js +1 -0
  82. package/dist/identity.js +1 -0
  83. package/dist/main.js +47 -24
  84. package/dist/server/createServer.js +2 -0
  85. package/dist/server/promptCompletion.js +144 -0
  86. package/dist/server/registerEnhancedTools.js +2 -6
  87. package/dist/server/registerEvidenceTools.js +2 -8
  88. package/dist/server/registerFunctionComparisonTool.js +1 -1
  89. package/dist/server/registerInvestigationTools.js +31 -11
  90. package/dist/server/registerOfficialTools.js +6 -13
  91. package/dist/server/registerProcessComparisonTool.js +28 -14
  92. package/dist/server/registerPrompts.js +67 -0
  93. package/dist/server/registerSessionStatusTool.js +11 -0
  94. package/dist/server/registerSessionTools.js +55 -43
  95. package/dist/server/sessionEvidence.js +2 -8
  96. package/dist/server/sessionToolPolicies.js +2 -48
  97. package/dist/server/toolResult.js +7 -3
  98. package/install.sh +11 -11
  99. package/package.json +9 -3
  100. package/scripts/hopper-demo-x11.py +349 -0
  101. package/scripts/prepare-node-pty.mjs +35 -0
  102. package/scripts/rea.mjs +6 -3
  103. package/skills/rea-analysis/SKILL.md +27 -5
@@ -1,63 +1,20 @@
1
- import { randomUUID } from "node:crypto";
2
- import { mkdtemp, realpath, rm } from "node:fs/promises";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
1
  import { spawn } from "node-pty";
6
- import { authorizeProcessScenario } from "../domain/processCapture.js";
2
+ import { validateProcessCapture } from "../domain/processCapture.js";
7
3
  import { err, ok } from "../domain/result.js";
8
- import { AnalysisError } from "../domain/errors.js";
4
+ import { ProcessCaptureError, processCaptureCancelled, } from "./ProcessCaptureError.js";
5
+ export { ProcessCaptureError } from "./ProcessCaptureError.js";
9
6
  import { startLoopbackReplay } from "./LoopbackReplay.js";
10
- import { cleanupOwnedProcessGroup } from "./ProcessOwnership.js";
11
7
  import { startProcessSampler } from "./ProcessSampling.js";
12
8
  import { snapshotRoots } from "./FilesystemSnapshot.js";
13
- import { normalizeProcessSamples, normalizeProcessText, redactProtocolEvents, } from "./ProcessNormalization.js";
14
- const isWithin = (candidate, root) => candidate === root ||
15
- candidate.startsWith(`${root.endsWith("/") ? root.slice(0, -1) : root}/`);
16
- const assertRealPathAuthority = async (scenario, policy) => {
17
- const executable = await realpath(scenario.executable);
18
- const executableRoots = await Promise.all(policy.executableRoots.map((root) => realpath(root)));
19
- if (!executableRoots.some((root) => isWithin(executable, root)))
20
- throw new ProcessCaptureError("resolved executable is outside approved roots");
21
- const workingDirectory = await realpath(scenario.working_directory);
22
- const workingRoots = await Promise.all(policy.workingRoots.map((root) => realpath(root)));
23
- if (!workingRoots.some((root) => isWithin(workingDirectory, root)))
24
- throw new ProcessCaptureError("resolved working directory is outside approved roots");
25
- for (const root of scenario.filesystem_roots) {
26
- const resolvedRoot = await realpath(root);
27
- if (!workingRoots.some((approved) => isWithin(resolvedRoot, approved)))
28
- throw new ProcessCaptureError("resolved filesystem root is outside approved roots");
29
- }
30
- };
31
- /** Expected refusal or runtime failure from the process capture adapter. */
32
- export class ProcessCaptureError extends AnalysisError {
33
- _tag = "ProcessCaptureError";
34
- }
35
- const assertNotCancelled = (signal) => {
36
- if (signal?.aborted === true)
37
- throw new ProcessCaptureError("process capture was cancelled");
38
- };
39
- /** Probe the actual native PTY seam instead of inferring support from the OS name. */
40
- export const probeProcessCaptureCapability = async () => {
41
- try {
42
- const terminal = spawn(process.platform === "win32" ? "cmd.exe" : "/bin/sh", process.platform === "win32" ? ["/c", "exit", "0"] : ["-c", "exit 0"], {
43
- cwd: tmpdir(),
44
- env: { HOME: tmpdir(), TERM: "xterm-256color" },
45
- cols: 80,
46
- rows: 24,
47
- name: "xterm-256color",
48
- });
49
- await new Promise((resolveExit) => terminal.onExit(() => resolveExit()));
50
- return { available: true, backend: "node-pty" };
51
- }
52
- catch {
53
- return {
54
- available: false,
55
- backend: "node-pty",
56
- reason: "the native PTY backend could not start a probe process",
57
- };
58
- }
59
- };
60
- const makeEnvironment = (scenario, home, replay, runId) => {
9
+ import { ProcessCheckpoints } from "./ProcessCheckpoints.js";
10
+ import { TerminalRenderer } from "./TerminalRenderer.js";
11
+ import { startCommandShimReplay, } from "./CommandShimReplay.js";
12
+ import { normalizeProcessText } from "./ProcessNormalization.js";
13
+ import { awaitTerminalExit, buildCaptureResult, captureTerminalFrames, createRunManifest, observeSettlement, prepareProcessCapture, releaseProcessResources, resolveProcessResult, } from "./ProcessCaptureLifecycle.js";
14
+ import { assertNotCancelled } from "./ProcessCaptureAuthority.js";
15
+ export { probeProcessCaptureCapability } from "./ProcessCaptureCapability.js";
16
+ const makeEnvironment = (options) => {
17
+ const { scenario, home, replay, shimReplay, runId } = options;
61
18
  const environment = {
62
19
  ...scenario.environment,
63
20
  HOME: home,
@@ -71,277 +28,300 @@ const makeEnvironment = (scenario, home, replay, runId) => {
71
28
  }
72
29
  environment.REA_REPLAY_HTTP_URL = replay.httpUrl;
73
30
  environment.REA_REPLAY_WEBSOCKET_URL = replay.websocketUrl;
31
+ environment.REA_SHIM_LEDGER_URL = shimReplay.url;
32
+ // PATH is evidence-producing input. Inheriting the host PATH here would let
33
+ // the authority and reconstruction probe different tools without recording
34
+ // that difference. Scenarios must opt in through environment or
35
+ // inherit_environment; deterministic shims always take precedence.
36
+ environment.PATH = [shimReplay.binPath, environment.PATH ?? ""]
37
+ .filter((part) => part.length > 0)
38
+ .join(":");
74
39
  return environment;
75
40
  };
76
- const scheduleScenarioEvents = (scenario, getTerminal, timers) => {
77
- for (const event of scenario.events) {
41
+ const scheduleScenarioEvents = (options) => {
42
+ const { scenario, getTerminal, timers, interactions, renderer, started } = options;
43
+ for (const [eventIndex, event] of scenario.events.entries()) {
78
44
  const timer = setTimeout(() => {
45
+ options.dispatchedEventIndexes.add(eventIndex);
79
46
  const terminal = getTerminal();
80
- if (event.type === "input")
81
- terminal?.write(event.data);
82
- else if (event.type === "resize")
83
- terminal?.resize(event.columns, event.rows);
84
- else
85
- terminal?.kill(event.signal);
47
+ const dispatchedAt = Math.max(0, Date.now() - started);
48
+ let outcome = "dispatched";
49
+ if (terminal === undefined)
50
+ outcome = "target_exited";
51
+ else {
52
+ try {
53
+ if (event.type === "input")
54
+ terminal.write(event.data);
55
+ else if (event.type === "resize") {
56
+ terminal.resize(event.columns, event.rows);
57
+ renderer.resize(event.columns, event.rows, dispatchedAt);
58
+ }
59
+ else
60
+ terminal.kill(event.signal);
61
+ }
62
+ catch {
63
+ outcome = "failed";
64
+ }
65
+ }
66
+ interactions.push({
67
+ sequence: interactions.length,
68
+ scheduled_at_ms: event.at_ms,
69
+ dispatched_at_ms: dispatchedAt,
70
+ type: event.type,
71
+ data: event.type === "input"
72
+ ? event.sensitive
73
+ ? `<redacted-input:${String(Buffer.byteLength(event.data))}-bytes>`
74
+ : normalizeProcessText(event.data, scenario, "<no-temporary-root>", -1)
75
+ : event.type === "resize"
76
+ ? `${String(event.columns)}x${String(event.rows)}`
77
+ : event.signal,
78
+ outcome,
79
+ });
86
80
  }, event.at_ms);
87
81
  timers.add(timer);
88
82
  }
89
83
  };
90
- const startScenarioEvents = (scenario, getTerminal, timers) => {
91
- let started = false;
84
+ const startScenarioEvents = (options) => {
85
+ let eventsStarted = false;
92
86
  return () => {
93
- if (started)
87
+ if (eventsStarted)
94
88
  return;
95
- started = true;
96
- scheduleScenarioEvents(scenario, getTerminal, timers);
89
+ eventsStarted = true;
90
+ scheduleScenarioEvents(options);
97
91
  };
98
92
  };
99
93
  const eventsRequireReadiness = (scenario) => scenario.events.some((event) => event.type === "input" || event.type === "resize");
100
- const classifyFilesystemEffects = (before, after) => {
101
- const beforeByPath = new Map(before.map((file) => [file.path, file]));
102
- const afterByPath = new Map(after.map((file) => [file.path, file]));
103
- const paths = [
104
- ...new Set([...beforeByPath.keys(), ...afterByPath.keys()]),
105
- ].sort();
106
- return paths.map((path) => {
107
- const beforeFile = beforeByPath.get(path) ?? null;
108
- const afterFile = afterByPath.get(path) ?? null;
109
- const status = beforeFile === null
110
- ? "created"
111
- : afterFile === null
112
- ? "deleted"
113
- : JSON.stringify(beforeFile) === JSON.stringify(afterFile)
114
- ? "unchanged"
115
- : "modified";
116
- return { path, status, before: beforeFile, after: afterFile };
94
+ const cleanupFailedStartup = async (options) => {
95
+ options.terminal?.kill("SIGKILL");
96
+ const cleanupFailure = await releaseProcessResources({
97
+ ...options,
98
+ sampledProcessGroupIds: [],
117
99
  });
100
+ if (cleanupFailure !== undefined)
101
+ throw new ProcessCaptureError(cleanupFailure, { cause: options.cause });
102
+ throw options.cause;
118
103
  };
119
- const buildCaptureResult = (options) => ({
120
- schema_version: 2,
121
- normalization: options.scenario.normalization,
122
- frames: options.frames,
123
- exit: {
124
- code: options.exit.exitCode < 0 ? null : options.exit.exitCode,
125
- signal: options.exit.signal ?? null,
126
- reason: options.exit.reason,
127
- },
128
- process_samples: normalizeProcessSamples(options.samples, options.scenario, options.rootPid),
129
- protocol_events: redactProtocolEvents(options.replay.events, options.scenario),
130
- files_before: options.before.files,
131
- files_after: options.after.files,
132
- filesystem_effects: classifyFilesystemEffects(options.before.files, options.after.files),
133
- truncated: options.truncated,
134
- limitations: [
135
- "Process trees are sampled and may omit short-lived descendants.",
136
- ...(options.samplingPartial
137
- ? ["Process-tree sampling ended with an incomplete observation."]
138
- : []),
139
- "Filesystem observations are before/after snapshots, not syscall traces.",
140
- "The harness does not enforce external network isolation.",
141
- ],
142
- residual_unknowns: [
143
- {
144
- scope: "process",
145
- reason: "Process trees are sampled and may omit short-lived descendants.",
146
- },
147
- {
148
- scope: "network",
149
- reason: "External network isolation is not enforced by this adapter.",
150
- },
151
- ],
152
- cleanup: {
153
- owned_process_group: "verified",
154
- temporary_root: "removed",
155
- },
104
+ const createTerminalRenderer = (scenario, temporaryRoot, terminalPid) => new TerminalRenderer({
105
+ columns: scenario.terminal.columns,
106
+ rows: scenario.terminal.rows,
107
+ scrollback: scenario.terminal.scrollback,
108
+ maxFrames: scenario.limits.frames,
109
+ maxBytes: scenario.limits.output_bytes,
110
+ normalize: (value) => normalizeProcessText(value, scenario, temporaryRoot, terminalPid()),
156
111
  });
157
- const awaitTerminalExit = async ({ terminal, scenario, started, lastOutput, signal, timers, }) => new Promise((resolveExit) => {
158
- let reason = "exited";
159
- terminal.onExit((exit) => {
160
- for (const timer of timers) {
161
- clearTimeout(timer);
162
- clearInterval(timer);
163
- }
164
- timers.clear();
165
- resolveExit({ ...exit, reason });
166
- });
167
- const timeout = setInterval(() => {
168
- if (signal?.aborted === true) {
169
- reason = "cancelled";
170
- terminal.kill("SIGKILL");
171
- }
172
- else if (Date.now() - started >= scenario.timeout_ms) {
173
- reason = "timeout";
174
- terminal.kill("SIGKILL");
175
- }
176
- else if (Date.now() - lastOutput() >= scenario.idle_timeout_ms) {
177
- reason = "idle_timeout";
178
- terminal.kill("SIGKILL");
179
- }
180
- }, 20);
181
- timers.add(timeout);
182
- });
183
- const releaseProcessResources = async (options) => {
184
- for (const timer of options.timers)
185
- clearTimeout(timer);
186
- let failure;
112
+ const startCaptureRuntime = async (options) => {
113
+ const { scenario } = options;
114
+ let replay;
115
+ let renderer;
116
+ let checkpoints;
117
+ let shimReplay;
118
+ let terminal;
187
119
  try {
188
- await options.replay?.close();
189
- }
190
- catch {
191
- failure = "loopback replay cleanup failed";
120
+ replay = await startLoopbackReplay(scenario);
121
+ const startedAt = new Date();
122
+ const started = Date.now();
123
+ let lastOutput = started;
124
+ renderer = createTerminalRenderer(scenario, options.temporaryRoot, () => terminal?.pid ?? -1);
125
+ checkpoints = new ProcessCheckpoints(scenario, started, options.before, options.signal);
126
+ shimReplay = await startCommandShimReplay(scenario, options.temporaryRoot, started);
127
+ terminal = spawn(scenario.executable, [...scenario.arguments], {
128
+ cwd: scenario.working_directory,
129
+ env: makeEnvironment({ ...options, replay, shimReplay }),
130
+ cols: scenario.terminal.columns,
131
+ rows: scenario.terminal.rows,
132
+ name: "xterm-256color",
133
+ });
134
+ const startEvents = startScenarioEvents({
135
+ ...options,
136
+ getTerminal: () => terminal,
137
+ renderer,
138
+ started,
139
+ });
140
+ if (!eventsRequireReadiness(scenario))
141
+ startEvents();
142
+ const framesTruncated = captureTerminalFrames({
143
+ ...options,
144
+ terminal,
145
+ started,
146
+ onOutput: () => (lastOutput = Date.now()),
147
+ onFirstOutput: startEvents,
148
+ renderer,
149
+ checkpoints,
150
+ });
151
+ const stopSampler = startProcessSampler(terminal.pid, started, scenario.limits.processes, options.samples);
152
+ return {
153
+ replay,
154
+ renderer,
155
+ checkpoints,
156
+ shimReplay,
157
+ terminal,
158
+ started,
159
+ startedAt,
160
+ lastOutput: () => lastOutput,
161
+ framesTruncated,
162
+ stopSampler,
163
+ };
192
164
  }
193
- if (options.terminal !== undefined && process.platform !== "win32") {
194
- const cleaned = await cleanupOwnedProcessGroup({
165
+ catch (cause) {
166
+ return cleanupFailedStartup({
167
+ cause,
168
+ timers: options.timers,
169
+ replay,
170
+ terminal,
171
+ renderer,
172
+ shimReplay,
173
+ checkpoints,
195
174
  runId: options.runId,
196
- leaderPid: options.terminal.pid,
197
- processGroupId: options.terminal.pid,
175
+ temporaryRoot: options.temporaryRoot,
198
176
  });
199
- if (!cleaned.cleaned)
200
- failure ??= cleaned.reason;
201
177
  }
202
- try {
203
- await rm(options.temporaryRoot, { recursive: true, force: true });
204
- }
205
- catch {
206
- failure ??= "temporary process root cleanup failed";
207
- }
208
- return failure;
209
178
  };
210
- const captureTerminalFrames = (options) => {
211
- let outputBytes = 0;
212
- let truncated = false;
213
- options.terminal.onData((data) => {
214
- options.onFirstOutput();
215
- options.onOutput();
216
- const bytes = Buffer.byteLength(data);
217
- if (options.frames.length >= options.scenario.limits.frames ||
218
- outputBytes + bytes > options.scenario.limits.output_bytes) {
219
- truncated = true;
220
- return;
221
- }
222
- outputBytes += bytes;
223
- options.frames.push({
224
- sequence: options.frames.length,
225
- at_ms: Math.floor((Date.now() - options.started) /
226
- options.scenario.normalization.time_bucket_ms) * options.scenario.normalization.time_bucket_ms,
227
- data: normalizeProcessText(data, options.scenario, options.temporaryRoot, options.terminal.pid),
228
- });
179
+ const normalizedShimEvents = (runtime, scenario, temporaryRoot) => runtime.shimReplay.events.map((event) => ({
180
+ ...event,
181
+ arguments: event.arguments.map((argument) => normalizeProcessText(argument, scenario, temporaryRoot, runtime.terminal.pid)),
182
+ working_directory: normalizeProcessText(event.working_directory, scenario, temporaryRoot, runtime.terminal.pid),
183
+ }));
184
+ const finishProcessRun = async (options) => {
185
+ await options.stopSampler();
186
+ const cleanupFailure = await releaseProcessResources({
187
+ timers: options.timers,
188
+ replay: options.runtime?.replay,
189
+ terminal: options.runtime?.terminal,
190
+ renderer: options.runtime?.renderer,
191
+ shimReplay: options.runtime?.shimReplay,
192
+ checkpoints: options.runtime?.checkpoints,
193
+ runId: options.runId,
194
+ temporaryRoot: options.temporaryRoot,
195
+ sampledProcessGroupIds: options.samples.flatMap(({ process_group_id }) => process_group_id === null ? [] : [process_group_id]),
229
196
  });
230
- return () => truncated;
231
- };
232
- const resolveProcessResult = (capture, executionFailure, cleanupFailure) => {
233
- if (executionFailure instanceof ProcessCaptureError)
234
- throw executionFailure;
235
- if (executionFailure !== undefined)
236
- throw new ProcessCaptureError("process capture failed", {
237
- cause: executionFailure,
238
- });
239
- if (cleanupFailure !== undefined)
240
- throw new ProcessCaptureError(cleanupFailure);
241
- if (capture === undefined)
242
- throw new ProcessCaptureError("process capture produced no result");
243
- return capture;
197
+ let { capture, executionFailure } = options;
198
+ if (capture !== undefined) {
199
+ capture = {
200
+ ...capture,
201
+ settlement: {
202
+ ...capture.settlement,
203
+ cleanup_outcome: capture.settlement.state === "quiesced"
204
+ ? "not_required"
205
+ : cleanupFailure === undefined
206
+ ? "cleaned"
207
+ : "failed",
208
+ },
209
+ };
210
+ const issue = validateProcessCapture(capture)[0];
211
+ if (issue !== undefined)
212
+ executionFailure = new ProcessCaptureError(`process capture validation failed: ${issue.path}: ${issue.message}`);
213
+ }
214
+ return resolveProcessResult(capture, executionFailure, cleanupFailure);
244
215
  };
245
- const prepareProcessCapture = async (scenario, policy, signal) => {
246
- const decision = authorizeProcessScenario(scenario, policy);
247
- if (!decision.allowed)
248
- throw new ProcessCaptureError(decision.reason);
249
- await assertRealPathAuthority(scenario, policy);
250
- assertNotCancelled(signal);
251
- const before = await snapshotRoots(scenario, signal);
252
- const temporaryRoot = await mkdtemp(join(tmpdir(), "rea-process-"));
253
- const runId = randomUUID();
254
- const home = join(temporaryRoot, "home");
255
- await import("node:fs/promises").then(({ mkdir }) => mkdir(home));
256
- return { temporaryRoot, runId, home, before };
216
+ const completeCapture = async (options) => {
217
+ const { runtime, scenario } = options;
218
+ runtime.checkpoints.trigger("root_exit");
219
+ const { reason } = options.exit;
220
+ if (reason === "cancelled")
221
+ throw processCaptureCancelled();
222
+ const settlement = await observeSettlement(options.runId, [
223
+ runtime.terminal.pid,
224
+ ...options.samples.flatMap(({ process_group_id }) => process_group_id === null ? [] : [process_group_id]),
225
+ ], scenario.settle_ms);
226
+ runtime.checkpoints.trigger("settled");
227
+ const samplingPartial = (await runtime.stopSampler()).partial;
228
+ assertNotCancelled(options.signal);
229
+ const after = await snapshotRoots(scenario, options.signal);
230
+ const renderedFrames = await runtime.renderer.frames();
231
+ const checkpoints = await runtime.checkpoints.finish(after);
232
+ const manifest = await createRunManifest(scenario, runtime.startedAt, new Date());
233
+ const truncated = options.initiallyTruncated ||
234
+ after.truncated ||
235
+ runtime.replay.truncated ||
236
+ runtime.shimReplay.truncated ||
237
+ runtime.framesTruncated() ||
238
+ checkpoints.some(({ truncated: partial }) => partial) ||
239
+ samplingPartial ||
240
+ runtime.renderer.truncated();
241
+ return buildCaptureResult({
242
+ frames: options.frames,
243
+ exit: { ...options.exit, reason },
244
+ samples: options.samples,
245
+ replay: runtime.replay,
246
+ before: options.before,
247
+ after,
248
+ truncated,
249
+ scenario,
250
+ rootPid: runtime.terminal.pid,
251
+ samplingPartial,
252
+ renderedFrames,
253
+ interactions: options.interactions,
254
+ checkpoints,
255
+ shimEvents: normalizedShimEvents(runtime, scenario, options.temporaryRoot),
256
+ settlement: { ...settlement, cleanup_outcome: "not_required" },
257
+ manifest,
258
+ });
257
259
  };
258
260
  /** Execute one authorized scenario and return bounded observations. */
259
261
  const runProcessScenario = async (scenario, policy, signal) => {
260
262
  const { temporaryRoot, runId, home, before } = await prepareProcessCapture(scenario, policy, signal);
261
263
  const frames = [];
262
264
  const samples = [];
263
- let truncated = before.truncated;
264
- let terminal;
265
- let replay;
266
- let started = 0;
267
- let lastOutput = 0;
265
+ let runtime;
268
266
  const timers = new Set();
269
267
  let capture;
270
268
  let executionFailure;
271
- let framesTruncated = () => false;
272
269
  let stopSampler = async () => ({ partial: false });
273
- let samplingPartial = false;
270
+ const interactions = [];
271
+ const dispatchedEventIndexes = new Set();
274
272
  try {
275
- replay = await startLoopbackReplay(scenario);
276
- started = Date.now();
277
- lastOutput = started;
278
- terminal = spawn(scenario.executable, [...scenario.arguments], {
279
- cwd: scenario.working_directory,
280
- env: makeEnvironment(scenario, home, replay, runId),
281
- cols: 80,
282
- rows: 24,
283
- name: "xterm-256color",
284
- });
285
- const startEvents = startScenarioEvents(scenario, () => terminal, timers);
286
- if (!eventsRequireReadiness(scenario))
287
- startEvents();
288
- framesTruncated = captureTerminalFrames({
289
- terminal,
273
+ runtime = await startCaptureRuntime({
290
274
  scenario,
291
- frames,
292
- started,
275
+ home,
293
276
  temporaryRoot,
294
- onOutput: () => (lastOutput = Date.now()),
295
- onFirstOutput: startEvents,
277
+ runId,
278
+ before,
279
+ frames,
280
+ samples,
281
+ interactions,
282
+ timers,
283
+ dispatchedEventIndexes,
284
+ ...(signal === undefined ? {} : { signal }),
296
285
  });
297
- stopSampler = startProcessSampler(terminal.pid, started, scenario.limits.processes, samples);
286
+ stopSampler = runtime.stopSampler;
298
287
  const exit = await awaitTerminalExit({
299
- terminal,
288
+ terminal: runtime.terminal,
300
289
  scenario,
301
- started,
302
- lastOutput: () => lastOutput,
290
+ started: runtime.started,
291
+ lastOutput: runtime.lastOutput,
303
292
  signal,
304
293
  timers,
294
+ interactions,
295
+ dispatchedEventIndexes,
305
296
  });
306
- const exitReason = exit.reason;
307
- samplingPartial = (await stopSampler()).partial;
308
- if (exitReason === "cancelled")
309
- throw new ProcessCaptureError("process capture was cancelled");
310
- await new Promise((resolveSettle) => setTimeout(resolveSettle, scenario.settle_ms));
311
- assertNotCancelled(signal);
312
- const after = await snapshotRoots(scenario, signal);
313
- truncated ||=
314
- after.truncated ||
315
- replay.truncated ||
316
- framesTruncated() ||
317
- samples.length >= scenario.limits.processes;
318
- truncated ||= samplingPartial;
319
- capture = buildCaptureResult({
297
+ capture = await completeCapture({
298
+ scenario,
299
+ runtime,
300
+ runId,
301
+ temporaryRoot,
302
+ before,
320
303
  frames,
321
- exit: { ...exit, reason: exitReason },
322
304
  samples,
323
- replay,
324
- before,
325
- after,
326
- truncated,
327
- scenario,
328
- rootPid: terminal.pid,
329
- samplingPartial,
305
+ interactions,
306
+ exit,
307
+ initiallyTruncated: before.truncated,
308
+ ...(signal === undefined ? {} : { signal }),
330
309
  });
331
310
  }
332
311
  catch (cause) {
333
- terminal?.kill("SIGKILL");
312
+ runtime?.terminal.kill("SIGKILL");
334
313
  executionFailure = cause;
335
314
  }
336
- samplingPartial ||= (await stopSampler()).partial;
337
- const cleanupFailure = await releaseProcessResources({
315
+ return finishProcessRun({
316
+ runtime,
338
317
  timers,
339
- replay,
340
- terminal,
341
318
  runId,
342
319
  temporaryRoot,
320
+ samples,
321
+ stopSampler,
322
+ capture,
323
+ executionFailure,
343
324
  });
344
- return resolveProcessResult(capture, executionFailure, cleanupFailure);
345
325
  };
346
326
  /** Execute one scenario through a typed expected-failure channel. */
347
327
  export const captureProcessScenario = async (scenario, policy, signal) => {
@@ -24,7 +24,12 @@ export const normalizeProcessText = (value, scenario, temporaryRoot, pid) => {
24
24
  export const normalizeProcessSamples = (samples, scenario, rootPid) => {
25
25
  const identifiers = [
26
26
  rootPid,
27
- ...samples.flatMap((sample) => [sample.pid, sample.parent_pid]),
27
+ ...samples.flatMap((sample) => [
28
+ sample.pid,
29
+ sample.parent_pid,
30
+ sample.process_group_id ?? 0,
31
+ sample.session_id ?? 0,
32
+ ]),
28
33
  ];
29
34
  const mapping = new Map();
30
35
  for (const identifier of identifiers)
@@ -35,6 +40,10 @@ export const normalizeProcessSamples = (samples, scenario, rootPid) => {
35
40
  scenario.normalization.time_bucket_ms,
36
41
  pid: mapping.get(sample.pid) ?? 1,
37
42
  parent_pid: mapping.get(sample.parent_pid) ?? 0,
43
+ process_group_id: sample.process_group_id === null
44
+ ? null
45
+ : (mapping.get(sample.process_group_id) ?? 0),
46
+ session_id: sample.session_id === null ? null : (mapping.get(sample.session_id) ?? 0),
38
47
  command: normalizeProcessText(sample.command, scenario, "<no-temporary-root>", rootPid),
39
48
  }));
40
49
  };