rea-agents 1.4.0 → 1.6.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 (123) hide show
  1. package/README.md +37 -13
  2. package/dist/application/AnalysisSnapshotCache.js +8 -6
  3. package/dist/application/ArtifactGraphConstruction.js +11 -2
  4. package/dist/application/ArtifactInventory.js +3 -2
  5. package/dist/application/AuthorizedArtifactInventory.js +2 -1
  6. package/dist/application/BinarySession.js +26 -11
  7. package/dist/application/BoundedJsonFiles.js +6 -29
  8. package/dist/application/BrowserEvidence.js +107 -0
  9. package/dist/application/BrowserObservationPort.js +1 -0
  10. package/dist/application/BrowserObservationService.js +116 -0
  11. package/dist/application/CapabilityInventory.js +14 -0
  12. package/dist/application/ClientConfigPath.js +33 -0
  13. package/dist/application/ConfiguredRoots.js +19 -0
  14. package/dist/application/CrossVersionInventory.js +16 -3
  15. package/dist/application/CrossVersionInvestigation.js +23 -62
  16. package/dist/application/CrossVersionInvestigationEvidence.js +64 -0
  17. package/dist/application/CrossVersionInvestigationReplay.js +170 -0
  18. package/dist/application/DeferredFileAuthorization.js +61 -0
  19. package/dist/application/DirectAnalysis.js +38 -18
  20. package/dist/application/Doctor.js +16 -11
  21. package/dist/application/ElectronEvidence.js +35 -0
  22. package/dist/application/ElectronObservationPort.js +1 -0
  23. package/dist/application/ElectronObservationService.js +46 -0
  24. package/dist/application/EvidenceLedger.js +11 -7
  25. package/dist/application/InvestigationWorkspaceStore.js +2 -26
  26. package/dist/application/PermissionAuthority.js +30 -0
  27. package/dist/application/ProcessCaptureAuthority.js +3 -2
  28. package/dist/application/ProcessCaptureLifecycle.js +19 -13
  29. package/dist/application/ProcessCheckpoints.js +1 -1
  30. package/dist/application/ProcessCli.js +17 -5
  31. package/dist/application/ProcessHarness.js +19 -26
  32. package/dist/application/ProcessNormalization.js +17 -2
  33. package/dist/application/ProcessOwnership.js +44 -23
  34. package/dist/application/ProcessSampling.js +111 -25
  35. package/dist/application/ProjectPermissionStore.js +107 -3
  36. package/dist/application/ReferenceSourceImport.js +13 -16
  37. package/dist/application/ReferenceSourceImportPolicy.js +3 -2
  38. package/dist/application/Setup.js +89 -30
  39. package/dist/application/SetupClients.js +7 -4
  40. package/dist/application/SetupInstallFailure.js +24 -0
  41. package/dist/application/SetupPlan.js +11 -7
  42. package/dist/application/SetupSkill.js +20 -3
  43. package/dist/application/TerminalRenderer.js +1 -1
  44. package/dist/application/Uninstall.js +20 -7
  45. package/dist/application/Upgrade.js +14 -3
  46. package/dist/artifacts/MachOSliceArtifactReader.js +1 -1
  47. package/dist/artifacts/NativeDmgArtifactReader.js +1 -1
  48. package/dist/artifacts/SafeOutputTree.js +3 -3
  49. package/dist/browser/CdpBrowserProvider.js +261 -0
  50. package/dist/browser/CdpCaptureCompleteness.js +90 -0
  51. package/dist/browser/CdpCaptureDocuments.js +299 -0
  52. package/dist/browser/CdpCaptureEvents.js +547 -0
  53. package/dist/browser/CdpCaptureStorage.js +53 -0
  54. package/dist/browser/CdpCaptureValues.js +68 -0
  55. package/dist/browser/CdpConnection.js +241 -0
  56. package/dist/browser/CdpElectronInspection.js +330 -0
  57. package/dist/browser/CdpElectronProvider.js +134 -0
  58. package/dist/browser/CdpEndpoint.js +173 -0
  59. package/dist/browser/CdpObservationSession.js +301 -0
  60. package/dist/browser/CdpOptionalCommand.js +15 -0
  61. package/dist/browser/CdpPageCapture.js +376 -0
  62. package/dist/browser/CdpSafeMetadata.js +203 -0
  63. package/dist/browser/CdpScreenshot.js +101 -0
  64. package/dist/browser/CdpTargetSession.js +47 -0
  65. package/dist/browser/CdpWebMcpDiscovery.js +242 -0
  66. package/dist/browser/ElectronFileScope.js +50 -0
  67. package/dist/browser/PngVisualDiff.js +177 -0
  68. package/dist/browser/SensitiveTextCapture.js +25 -0
  69. package/dist/browser/WebSourceMapFetcher.js +357 -0
  70. package/dist/catalogIdentity.js +10 -0
  71. package/dist/cli.js +6 -0
  72. package/dist/cliBrowserAdvancedCommands.js +169 -0
  73. package/dist/cliBrowserCommands.js +312 -0
  74. package/dist/cliElectronCommands.js +114 -0
  75. package/dist/cliInvestigationCommands.js +26 -34
  76. package/dist/cliPolicyCommands.js +60 -12
  77. package/dist/config.js +80 -1
  78. package/dist/contracts/browserToolContracts.js +381 -0
  79. package/dist/contracts/electronToolContracts.js +73 -0
  80. package/dist/contracts/errorSchemas.js +2 -0
  81. package/dist/contracts/toolContracts.js +5 -5
  82. package/dist/domain/browserCompleteness.js +101 -0
  83. package/dist/domain/browserObservation.js +587 -0
  84. package/dist/domain/browserSession.js +63 -0
  85. package/dist/domain/changedBehavior.js +4 -15
  86. package/dist/domain/electronObservation.js +162 -0
  87. package/dist/domain/errors.js +41 -0
  88. package/dist/domain/investigationWorkspace.js +4 -0
  89. package/dist/domain/jsonLimits.js +26 -0
  90. package/dist/domain/jsonShape.js +125 -0
  91. package/dist/domain/permissionPolicy.js +6 -2
  92. package/dist/domain/processCapture.js +3 -1
  93. package/dist/domain/processComparison.js +22 -34
  94. package/dist/domain/reconstructionVerification.js +2 -15
  95. package/dist/domain/reconstructionVerificationSchemas.js +2 -0
  96. package/dist/domain/referenceSourceGraph.js +3 -1
  97. package/dist/domain/staticRuntimeCorrelation.js +2 -0
  98. package/dist/domain/symbolAnalysis.js +1 -1
  99. package/dist/domain/webBundleAnalysis.js +195 -0
  100. package/dist/domain/webBundleAnalyzer.js +423 -0
  101. package/dist/domain/webCaptureDiff.js +184 -0
  102. package/dist/domain/webContentArtifact.js +47 -0
  103. package/dist/domain/webInventory.js +61 -0
  104. package/dist/domain/webMcpDiscovery.js +68 -0
  105. package/dist/domain/webScreenshot.js +114 -0
  106. package/dist/generatedPackageMetadata.js +2 -2
  107. package/dist/hopper/BridgeLauncher.js +2 -3
  108. package/dist/hopper/HopperClient.js +51 -11
  109. package/dist/hopper/HopperProvider.js +6 -1
  110. package/dist/main.js +42 -31
  111. package/dist/native/CommandRunner.js +11 -4
  112. package/dist/native/NativeMacOSProvider.js +3 -2
  113. package/dist/native/NativeMachoInspection.js +21 -6
  114. package/dist/server/createServer.js +14 -0
  115. package/dist/server/registerBrowserTools.js +95 -0
  116. package/dist/server/registerElectronTools.js +36 -0
  117. package/dist/server/registerInvestigationTools.js +31 -37
  118. package/dist/server/registerProcessComparisonTool.js +2 -0
  119. package/dist/server/registerSessionTools.js +16 -2
  120. package/dist/server/runDerivedOperation.js +10 -2
  121. package/package.json +6 -1
  122. package/scripts/rea.mjs +2 -2
  123. package/skills/rea-analysis/SKILL.md +28 -3
