rea-agents 1.2.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 (79) hide show
  1. package/README.md +74 -27
  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 +4 -0
  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/CrossVersionInventory.js +98 -0
  11. package/dist/application/CrossVersionInvestigation.js +295 -0
  12. package/dist/application/DirectAnalysis.js +115 -22
  13. package/dist/application/Doctor.js +30 -1
  14. package/dist/application/EvidenceBundleFiles.js +15 -108
  15. package/dist/application/EvidenceLedger.js +3 -2
  16. package/dist/application/InvestigationProviders.js +48 -0
  17. package/dist/application/InvestigationWorkspaceStore.js +214 -0
  18. package/dist/application/LinuxHopper.js +72 -48
  19. package/dist/application/ProcessCaptureAuthority.js +2 -2
  20. package/dist/application/ProcessCaptureError.js +12 -0
  21. package/dist/application/ProcessCaptureLifecycle.js +19 -1
  22. package/dist/application/ProcessCli.js +98 -31
  23. package/dist/application/ProcessHarness.js +2 -2
  24. package/dist/application/ReferenceSourceImportEntries.js +18 -3
  25. package/dist/application/ReferenceSourceImportTypes.js +32 -0
  26. package/dist/application/Setup.js +33 -60
  27. package/dist/application/SetupClients.js +19 -0
  28. package/dist/application/SetupInstallFailure.js +53 -0
  29. package/dist/application/SetupPlan.js +31 -0
  30. package/dist/application/Uninstall.js +33 -23
  31. package/dist/application/UnknownEvidence.js +33 -0
  32. package/dist/application/Upgrade.js +1 -1
  33. package/dist/application/runtime.js +1 -1
  34. package/dist/artifacts/ArtifactProvider.js +2 -5
  35. package/dist/cli.js +31 -11
  36. package/dist/cliEvidenceCommands.js +11 -12
  37. package/dist/cliInvestigationCommands.js +86 -0
  38. package/dist/cliOutput.js +41 -0
  39. package/dist/cliProcessCommands.js +14 -4
  40. package/dist/config.js +39 -24
  41. package/dist/contracts/promptContracts.js +256 -0
  42. package/dist/contracts/sessionLifecycleInputs.js +11 -0
  43. package/dist/contracts/toolContractTypes.js +1 -0
  44. package/dist/contracts/toolContracts.js +10 -6
  45. package/dist/contracts/toolOutputSchemas.js +33 -4
  46. package/dist/domain/analysisSnapshot.js +149 -0
  47. package/dist/domain/changedBehavior.js +30 -7
  48. package/dist/domain/errors.js +180 -41
  49. package/dist/domain/evidence.js +4 -2
  50. package/dist/domain/evidenceBundle.js +28 -0
  51. package/dist/domain/hopperStartupFailure.js +55 -0
  52. package/dist/domain/investigationWorkspace.js +214 -0
  53. package/dist/domain/jsonValue.js +2 -0
  54. package/dist/domain/nativeInspection.js +3 -2
  55. package/dist/domain/processCapture.js +5 -4
  56. package/dist/domain/processComparison.js +5 -4
  57. package/dist/hopper/BridgeLauncher.js +47 -20
  58. package/dist/hopper/HopperClient.js +17 -2
  59. package/dist/hopper/HopperProvider.js +1 -0
  60. package/dist/main.js +47 -24
  61. package/dist/server/createServer.js +2 -0
  62. package/dist/server/promptCompletion.js +144 -0
  63. package/dist/server/registerEnhancedTools.js +2 -6
  64. package/dist/server/registerEvidenceTools.js +2 -8
  65. package/dist/server/registerFunctionComparisonTool.js +1 -1
  66. package/dist/server/registerInvestigationTools.js +31 -11
  67. package/dist/server/registerOfficialTools.js +6 -13
  68. package/dist/server/registerProcessComparisonTool.js +1 -7
  69. package/dist/server/registerPrompts.js +67 -0
  70. package/dist/server/registerSessionStatusTool.js +11 -0
  71. package/dist/server/registerSessionTools.js +52 -18
  72. package/dist/server/sessionEvidence.js +2 -8
  73. package/dist/server/sessionToolPolicies.js +1 -42
  74. package/dist/server/toolResult.js +7 -3
  75. package/install.sh +11 -11
  76. package/package.json +5 -3
  77. package/scripts/hopper-demo-x11.py +349 -0
  78. package/scripts/rea.mjs +6 -3
  79. package/skills/rea-analysis/SKILL.md +9 -2
