rea-agents 1.0.0 → 1.2.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 (52) hide show
  1. package/README.md +49 -18
  2. package/dist/application/ArtifactInventory.js +80 -44
  3. package/dist/application/CommandShimReplay.js +136 -0
  4. package/dist/application/Doctor.js +4 -4
  5. package/dist/application/EvidenceBundleFiles.js +13 -4
  6. package/dist/application/MacHopper.js +175 -0
  7. package/dist/application/ProcessCaptureAuthority.js +25 -0
  8. package/dist/application/ProcessCaptureCapability.js +23 -0
  9. package/dist/application/ProcessCaptureError.js +5 -0
  10. package/dist/application/ProcessCaptureLifecycle.js +269 -0
  11. package/dist/application/ProcessCheckpoints.js +129 -0
  12. package/dist/application/ProcessCli.js +61 -0
  13. package/dist/application/ProcessEvidence.js +38 -0
  14. package/dist/application/ProcessHarness.js +252 -272
  15. package/dist/application/ProcessNormalization.js +10 -1
  16. package/dist/application/ProcessOwnership.js +39 -1
  17. package/dist/application/ProcessSampling.js +39 -23
  18. package/dist/application/Setup.js +104 -100
  19. package/dist/application/SetupSkill.js +44 -0
  20. package/dist/application/TerminalRenderer.js +92 -0
  21. package/dist/application/runtime.js +1 -1
  22. package/dist/artifacts/ArtifactProvider.js +18 -6
  23. package/dist/artifacts/ArtifactReader.js +3 -1
  24. package/dist/artifacts/AsarArtifactReader.js +8 -1
  25. package/dist/artifacts/DirectoryArtifactReader.js +1 -0
  26. package/dist/artifacts/MachOSliceArtifactReader.js +1 -0
  27. package/dist/artifacts/NativeDmgArtifactReader.js +151 -0
  28. package/dist/artifacts/ZipArtifactReader.js +1 -0
  29. package/dist/cli.js +60 -26
  30. package/dist/cliProcessCommands.js +19 -0
  31. package/dist/config.js +2 -0
  32. package/dist/contracts/artifactToolContracts.js +1 -0
  33. package/dist/contracts/investigationExamples.js +3 -3
  34. package/dist/contracts/processCaptureExample.js +50 -7
  35. package/dist/contracts/toolContracts.js +3 -2
  36. package/dist/domain/changedBehavior.js +2 -2
  37. package/dist/domain/errors.js +11 -3
  38. package/dist/domain/processCapture.js +99 -293
  39. package/dist/domain/processCaptureValidation.js +124 -0
  40. package/dist/domain/processComparison.js +176 -32
  41. package/dist/domain/processScenario.js +370 -0
  42. package/dist/domain/reconstructionVerification.js +3 -3
  43. package/dist/domain/runtimeVersion.js +8 -0
  44. package/dist/domain/staticRuntimeCorrelation.js +3 -2
  45. package/dist/identity.js +1 -0
  46. package/dist/server/registerProcessComparisonTool.js +28 -8
  47. package/dist/server/registerSessionTools.js +3 -25
  48. package/dist/server/sessionToolPolicies.js +1 -6
  49. package/install.sh +79 -164
  50. package/package.json +7 -4
  51. package/scripts/prepare-node-pty.mjs +35 -0
  52. package/skills/rea-analysis/SKILL.md +19 -4
