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,14 +1,31 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { execFile } from "node:child_process";
3
+ import { createReadStream } from "node:fs";
3
4
  import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
4
5
  import { tmpdir } from "node:os";
5
6
  import { join } from "node:path";
6
7
  import { promisify } from "node:util";
7
- import { z } from "zod";
8
8
  const execFileAsync = promisify(execFile);
9
- const RELEASES_URL = "https://www.hopperapp.com/include/files-api.php?request=releases&public=true";
10
9
  const DOWNLOAD_PREFIX = "https://www.hopperapp.com:443/downloader/public/";
11
10
  const MAX_PACKAGE_BYTES = 100_000_000;
11
+ const SUPPORTED_HOPPER_SHA256 = "0294ced141cc373468ee22d8343e7dac41980cb05a937994ca81c9f09afe7ded";
12
+ const SUPPORTED_RELEASES = {
13
+ deb: {
14
+ filename: `${DOWNLOAD_PREFIX}Hopper-6.4.2-Linux-demo.deb`,
15
+ file_length: "35755772",
16
+ file_hash: "e4f79dff602648a8ff4a88b773875b6bfac0dc65",
17
+ },
18
+ rpm: {
19
+ filename: `${DOWNLOAD_PREFIX}Hopper-6.4.2-Linux-demo.rpm`,
20
+ file_length: "32264475",
21
+ file_hash: "b9a65f2f583a386d440d4401d9140aef3bf305de",
22
+ },
23
+ arch: {
24
+ filename: `${DOWNLOAD_PREFIX}Hopper-6.4.2-Linux-demo.pkg.tar.xz`,
25
+ file_length: "29405968",
26
+ file_hash: "92d780b61cab38eda95dc4112dee4fbeb0920f46",
27
+ },
28
+ };
12
29
  /** Parse an os-release document without executing its shell syntax. */