@@ -1,3 +1,4 @@
1
+ import canonicalize from "canonicalize";
1
2
  import { createEvidenceBundle, parseEvidenceBundle, } from "../domain/evidenceBundle.js";
2
3
  import { EvidenceIntegrityError, EvidenceLimitError, UnknownRegistryError, } from "../domain/errors.js";
3
4
  import { parseEvidence } from "../domain/evidence.js";
@@ -258,8 +259,8 @@ export class EvidenceLedger {
258
259
  }
259
260
  const serializedBytes = (value) => Buffer.byteLength(JSON.stringify(value), "utf8");
260
261
  const unknownRevisionKey = (unknown) => `${unknown.unknown_id}:${String(unknown.revision)}`;
261
- const recordsAgree = (left, right) => JSON.stringify(withoutLocalPath(left)) ===
262
- JSON.stringify(withoutLocalPath(right));
262
+ const recordsAgree = (left, right) => canonicalize(withoutLocalPath(left)) ===
263
+ canonicalize(withoutLocalPath(right));
263
264
  const withoutLocalPath = (evidence) => ({
264
265
  ...evidence,
265
266
  subject: evidence.subject === null
@@ -0,0 +1,48 @@
1
+ /** Provider identity for deterministic artifact inventories. */
2
+ export const ARTIFACT_GRAPH_PROVIDER = {
3
+ id: "rea-artifact-graph",
4
+ name: "REA safe artifact graph provider",
5
+ version: "1",
6
+ };
7
+ /** Provider identity for deterministic cross-artifact comparisons. */
8
+ export const ARTIFACT_COMPARISON_PROVIDER = {
9
+ id: "rea-artifact-comparison",
10
+ name: "REA artifact comparison",
11
+ version: "1",
12
+ };
13
+ /** Provider identity for deterministic cross-function comparisons. */
14
+ export const FUNCTION_COMPARISON_PROVIDER = {
15
+ id: "rea-function-comparison",
16
+ name: "REA function comparison",
17
+ version: "1",
18
+ };
19
+ /** Provider identity for deterministic Evidence bundle comparisons. */
20
+ export const BUNDLE_COMPARISON_PROVIDER = {
21
+ id: "rea-bundle-comparison",
22
+ name: "REA Evidence bundle comparison",
23
+ version: "1",
24
+ };
25
+ /** Provider identity for comparison-Evidence behavior aggregation. */
26
+ export const CHANGED_BEHAVIOR_PROVIDER = {
27
+ id: "rea-changed-behavior",
28
+ name: "REA changed behavior investigation",
29
+ version: "1",
30
+ };
31
+ /** Provider identity for Evidence-backed call-path reconstruction. */
32
+ export const CALL_PATH_PROVIDER = {
33
+ id: "rea-call-path",
34
+ name: "REA call path reconstruction",
35
+ version: "1",
36
+ };
37
+ /** Provider identity for explicit static/runtime hypothesis correlation. */
38
+ export const STATIC_RUNTIME_PROVIDER = {
39
+ id: "rea-static-runtime-correlation",
40
+ name: "REA static/runtime correlation",
41
+ version: "1",
42
+ };
43
+ /** Provider identity for finite reconstruction specification verification. */
44
+ export const RECONSTRUCTION_PROVIDER = {
45
+ id: "rea-reconstruction-verifier",
46
+ name: "REA reconstruction verifier",
47
+ version: "1",
48
+ };
@@ -0,0 +1,214 @@
1
+ import { lstat, open, readFile, realpath, unlink, } from "node:fs/promises";
2
+ import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
3
+ import writeFileAtomic from "write-file-atomic";
4
+ import { InvestigationWorkspaceError } from "../domain/errors.js";
5
+ import { parseInvestigationWorkspace, serializeInvestigationWorkspace, } from "../domain/investigationWorkspace.js";
6
+ import { err, ok } from "../domain/result.js";
7
+ /** Read a validated workspace, returning null only when it does not exist. */
8
+ export const readInvestigationWorkspace = async (path, policy) => {
9
+ if (policy.roots.length === 0)
10
+ return err(new InvestigationWorkspaceError("read", "disabled"));
11
+ try {
12
+ const destination = await resolveDestination(path, policy.roots);
13
+ if (destination === null)
14
+ return err(new InvestigationWorkspaceError("read", "outside-root"));
15
+ return await readWorkspaceFile(destination, policy);
16
+ }
17
+ catch (cause) {
18
+ return err(new InvestigationWorkspaceError("read", "io", { cause }));
19
+ }
20
+ };
21
+ /** Atomically append one CAS-linked workspace revision under an exclusive lock. */
22
+ export const writeInvestigationWorkspace = async (workspace, path, expectedRevision, policy) => {
23
+ if (policy.roots.length === 0)
24
+ return err(new InvestigationWorkspaceError("update", "disabled"));
25
+ let encoded;
26
+ try {
27
+ encoded = serializeInvestigationWorkspace(workspace);
28
+ }
29
+ catch (cause) {
30
+ return err(new InvestigationWorkspaceError("update", "integrity", { cause }));
31
+ }
32
+ const bytes = Buffer.byteLength(encoded, "utf8");
33
+ if (bytes > policy.maxBytes)
34
+ return err(new InvestigationWorkspaceError("update", "too-large"));
35
+ let lock;
36
+ try {
37
+ const destination = await resolveDestination(path, policy.roots);
38
+ if (destination === null)
39
+ return err(new InvestigationWorkspaceError("update", "outside-root"));
40
+ const acquired = await acquireLock(destination);
41
+ if (!acquired.ok)
42
+ return acquired;
43
+ lock = acquired.value;
44
+ const current = await readWorkspaceFile(destination, policy);
45
+ if (!current.ok)
46
+ return current;
47
+ const checked = validateNextRevision(current.value, workspace, expectedRevision);
48
+ if (!checked.ok)
49
+ return checked;
50
+ await writeFileAtomic(destination, encoded, {
51
+ encoding: "utf8",
52
+ mode: 0o600,
53
+ fsync: true,
54
+ });
55
+ return ok({ path: resolve(path), bytes });
56
+ }
57
+ catch (cause) {
58
+ return err(new InvestigationWorkspaceError("update", "io", { cause }));
59
+ }
60
+ finally {
61
+ if (lock !== undefined)
62
+ await releaseLock(lock);
63
+ }
64
+ };
65
+ const resolveDestination = async (path, roots) => {
66
+ const requested = resolve(path);
67
+ const canonicalParent = await realpath(dirname(requested));
68
+ const destination = resolve(canonicalParent, basename(requested));
69
+ for (const configuredRoot of roots) {
70
+ const root = await realpath(resolve(configuredRoot));
71
+ const relation = relative(root, destination);
72
+ if (relation === "" ||
73
+ (!relation.startsWith("..") && !isAbsolute(relation)))
74
+ return destination;
75
+ }
76
+ return null;
77
+ };
78
+ const readWorkspaceFile = async (destination, policy) => {
79
+ const stats = await lstat(destination).catch((cause) => {
80
+ if (isFileNotFound(cause))
81
+ return undefined;
82
+ throw cause;
83
+ });
84
+ if (stats === undefined)
85
+ return ok(null);
86
+ if (!stats.isFile() || stats.isSymbolicLink())
87
+ return err(new InvestigationWorkspaceError("read", "not-file"));
88
+ if (stats.size > policy.maxBytes)
89
+ return err(new InvestigationWorkspaceError("read", "too-large"));
90
+ const encoded = await readFile(destination);
91
+ if (encoded.byteLength > policy.maxBytes)
92
+ return err(new InvestigationWorkspaceError("read", "too-large"));
93
+ let decoded;
94
+ try {
95
+ decoded = JSON.parse(encoded.toString("utf8"));
96
+ }
97
+ catch (cause) {
98
+ return err(new InvestigationWorkspaceError("read", "invalid-json", { cause }));
99
+ }
100
+ if (!withinJsonLimits(decoded, policy))
101
+ return err(new InvestigationWorkspaceError("read", "too-large"));
102
+ try {
103
+ return ok(parseInvestigationWorkspace(decoded));
104
+ }
105
+ catch (cause) {
106
+ return err(new InvestigationWorkspaceError("read", "integrity", { cause }));
107
+ }
108
+ };
109
+ const acquireLock = async (destination) => {
110
+ const path = `${destination}.lock`;
111
+ try {
112
+ return ok(await createLock(path));
113
+ }
114
+ catch (cause) {
115
+ if (isAlreadyExists(cause) && (await removeStaleLock(path))) {
116
+ try {
117
+ return ok(await createLock(path));
118
+ }
119
+ catch (retryCause) {
120
+ return err(new InvestigationWorkspaceError("update", isAlreadyExists(retryCause) ? "locked" : "io", { cause: retryCause }));
121
+ }
122
+ }
123
+ return err(new InvestigationWorkspaceError("update", isAlreadyExists(cause) ? "locked" : "io", { cause }));
124
+ }
125
+ };
126
+ const createLock = async (path) => {
127
+ const handle = await open(path, "wx", 0o600);
128
+ try {
129
+ await handle.writeFile(`${String(process.pid)}\n`, "utf8");
130
+ await handle.sync();
131
+ return { path, handle };
132
+ }
133
+ catch (cause) {
134
+ await handle.close().catch(() => undefined);
135
+ await unlink(path).catch(() => undefined);
136
+ throw cause;
137
+ }
138
+ };
139
+ const removeStaleLock = async (path) => {
140
+ try {
141
+ const stats = await lstat(path);
142
+ if (!stats.isFile() || stats.isSymbolicLink() || stats.size > 32)
143
+ return false;
144
+ const encoded = (await readFile(path, "utf8")).trim();
145
+ if (!/^[1-9][0-9]{0,9}$/u.test(encoded))
146
+ return false;
147
+ const pid = Number(encoded);
148
+ if (processIsAlive(pid))
149
+ return false;
150
+ await unlink(path);
151
+ return true;
152
+ }
153
+ catch {
154
+ return false;
155
+ }
156
+ };
157
+ const processIsAlive = (pid) => {
158
+ try {
159
+ process.kill(pid, 0);
160
+ return true;
161
+ }
162
+ catch (cause) {
163
+ return errorCode(cause) !== "ESRCH";
164
+ }
165
+ };
166
+ const releaseLock = async (lock) => {
167
+ await lock.handle.close().catch(() => undefined);
168
+ await unlink(lock.path).catch(() => undefined);
169
+ };
170
+ const validateNextRevision = (current, next, expectedRevision) => {
171
+ if ((current?.revision ?? null) !== expectedRevision)
172
+ return err(new InvestigationWorkspaceError("update", "revision-conflict"));
173
+ if (current === null) {
174
+ return next.revision === 1 && next.previous_revision_digest === null
175
+ ? ok(null)
176
+ : err(new InvestigationWorkspaceError("update", "revision-conflict"));
177
+ }
178
+ if (current.name !== next.name || current.workspace_id !== next.workspace_id)
179
+ return err(new InvestigationWorkspaceError("update", "name-conflict"));
180
+ return next.revision === current.revision + 1 &&
181
+ next.previous_revision_digest === current.revision_digest
182
+ ? ok(null)
183
+ : err(new InvestigationWorkspaceError("update", "revision-conflict"));
184
+ };
185
+ const withinJsonLimits = (root, policy) => {
186
+ const pending = [
187
+ { value: root, depth: 0 },
188
+ ];
189
+ let nodes = 0;
190
+ while (pending.length > 0) {
191
+ const current = pending.pop();
192
+ if (current === undefined)
193
+ break;
194
+ nodes += 1;
195
+ if (nodes > policy.maxNodes || current.depth > policy.maxDepth)
196
+ return false;
197
+ if (typeof current.value === "string" &&
198
+ current.value.length > policy.maxStringLength)
199
+ return false;
200
+ if (typeof current.value !== "object" || current.value === null)
201
+ continue;
202
+ for (const [key, value] of Object.entries(current.value)) {
203
+ if (key.length > policy.maxStringLength)
204
+ return false;
205
+ pending.push({ value, depth: current.depth + 1 });
206
+ }
207
+ }
208
+ return true;
209
+ };
210
+ const isFileNotFound = (cause) => errorCode(cause) === "ENOENT";
211
+ const isAlreadyExists = (cause) => errorCode(cause) === "EEXIST";
212
+ const errorCode = (cause) => typeof cause === "object" && cause !== null && "code" in cause
213
+ ? cause.code
214
+ : undefined;
@@ -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
- });
@@ -1,5 +1,5 @@
1
1
  import { realpath } from "node:fs/promises";