@@ -1,8 +1,19 @@
1
1
  import { execFile } from "node:child_process";
2
- import { readFile } from "node:fs/promises";
2
+ import { readFile, readdir } from "node:fs/promises";
3
3
  import { promisify } from "node:util";
4
+ import { readProcessRunId } from "./ProcessOwnership.js";
4
5
  const execFileAsync = promisify(execFile);
5
6
  const PROC_READ_CONCURRENCY = 64;
7
+ /** Prove that forkpty completed session setup and installed the run token. */
8
+ export const isInitializedPtyRoot = (options) => options.before.pid === options.rootPid &&
9
+ options.before.process_group_id === options.rootPid &&
10
+ options.before.session_id === options.rootPid &&
11
+ options.after.pid === options.rootPid &&
12
+ options.after.parent_pid === options.before.parent_pid &&
13
+ options.after.process_group_id === options.rootPid &&
14
+ options.after.session_id === options.rootPid &&
15
+ options.after.startTime === options.before.startTime &&
16
+ options.observedRunId === options.expectedRunId;
6
17
  const isExpectedProcReadFailure = (cause) => cause instanceof Error &&
7
18
  "code" in cause &&
8
19
  (cause.code === "ENOENT" ||
@@ -67,23 +78,58 @@ const readProcessCommand = async (pid, signal) => {
67
78
  return handleProcReadFailure(cause);
68
79
  }
69
80
  };