13
30
  export const parseLinuxDistribution = (text) => {
14
31
  const fields = new Map();
@@ -57,15 +74,7 @@ export const installLinuxHopper = async (host = systemLinuxHopperInstallHost(),
57
74
  return { status: "failed", reason: "unsupported_host" };
58
75
  let temporary;
59
76
  try {
60
- const metadataDownload = await host.download(RELEASES_URL, options);
61
- if (!metadataDownload.ok)
62
- return { status: "failed", reason: "release_metadata" };
63
- const releases = parseReleases(metadataDownload.bytes);
64
- if (releases === undefined)
65
- return { status: "failed", reason: "release_metadata" };
66
- const release = releases[releaseKey(distribution.packageFamily)];
67
- if (!release.filename.startsWith(DOWNLOAD_PREFIX))
68
- return { status: "failed", reason: "release_metadata" };
77
+ const release = (options.releases ?? SUPPORTED_RELEASES)[distribution.packageFamily];
69
78
  const archiveDownload = await host.download(release.filename, options);
70
79
  if (!archiveDownload.ok)
71
80
  return { status: "failed", reason: "download" };
@@ -77,8 +86,12 @@ export const installLinuxHopper = async (host = systemLinuxHopperInstallHost(),
77
86
  if (!(await host.installPackage(distribution.packageFamily, archive)))
78
87
  return { status: "failed", reason: "authorization_or_package_manager" };
79
88
  const launcherPath = "/opt/hopper/bin/Hopper";
80
- if (!(await host.launcherReady(launcherPath)))
81
- return { status: "failed", reason: "launcher_missing" };
89
+ const launcherStatus = await host.launcherStatus(launcherPath);
90
+ if (launcherStatus !== "ready")
91
+ return {
92
+ status: "failed",
93
+ reason: launcherStatus === "missing" ? "launcher_missing" : launcherStatus,
94
+ };
82
95
  return { status: "installed", launcherPath };
83
96
  }
84
97
  catch (cause) {
@@ -108,24 +121,40 @@ const systemLinuxHopperInstallHost = () => ({
108
121
  createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-")),
109
122
  writeArchive: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }),
110
123
  installPackage: installSystemPackage,
111
- async launcherReady(path) {
124
+ async launcherStatus(path) {
112
125
  try {
113
126
  await access(path);
114
127
  const linked = await execFileAsync("ldd", [path]);
115
- return linuxSharedLibrariesAvailable(`${linked.stdout}\n${linked.stderr}`);
128
+ if (!linuxSharedLibrariesAvailable(`${linked.stdout}\n${linked.stderr}`))
129
+ return "runtime_dependencies";
130
+ return (await linuxHopperBinarySupported(path))
131
+ ? "ready"
132
+ : "unsupported_hopper_build";
116
133
  }
117
134
  catch {
118
- return false;
135
+ return "missing";
119
136
  }
120
137
  },
121
138
  cleanup: (path) => rm(path, { recursive: true, force: true }),
122
139
  });
140
+ /** Match the Linux Hopper build validated by the packaged demo-dialog adapter. */
141
+ export const linuxHopperBinarySupported = async (path) => {
142
+ try {
143
+ const hash = createHash("sha256");
144
+ for await (const chunk of createReadStream(path))
145
+ hash.update(chunk);
146
+ return hash.digest("hex") === SUPPORTED_HOPPER_SHA256;
147
+ }
148
+ catch {
149
+ return false;
150
+ }
151
+ };
123
152
  /** Interpret ldd output conservatively so a present but broken launcher is unhealthy. */
124
153
  export const linuxSharedLibrariesAvailable = (output) => !/(?:=>\s+not found|error while loading shared libraries)/iu.test(output);
125
154
  const installSystemPackage = async (family, archive) => {
126
155
  try {
127
- const command = linuxPackageManagerCommand(family, archive, process.getuid?.() === 0);
128
- await execFileAsync(command.executable, command.args);
156
+ for (const command of linuxPackageManagerCommands(family, archive, process.getuid?.() === 0))
157
+ await execFileAsync(command.executable, command.args);
129
158
  return true;
130
159
  }
131
160
  catch {
@@ -133,26 +162,32 @@ const installSystemPackage = async (family, archive) => {
133
162
  }
134
163
  };
135
164
  /** Select the native package manager and authorization boundary without shell evaluation. */
136
- export const linuxPackageManagerCommand = (family, archive, isRoot) => {
137
- const command = family === "deb"
138
- ? ["apt-get", "install", "-y", archive]
165
+ export const linuxPackageManagerCommands = (family, archive, isRoot) => {
166
+ const runtimeDependencies = family === "deb"
167
+ ? ["xvfb", "xauth", "python3", "libx11-6", "libxtst6"]
139
168
  : family === "rpm"
140
- ? ["dnf", "install", "-y", archive]
141
- : ["pacman", "-U", "--noconfirm", archive];
142
- const executable = isRoot ? command[0] : "pkexec";
143
- if (executable === undefined)
144
- throw new Error("package command is empty");
145
- return { executable, args: isRoot ? command.slice(1) : command };
146
- };
147
- const parseReleases = (bytes) => {
148
- try {
149
- const parsed = JSON.parse(new TextDecoder().decode(bytes));
150
- const result = releasesSchema.safeParse(parsed);
151
- return result.success ? result.data : undefined;
152
- }
153
- catch {
154
- return undefined;
155
- }
169
+ ? [
170
+ "xorg-x11-server-Xvfb",
171
+ "xorg-x11-xauth",
172
+ "python3",
173
+ "libX11",
174
+ "libXtst",
175
+ ]
176
+ : ["xorg-server-xvfb", "xorg-xauth", "python", "libx11", "libxtst"];
177
+ const commands = family === "deb"
178
+ ? [["apt-get", "install", "-y", archive, ...runtimeDependencies]]
179
+ : family === "rpm"
180
+ ? [["dnf", "install", "-y", archive, ...runtimeDependencies]]
181
+ : [
182
+ ["pacman", "-S", "--needed", "--noconfirm", ...runtimeDependencies],
183
+ ["pacman", "-U", "--noconfirm", archive],
184
+ ];
185
+ return commands.map((command) => {
186
+ const executable = isRoot ? command[0] : "pkexec";
187
+ if (executable === undefined)
188
+ throw new Error("package command is empty");
189
+ return { executable, args: isRoot ? command.slice(1) : command };
190
+ });
156
191
  };
157
192
  const packageIntegrityMatches = (bytes, release) => bytes.byteLength === Number(release.file_length) &&
158
193
  bytes.byteLength <= MAX_PACKAGE_BYTES &&
@@ -172,15 +207,4 @@ const packageFamilyFor = (id, idLike) => {
172
207
  };
173
208
  const versionAtLeast = (version, minimum) => version !== undefined &&
174
209
  Number.parseInt(version.split(".")[0] ?? "0", 10) >= minimum;
175
- const releaseKey = (family) => family === "deb" ? "Ubuntu / Mint" : family === "rpm" ? "Fedora" : "Arch";
176
210
  const isAbortCause = (cause) => cause instanceof Error && cause.name === "AbortError";
177
- const releaseSchema = z.object({
178
- filename: z.string().url(),
179
- file_length: z.string().regex(/^\d+$/u),
180
- file_hash: z.string().regex(/^[a-f0-9]{40}$/u),
181
- });
182
- const releasesSchema = z.object({
183
- "Ubuntu / Mint": releaseSchema,
184
- Fedora: releaseSchema,
185
- Arch: releaseSchema,
186
- });
@@ -0,0 +1,25 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import { ProcessCaptureError, processCaptureCancelled, } 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 processCaptureCancelled();
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,17 @@
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
+ userMessage;
6
+ userCategory;
7
+ constructor(message, options) {
8
+ super(message, options);
9
+ this.userMessage = options?.userMessage;
10
+ this.userCategory = options?.userCategory;
11
+ }
12
+ }
13
+ /** Caller-visible cancellation without exposing capture implementation state. */
14
+ export const processCaptureCancelled = () => new ProcessCaptureError("process capture was cancelled", {
15
+ userCategory: "cancelled",
16
+ userMessage: "Process capture was cancelled. Start it again when ready.",
17
+ });
@@ -0,0 +1,287 @@
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
+ userCategory: "permission_required",
262
+ userMessage: processPolicyMessage(decision.reason),
263
+ });
264
+ await assertRealPathAuthority(scenario, policy);
265
+ assertNotCancelled(signal);
266
+ const before = await snapshotRoots(scenario, signal);
267
+ const temporaryRoot = await mkdtemp(join(tmpdir(), "rea-process-"));
268
+ const runId = randomUUID();
269
+ const home = join(temporaryRoot, "home");
270
+ await import("node:fs/promises").then(({ mkdir }) => mkdir(home));
271
+ return { temporaryRoot, runId, home, before };
272
+ };
273
+ const processPolicyMessage = (reason) => {
274
+ if (reason === "process capture is disabled")
275
+ return "Process capture is disabled. Set `REA_PROCESS_CAPTURE_ENABLED=true`, configure approved roots, then restart REA.";
276
+ if (reason === "host network access is not approved by operator policy")
277
+ return "This capture requests host network access, but policy does not allow it. Use replayed network access or ask the operator to enable external network capture.";
278
+ if (reason === "executable is outside approved roots")
279
+ return "The executable is outside the approved capture directories. Choose an approved executable or add its directory to `REA_PROCESS_EXECUTABLE_ROOTS_JSON`.";
280
+ if (reason === "working directory is outside approved roots")
281
+ return "The working directory is outside the approved capture directories. Choose an approved directory or add it to `REA_PROCESS_WORKING_ROOTS_JSON`.";
282
+ if (reason === "filesystem root is outside approved roots")
283
+ return "A requested filesystem root is outside the approved capture directories. Remove it or add its directory to `REA_PROCESS_WORKING_ROOTS_JSON`.";
284
+ if (reason === "scenario requests an environment variable not allowed by policy")
285
+ return "The capture requests an environment variable that policy does not allow. Remove it or add its name to `REA_PROCESS_ALLOWED_ENV_JSON`.";
286
+ return reason;
287
+ };
@@ -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
+ };