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
@@ -0,0 +1,35 @@
1
+ import { createEvidence, } from "../domain/evidence.js";
2
+ import { jsonValueSchema } from "../domain/jsonValue.js";
3
+ /** Create Evidence v2 for one root-confined Electron observation. */
4
+ export const createElectronEvidence = (operation, input, result, provider) => createEvidence(undefined, provider, {
5
+ predicateType: operation === "list_electron_targets"
6
+ ? "rea.electron-target-list/v1"
7
+ : "rea.electron-page-inspection/v1",
8
+ operation,
9
+ parameters: parameters(input),
10
+ result: jsonValueSchema.parse(result),
11
+ confidence: "observed",
12
+ authority: "external-service",
13
+ environment: "target" in result
14
+ ? {
15
+ id: `${result.browser.product}@${result.browser.revision}`,
16
+ platform: process.platform,
17
+ architecture: process.arch,
18
+ isolation: "none",
19
+ }
20
+ : null,
21
+ limitations: result.limitations,
22
+ });
23
+ const parameters = (input) => ({
24
+ cdp_endpoint: input.cdp_endpoint,
25
+ allowed_file_roots: input.allowed_file_roots,
26
+ ...("target_id" in input
27
+ ? {
28
+ target_id: input.target_id,
29
+ observation_ms: input.observation_ms,
30
+ include_script_sources: input.include_script_sources,
31
+ source_capture_approved: input.source_capture_approved,
32
+ limits: input.limits,
33
+ }
34
+ : { offset: input.offset, limit: input.limit }),
35
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,46 @@
1
+ import { AnalysisCapabilityUnavailableError, AnalysisProtocolError, PermissionRequiredError, } from "../domain/errors.js";
2
+ import { err, ok } from "../domain/result.js";
3
+ import { createElectronEvidence } from "./ElectronEvidence.js";
4
+ /** Authorize and list root-confined Electron page targets. */
5
+ export const listElectronTargets = async (provider, authority, input, options = {}) => {
6
+ const ready = await prepare(provider, authority, input, "list_electron_targets");
7
+ if (!ready.ok)
8
+ return ready;
9
+ const result = await ready.value.listTargets(input, options);
10
+ return result.ok
11
+ ? ok(createElectronEvidence("list_electron_targets", input, result.value, ready.value.identity()))
12
+ : result;
13
+ };
14
+ /** Authorize and inspect one root-confined Electron page. */
15
+ export const inspectElectronPage = async (provider, authority, input, options = {}) => {
16
+ const ready = await prepare(provider, authority, input, "inspect_electron_page");
17
+ if (!ready.ok)
18
+ return ready;
19
+ const result = await ready.value.inspectPage(input, options);
20
+ return result.ok
21
+ ? ok(createElectronEvidence("inspect_electron_page", input, result.value, ready.value.identity()))
22
+ : result;
23
+ };
24
+ const prepare = async (provider, authority, input, operation) => {
25
+ if (authority === undefined)
26
+ return err(new AnalysisCapabilityUnavailableError("rea-cdp-electron", operation, "Electron observation permission policy is not configured"));
27
+ const authorized = await authority.authorize({
28
+ capability: "electron_observe",
29
+ roots: input.allowed_file_roots,
30
+ executables: [],
31
+ environment_names: [],
32
+ origins: [input.cdp_endpoint],
33
+ network: "loopback",
34
+ mount: false,
35
+ operation_identity: `${operation}:${"target_id" in input ? input.target_id : input.cdp_endpoint}`,
36
+ }, "read");
37
+ if (!authorized.ok)
38
+ return err(authorized.error instanceof PermissionRequiredError
39
+ ? authorized.error
40
+ : new AnalysisProtocolError(authorized.error.message, {
41
+ cause: authorized.error,
42
+ }));
43
+ return provider === undefined
44
+ ? err(new AnalysisCapabilityUnavailableError("rea-cdp-electron", operation, "Electron observation provider is not configured"))
45
+ : ok(provider);
46
+ };
@@ -35,7 +35,7 @@ export class EvidenceLedger {
35
35
  ? ok("duplicate")
36
36
  : err(new EvidenceIntegrityError("Conflicting evidence record"));
37
37
  }
38
- if (this.#records.size >= this.limits.maxRecords)
38
+ if (this.#exceedsRecordLimit(this.#records.size + 1, this.#unknownRevisions.size))
39
39
  return err(new EvidenceLimitError("records", this.limits.maxRecords));
40
40
  const bytes = serializedBytes(evidence);
41
41
  if (this.#bytes + bytes > this.limits.maxBytes)
@@ -85,7 +85,7 @@ export class EvidenceLedger {
85
85
  }
86
86
  /** Export records in deterministic, semantically irrelevant ID order. */
87
87
  export() {
88
- return createEvidenceBundle([...this.#records.values()], [...this.#unknownRevisions.values()]);
88
+ return structuredClone(createEvidenceBundle([...this.#records.values()], [...this.#unknownRevisions.values()]));
89
89
  }
90
90
  /** Check whether one immutable Evidence ID is present in this ledger. */
91
91
  has(evidenceId) {
@@ -162,7 +162,7 @@ export class EvidenceLedger {
162
162
  if (!checked.ok)
163
163
  return checked;
164
164
  this.#commit(pendingRecords, pendingUnknowns, checked.value);
165
- return ok(unknown);
165
+ return ok(structuredClone(unknown));
166
166
  }
167
167
  /** Apply one approved full-state update using optimistic revision matching. */
168
168
  updateUnknown(input, mutationEvidence) {
@@ -187,7 +187,8 @@ export class EvidenceLedger {
187
187
  (filters.severity === undefined ||
188
188
  unknown.severity === filters.severity) &&
189
189
  (filters.domain === undefined || unknown.domain === filters.domain))
190
- .sort((left, right) => left.unknown_id.localeCompare(right.unknown_id));
190
+ .sort((left, right) => left.unknown_id.localeCompare(right.unknown_id))
191
+ .map((unknown) => structuredClone(unknown));
191
192
  }
192
193
  /** Return current resolution validity; imported invalid states are rejected. */
193
194
  verifyUnknownResolution(unknownId) {
@@ -197,7 +198,7 @@ export class EvidenceLedger {
197
198
  return ok({
198
199
  valid: unknown.status === "resolved",
199
200
  truthVerified: unknown.resolution?.disposition === "verified",
200
- unknown,
201
+ unknown: structuredClone(unknown),
201
202
  });
202
203
  }
203
204
  /** Clear records when the owning session closes. */
@@ -225,10 +226,10 @@ export class EvidenceLedger {
225
226
  if (!checked.ok)
226
227
  return checked;
227
228
  this.#commit(pendingRecords, pendingUnknowns, checked.value);
228
- return ok(unknown);
229
+ return ok(structuredClone(unknown));
229
230
  }
230
231
  #validateCandidate(records, unknowns) {
231
- if (records.size + unknowns.size > this.limits.maxRecords)
232
+ if (this.#exceedsRecordLimit(records.size, unknowns.size))
232
233
  return err(new EvidenceLimitError("records", this.limits.maxRecords));
233
234
  const bytes = [...records.values()].reduce((total, evidence) => total + serializedBytes(evidence), 0) +
234
235
  [...unknowns.values()].reduce((total, unknown) => total + serializedBytes(unknown), 0);
@@ -242,6 +243,9 @@ export class EvidenceLedger {
242
243
  }
243
244
  return ok(bytes);
244
245
  }
246
+ #exceedsRecordLimit(recordCount, unknownCount) {
247
+ return recordCount + unknownCount > this.limits.maxRecords;
248
+ }
245
249
  #commit(records, unknowns, bytes) {
246
250
  this.#records.clear();
247
251
  for (const [id, evidence] of records)
@@ -1,6 +1,7 @@
1
1
  import { lstat, open, readFile, realpath, unlink, } from "node:fs/promises";
2
2
  import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
3
3
  import writeFileAtomic from "write-file-atomic";
4
+ import { isJsonWithinLimits } from "../domain/jsonLimits.js";
4
5
  import { InvestigationWorkspaceError } from "../domain/errors.js";
5
6
  import { parseInvestigationWorkspace, serializeInvestigationWorkspace, } from "../domain/investigationWorkspace.js";
6
7
  import { err, ok } from "../domain/result.js";
@@ -97,7 +98,7 @@ const readWorkspaceFile = async (destination, policy) => {
97
98
  catch (cause) {
98
99
  return err(new InvestigationWorkspaceError("read", "invalid-json", { cause }));
99
100
  }
100
- if (!withinJsonLimits(decoded, policy))
101
+ if (!isJsonWithinLimits(decoded, policy))
101
102
  return err(new InvestigationWorkspaceError("read", "too-large"));
102
103
  try {
103
104
  return ok(parseInvestigationWorkspace(decoded));
@@ -182,31 +183,6 @@ const validateNextRevision = (current, next, expectedRevision) => {
182
183
  ? ok(null)
183
184
  : err(new InvestigationWorkspaceError("update", "revision-conflict"));
184
185
  };
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
186
  const isFileNotFound = (cause) => errorCode(cause) === "ENOENT";
211
187
  const isAlreadyExists = (cause) => errorCode(cause) === "EEXIST";
212
188
  const errorCode = (cause) => typeof cause === "object" && cause !== null && "code" in cause
@@ -150,6 +150,36 @@ export class PermissionAuthority {
150
150
  this.policy = candidate;
151
151
  return ok(null);
152
152
  }
153
+ /** Atomically replace reloadable ceilings and persisted grants. */
154
+ async replaceConfiguredPolicy(configuration) {
155
+ const ceilings = await canonicalizePermissionCeilings(configuration.ceilings);
156
+ if (!ceilings.ok)
157
+ return ceilings;
158
+ const grants = [];
159
+ for (const grant of [
160
+ ...configuration.administratorGrants,
161
+ ...configuration.projectGrants,
162
+ ]) {
163
+ const canonical = await canonicalizeGrant(grant);
164
+ if (!canonical.ok)
165
+ return canonical;
166
+ grants.push(canonical.value);
167
+ }
168
+ let candidate = {
169
+ ...reloadPermissionCeilings(this.policy, ceilings.value),
170
+ grants: this.policy.grants.filter(({ lifetime }) => lifetime !== "administrator" && lifetime !== "project"),
171
+ };
172
+ for (const grant of grants) {
173
+ const added = grantPermission(candidate, grant);
174
+ if (!added.ok)
175
+ return err(new PermissionPathError(grant.grant_id, "io", {
176
+ cause: added.error,
177
+ }));
178
+ candidate = added.value;
179
+ }
180
+ this.policy = candidate;
181
+ return ok(null);
182
+ }
153
183
  /** Replace ceilings without restarting the MCP process. */
154
184
  async reload(ceilings) {
155
185
  const canonical = await canonicalizePermissionCeilings(ceilings);
@@ -1,14 +1,15 @@
1
1
  import { realpath } from "node:fs/promises";
2
2
  import { ProcessCaptureError, processCaptureCancelled, } from "./ProcessCaptureError.js";
3
+ import { canonicalizeConfiguredRoots } from "./ConfiguredRoots.js";
3
4
  const isWithin = (candidate, root) => candidate === root ||
4
5
  candidate.startsWith(`${root.endsWith("/") ? root.slice(0, -1) : root}/`);
5
6
  export const assertRealPathAuthority = async (scenario, policy) => {
6
7
  const executable = await realpath(scenario.executable);
7
- const executableRoots = await Promise.all(policy.executableRoots.map((root) => realpath(root)));
8
+ const executableRoots = await canonicalizeConfiguredRoots(policy.executableRoots);
8
9
  if (!executableRoots.some((root) => isWithin(executable, root)))
9
10
  throw new ProcessCaptureError("resolved executable is outside approved roots");
10
11
  const workingDirectory = await realpath(scenario.working_directory);
11
- const workingRoots = await Promise.all(policy.workingRoots.map((root) => realpath(root)));
12
+ const workingRoots = await canonicalizeConfiguredRoots(policy.workingRoots);
12
13
  if (!workingRoots.some((root) => isWithin(workingDirectory, root)))
13
14
  throw new ProcessCaptureError("resolved working directory is outside approved roots");
14
15
  for (const root of scenario.filesystem_roots) {
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { createReadStream } from "node:fs";
3
- import { mkdtemp, rm } from "node:fs/promises";
3
+ import { mkdir, mkdtemp, rm } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { authorizeProcessScenario, digestProcessCommitment, processComparisonContract, processScenarioCommitment, } from "../domain/processCapture.js";
@@ -193,11 +193,7 @@ export const releaseProcessResources = async (options) => {
193
193
  failure ??= "filesystem checkpoint cleanup failed";
194
194
  }
195
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) {
196
+ for (const processGroupId of new Set(options.capturedProcessGroupIds)) {
201
197
  const cleaned = await cleanupOwnedProcessGroup({
202
198
  runId: options.runId,
203
199
  leaderPid: processGroupId,
@@ -219,7 +215,6 @@ export const captureTerminalFrames = (options) => {
219
215
  let outputBytes = 0;
220
216
  let truncated = false;
221
217
  options.terminal.onData((data) => {
222
- options.onFirstOutput();
223
218
  options.onOutput();
224
219
  const bytes = Buffer.byteLength(data);
225
220
  if (options.frames.length >= options.scenario.limits.frames ||
@@ -256,7 +251,7 @@ export const resolveProcessResult = (capture, executionFailure, cleanupFailure)
256
251
  throw new ProcessCaptureError("process capture produced no result");
257
252
  return capture;
258
253
  };
259
- export const prepareProcessCapture = async (scenario, policy, signal) => {
254
+ export const prepareProcessCapture = async (scenario, policy, signal, host = systemProcessPreparationHost) => {
260
255
  const decision = authorizeProcessScenario(scenario, policy);
261
256
  if (!decision.allowed)
262
257
  throw new ProcessCaptureError(decision.reason, {
@@ -266,11 +261,22 @@ export const prepareProcessCapture = async (scenario, policy, signal) => {
266
261
  await assertRealPathAuthority(scenario, policy);
267
262
  assertNotCancelled(signal);
268
263
  const before = await snapshotRoots(scenario, signal);
269
- const temporaryRoot = await mkdtemp(join(tmpdir(), "rea-process-"));
270
- const runId = randomUUID();
271
- const home = join(temporaryRoot, "home");
272
- await import("node:fs/promises").then(({ mkdir }) => mkdir(home));
273
- return { temporaryRoot, runId, home, before };
264
+ const temporaryRoot = await host.createTemporaryRoot();
265
+ try {
266
+ const runId = randomUUID();
267
+ const home = join(temporaryRoot, "home");
268
+ await host.createHome(home);
269
+ return { temporaryRoot, runId, home, before };
270
+ }
271
+ catch (cause) {
272
+ await host.cleanup(temporaryRoot);
273
+ throw cause;
274
+ }
275
+ };
276
+ const systemProcessPreparationHost = {
277
+ createTemporaryRoot: () => mkdtemp(join(tmpdir(), "rea-process-")),
278
+ createHome: (path) => mkdir(path),
279
+ cleanup: (path) => rm(path, { recursive: true, force: true }),
274
280
  };
275
281
  const processPolicyMessage = (reason) => {
276
282
  if (reason === "process capture is disabled")
@@ -105,7 +105,7 @@ export class ProcessCheckpoints {
105
105
  effects: classifyFilesystemEffects(previous, after.files),
106
106
  truncated: after.truncated,
107
107
  });
108
- return this.#captures;
108
+ return structuredClone(this.#captures);
109
109
  }
110
110
  /** Cancel scheduled captures and await any snapshot already in progress. */
111
111
  async dispose() {
@@ -79,7 +79,13 @@ export const compareProcessEvidenceFiles = async (leftPath, rightPath) => {
79
79
  }
80
80
  };
81
81
  const parseCaptureEvidence = (input) => {
82
- const evidence = parseEvidence(input);
82
+ let evidence;
83
+ try {
84
+ evidence = parseEvidence(input);
85
+ }
86
+ catch {
87
+ throw invalidCaptureEvidence();
88
+ }
83
89
  if (evidence.operation !== "capture_process_scenario" ||
84
90
  evidence.predicate_type !== "rea.process-capture/v4" ||
85
91
  evidence.provider.id !== PROCESS_PROVIDER.id ||
@@ -88,11 +94,17 @@ const parseCaptureEvidence = (input) => {
88
94
  throw new ProcessCliFailure("invalid_input", LEGACY_PROCESS_CAPTURE_MESSAGE);
89
95
  throw new ProcessCliFailure("invalid_input", "Capture evidence is not from the current process-capture workflow. Create new capture evidence, then try again.");
90
96
  }
91
- return {
92
- id: evidence.evidence_id,
93
- capture: parseProcessCapture(evidence.normalized_result),
94
- };
97
+ try {
98
+ return {
99
+ id: evidence.evidence_id,
100
+ capture: parseProcessCapture(evidence.normalized_result),
101
+ };
102
+ }
103
+ catch {
104
+ throw invalidCaptureEvidence();
105
+ }
95
106
  };
107
+ const invalidCaptureEvidence = () => new ProcessCliFailure("invalid_input", "Capture evidence is malformed. Create new capture evidence, then try again.");
96
108
  const readJson = async (path) => {
97
109
  let bytes;
98
110
  try {
@@ -10,6 +10,7 @@ import { ProcessCheckpoints } from "./ProcessCheckpoints.js";
10
10
  import { TerminalRenderer } from "./TerminalRenderer.js";
11
11
  import { startCommandShimReplay, } from "./CommandShimReplay.js";
12
12
  import { normalizeProcessText } from "./ProcessNormalization.js";
13
+ import { selectCapturedProcessGroupIds } from "./ProcessOwnership.js";
13
14
  import { awaitTerminalExit, buildCaptureResult, captureTerminalFrames, createRunManifest, observeSettlement, prepareProcessCapture, releaseProcessResources, resolveProcessResult, } from "./ProcessCaptureLifecycle.js";
14
15
  import { assertNotCancelled } from "./ProcessCaptureAuthority.js";
15
16
  export { probeProcessCaptureCapability } from "./ProcessCaptureCapability.js";
@@ -81,21 +82,11 @@ const scheduleScenarioEvents = (options) => {
81
82
  timers.add(timer);
82
83
  }
83
84
  };
84
- const startScenarioEvents = (options) => {
85
- let eventsStarted = false;
86
- return () => {
87
- if (eventsStarted)
88
- return;
89
- eventsStarted = true;
90
- scheduleScenarioEvents(options);
91
- };
92
- };
93
- const eventsRequireReadiness = (scenario) => scenario.events.some((event) => event.type === "input" || event.type === "resize");
94
85
  const cleanupFailedStartup = async (options) => {
95
86
  options.terminal?.kill("SIGKILL");
96
87
  const cleanupFailure = await releaseProcessResources({
97
88
  ...options,
98
- sampledProcessGroupIds: [],
89
+ capturedProcessGroupIds: options.terminal === undefined ? [] : [options.terminal.pid],
99
90
  });
100
91
  if (cleanupFailure !== undefined)
101
92
  throw new ProcessCaptureError(cleanupFailure, {
@@ -134,24 +125,27 @@ const startCaptureRuntime = async (options) => {
134
125
  rows: scenario.terminal.rows,
135
126
  name: "xterm-256color",
136
127
  });
137
- const startEvents = startScenarioEvents({
138
- ...options,
139
- getTerminal: () => terminal,
140
- renderer,
141
- started,
142
- });
143
- if (!eventsRequireReadiness(scenario))
144
- startEvents();
145
128
  const framesTruncated = captureTerminalFrames({
146
129
  ...options,
147
130
  terminal,
148
131
  started,
149
132
  onOutput: () => (lastOutput = Date.now()),
150
- onFirstOutput: startEvents,
151
133
  renderer,
152
134
  checkpoints,
153
135
  });
154
- const stopSampler = startProcessSampler(terminal.pid, started, scenario.limits.processes, options.samples);
136
+ scheduleScenarioEvents({
137
+ ...options,
138
+ getTerminal: () => terminal,
139
+ renderer,
140
+ started,
141
+ });
142
+ const stopSampler = startProcessSampler({
143
+ rootPid: terminal.pid,
144
+ runId: options.runId,
145
+ started,
146
+ limit: scenario.limits.processes,
147
+ samples: options.samples,
148
+ });
155
149
  return {
156
150
  replay,
157
151
  renderer,
@@ -195,7 +189,9 @@ const finishProcessRun = async (options) => {
195
189
  checkpoints: options.runtime?.checkpoints,
196
190
  runId: options.runId,
197
191
  temporaryRoot: options.temporaryRoot,
198
- sampledProcessGroupIds: options.samples.flatMap(({ process_group_id }) => process_group_id === null ? [] : [process_group_id]),
192
+ capturedProcessGroupIds: options.runtime === undefined
193
+ ? []
194
+ : selectCapturedProcessGroupIds(options.runtime.terminal.pid, options.samples),
199
195
  });
200
196
  let { capture, executionFailure } = options;
201
197
  if (capture !== undefined) {
@@ -222,10 +218,7 @@ const completeCapture = async (options) => {
222
218
  const { reason } = options.exit;
223
219
  if (reason === "cancelled")
224
220
  throw processCaptureCancelled();
225
- const settlement = await observeSettlement(options.runId, [
226
- runtime.terminal.pid,
227
- ...options.samples.flatMap(({ process_group_id }) => process_group_id === null ? [] : [process_group_id]),
228
- ], scenario.settle_ms);
221
+ const settlement = await observeSettlement(options.runId, selectCapturedProcessGroupIds(runtime.terminal.pid, options.samples), scenario.settle_ms);
229
222
  runtime.checkpoints.trigger("settled");
230
223
  const samplingPartial = (await runtime.stopSampler()).partial;
231
224
  assertNotCancelled(options.signal);
@@ -14,7 +14,7 @@ export const normalizeProcessText = (value, scenario, temporaryRoot, pid) => {
14
14
  normalized = normalized.replaceAll(root, `<filesystem-root-${String(index)}>`);
15
15
  }
16
16
  if (scenario.normalization.pids)
17
- normalized = normalized.replaceAll(String(pid), "<pid>");
17
+ normalized = normalizePidTokens(normalized, [pid]);
18
18
  if (scenario.normalization.ports)
19
19
  normalized = normalized.replaceAll(/(?<=[:=])\d{2,5}\b/g, "<port>");
20
20
  for (const pattern of scenario.normalization.patterns)
@@ -35,6 +35,12 @@ export const normalizeProcessSamples = (samples, scenario, rootPid) => {
35
35
  for (const identifier of identifiers)
36
36
  if (identifier > 0 && !mapping.has(identifier))
37
37
  mapping.set(identifier, mapping.size + 1);
38
+ const normalizeCommand = (command) => {
39
+ const normalized = normalizeProcessText(command, scenario, "<no-temporary-root>", rootPid);
40
+ return scenario.normalization.pids
41
+ ? normalizePidTokens(normalized, identifiers)
42
+ : normalized;
43
+ };
38
44
  return samples.map((sample) => ({
39
45
  at_ms: Math.floor(sample.at_ms / scenario.normalization.time_bucket_ms) *
40
46
  scenario.normalization.time_bucket_ms,
@@ -44,9 +50,18 @@ export const normalizeProcessSamples = (samples, scenario, rootPid) => {
44
50
  ? null
45
51
  : (mapping.get(sample.process_group_id) ?? 0),
46
52
  session_id: sample.session_id === null ? null : (mapping.get(sample.session_id) ?? 0),
47
- command: normalizeProcessText(sample.command, scenario, "<no-temporary-root>", rootPid),
53
+ command: normalizeCommand(sample.command),
48
54
  }));
49
55
  };
56
+ const normalizePidTokens = (value, identifiers) => {
57
+ const tokens = [...new Set(identifiers)]
58
+ .filter((identifier) => Number.isSafeInteger(identifier) && identifier > 0)
59
+ .sort((left, right) => right - left)
60
+ .map(String);
61
+ if (tokens.length === 0)
62
+ return value;
63
+ return value.replace(new RegExp(`(?<!\\d)(?:${tokens.join("|")})(?!\\d)`, "gu"), "<pid>");
64
+ };
50
65
  export const redactProtocolEvents = (events, scenario) => events.map((event) => ({
51
66
  ...event,
52
67
  data: normalizeProcessText(event.data, scenario, "<no-temporary-root>", -1),
@@ -2,9 +2,23 @@ import { execFile } from "node:child_process";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { promisify } from "node:util";
4
4
  const execFileAsync = promisify(execFile);
5
- const parseEnvironment = (value) => Object.fromEntries(value
5
+ /** Select the PTY root group and groups led by an observed captured process. */
6
+ export const selectCapturedProcessGroupIds = (rootPid, samples) => {
7
+ const processGroupIds = new Set([rootPid]);
8
+ for (const sample of samples) {
9
+ // A sampled member may transiently inherit an unrelated group. POSIX group
10
+ // leaders have pid === pgid, so only that observation establishes that the
11
+ // group leader itself belonged to the captured tree. Token checks below
12
+ // remain the final authority immediately before observation or signaling.
13
+ if (sample.pid === sample.process_group_id)
14
+ processGroupIds.add(sample.pid);
15
+ }
16
+ return [...processGroupIds];
17
+ };
18
+ /** Parse the NUL-delimited Linux process environment without nameless keys. */
19
+ export const parseProcessEnvironment = (value) => Object.fromEntries(value
6
20
  .split("\0")
7
- .filter((entry) => entry.includes("="))
21
+ .filter((entry) => entry.indexOf("=") > 0)
8
22
  .map((entry) => {
9
23
  const separator = entry.indexOf("=");
10
24
  return [entry.slice(0, separator), entry.slice(separator + 1)];
@@ -15,23 +29,24 @@ const systemHost = {
15
29
  return [];
16
30
  const { stdout } = await execFileAsync("ps", [
17
31
  "-axo",
18
- "pid=,ppid=,pgid=,command=",
32
+ "pid=,ppid=,pgid=,stat=,command=",
19
33
  ]);
20
34
  return stdout
21
35
  .split("\n")
22
- .map((line) => /\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)/u.exec(line))
36
+ .map((line) => /\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)/u.exec(line))
23
37
  .filter((match) => match !== null)
24
38
  .map((match) => ({
25
39
  pid: Number(match[1]),
26
40
  parentPid: Number(match[2]),
27
41
  processGroupId: Number(match[3]),
28
- command: match[4] ?? "",
42
+ state: match[4] ?? "",
43
+ command: match[5] ?? "",
29
44
  }))
30
45
  .filter((member) => member.processGroupId === processGroupId);
31
46
  },
32
47
  async environment(pid) {
33
48
  if (process.platform === "linux") {
34
- return parseEnvironment(await readFile(`/proc/${pid}/environ`, "utf8"));
49
+ return parseProcessEnvironment(await readFile(`/proc/${pid}/environ`, "utf8"));
35
50
  }
36
51
  const { stdout } = await execFileAsync("ps", ["eww", "-p", String(pid)]);
37
52
  const environment = {};
@@ -43,6 +58,8 @@ const systemHost = {
43
58
  process.kill(-processGroupId, signal);
44
59
  },
45
60
  };
61
+ /** Read the capture run token currently exposed by one live process. */
62
+ export const readProcessRunId = async (pid, host = systemHost) => (await host.environment(pid)).REA_PROCESS_RUN_ID;
46
63
  /**
47
64
  * Verify run-token ownership before signaling a POSIX process group.
48
65
  *
@@ -58,9 +75,10 @@ export const cleanupOwnedProcessGroup = async (ownership, host = systemHost) =>
58
75
  catch {
59
76
  return { cleaned: false, reason: "process group could not be inspected" };
60
77
  }
61
- if (members.length === 0)
78
+ const liveMembers = liveProcessGroupMembers(members);
79
+ if (liveMembers.length === 0)
62
80
  return { cleaned: true, signaled: false };
63
- const leader = members.find((member) => member.pid === ownership.leaderPid);
81
+ const leader = liveMembers.find((member) => member.pid === ownership.leaderPid);
64
82
  if (leader !== undefined) {
65
83
  if (ownership.expectedParentPid !== undefined &&
66
84
  leader.parentPid !== ownership.expectedParentPid)
@@ -75,24 +93,25 @@ export const cleanupOwnedProcessGroup = async (ownership, host = systemHost) =>
75
93
  reason: "owned launcher command identity did not match",
76
94
  };
77
95
  }
78
- for (const member of members) {
79
- let environment;
96
+ const failures = [];
97
+ for (const member of liveMembers) {
80
98
  try {
81
- environment = await host.environment(member.pid);
99
+ const environment = await host.environment(member.pid);
100
+ if (environment.REA_PROCESS_RUN_ID !== ownership.runId)
101
+ failures.push({ pid: member.pid, reason: "run-token-mismatch" });
82
102
  }
83
103
  catch {
84
- return {
85
- cleaned: false,
86
- reason: "process ownership could not be revalidated",
87
- };
88
- }
89
- if (environment.REA_PROCESS_RUN_ID !== ownership.runId) {
90
- return {
91
- cleaned: false,
92
- reason: "process group contains an unowned or PID-reused process",
93
- };
104
+ failures.push({ pid: member.pid, reason: "environment-unreadable" });
94
105
  }
95
106
  }
107
+ if (failures.length > 0)
108
+ return {
109
+ cleaned: false,
110
+ reason: failures.some(({ reason }) => reason === "run-token-mismatch")
111
+ ? "process group contains an unowned or PID-reused process"
112
+ : "process ownership could not be revalidated",
113
+ failures,
114
+ };
96
115
  try {
97
116
  host.signalGroup(ownership.processGroupId, "SIGKILL");
98
117
  }
@@ -115,9 +134,10 @@ export const observeOwnedProcessGroup = async (ownership, host = systemHost) =>
115
134
  reason: "process group could not be inspected",
116
135
  };
117
136
  }
118
- if (members.length === 0)
137
+ const liveMembers = liveProcessGroupMembers(members);
138
+ if (liveMembers.length === 0)
119
139
  return { state: "empty" };
120
- for (const member of members) {
140
+ for (const member of liveMembers) {
121
141
  try {
122
142
  if ((await host.environment(member.pid)).REA_PROCESS_RUN_ID !==
123
143
  ownership.runId)
@@ -135,6 +155,7 @@ export const observeOwnedProcessGroup = async (ownership, host = systemHost) =>
135
155
  }
136
156
  return { state: "alive" };
137
157
  };
158
+ const liveProcessGroupMembers = (members) => members.filter(({ state }) => !state.startsWith("Z"));
138
159
  const commandMatches = (actual, expected) => {
139
160
  const normalizedActual = actual.trim();
140
161
  const normalizedExpected = expected.trim();