2
- import { ProcessCaptureError } from "./ProcessCaptureError.js";
2
+ import { ProcessCaptureError, processCaptureCancelled, } from "./ProcessCaptureError.js";
3
3
  const isWithin = (candidate, root) => candidate === root ||
4
4
  candidate.startsWith(`${root.endsWith("/") ? root.slice(0, -1) : root}/`);
5
5
  export const assertRealPathAuthority = async (scenario, policy) => {
@@ -20,6 +20,6 @@ export const assertRealPathAuthority = async (scenario, policy) => {
20
20
  /** Expected refusal or runtime failure from the process capture adapter. */
21
21
  export const assertNotCancelled = (signal) => {
22
22
  if (signal?.aborted === true)
23
- throw new ProcessCaptureError("process capture was cancelled");
23
+ throw processCaptureCancelled();
24
24
  };
25
25
  /** Runtime availability of the native PTY adapter on this host. */
@@ -2,4 +2,16 @@ import { AnalysisError } from "../domain/errors.js";
2
2
  /** Typed application failure produced by controlled process capture. */
3
3
  export class ProcessCaptureError extends AnalysisError {
4
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
+ }
5
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
+ });
@@ -257,7 +257,10 @@ export const resolveProcessResult = (capture, executionFailure, cleanupFailure)
257
257
  export const prepareProcessCapture = async (scenario, policy, signal) => {
258
258
  const decision = authorizeProcessScenario(scenario, policy);
259
259
  if (!decision.allowed)
260
- throw new ProcessCaptureError(decision.reason);
260
+ throw new ProcessCaptureError(decision.reason, {
261
+ userCategory: "permission_required",
262
+ userMessage: processPolicyMessage(decision.reason),
263
+ });
261
264
  await assertRealPathAuthority(scenario, policy);
262
265
  assertNotCancelled(signal);
263
266
  const before = await snapshotRoots(scenario, signal);
@@ -267,3 +270,18 @@ export const prepareProcessCapture = async (scenario, policy, signal) => {
267
270
  await import("node:fs/promises").then(({ mkdir }) => mkdir(home));
268
271
  return { temporaryRoot, runId, home, before };
269
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
+ };