70
- const readLinuxChildren = async (pid, signal) => {
71
- let text;
72
- try {
73
- text = await readFile(`/proc/${String(pid)}/task/${String(pid)}/children`, {
74
- encoding: "utf8",
75
- signal,
76
- });
77
- }
78
- catch (cause) {
79
- return handleProcReadFailure(cause);
81
+ const systemLinuxChildrenHost = {
82
+ async taskIds(pid, signal) {
83
+ try {
84
+ signal.throwIfAborted();
85
+ const entries = await readdir(`/proc/${String(pid)}/task`);
86
+ signal.throwIfAborted();
87
+ return entries
88
+ .filter((entry) => /^\d+$/.test(entry))
89
+ .map(Number)
90
+ .filter((taskId) => Number.isSafeInteger(taskId) && taskId > 0)
91
+ .sort((left, right) => left - right);
92
+ }
93
+ catch (cause) {
94
+ return handleProcReadFailure(cause);
95
+ }
96
+ },
97
+ async children(pid, taskId, signal) {
98
+ try {
99
+ return await readFile(`/proc/${String(pid)}/task/${String(taskId)}/children`, { encoding: "utf8", signal });
100
+ }
101
+ catch (cause) {
102
+ return handleProcReadFailure(cause);
103
+ }
104
+ },
105
+ };
106
+ /** Read the deduplicated children created by every thread in a Linux process. */
107
+ export const readLinuxChildren = async (pid, signal, host = systemLinuxChildrenHost) => {
108
+ const taskIds = await host.taskIds(pid, signal);
109
+ if (taskIds === undefined)
110
+ return undefined;
111
+ const children = new Set();
112
+ let successfulReads = 0;
113
+ for (let offset = 0; offset < taskIds.length; offset += PROC_READ_CONCURRENCY) {
114
+ const texts = await Promise.all(taskIds
115
+ .slice(offset, offset + PROC_READ_CONCURRENCY)
116
+ .map((taskId) => host.children(pid, taskId, signal)));
117
+ for (const text of texts) {
118
+ if (text === undefined)
119
+ continue;
120
+ successfulReads += 1;
121
+ for (const value of text.trim().split(/\s+/)) {
122
+ if (!/^\d+$/.test(value))
123
+ continue;
124
+ const child = Number(value);
125
+ if (Number.isSafeInteger(child) && child > 0)
126
+ children.add(child);
127
+ }
128
+ }
80
129
  }
81
- return text
82
- .trim()
83
- .split(/\s+/)
84
- .filter((value) => /^\d+$/.test(value))
85
- .map(Number)
86
- .filter((child) => Number.isSafeInteger(child) && child > 0);
130
+ return successfulReads === 0
131
+ ? undefined
132
+ : [...children].sort((left, right) => left - right);
87
133
  };
88
134
  const inspectProcess = async (pid, expectedParent, signal, identities) => {
89
135
  const before = await readProcessStat(pid, signal);
@@ -179,6 +225,34 @@ const readPsRows = async (signal) => {
179
225
  row.parent_pid >= 0)
180
226
  .sort((left, right) => left.pid - right.pid);
181
227
  };
228
+ const readRootIdentity = async (rootPid, signal) => process.platform === "linux"
229
+ ? readProcessStat(rootPid, signal)
230
+ : (await readPsRows(signal)).find(({ pid }) => pid === rootPid);
231
+ const inspectInitializedPtyRoot = async (rootPid, runId, signal) => {
232
+ const before = await readRootIdentity(rootPid, signal);
233
+ if (before === undefined ||
234
+ before.process_group_id !== rootPid ||
235
+ before.session_id !== rootPid)
236
+ return undefined;
237
+ let observedRunId;
238
+ try {
239
+ observedRunId = await readProcessRunId(rootPid);
240
+ }
241
+ catch {
242
+ return undefined;
243
+ }
244
+ const after = await readRootIdentity(rootPid, signal);
245
+ if (after === undefined ||
246
+ !isInitializedPtyRoot({
247
+ rootPid,
248
+ expectedRunId: runId,
249
+ before,
250
+ observedRunId,
251
+ after,
252
+ }))
253
+ return undefined;
254
+ return { startTime: after.startTime };
255
+ };
182
256
  const samplePs = async (context) => {
183
257
  const { rootPid, limit, signal } = context;
184
258
  if (limit <= 0)
@@ -242,25 +316,37 @@ const sampleProcesses = async (context) => {
242
316
  return samples;
243
317
  };
244
318
  /** Start bounded process-tree sampling and expose an awaited stop. */
245
- export const startProcessSampler = (rootPid, started, limit, samples) => {
319
+ export const startProcessSampler = (options) => {
320
+ const { rootPid, runId, started, limit, samples } = options;
246
321
  const identities = new Map();
247
322
  const lastObservations = new Map();
248
323
  let pending;
249
324
  let stopped = false;
250
325
  let abortCurrent;
251
326
  let partial = false;
327
+ let rootInitialized = false;
252
328
  const sample = () => {
253
329
  if (stopped || pending !== undefined)
254
330
  return;
255
331
  const controller = new AbortController();
256
332
  abortCurrent = () => controller.abort();
257
- pending = sampleProcesses({
258
- rootPid,
259
- elapsedMs: Date.now() - started,
260
- limit: limit + 1,
261
- identities,
262
- signal: controller.signal,
263
- })
333
+ pending = (async () => {
334
+ if (!rootInitialized) {
335
+ const identity = await inspectInitializedPtyRoot(rootPid, runId, controller.signal);
336
+ if (identity === undefined)
337
+ return [];
338
+ if (identity.startTime !== undefined)
339
+ identities.set(rootPid, identity.startTime);
340
+ rootInitialized = true;
341
+ }
342
+ return sampleProcesses({
343
+ rootPid,
344
+ elapsedMs: Date.now() - started,
345
+ limit: limit + 1,
346
+ identities,
347
+ signal: controller.signal,
348
+ });
349
+ })()
264
350
  .then((values) => {
265
351
  for (const value of values) {
266
352
  const observation = JSON.stringify({
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
- import { mkdir, readFile, realpath, stat } from "node:fs/promises";
2
+ import { lstat, mkdir, open, readFile, realpath, stat, unlink, } from "node:fs/promises";
3
3
  import { dirname } from "node:path";
4
+ import { setTimeout as delay } from "node:timers/promises";
4
5
  import writeFileAtomic from "write-file-atomic";
5
6
  import { z } from "zod";
6
7
  import { err, ok } from "../domain/result.js";
@@ -8,6 +9,8 @@ const grantSchema = z.object({
8
9
  grant_id: z.string().min(1),
9
10
  capability: z.enum([
10
11
  "process_capture",
12
+ "browser_observe",
13
+ "electron_observe",
11
14
  "evidence_read",
12
15
  "evidence_write",
13
16
  "investigation_input",
@@ -22,6 +25,7 @@ const grantSchema = z.object({
22
25
  roots: z.array(z.string()),
23
26
  executables: z.array(z.string()),
24
27
  environment_names: z.array(z.string()),
28
+ origins: z.array(z.string()).optional(),
25
29
  network: z.enum(["none", "loopback", "external"]),
26
30
  mount: z.boolean(),
27
31
  lifetime: z.literal("project"),
@@ -65,7 +69,15 @@ export const readProjectPermissionStore = async (path, projectRoot) => {
65
69
  const metadata = await stat(path);
66
70
  if ((metadata.mode & 0o077) !== 0)
67
71
  return err(new ProjectPermissionStoreError("not_owner_only"));
68
- const parsed = storeSchema.safeParse(JSON.parse(await readFile(path, "utf8")));
72
+ const encoded = await readFile(path, "utf8");
73
+ let decoded;
74
+ try {
75
+ decoded = JSON.parse(encoded);
76
+ }
77
+ catch (cause) {
78
+ return err(new ProjectPermissionStoreError("invalid", { cause }));
79
+ }
80
+ const parsed = storeSchema.safeParse(decoded);
69
81
  if (!parsed.success ||
70
82
  parsed.data.project_id !== project.value.id ||
71
83
  parsed.data.project_root !== project.value.root)
@@ -107,4 +119,96 @@ export const writeProjectPermissionStore = async (path, projectRoot, grants) =>
107
119
  return err(new ProjectPermissionStoreError("io", { cause }));
108
120
  }
109
121
  };
110
- const isNotFound = (cause) => cause instanceof Error && "code" in cause && cause.code === "ENOENT";
122
+ /** Revoke one grant while serializing the complete read-modify-write cycle. */
123
+ export const revokeProjectPermissionGrant = async (path, projectRoot, grantId) => {
124
+ let lock;
125
+ try {
126
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
127
+ const acquired = await acquirePermissionStoreLock(path);
128
+ if (!acquired.ok)
129
+ return acquired;
130
+ lock = acquired.value;
131
+ const current = await readProjectPermissionStore(path, projectRoot);
132
+ if (!current.ok)
133
+ return current;
134
+ const grants = current.value?.grants ?? [];
135
+ const retained = grants.filter(({ grant_id }) => grant_id !== grantId);
136
+ if (retained.length === grants.length)
137
+ return ok(false);
138
+ const written = await writeProjectPermissionStore(path, projectRoot, retained);
139
+ return written.ok ? ok(true) : written;
140
+ }
141
+ catch (cause) {
142
+ return err(new ProjectPermissionStoreError("io", { cause }));
143
+ }
144
+ finally {
145
+ if (lock !== undefined)
146
+ await releasePermissionStoreLock(lock);
147
+ }
148
+ };
149
+ const acquirePermissionStoreLock = async (destination) => {
150
+ const path = `${destination}.lock`;
151
+ for (let attempt = 0; attempt < 100; attempt += 1) {
152
+ try {
153
+ return ok(await createPermissionStoreLock(path));
154
+ }
155
+ catch (cause) {
156
+ if (!isAlreadyExists(cause))
157
+ return err(new ProjectPermissionStoreError("io", { cause }));
158
+ if (await removeStalePermissionStoreLock(path))
159
+ continue;
160
+ if (attempt === 99)
161
+ return err(new ProjectPermissionStoreError("locked", { cause }));
162
+ await delay(10);
163
+ }
164
+ }
165
+ return err(new ProjectPermissionStoreError("locked"));
166
+ };
167
+ const createPermissionStoreLock = async (path) => {
168
+ const handle = await open(path, "wx", 0o600);
169
+ try {
170
+ await handle.writeFile(`${String(process.pid)}\n`, "utf8");
171
+ await handle.sync();
172
+ return { path, handle };
173
+ }
174
+ catch (cause) {
175
+ await handle.close().catch(() => undefined);
176
+ await unlink(path).catch(() => undefined);
177
+ throw cause;
178
+ }
179
+ };
180
+ const removeStalePermissionStoreLock = async (path) => {
181
+ try {
182
+ const metadata = await lstat(path);
183
+ if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > 32)
184
+ return false;
185
+ const encoded = (await readFile(path, "utf8")).trim();
186
+ if (!/^[1-9][0-9]{0,9}$/u.test(encoded))
187
+ return false;
188
+ if (processIsAlive(Number(encoded)))
189
+ return false;
190
+ await unlink(path);
191
+ return true;
192
+ }
193
+ catch {
194
+ return false;
195
+ }
196
+ };
197
+ const processIsAlive = (pid) => {
198
+ try {
199
+ process.kill(pid, 0);
200
+ return true;
201
+ }
202
+ catch (cause) {
203
+ return errorCode(cause) !== "ESRCH";
204
+ }
205
+ };
206
+ const releasePermissionStoreLock = async (lock) => {
207
+ await lock.handle.close().catch(() => undefined);
208
+ await unlink(lock.path).catch(() => undefined);
209
+ };
210
+ const isNotFound = (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,5 +1,5 @@
1
1
  import { isSecretLikePath } from "../domain/referenceSourceClassification.js";
2
- import { createHistoricalSourceGraph, } from "../domain/referenceSourceGraph.js";
2
+ import { createHistoricalSourceGraph, historicalSourceParseFailureKey, } from "../domain/referenceSourceGraph.js";
3
3
  import { err, ok } from "../domain/result.js";
4
4
  import { readReferenceSource } from "../reference/ReferenceSourceReader.js";
5
5
  import { parseReferenceSourceEntries } from "./ReferenceSourceImportEntries.js";
@@ -38,6 +38,17 @@ const deduplicateRelationships = (relationships) => {
38
38
  }
39
39
  return result;
40
40
  };
41
+ /** Sort parse failures deterministically while retaining distinct reasons. */
42
+ export const normalizeHistoricalSourceParseFailures = (failures) => [...failures]
43
+ .sort((left, right) => byCodePoint(historicalSourceParseFailureKey(left), historicalSourceParseFailureKey(right)))
44
+ .filter((value, index, array) => {
45
+ if (index === 0)
46
+ return true;
47
+ const previous = array[index - 1];
48
+ return (previous === undefined ||
49
+ historicalSourceParseFailureKey(value) !==
50
+ historicalSourceParseFailureKey(previous));
51
+ });
41
52
  const buildProvenance = (options) => ({
42
53
  importer: options.importer ?? "rea-reference-source-import",
43
54
  importer_version: options.importerVersion ?? null,
@@ -134,21 +145,7 @@ export const importReferenceSource = async (options) => {
134
145
  if (isAborted(options.signal))
135
146
  return err(cancelled());
136
147
  const uniqueRelationships = deduplicateRelationships(relationships);
137
- const uniqueFailures = [...parseFailures]
138
- .sort((left, right) => {
139
- const byPath = byCodePoint(left.path, right.path);
140
- if (byPath !== 0)
141
- return byPath;
142
- return byCodePoint(left.parser, right.parser);
143
- })
144
- .filter((value, index, array) => {
145
- if (index === 0)
146
- return true;
147
- const previous = array[index - 1];
148
- if (previous === undefined)
149
- return true;
150
- return value.path !== previous.path || value.parser !== previous.parser;
151
- });
148
+ const uniqueFailures = normalizeHistoricalSourceParseFailures(parseFailures);
152
149
  const sortedEntries = [...entries].sort((left, right) => byCodePoint(left.path, right.path));
153
150
  const sortedExclusions = sortAndLimit(exclusions, limits.maxEntries);
154
151
  const sortedLimitations = [...limitations].sort(byCodePoint);
@@ -4,6 +4,7 @@ import ignore from "ignore";
4
4
  import { err, ok } from "../domain/result.js";
5
5
  import { withinRoot } from "../reference/ReferenceSourceReaderPaths.js";
6
6
  import { DEFAULT_REFERENCE_SOURCE_IGNORE_PATTERNS, DEFAULT_REFERENCE_SOURCE_LIMITS, } from "./ReferenceSourceImportTypes.js";
7
+ import { canonicalizeConfiguredRoots } from "./ConfiguredRoots.js";
7
8
  const failure = (code, message) => ({
8
9
  tag: "reference-source-import",
9
10
  code,
@@ -29,8 +30,8 @@ const authorizeRoot = async (requestedRoot, approvedRoots) => {
29
30
  if (!(await stat(requestedRoot)).isDirectory())
30
31
  return err(failure("invalid-root", "Reference source root is not a directory"));
31
32
  const canonicalRoot = await realpath(resolve(requestedRoot));
32
- for (const approvedRoot of approvedRoots) {
33
- const canonicalApproved = await realpath(resolve(approvedRoot));
33
+ const canonicalApprovedRoots = await canonicalizeConfiguredRoots(approvedRoots.map((approvedRoot) => resolve(approvedRoot)));
34
+ for (const canonicalApproved of canonicalApprovedRoots) {
34
35
  if (withinRoot(canonicalApproved, canonicalRoot))
35
36
  return ok(canonicalRoot);
36
37
  }
@@ -1,4 +1,4 @@
1
- import { access, copyFile, mkdir, readFile, rename, rm, writeFile, } from "node:fs/promises";
1
+ import { access, copyFile, mkdir, readFile, rm, writeFile, } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import { z } from "zod";
@@ -6,15 +6,16 @@ import writeFileAtomic from "write-file-atomic";
6
6
  import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
7
7
  import { PRODUCT_IDENTITY } from "../identity.js";
8
8
  import { supportsNodeVersion } from "../domain/runtimeVersion.js";
9
+ import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
9
10
  import { runDoctor, systemDoctorHost } from "./Doctor.js";
10
11
  import { installLinuxHopper, readLinuxDistribution, } from "./LinuxHopper.js";
11
12
  import { installMacHopper } from "./MacHopper.js";
12
13
  import { configureDetectedClients } from "./SetupClients.js";
13
14
  import { supportedClients } from "./SupportedClients.js";
14
- import { installCanonicalSkill } from "./SetupSkill.js";
15
+ import { canonicalSkillNeedsInstall, installCanonicalSkill, } from "./SetupSkill.js";
15
16
  import { setupPlan } from "./SetupPlan.js";
16
17
  import { setupInstallFailure, } from "./SetupInstallFailure.js";
17
- export { installCanonicalSkill } from "./SetupSkill.js";
18
+ export { canonicalSkillNeedsInstall, installCanonicalSkill, } from "./SetupSkill.js";
18
19
  const registrationCommand = () => process.env.npm_command === "exec"
19
20
  ? ["npx", "-y", PRODUCT_IDENTITY.packageName, "mcp"]
20
21
  : [resolve(process.argv[1] ?? PRODUCT_IDENTITY.cliBinary), "mcp"];
@@ -39,14 +40,10 @@ export const runSetup = async (options, host = systemSetupHost(), confirm) => {
39
40
  if (unsupported !== undefined)
40
41
  return fail(unsupported);
41
42
  let hopperPath = await host.hopperPath();
42
- const initialDoctor = await host.doctor();
43
- const linuxHopperRepairNeeded = initialDoctor.checks.some(({ name, ok, detail }) => !ok &&
44
- (name === "hopper-demo-runtime" ||
45
- (name === "hopper-version" && detail === "/opt/hopper/bin/Hopper")));
46
- const installHopper = hopperPath === undefined || linuxHopperRepairNeeded;
47
- const detectedClients = await host.detectedClients();
48
- plannedActions = setupPlan(host.platform, installHopper, detectedClients);
49
- let approved = options.approved;
43
+ const discovery = await discoverSetupActions(host, hopperPath);
44
+ const { detectedClients, installHopper, installSkill } = discovery;
45
+ plannedActions = discovery.plannedActions;
46
+ let approved = options.approved || plannedActions.length === 0;
50
47
  let interactiveApproval = false;
51
48
  if (!approved && confirm !== undefined && !options.structured) {
52
49
  interactiveApproval = await confirm(plannedActions);
@@ -86,11 +83,13 @@ export const runSetup = async (options, host = systemSetupHost(), confirm) => {
86
83
  });
87
84
  if (clientFailure !== undefined)
88
85
  return fail(clientFailure);
89
- const skill = await host.installSkill();
90
- if (skill === "failed")
91
- return fail("REA analysis skill could not be installed or verified. Check permissions for `~/.agents/skills`, then rerun setup.");
92
- if (skill === "installed")
93
- appliedActions.push("installed_skill");
86
+ if (installSkill) {
87
+ const skill = await host.installSkill();
88
+ if (skill === "failed")
89
+ return fail("REA analysis skill could not be installed or verified. Check permissions for `~/.agents/skills`, then rerun setup.");
90
+ if (skill === "installed")
91
+ appliedActions.push("installed_skill");
92
+ }
94
93
  const doctor = await host.doctor();
95
94
  const remediation = finalSetupRemediation(host.platform, appliedActions.includes("installed_hopper"), doctor.healthy, hopperPath);
96
95
  return {
@@ -102,6 +101,32 @@ export const runSetup = async (options, host = systemSetupHost(), confirm) => {
102
101
  ...(remediation === undefined ? {} : { remediation }),
103
102
  };
104
103
  };
104
+ const discoverSetupActions = async (host, hopperPath) => {
105
+ const initialDoctor = await host.doctor();
106
+ const linuxHopperRepairNeeded = initialDoctor.checks.some(({ name, ok, detail }) => !ok &&
107
+ (name === "hopper-demo-runtime" ||
108
+ (name === "hopper-version" && detail === "/opt/hopper/bin/Hopper")));
109
+ const installHopper = hopperPath === undefined || linuxHopperRepairNeeded;
110
+ const [detectedClients, installSkill] = await Promise.all([
111
+ host.detectedClients(),
112
+ host.skillNeedsInstall(),
113
+ ]);
114
+ const command = registrationCommand();
115
+ const clients = installHopper
116
+ ? detectedClients
117
+ : await filterClientsNeedingConfigure(host, detectedClients, hopperPath, command);
118
+ const plannedActions = setupPlan(host.platform, installHopper, installSkill, clients);
119
+ return {
120
+ installHopper,
121
+ installSkill,
122
+ detectedClients: clients,
123
+ plannedActions,
124
+ };
125
+ };
126
+ const filterClientsNeedingConfigure = async (host, detectedClients, hopperPath, command) => {
127
+ const needs = await Promise.all(detectedClients.map((client) => host.clientNeedsConfigure(client, hopperPath, command)));
128
+ return detectedClients.filter((_, index) => needs[index]);
129
+ };
105
130
  const finalSetupRemediation = (platform, installedHopper, healthy, hopperPath) => {
106
131
  if (platform === "darwin" && installedHopper)
107
132
  return "Open Hopper, choose its demo mode or activate a license, then rerun rea doctor --json.";
@@ -149,6 +174,8 @@ const systemSetupHost = () => {
149
174
  : client.format === "toml"
150
175
  ? configureTomlClient(client, hopperPath, command)
151
176
  : configureJsonClient(client, hopperPath, command),
177
+ clientNeedsConfigure: (client, hopperPath, command) => clientConfigurationAligned(client, hopperPath, command).then((aligned) => !aligned),
178
+ skillNeedsInstall: () => canonicalSkillNeedsInstall(homedir()),
152
179
  installSkill: () => installCanonicalSkill(homedir()),
153
180
  doctor: () => runDoctor(undefined, doctorHost),
154
181
  };
@@ -168,10 +195,13 @@ export const configureJsonClient = async (client, hopperPath, command = [
168
195
  PRODUCT_IDENTITY.packageName,
169
196
  "mcp",
170
197
  ]) => {
198
+ const transactionPath = await resolveClientConfigTransactionPath(client.configPath);
199
+ if (transactionPath === undefined)
200
+ return { status: "failed", reason: "path" };
171
201
  let document = {};
172
202
  let original;
173
203
  try {
174
- original = await readFile(client.configPath, "utf8");
204
+ original = await readFile(transactionPath, "utf8");
175
205
  document = parseObject(original);
176
206
  }
177
207
  catch (cause) {
@@ -199,7 +229,7 @@ export const configureJsonClient = async (client, hopperPath, command = [
199
229
  if (original !== undefined) {
200
230
  backupPath = `${client.configPath}.rea.backup`;
201
231
  try {
202
- await copyFile(client.configPath, backupPath);
232
+ await copyFile(transactionPath, backupPath);
203
233
  }
204
234
  catch {
205
235
  return { status: "failed", reason: "backup" };
@@ -210,26 +240,26 @@ export const configureJsonClient = async (client, hopperPath, command = [
210
240
  [PRODUCT_IDENTITY.mcpServerKey]: desired,
211
241
  };
212
242
  const encoded = `${JSON.stringify(document, null, 2)}\n`;
213
- const temporary = `${client.configPath}.rea.tmp`;
214
243
  try {
215
244
  await mkdir(dirname(client.configPath), { recursive: true });
216
- await writeFile(temporary, encoded, { encoding: "utf8", mode: 0o600 });
217
- await rename(temporary, client.configPath);
245
+ await writeFileAtomic(transactionPath, encoded, {
246
+ encoding: "utf8",
247
+ mode: 0o600,
248
+ });
218
249
  }
219
250
  catch {
220
- await rm(temporary, { force: true }).catch(() => undefined);
221
251
  return { status: "failed", reason: "write" };
222
252
  }
223
253
  try {
224
- const readback = parseObject(await readFile(client.configPath, "utf8"));
254
+ const readback = parseObject(await readFile(transactionPath, "utf8"));
225
255
  const value = parseOptionalObject(readback.mcpServers)[PRODUCT_IDENTITY.mcpServerKey];
226
256
  if (JSON.stringify(value) !== JSON.stringify(desired)) {
227
- await restoreConfig(client.configPath, original);
257
+ await restoreConfig(transactionPath, original);
228
258
  return { status: "failed", reason: "readback" };
229
259
  }
230
260
  }
231
261
  catch {
232
- await restoreConfig(client.configPath, original);
262
+ await restoreConfig(transactionPath, original);
233
263
  return { status: "failed", reason: "readback" };
234
264
  }
235
265
  return {
@@ -244,10 +274,13 @@ export const configureTomlClient = async (client, hopperPath, command = [
244
274
  PRODUCT_IDENTITY.packageName,
245
275
  "mcp",
246
276
  ]) => {
277
+ const transactionPath = await resolveClientConfigTransactionPath(client.configPath);
278
+ if (transactionPath === undefined)
279
+ return { status: "failed", reason: "path" };
247
280
  let document = {};
248
281
  let original;
249
282
  try {
250
- original = await readFile(client.configPath, "utf8");
283
+ original = await readFile(transactionPath, "utf8");
251
284
  document = objectSchema.parse(parseToml(original));
252
285
  }
253
286
  catch (cause) {
@@ -274,7 +307,7 @@ export const configureTomlClient = async (client, hopperPath, command = [
274
307
  const backupPath = original === undefined ? undefined : `${client.configPath}.rea.backup`;
275
308
  try {
276
309
  if (backupPath !== undefined)
277
- await copyFile(client.configPath, backupPath);
310
+ await copyFile(transactionPath, backupPath);
278
311
  }
279
312
  catch {
280
313
  return { status: "failed", reason: "backup" };
@@ -285,16 +318,16 @@ export const configureTomlClient = async (client, hopperPath, command = [
285
318
  };
286
319
  try {
287
320
  await mkdir(dirname(client.configPath), { recursive: true });
288
- await writeFileAtomic(client.configPath, stringifyToml(document), {
321
+ await writeFileAtomic(transactionPath, stringifyToml(document), {
289
322
  encoding: "utf8",
290
323
  mode: 0o600,
291
324
  });
292
- const readback = objectSchema.parse(parseToml(await readFile(client.configPath, "utf8")));
325
+ const readback = objectSchema.parse(parseToml(await readFile(transactionPath, "utf8")));
293
326
  if (JSON.stringify(parseOptionalObject(readback.mcp_servers)[PRODUCT_IDENTITY.mcpServerKey]) !== JSON.stringify(desired))
294
327
  throw new Error("TOML readback mismatch");
295
328
  }
296
329
  catch {
297
- await restoreConfig(client.configPath, original);
330
+ await restoreConfig(transactionPath, original);
298
331
  return { status: "failed", reason: "readback" };
299
332
  }
300
333
  return {
@@ -327,3 +360,29 @@ const restoreConfig = async (path, original) => {
327
360
  // The backup remains available for the remediation reported by setup.
328
361
  }
329
362
  };
363
+ const clientConfigurationDesired = (hopperPath, command) => ({
364
+ command: command[0] ?? PRODUCT_IDENTITY.cliBinary,
365
+ args: command.slice(1),
366
+ ...(hopperPath === undefined
367
+ ? {}
368
+ : { env: { HOPPER_LAUNCHER_PATH: hopperPath } }),
369
+ });
370
+ const clientConfigurationAligned = async (client, hopperPath, command) => {
371
+ const desired = clientConfigurationDesired(hopperPath, command);
372
+ try {
373
+ const original = await readFile(client.configPath, "utf8");
374
+ if (client.format === "toml") {
375
+ const document = objectSchema.parse(parseToml(original));
376
+ const servers = parseOptionalObject(document.mcp_servers);
377
+ return (JSON.stringify(servers[PRODUCT_IDENTITY.mcpServerKey]) ===
378
+ JSON.stringify(desired));
379
+ }
380
+ const document = parseObject(original);
381
+ const servers = parseOptionalObject(document.mcpServers);
382
+ return (JSON.stringify(servers[PRODUCT_IDENTITY.mcpServerKey]) ===
383
+ JSON.stringify(desired));
384
+ }
385
+ catch {
386
+ return false;
387
+ }
388
+ };
@@ -1,19 +1,22 @@
1
1
  const failedConfigurationMessage = (reason) => {
2
+ if (reason === "path")
3
+ return "Agent configuration path could not be safely verified. Check its permissions and, if it is a symbolic link, verify that the link resolves to a regular file owned by the current user, then rerun setup.";
2
4
  if (reason === "backup")
3
5
  return "Agent configuration could not be backed up, so no change was made. Check file permissions, then rerun setup.";
4
6
  if (reason === "write")
5
7
  return "Agent configuration could not be updated. Check file permissions, then rerun setup.";
6
8
  return "Agent configuration could not be verified after writing. Repair the configuration file or restore its `.rea.backup`, then rerun setup.";
7
9
  };
8
- /** Configure each detected agent, stopping after the first failed transaction. */
10
+ /** Configure every detected agent and report the first failed transaction. */
9
11
  export const configureDetectedClients = async (options) => {
12
+ let failure;
10
13
  for (const client of options.detectedClients) {
11
14
  const result = await options.host.configureClient(client, options.hopperPath, options.command);
12
15
  options.clients[client.name] = result;
13
16
  if (result.status === "failed")
14
- return failedConfigurationMessage(result.reason);
15
- if (result.status === "configured")
17
+ failure ??= failedConfigurationMessage(result.reason);
18
+ else if (result.status === "configured")
16
19
  options.appliedActions.push(`configured_${client.name}`);
17
20
  }
18
- return undefined;
21
+ return failure;
19
22
  };