@@ -0,0 +1,175 @@
1
+ import { createHash } from "node:crypto";
2
+ import { execFile } from "node:child_process";
3
+ import { access, mkdir, mkdtemp, readdir, rename, rm, writeFile, } from "node:fs/promises";
4
+ import { homedir, tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { promisify } from "node:util";
7
+ import { z } from "zod";
8
+ const execFileAsync = promisify(execFile);
9
+ const RELEASES_URL = "https://www.hopperapp.com/include/files-api.php?request=releases&public=true";
10
+ const DOWNLOAD_PREFIX = "https://www.hopperapp.com:443/downloader/public/";
11
+ const MAX_PACKAGE_BYTES = 100_000_000;
12
+ const BUNDLE_IDENTIFIER = "com.cryptic-apps.hopper-web-4";
13
+ /** Download, verify, and install Hopper into the current user's Applications directory. */
14
+ export const installMacHopper = async (host = systemMacHopperInstallHost()) => {
15
+ let temporary;
16
+ let mounted = false;
17
+ try {
18
+ const metadata = await host.download(RELEASES_URL);
19
+ if (!metadata.ok)
20
+ return { status: "failed", reason: "release_metadata" };
21
+ const parsed = releasesSchema.safeParse(parseJson(metadata.bytes));
22
+ if (!parsed.success)
23
+ return { status: "failed", reason: "release_metadata" };
24
+ const release = parsed.data["OS X"];
25
+ if (!release.filename.startsWith(DOWNLOAD_PREFIX))
26
+ return { status: "failed", reason: "release_metadata" };
27
+ const archive = await host.download(release.filename);
28
+ if (!archive.ok)
29
+ return { status: "failed", reason: "download" };
30
+ if (!integrityMatches(archive.bytes, release))
31
+ return { status: "failed", reason: "integrity" };
32
+ const destination = host.destination();
33
+ if (await host.destinationExists(destination))
34
+ return { status: "failed", reason: "destination_exists" };
35
+ temporary = await host.createTemporaryDirectory();
36
+ const packagePath = join(temporary, "hopper.dmg");
37
+ const mountPath = join(temporary, "mounted");
38
+ await host.createMountDirectory(mountPath);
39
+ await host.writePackage(packagePath, archive.bytes);
40
+ if (!(await host.mount(packagePath, mountPath)))
41
+ return { status: "failed", reason: "mount" };
42
+ mounted = true;
43
+ const source = await host.appBundle(mountPath);
44
+ if (source === undefined || !(await host.validBundle(source)))
45
+ return { status: "failed", reason: "bundle" };
46
+ const stage = `${destination}.rea-stage`;
47
+ if (!(await host.installBundle(source, destination, stage)))
48
+ return { status: "failed", reason: "copy" };
49
+ const launcherPath = join(destination, "Contents/MacOS/hopper");
50
+ if (!(await host.launcherReady(launcherPath)))
51
+ return { status: "failed", reason: "launcher_missing" };
52
+ await host.openApplication(destination);
53
+ return { status: "installed", launcherPath };
54
+ }
55
+ catch {
56
+ return { status: "failed", reason: "download" };
57
+ }
58
+ finally {
59
+ if (temporary !== undefined) {
60
+ const mountPath = join(temporary, "mounted");
61
+ if (mounted)
62
+ await host.unmount(mountPath);
63
+ await host.cleanup(temporary);
64
+ }
65
+ }
66
+ };
67
+ const systemMacHopperInstallHost = () => ({
68
+ async download(url) {
69
+ const response = await fetch(url, {
70
+ headers: { accept: "application/json", "user-agent": "rea-installer" },
71
+ signal: AbortSignal.timeout(120_000),
72
+ });
73
+ const length = Number(response.headers.get("content-length") ?? "0");
74
+ if (length > MAX_PACKAGE_BYTES)
75
+ return { ok: false, bytes: new Uint8Array() };
76
+ const bytes = new Uint8Array(await response.arrayBuffer());
77
+ return { ok: response.ok && bytes.byteLength <= MAX_PACKAGE_BYTES, bytes };
78
+ },
79
+ createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-mac-")),
80
+ createMountDirectory: (path) => mkdir(path),
81
+ writePackage: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }),
82
+ async mount(packagePath, mountPath) {
83
+ return commandSucceeds("hdiutil", [
84
+ "attach",
85
+ "-readonly",
86
+ "-nobrowse",
87
+ "-mountpoint",
88
+ mountPath,
89
+ packagePath,
90
+ ]);
91
+ },
92
+ async unmount(mountPath) {
93
+ await commandSucceeds("hdiutil", ["detach", mountPath, "-force"]);
94
+ },
95
+ async appBundle(mountPath) {
96
+ const entries = await readdir(mountPath, { withFileTypes: true });
97
+ const app = entries.find((entry) => entry.isDirectory() && /^Hopper.*\.app$/iu.test(entry.name));
98
+ return app === undefined ? undefined : join(mountPath, app.name);
99
+ },
100
+ async validBundle(appPath) {
101
+ if (!(await commandSucceeds("codesign", [
102
+ "--verify",
103
+ "--deep",
104
+ "--strict",
105
+ appPath,
106
+ ])))
107
+ return false;
108
+ try {
109
+ const identifier = (await execFileAsync("/usr/libexec/PlistBuddy", [
110
+ "-c",
111
+ "Print :CFBundleIdentifier",
112
+ join(appPath, "Contents/Info.plist"),
113
+ ])).stdout.trim();
114
+ return identifier === BUNDLE_IDENTIFIER;
115
+ }
116
+ catch {
117
+ return false;
118
+ }
119
+ },
120
+ async destinationExists(path) {
121
+ try {
122
+ await access(path);
123
+ return true;
124
+ }
125
+ catch {
126
+ return false;
127
+ }
128
+ },
129
+ async installBundle(source, destination, stage) {
130
+ try {
131
+ await mkdir(join(homedir(), "Applications"), { recursive: true });
132
+ await rm(stage, { recursive: true, force: true });
133
+ await execFileAsync("ditto", [source, stage]);
134
+ await rename(stage, destination);
135
+ return true;
136
+ }
137
+ catch {
138
+ await rm(stage, { recursive: true, force: true });
139
+ return false;
140
+ }
141
+ },
142
+ async launcherReady(path) {
143
+ try {
144
+ await access(path);
145
+ return true;
146
+ }
147
+ catch {
148
+ return false;
149
+ }
150
+ },
151
+ async openApplication(path) {
152
+ await execFileAsync("open", [path]);
153
+ },
154
+ cleanup: (path) => rm(path, { recursive: true, force: true }),
155
+ destination: () => join(homedir(), "Applications/Hopper Disassembler.app"),
156
+ });
157
+ const parseJson = (bytes) => JSON.parse(new TextDecoder().decode(bytes));
158
+ const integrityMatches = (bytes, release) => bytes.byteLength === Number(release.file_length) &&
159
+ bytes.byteLength <= MAX_PACKAGE_BYTES &&
160
+ createHash("sha1").update(bytes).digest("hex") === release.file_hash;
161
+ const commandSucceeds = async (command, args) => {
162
+ try {
163
+ await execFileAsync(command, args);
164
+ return true;
165
+ }
166
+ catch {
167
+ return false;
168
+ }
169
+ };
170
+ const releaseSchema = z.object({
171
+ filename: z.string().url(),
172
+ file_length: z.string().regex(/^\d+$/u),
173
+ file_hash: z.string().regex(/^[a-f0-9]{40}$/u),
174
+ });
175
+ const releasesSchema = z.object({ "OS X": releaseSchema });
@@ -0,0 +1,25 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import { ProcessCaptureError } from "./ProcessCaptureError.js";
3
+ const isWithin = (candidate, root) => candidate === root ||
4
+ candidate.startsWith(`${root.endsWith("/") ? root.slice(0, -1) : root}/`);
5
+ export const assertRealPathAuthority = async (scenario, policy) => {
6
+ const executable = await realpath(scenario.executable);
7
+ const executableRoots = await Promise.all(policy.executableRoots.map((root) => realpath(root)));
8
+ if (!executableRoots.some((root) => isWithin(executable, root)))
9
+ throw new ProcessCaptureError("resolved executable is outside approved roots");
10
+ const workingDirectory = await realpath(scenario.working_directory);
11
+ const workingRoots = await Promise.all(policy.workingRoots.map((root) => realpath(root)));
12
+ if (!workingRoots.some((root) => isWithin(workingDirectory, root)))
13
+ throw new ProcessCaptureError("resolved working directory is outside approved roots");
14
+ for (const root of scenario.filesystem_roots) {
15
+ const resolvedRoot = await realpath(root);
16
+ if (!workingRoots.some((approved) => isWithin(resolvedRoot, approved)))
17
+ throw new ProcessCaptureError("resolved filesystem root is outside approved roots");
18
+ }
19
+ };
20
+ /** Expected refusal or runtime failure from the process capture adapter. */
21
+ export const assertNotCancelled = (signal) => {
22
+ if (signal?.aborted === true)
23
+ throw new ProcessCaptureError("process capture was cancelled");
24
+ };
25
+ /** Runtime availability of the native PTY adapter on this host. */
@@ -0,0 +1,23 @@
1
+ import { tmpdir } from "node:os";
2
+ import { spawn } from "node-pty";
3
+ /** Probe the actual native PTY seam instead of inferring support from the OS name. */
4
+ export const probeProcessCaptureCapability = async () => {
5
+ try {
6
+ const terminal = spawn(process.platform === "win32" ? "cmd.exe" : "/bin/sh", process.platform === "win32" ? ["/c", "exit", "0"] : ["-c", "exit 0"], {
7
+ cwd: tmpdir(),
8
+ env: { HOME: tmpdir(), TERM: "xterm-256color" },
9
+ cols: 80,
10
+ rows: 24,
11
+ name: "xterm-256color",
12
+ });
13
+ await new Promise((resolveExit) => terminal.onExit(() => resolveExit()));
14
+ return { available: true, backend: "node-pty" };
15
+ }
16
+ catch {
17
+ return {
18
+ available: false,
19
+ backend: "node-pty",
20
+ reason: "the native PTY backend could not start a probe process",
21
+ };
22
+ }
23
+ };
@@ -0,0 +1,5 @@
1
+ import { AnalysisError } from "../domain/errors.js";
2
+ /** Typed application failure produced by controlled process capture. */
3
+ export class ProcessCaptureError extends AnalysisError {
4
+ _tag = "ProcessCaptureError";
5
+ }
@@ -0,0 +1,269 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { createReadStream } from "node:fs";
3
+ import { mkdtemp, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { authorizeProcessScenario, digestProcessCommitment, processComparisonContract, processScenarioCommitment, } from "../domain/processCapture.js";
7
+ import { PRODUCT_IDENTITY } from "../identity.js";
8
+ import { snapshotRoots } from "./FilesystemSnapshot.js";
9
+ import { cleanupOwnedProcessGroup, observeOwnedProcessGroup, } from "./ProcessOwnership.js";
10
+ import { classifyFilesystemEffects, ProcessCheckpoints, } from "./ProcessCheckpoints.js";
11
+ import { ProcessCaptureError } from "./ProcessCaptureError.js";
12
+ import { assertNotCancelled, assertRealPathAuthority, } from "./ProcessCaptureAuthority.js";
13
+ import { normalizeProcessSamples, normalizeProcessText, redactProtocolEvents, } from "./ProcessNormalization.js";
14
+ import { PROCESS_PROVIDER } from "./ProcessEvidence.js";
15
+ import { TerminalRenderer } from "./TerminalRenderer.js";
16
+ export const buildCaptureResult = (options) => ({
17
+ schema_version: 4,
18
+ manifest: options.manifest,
19
+ normalization: options.scenario.normalization,
20
+ frames: options.frames,
21
+ rendered_frames: [...options.renderedFrames]
22
+ .sort((left, right) => left.at_ms - right.at_ms || left.sequence - right.sequence)
23
+ .map((frame, sequence) => ({ ...frame, sequence })),
24
+ interaction_events: options.interactions,
25
+ exit: {
26
+ code: options.exit.reason === "exited" && options.exit.exitCode >= 0
27
+ ? options.exit.exitCode
28
+ : null,
29
+ signal: options.exit.signal ?? null,
30
+ reason: options.exit.reason,
31
+ },
32
+ settlement: options.settlement,
33
+ process_samples: normalizeProcessSamples(options.samples, options.scenario, options.rootPid),
34
+ filesystem_checkpoints: options.checkpoints,
35
+ shim_events: options.shimEvents,
36
+ protocol_events: redactProtocolEvents(options.replay.events, options.scenario),
37
+ files_before: options.before.files,
38
+ files_after: options.after.files,
39
+ filesystem_effects: classifyFilesystemEffects(options.before.files, options.after.files),
40
+ truncated: options.truncated,
41
+ limitations: [
42
+ "Process trees are sampled and may omit short-lived descendants.",
43
+ ...(options.samplingPartial
44
+ ? ["Process-tree sampling ended with an incomplete observation."]
45
+ : []),
46
+ "Filesystem observations are before/after snapshots, not syscall traces.",
47
+ "The harness does not enforce external network isolation.",
48
+ ],
49
+ residual_unknowns: [
50
+ {
51
+ scope: "process",
52
+ reason: "Process trees are sampled and may omit short-lived descendants.",
53
+ },
54
+ {
55
+ scope: "network",
56
+ reason: "External network isolation is not enforced by this adapter.",
57
+ },
58
+ ],
59
+ cleanup: {
60
+ owned_process_group: "verified",
61
+ temporary_root: "removed",
62
+ },
63
+ });
64
+ const hashFile = async (path) => {
65
+ const hash = createHash("sha256");
66
+ for await (const chunk of createReadStream(path))
67
+ hash.update(chunk);
68
+ return hash.digest("hex");
69
+ };
70
+ export const createRunManifest = async (scenario, startedAt, completedAt) => {
71
+ const executableSha256 = await hashFile(scenario.executable);
72
+ const scenarioCommitment = processScenarioCommitment(scenario, executableSha256);
73
+ const comparisonContract = processComparisonContract(scenario);
74
+ return {
75
+ rea_version: PRODUCT_IDENTITY.packageVersion,
76
+ provider_version: PROCESS_PROVIDER.version,
77
+ platform: process.platform,
78
+ architecture: process.arch,
79
+ pty_backend: "node-pty",
80
+ started_at: startedAt.toISOString(),
81
+ completed_at: completedAt.toISOString(),
82
+ scenario: scenarioCommitment,
83
+ comparison_contract: comparisonContract,
84
+ shim_plan: scenario.command_shims,
85
+ replay_plan: scenario.replay,
86
+ full_scenario_sha256: digestProcessCommitment(scenarioCommitment),
87
+ comparison_contract_sha256: digestProcessCommitment(comparisonContract),
88
+ executable_sha256: executableSha256,
89
+ normalization_sha256: digestProcessCommitment(scenario.normalization),
90
+ shim_plan_sha256: digestProcessCommitment(scenario.command_shims),
91
+ replay_plan_sha256: digestProcessCommitment(scenario.replay),
92
+ };
93
+ };
94
+ export const observeSettlement = async (runId, processGroupIds, settleMs) => {
95
+ if (process.platform === "win32")
96
+ return { state: "unverifiable", elapsed_ms: 0 };
97
+ const started = Date.now();
98
+ let consecutiveEmpty = 0;
99
+ let deadlineReached = false;
100
+ while (!deadlineReached) {
101
+ const observations = await Promise.all([...new Set(processGroupIds)].map((processGroupId) => observeOwnedProcessGroup({
102
+ runId,
103
+ leaderPid: processGroupId,
104
+ processGroupId,
105
+ })));
106
+ if (observations.some(({ state }) => state === "unverifiable"))
107
+ return { state: "unverifiable", elapsed_ms: Date.now() - started };
108
+ if (observations.every(({ state }) => state === "empty")) {
109
+ consecutiveEmpty += 1;
110
+ if (consecutiveEmpty >= 2)
111
+ return { state: "quiesced", elapsed_ms: Date.now() - started };
112
+ }
113
+ else
114
+ consecutiveEmpty = 0;
115
+ deadlineReached = Date.now() - started >= settleMs;
116
+ if (deadlineReached)
117
+ break;
118
+ await new Promise((resolve) => setTimeout(resolve, 50));
119
+ }
120
+ return { state: "alive_at_deadline", elapsed_ms: Date.now() - started };
121
+ };
122
+ export const awaitTerminalExit = async ({ terminal, scenario, started, lastOutput, signal, timers, interactions, dispatchedEventIndexes, }) => new Promise((resolveExit) => {
123
+ // The kill caused by a deadline is observed later as an ordinary PTY exit.
124
+ // Keep the initiating lifecycle reason so comparisons distinguish a target
125
+ // exit from harness-owned timeout, idle-timeout, and cancellation cleanup.
126
+ let reason = "exited";
127
+ terminal.onExit((exit) => {
128
+ for (const [eventIndex, event] of scenario.events.entries()) {
129
+ if (dispatchedEventIndexes.has(eventIndex))
130
+ continue;
131
+ interactions.push({
132
+ sequence: interactions.length,
133
+ scheduled_at_ms: event.at_ms,
134
+ dispatched_at_ms: Math.max(0, Date.now() - started),
135
+ type: event.type,
136
+ data: event.type === "input"
137
+ ? "<not-dispatched>"
138
+ : event.type === "resize"
139
+ ? `${String(event.columns)}x${String(event.rows)}`
140
+ : event.signal,
141
+ outcome: "target_exited",
142
+ });
143
+ }
144
+ for (const timer of timers) {
145
+ clearTimeout(timer);
146
+ clearInterval(timer);
147
+ }
148
+ timers.clear();
149
+ resolveExit({ ...exit, reason });
150
+ });
151
+ const timeout = setInterval(() => {
152
+ if (signal?.aborted === true) {
153
+ reason = "cancelled";
154
+ terminal.kill("SIGKILL");
155
+ }
156
+ else if (Date.now() - started >= scenario.timeout_ms) {
157
+ reason = "timeout";
158
+ terminal.kill("SIGKILL");
159
+ }
160
+ else if (Date.now() - lastOutput() >= scenario.idle_timeout_ms) {
161
+ reason = "idle_timeout";
162
+ terminal.kill("SIGKILL");
163
+ }
164
+ }, 20);
165
+ timers.add(timeout);
166
+ });
167
+ export const releaseProcessResources = async (options) => {
168
+ for (const timer of options.timers)
169
+ clearTimeout(timer);
170
+ let failure;
171
+ try {
172
+ await options.replay?.close();
173
+ }
174
+ catch {
175
+ failure = "loopback replay cleanup failed";
176
+ }
177
+ try {
178
+ await options.shimReplay?.close();
179
+ }
180
+ catch {
181
+ failure ??= "command shim replay cleanup failed";
182
+ }
183
+ try {
184
+ await options.renderer?.dispose();
185
+ }
186
+ catch {
187
+ failure ??= "terminal renderer cleanup failed";
188
+ }
189
+ try {
190
+ await options.checkpoints?.dispose();
191
+ }
192
+ catch {
193
+ failure ??= "filesystem checkpoint cleanup failed";
194
+ }
195
+ if (options.terminal !== undefined && process.platform !== "win32") {
196
+ const processGroupIds = new Set([
197
+ options.terminal.pid,
198
+ ...options.sampledProcessGroupIds,
199
+ ]);
200
+ for (const processGroupId of processGroupIds) {
201
+ const cleaned = await cleanupOwnedProcessGroup({
202
+ runId: options.runId,
203
+ leaderPid: processGroupId,
204
+ processGroupId,
205
+ });
206
+ if (!cleaned.cleaned)
207
+ failure ??= cleaned.reason;
208
+ }
209
+ }
210
+ try {
211
+ await rm(options.temporaryRoot, { recursive: true, force: true });
212
+ }
213
+ catch {
214
+ failure ??= "temporary process root cleanup failed";
215
+ }
216
+ return failure;
217
+ };
218
+ export const captureTerminalFrames = (options) => {
219
+ let outputBytes = 0;
220
+ let truncated = false;
221
+ options.terminal.onData((data) => {
222
+ options.onFirstOutput();
223
+ options.onOutput();
224
+ const bytes = Buffer.byteLength(data);
225
+ if (options.frames.length >= options.scenario.limits.frames ||
226
+ outputBytes + bytes > options.scenario.limits.output_bytes) {
227
+ truncated = true;
228
+ return;
229
+ }
230
+ outputBytes += bytes;
231
+ const atMs = Math.floor((Date.now() - options.started) /
232
+ options.scenario.normalization.time_bucket_ms) * options.scenario.normalization.time_bucket_ms;
233
+ const normalized = normalizeProcessText(data, options.scenario, options.temporaryRoot, options.terminal.pid);
234
+ options.frames.push({
235
+ sequence: options.frames.length,
236
+ at_ms: atMs,
237
+ data: normalized,
238
+ });
239
+ options.renderer.write(data, atMs);
240
+ options.checkpoints.observeTerminal(normalized);
241
+ });
242
+ return () => truncated;
243
+ };
244
+ export const resolveProcessResult = (capture, executionFailure, cleanupFailure) => {
245
+ if (executionFailure instanceof ProcessCaptureError)
246
+ throw executionFailure;
247
+ if (executionFailure !== undefined)
248
+ throw new ProcessCaptureError("process capture failed", {
249
+ cause: executionFailure,
250
+ });
251
+ if (cleanupFailure !== undefined)
252
+ throw new ProcessCaptureError(cleanupFailure);
253
+ if (capture === undefined)
254
+ throw new ProcessCaptureError("process capture produced no result");
255
+ return capture;
256
+ };
257
+ export const prepareProcessCapture = async (scenario, policy, signal) => {
258
+ const decision = authorizeProcessScenario(scenario, policy);
259
+ if (!decision.allowed)
260
+ throw new ProcessCaptureError(decision.reason);
261
+ await assertRealPathAuthority(scenario, policy);
262
+ assertNotCancelled(signal);
263
+ const before = await snapshotRoots(scenario, signal);
264
+ const temporaryRoot = await mkdtemp(join(tmpdir(), "rea-process-"));
265
+ const runId = randomUUID();
266
+ const home = join(temporaryRoot, "home");
267
+ await import("node:fs/promises").then(({ mkdir }) => mkdir(home));
268
+ return { temporaryRoot, runId, home, before };
269
+ };
@@ -0,0 +1,129 @@
1
+ import { snapshotRoots } from "./FilesystemSnapshot.js";
2
+ /** Classify path-stable filesystem effects between two bounded states. */
3
+ export const classifyFilesystemEffects = (before, after) => {
4
+ const beforeByPath = new Map(before.map((file) => [file.path, file]));
5
+ const afterByPath = new Map(after.map((file) => [file.path, file]));
6
+ return [...new Set([...beforeByPath.keys(), ...afterByPath.keys()])]
7
+ .sort()
8
+ .map((path) => {
9
+ const beforeFile = beforeByPath.get(path) ?? null;
10
+ const afterFile = afterByPath.get(path) ?? null;
11
+ const status = beforeFile === null
12
+ ? "created"
13
+ : afterFile === null
14
+ ? "deleted"
15
+ : JSON.stringify(beforeFile) === JSON.stringify(afterFile)
16
+ ? "unchanged"
17
+ : "modified";
18
+ return { path, status, before: beforeFile, after: afterFile };
19
+ });
20
+ };
21
+ /** Coordinates named lifecycle filesystem observations without overlapping scans. */
22
+ export class ProcessCheckpoints {
23
+ scenario;
24
+ started;
25
+ signal;
26
+ #captures = [];
27
+ #captured = new Set();
28
+ #terminalCounts = new Map();
29
+ #terminalTails = new Map();
30
+ #timers = new Set();
31
+ #pending = Promise.resolve();
32
+ constructor(scenario, started, before, signal) {
33
+ this.scenario = scenario;
34
+ this.started = started;
35
+ this.signal = signal;
36
+ this.#captures.push({
37
+ name: "before",
38
+ at_ms: 0,
39
+ files: before.files,
40
+ effects: [],
41
+ truncated: before.truncated,
42
+ });
43
+ this.#captured.add("before");
44
+ for (const checkpoint of scenario.checkpoints) {
45
+ if (checkpoint.trigger.type !== "time")
46
+ continue;
47
+ const timer = setTimeout(() => this.capture(checkpoint.name), checkpoint.trigger.at_ms);
48
+ this.#timers.add(timer);
49
+ }
50
+ }
51
+ /** Observe normalized terminal text and fire matching literal checkpoints once. */
52
+ observeTerminal(data) {
53
+ for (const checkpoint of this.scenario.checkpoints) {
54
+ const trigger = checkpoint.trigger;
55
+ if (trigger.type !== "terminal_literal" ||
56
+ this.#captured.has(checkpoint.name))
57
+ continue;
58
+ const combined = `${this.#terminalTails.get(checkpoint.name) ?? ""}${data}`;
59
+ const count = (this.#terminalCounts.get(checkpoint.name) ?? 0) +
60
+ countOccurrences(combined, trigger.value);
61
+ this.#terminalCounts.set(checkpoint.name, count);
62
+ this.#terminalTails.set(checkpoint.name, trigger.value.length === 1
63
+ ? ""
64
+ : combined.slice(-(trigger.value.length - 1)));
65
+ if (count >= trigger.occurrence)
66
+ this.capture(checkpoint.name);
67
+ }
68
+ }
69
+ /** Capture every checkpoint assigned to the specified lifecycle trigger. */
70
+ trigger(type) {
71
+ for (const checkpoint of this.scenario.checkpoints)
72
+ if (checkpoint.trigger.type === type)
73
+ this.capture(checkpoint.name);
74
+ }
75
+ /** Queue one idempotent named snapshot. */
76
+ capture(name) {
77
+ if (this.#captured.has(name))
78
+ return;
79
+ this.#captured.add(name);
80
+ // Serialize scans even when time, terminal, and lifecycle triggers fire
81
+ // together. Each effect set is intentionally relative to the immediately
82
+ // preceding checkpoint, which preserves transient create/delete behavior.
83
+ this.#pending = this.#pending.then(async () => {
84
+ const snapshot = await snapshotRoots(this.scenario, this.signal);
85
+ const previous = this.#captures.at(-1)?.files ?? [];
86
+ this.#captures.push({
87
+ name,
88
+ at_ms: Math.max(0, Date.now() - this.started),
89
+ files: snapshot.files,
90
+ effects: classifyFilesystemEffects(previous, snapshot.files),
91
+ truncated: snapshot.truncated,
92
+ });
93
+ });
94
+ }
95
+ /** Finish pending snapshots and return them in observation order. */
96
+ async finish(after) {
97
+ for (const timer of this.#timers)
98
+ clearTimeout(timer);
99
+ await this.#pending;
100
+ const previous = this.#captures.at(-1)?.files ?? [];
101
+ this.#captures.push({
102
+ name: "after_settlement",
103
+ at_ms: Math.max(0, Date.now() - this.started),
104
+ files: after.files,
105
+ effects: classifyFilesystemEffects(previous, after.files),
106
+ truncated: after.truncated,
107
+ });
108
+ return this.#captures;
109
+ }
110
+ /** Cancel scheduled captures and await any snapshot already in progress. */
111
+ async dispose() {
112
+ for (const timer of this.#timers)
113
+ clearTimeout(timer);
114
+ this.#timers.clear();
115
+ await this.#pending;
116
+ }
117
+ }
118
+ const countOccurrences = (text, value) => {
119
+ let count = 0;
120
+ let offset = 0;
121
+ while (offset <= text.length - value.length) {
122
+ const found = text.indexOf(value, offset);
123
+ if (found < 0)
124
+ break;
125
+ count += 1;
126
+ offset = found + value.length;
127
+ }
128
+ return count;
129
+ };