rea-agents 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -18
- package/dist/application/ArtifactInventory.js +80 -44
- package/dist/application/CommandShimReplay.js +136 -0
- package/dist/application/Doctor.js +4 -4
- package/dist/application/EvidenceBundleFiles.js +13 -4
- package/dist/application/MacHopper.js +175 -0
- package/dist/application/ProcessCaptureAuthority.js +25 -0
- package/dist/application/ProcessCaptureCapability.js +23 -0
- package/dist/application/ProcessCaptureError.js +5 -0
- package/dist/application/ProcessCaptureLifecycle.js +269 -0
- package/dist/application/ProcessCheckpoints.js +129 -0
- package/dist/application/ProcessCli.js +61 -0
- package/dist/application/ProcessEvidence.js +38 -0
- package/dist/application/ProcessHarness.js +252 -272
- package/dist/application/ProcessNormalization.js +10 -1
- package/dist/application/ProcessOwnership.js +39 -1
- package/dist/application/ProcessSampling.js +39 -23
- package/dist/application/Setup.js +104 -100
- package/dist/application/SetupSkill.js +44 -0
- package/dist/application/TerminalRenderer.js +92 -0
- package/dist/application/runtime.js +1 -1
- package/dist/artifacts/ArtifactProvider.js +18 -6
- package/dist/artifacts/ArtifactReader.js +3 -1
- package/dist/artifacts/AsarArtifactReader.js +8 -1
- package/dist/artifacts/DirectoryArtifactReader.js +1 -0
- package/dist/artifacts/MachOSliceArtifactReader.js +1 -0
- package/dist/artifacts/NativeDmgArtifactReader.js +151 -0
- package/dist/artifacts/ZipArtifactReader.js +1 -0
- package/dist/cli.js +60 -26
- package/dist/cliProcessCommands.js +19 -0
- package/dist/config.js +2 -0
- package/dist/contracts/artifactToolContracts.js +1 -0
- package/dist/contracts/investigationExamples.js +3 -3
- package/dist/contracts/processCaptureExample.js +50 -7
- package/dist/contracts/toolContracts.js +3 -2
- package/dist/domain/changedBehavior.js +2 -2
- package/dist/domain/errors.js +11 -3
- package/dist/domain/processCapture.js +99 -293
- package/dist/domain/processCaptureValidation.js +124 -0
- package/dist/domain/processComparison.js +176 -32
- package/dist/domain/processScenario.js +370 -0
- package/dist/domain/reconstructionVerification.js +3 -3
- package/dist/domain/runtimeVersion.js +8 -0
- package/dist/domain/staticRuntimeCorrelation.js +3 -2
- package/dist/identity.js +1 -0
- package/dist/server/registerProcessComparisonTool.js +28 -8
- package/dist/server/registerSessionTools.js +3 -25
- package/dist/server/sessionToolPolicies.js +1 -6
- package/install.sh +79 -164
- package/package.json +7 -4
- package/scripts/prepare-node-pty.mjs +35 -0
- package/skills/rea-analysis/SKILL.md +19 -4
|
@@ -1,63 +1,20 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { mkdtemp, realpath, rm } from "node:fs/promises";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
1
|
import { spawn } from "node-pty";
|
|
6
|
-
import {
|
|
2
|
+
import { validateProcessCapture } from "../domain/processCapture.js";
|
|
7
3
|
import { err, ok } from "../domain/result.js";
|
|
8
|
-
import {
|
|
4
|
+
import { ProcessCaptureError } from "./ProcessCaptureError.js";
|
|
5
|
+
export { ProcessCaptureError } from "./ProcessCaptureError.js";
|
|
9
6
|
import { startLoopbackReplay } from "./LoopbackReplay.js";
|
|
10
|
-
import { cleanupOwnedProcessGroup } from "./ProcessOwnership.js";
|
|
11
7
|
import { startProcessSampler } from "./ProcessSampling.js";
|
|
12
8
|
import { snapshotRoots } from "./FilesystemSnapshot.js";
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
const workingRoots = await Promise.all(policy.workingRoots.map((root) => realpath(root)));
|
|
23
|
-
if (!workingRoots.some((root) => isWithin(workingDirectory, root)))
|
|
24
|
-
throw new ProcessCaptureError("resolved working directory is outside approved roots");
|
|
25
|
-
for (const root of scenario.filesystem_roots) {
|
|
26
|
-
const resolvedRoot = await realpath(root);
|
|
27
|
-
if (!workingRoots.some((approved) => isWithin(resolvedRoot, approved)))
|
|
28
|
-
throw new ProcessCaptureError("resolved filesystem root is outside approved roots");
|
|
29
|
-
}
|
|
30
|
-
};
|
|
31
|
-
/** Expected refusal or runtime failure from the process capture adapter. */
|
|
32
|
-
export class ProcessCaptureError extends AnalysisError {
|
|
33
|
-
_tag = "ProcessCaptureError";
|
|
34
|
-
}
|
|
35
|
-
const assertNotCancelled = (signal) => {
|
|
36
|
-
if (signal?.aborted === true)
|
|
37
|
-
throw new ProcessCaptureError("process capture was cancelled");
|
|
38
|
-
};
|
|
39
|
-
/** Probe the actual native PTY seam instead of inferring support from the OS name. */
|
|
40
|
-
export const probeProcessCaptureCapability = async () => {
|
|
41
|
-
try {
|
|
42
|
-
const terminal = spawn(process.platform === "win32" ? "cmd.exe" : "/bin/sh", process.platform === "win32" ? ["/c", "exit", "0"] : ["-c", "exit 0"], {
|
|
43
|
-
cwd: tmpdir(),
|
|
44
|
-
env: { HOME: tmpdir(), TERM: "xterm-256color" },
|
|
45
|
-
cols: 80,
|
|
46
|
-
rows: 24,
|
|
47
|
-
name: "xterm-256color",
|
|
48
|
-
});
|
|
49
|
-
await new Promise((resolveExit) => terminal.onExit(() => resolveExit()));
|
|
50
|
-
return { available: true, backend: "node-pty" };
|
|
51
|
-
}
|
|
52
|
-
catch {
|
|
53
|
-
return {
|
|
54
|
-
available: false,
|
|
55
|
-
backend: "node-pty",
|
|
56
|
-
reason: "the native PTY backend could not start a probe process",
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
};
|
|
60
|
-
const makeEnvironment = (scenario, home, replay, runId) => {
|
|
9
|
+
import { ProcessCheckpoints } from "./ProcessCheckpoints.js";
|
|
10
|
+
import { TerminalRenderer } from "./TerminalRenderer.js";
|
|
11
|
+
import { startCommandShimReplay, } from "./CommandShimReplay.js";
|
|
12
|
+
import { normalizeProcessText } from "./ProcessNormalization.js";
|
|
13
|
+
import { awaitTerminalExit, buildCaptureResult, captureTerminalFrames, createRunManifest, observeSettlement, prepareProcessCapture, releaseProcessResources, resolveProcessResult, } from "./ProcessCaptureLifecycle.js";
|
|
14
|
+
import { assertNotCancelled } from "./ProcessCaptureAuthority.js";
|
|
15
|
+
export { probeProcessCaptureCapability } from "./ProcessCaptureCapability.js";
|
|
16
|
+
const makeEnvironment = (options) => {
|
|
17
|
+
const { scenario, home, replay, shimReplay, runId } = options;
|
|
61
18
|
const environment = {
|
|
62
19
|
...scenario.environment,
|
|
63
20
|
HOME: home,
|
|
@@ -71,277 +28,300 @@ const makeEnvironment = (scenario, home, replay, runId) => {
|
|
|
71
28
|
}
|
|
72
29
|
environment.REA_REPLAY_HTTP_URL = replay.httpUrl;
|
|
73
30
|
environment.REA_REPLAY_WEBSOCKET_URL = replay.websocketUrl;
|
|
31
|
+
environment.REA_SHIM_LEDGER_URL = shimReplay.url;
|
|
32
|
+
// PATH is evidence-producing input. Inheriting the host PATH here would let
|
|
33
|
+
// the authority and reconstruction probe different tools without recording
|
|
34
|
+
// that difference. Scenarios must opt in through environment or
|
|
35
|
+
// inherit_environment; deterministic shims always take precedence.
|
|
36
|
+
environment.PATH = [shimReplay.binPath, environment.PATH ?? ""]
|
|
37
|
+
.filter((part) => part.length > 0)
|
|
38
|
+
.join(":");
|
|
74
39
|
return environment;
|
|
75
40
|
};
|
|
76
|
-
const scheduleScenarioEvents = (
|
|
77
|
-
|
|
41
|
+
const scheduleScenarioEvents = (options) => {
|
|
42
|
+
const { scenario, getTerminal, timers, interactions, renderer, started } = options;
|
|
43
|
+
for (const [eventIndex, event] of scenario.events.entries()) {
|
|
78
44
|
const timer = setTimeout(() => {
|
|
45
|
+
options.dispatchedEventIndexes.add(eventIndex);
|
|
79
46
|
const terminal = getTerminal();
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
else
|
|
85
|
-
|
|
47
|
+
const dispatchedAt = Math.max(0, Date.now() - started);
|
|
48
|
+
let outcome = "dispatched";
|
|
49
|
+
if (terminal === undefined)
|
|
50
|
+
outcome = "target_exited";
|
|
51
|
+
else {
|
|
52
|
+
try {
|
|
53
|
+
if (event.type === "input")
|
|
54
|
+
terminal.write(event.data);
|
|
55
|
+
else if (event.type === "resize") {
|
|
56
|
+
terminal.resize(event.columns, event.rows);
|
|
57
|
+
renderer.resize(event.columns, event.rows, dispatchedAt);
|
|
58
|
+
}
|
|
59
|
+
else
|
|
60
|
+
terminal.kill(event.signal);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
outcome = "failed";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
interactions.push({
|
|
67
|
+
sequence: interactions.length,
|
|
68
|
+
scheduled_at_ms: event.at_ms,
|
|
69
|
+
dispatched_at_ms: dispatchedAt,
|
|
70
|
+
type: event.type,
|
|
71
|
+
data: event.type === "input"
|
|
72
|
+
? event.sensitive
|
|
73
|
+
? `<redacted-input:${String(Buffer.byteLength(event.data))}-bytes>`
|
|
74
|
+
: normalizeProcessText(event.data, scenario, "<no-temporary-root>", -1)
|
|
75
|
+
: event.type === "resize"
|
|
76
|
+
? `${String(event.columns)}x${String(event.rows)}`
|
|
77
|
+
: event.signal,
|
|
78
|
+
outcome,
|
|
79
|
+
});
|
|
86
80
|
}, event.at_ms);
|
|
87
81
|
timers.add(timer);
|
|
88
82
|
}
|
|
89
83
|
};
|
|
90
|
-
const startScenarioEvents = (
|
|
91
|
-
let
|
|
84
|
+
const startScenarioEvents = (options) => {
|
|
85
|
+
let eventsStarted = false;
|
|
92
86
|
return () => {
|
|
93
|
-
if (
|
|
87
|
+
if (eventsStarted)
|
|
94
88
|
return;
|
|
95
|
-
|
|
96
|
-
scheduleScenarioEvents(
|
|
89
|
+
eventsStarted = true;
|
|
90
|
+
scheduleScenarioEvents(options);
|
|
97
91
|
};
|
|
98
92
|
};
|
|
99
93
|
const eventsRequireReadiness = (scenario) => scenario.events.some((event) => event.type === "input" || event.type === "resize");
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
].sort();
|
|
106
|
-
return paths.map((path) => {
|
|
107
|
-
const beforeFile = beforeByPath.get(path) ?? null;
|
|
108
|
-
const afterFile = afterByPath.get(path) ?? null;
|
|
109
|
-
const status = beforeFile === null
|
|
110
|
-
? "created"
|
|
111
|
-
: afterFile === null
|
|
112
|
-
? "deleted"
|
|
113
|
-
: JSON.stringify(beforeFile) === JSON.stringify(afterFile)
|
|
114
|
-
? "unchanged"
|
|
115
|
-
: "modified";
|
|
116
|
-
return { path, status, before: beforeFile, after: afterFile };
|
|
94
|
+
const cleanupFailedStartup = async (options) => {
|
|
95
|
+
options.terminal?.kill("SIGKILL");
|
|
96
|
+
const cleanupFailure = await releaseProcessResources({
|
|
97
|
+
...options,
|
|
98
|
+
sampledProcessGroupIds: [],
|
|
117
99
|
});
|
|
100
|
+
if (cleanupFailure !== undefined)
|
|
101
|
+
throw new ProcessCaptureError(cleanupFailure, { cause: options.cause });
|
|
102
|
+
throw options.cause;
|
|
118
103
|
};
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
reason: options.exit.reason,
|
|
127
|
-
},
|
|
128
|
-
process_samples: normalizeProcessSamples(options.samples, options.scenario, options.rootPid),
|
|
129
|
-
protocol_events: redactProtocolEvents(options.replay.events, options.scenario),
|
|
130
|
-
files_before: options.before.files,
|
|
131
|
-
files_after: options.after.files,
|
|
132
|
-
filesystem_effects: classifyFilesystemEffects(options.before.files, options.after.files),
|
|
133
|
-
truncated: options.truncated,
|
|
134
|
-
limitations: [
|
|
135
|
-
"Process trees are sampled and may omit short-lived descendants.",
|
|
136
|
-
...(options.samplingPartial
|
|
137
|
-
? ["Process-tree sampling ended with an incomplete observation."]
|
|
138
|
-
: []),
|
|
139
|
-
"Filesystem observations are before/after snapshots, not syscall traces.",
|
|
140
|
-
"The harness does not enforce external network isolation.",
|
|
141
|
-
],
|
|
142
|
-
residual_unknowns: [
|
|
143
|
-
{
|
|
144
|
-
scope: "process",
|
|
145
|
-
reason: "Process trees are sampled and may omit short-lived descendants.",
|
|
146
|
-
},
|
|
147
|
-
{
|
|
148
|
-
scope: "network",
|
|
149
|
-
reason: "External network isolation is not enforced by this adapter.",
|
|
150
|
-
},
|
|
151
|
-
],
|
|
152
|
-
cleanup: {
|
|
153
|
-
owned_process_group: "verified",
|
|
154
|
-
temporary_root: "removed",
|
|
155
|
-
},
|
|
104
|
+
const createTerminalRenderer = (scenario, temporaryRoot, terminalPid) => new TerminalRenderer({
|
|
105
|
+
columns: scenario.terminal.columns,
|
|
106
|
+
rows: scenario.terminal.rows,
|
|
107
|
+
scrollback: scenario.terminal.scrollback,
|
|
108
|
+
maxFrames: scenario.limits.frames,
|
|
109
|
+
maxBytes: scenario.limits.output_bytes,
|
|
110
|
+
normalize: (value) => normalizeProcessText(value, scenario, temporaryRoot, terminalPid()),
|
|
156
111
|
});
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
timers.clear();
|
|
165
|
-
resolveExit({ ...exit, reason });
|
|
166
|
-
});
|
|
167
|
-
const timeout = setInterval(() => {
|
|
168
|
-
if (signal?.aborted === true) {
|
|
169
|
-
reason = "cancelled";
|
|
170
|
-
terminal.kill("SIGKILL");
|
|
171
|
-
}
|
|
172
|
-
else if (Date.now() - started >= scenario.timeout_ms) {
|
|
173
|
-
reason = "timeout";
|
|
174
|
-
terminal.kill("SIGKILL");
|
|
175
|
-
}
|
|
176
|
-
else if (Date.now() - lastOutput() >= scenario.idle_timeout_ms) {
|
|
177
|
-
reason = "idle_timeout";
|
|
178
|
-
terminal.kill("SIGKILL");
|
|
179
|
-
}
|
|
180
|
-
}, 20);
|
|
181
|
-
timers.add(timeout);
|
|
182
|
-
});
|
|
183
|
-
const releaseProcessResources = async (options) => {
|
|
184
|
-
for (const timer of options.timers)
|
|
185
|
-
clearTimeout(timer);
|
|
186
|
-
let failure;
|
|
112
|
+
const startCaptureRuntime = async (options) => {
|
|
113
|
+
const { scenario } = options;
|
|
114
|
+
let replay;
|
|
115
|
+
let renderer;
|
|
116
|
+
let checkpoints;
|
|
117
|
+
let shimReplay;
|
|
118
|
+
let terminal;
|
|
187
119
|
try {
|
|
188
|
-
await
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
120
|
+
replay = await startLoopbackReplay(scenario);
|
|
121
|
+
const startedAt = new Date();
|
|
122
|
+
const started = Date.now();
|
|
123
|
+
let lastOutput = started;
|
|
124
|
+
renderer = createTerminalRenderer(scenario, options.temporaryRoot, () => terminal?.pid ?? -1);
|
|
125
|
+
checkpoints = new ProcessCheckpoints(scenario, started, options.before, options.signal);
|
|
126
|
+
shimReplay = await startCommandShimReplay(scenario, options.temporaryRoot, started);
|
|
127
|
+
terminal = spawn(scenario.executable, [...scenario.arguments], {
|
|
128
|
+
cwd: scenario.working_directory,
|
|
129
|
+
env: makeEnvironment({ ...options, replay, shimReplay }),
|
|
130
|
+
cols: scenario.terminal.columns,
|
|
131
|
+
rows: scenario.terminal.rows,
|
|
132
|
+
name: "xterm-256color",
|
|
133
|
+
});
|
|
134
|
+
const startEvents = startScenarioEvents({
|
|
135
|
+
...options,
|
|
136
|
+
getTerminal: () => terminal,
|
|
137
|
+
renderer,
|
|
138
|
+
started,
|
|
139
|
+
});
|
|
140
|
+
if (!eventsRequireReadiness(scenario))
|
|
141
|
+
startEvents();
|
|
142
|
+
const framesTruncated = captureTerminalFrames({
|
|
143
|
+
...options,
|
|
144
|
+
terminal,
|
|
145
|
+
started,
|
|
146
|
+
onOutput: () => (lastOutput = Date.now()),
|
|
147
|
+
onFirstOutput: startEvents,
|
|
148
|
+
renderer,
|
|
149
|
+
checkpoints,
|
|
150
|
+
});
|
|
151
|
+
const stopSampler = startProcessSampler(terminal.pid, started, scenario.limits.processes, options.samples);
|
|
152
|
+
return {
|
|
153
|
+
replay,
|
|
154
|
+
renderer,
|
|
155
|
+
checkpoints,
|
|
156
|
+
shimReplay,
|
|
157
|
+
terminal,
|
|
158
|
+
started,
|
|
159
|
+
startedAt,
|
|
160
|
+
lastOutput: () => lastOutput,
|
|
161
|
+
framesTruncated,
|
|
162
|
+
stopSampler,
|
|
163
|
+
};
|
|
192
164
|
}
|
|
193
|
-
|
|
194
|
-
|
|
165
|
+
catch (cause) {
|
|
166
|
+
return cleanupFailedStartup({
|
|
167
|
+
cause,
|
|
168
|
+
timers: options.timers,
|
|
169
|
+
replay,
|
|
170
|
+
terminal,
|
|
171
|
+
renderer,
|
|
172
|
+
shimReplay,
|
|
173
|
+
checkpoints,
|
|
195
174
|
runId: options.runId,
|
|
196
|
-
|
|
197
|
-
processGroupId: options.terminal.pid,
|
|
175
|
+
temporaryRoot: options.temporaryRoot,
|
|
198
176
|
});
|
|
199
|
-
if (!cleaned.cleaned)
|
|
200
|
-
failure ??= cleaned.reason;
|
|
201
177
|
}
|
|
202
|
-
try {
|
|
203
|
-
await rm(options.temporaryRoot, { recursive: true, force: true });
|
|
204
|
-
}
|
|
205
|
-
catch {
|
|
206
|
-
failure ??= "temporary process root cleanup failed";
|
|
207
|
-
}
|
|
208
|
-
return failure;
|
|
209
178
|
};
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
options.
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
data: normalizeProcessText(data, options.scenario, options.temporaryRoot, options.terminal.pid),
|
|
228
|
-
});
|
|
179
|
+
const normalizedShimEvents = (runtime, scenario, temporaryRoot) => runtime.shimReplay.events.map((event) => ({
|
|
180
|
+
...event,
|
|
181
|
+
arguments: event.arguments.map((argument) => normalizeProcessText(argument, scenario, temporaryRoot, runtime.terminal.pid)),
|
|
182
|
+
working_directory: normalizeProcessText(event.working_directory, scenario, temporaryRoot, runtime.terminal.pid),
|
|
183
|
+
}));
|
|
184
|
+
const finishProcessRun = async (options) => {
|
|
185
|
+
await options.stopSampler();
|
|
186
|
+
const cleanupFailure = await releaseProcessResources({
|
|
187
|
+
timers: options.timers,
|
|
188
|
+
replay: options.runtime?.replay,
|
|
189
|
+
terminal: options.runtime?.terminal,
|
|
190
|
+
renderer: options.runtime?.renderer,
|
|
191
|
+
shimReplay: options.runtime?.shimReplay,
|
|
192
|
+
checkpoints: options.runtime?.checkpoints,
|
|
193
|
+
runId: options.runId,
|
|
194
|
+
temporaryRoot: options.temporaryRoot,
|
|
195
|
+
sampledProcessGroupIds: options.samples.flatMap(({ process_group_id }) => process_group_id === null ? [] : [process_group_id]),
|
|
229
196
|
});
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
197
|
+
let { capture, executionFailure } = options;
|
|
198
|
+
if (capture !== undefined) {
|
|
199
|
+
capture = {
|
|
200
|
+
...capture,
|
|
201
|
+
settlement: {
|
|
202
|
+
...capture.settlement,
|
|
203
|
+
cleanup_outcome: capture.settlement.state === "quiesced"
|
|
204
|
+
? "not_required"
|
|
205
|
+
: cleanupFailure === undefined
|
|
206
|
+
? "cleaned"
|
|
207
|
+
: "failed",
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
const issue = validateProcessCapture(capture)[0];
|
|
211
|
+
if (issue !== undefined)
|
|
212
|
+
executionFailure = new ProcessCaptureError(`process capture validation failed: ${issue.path}: ${issue.message}`);
|
|
213
|
+
}
|
|
214
|
+
return resolveProcessResult(capture, executionFailure, cleanupFailure);
|
|
244
215
|
};
|
|
245
|
-
const
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
216
|
+
const completeCapture = async (options) => {
|
|
217
|
+
const { runtime, scenario } = options;
|
|
218
|
+
runtime.checkpoints.trigger("root_exit");
|
|
219
|
+
const { reason } = options.exit;
|
|
220
|
+
if (reason === "cancelled")
|
|
221
|
+
throw new ProcessCaptureError("process capture was cancelled");
|
|
222
|
+
const settlement = await observeSettlement(options.runId, [
|
|
223
|
+
runtime.terminal.pid,
|
|
224
|
+
...options.samples.flatMap(({ process_group_id }) => process_group_id === null ? [] : [process_group_id]),
|
|
225
|
+
], scenario.settle_ms);
|
|
226
|
+
runtime.checkpoints.trigger("settled");
|
|
227
|
+
const samplingPartial = (await runtime.stopSampler()).partial;
|
|
228
|
+
assertNotCancelled(options.signal);
|
|
229
|
+
const after = await snapshotRoots(scenario, options.signal);
|
|
230
|
+
const renderedFrames = await runtime.renderer.frames();
|
|
231
|
+
const checkpoints = await runtime.checkpoints.finish(after);
|
|
232
|
+
const manifest = await createRunManifest(scenario, runtime.startedAt, new Date());
|
|
233
|
+
const truncated = options.initiallyTruncated ||
|
|
234
|
+
after.truncated ||
|
|
235
|
+
runtime.replay.truncated ||
|
|
236
|
+
runtime.shimReplay.truncated ||
|
|
237
|
+
runtime.framesTruncated() ||
|
|
238
|
+
checkpoints.some(({ truncated: partial }) => partial) ||
|
|
239
|
+
samplingPartial ||
|
|
240
|
+
runtime.renderer.truncated();
|
|
241
|
+
return buildCaptureResult({
|
|
242
|
+
frames: options.frames,
|
|
243
|
+
exit: { ...options.exit, reason },
|
|
244
|
+
samples: options.samples,
|
|
245
|
+
replay: runtime.replay,
|
|
246
|
+
before: options.before,
|
|
247
|
+
after,
|
|
248
|
+
truncated,
|
|
249
|
+
scenario,
|
|
250
|
+
rootPid: runtime.terminal.pid,
|
|
251
|
+
samplingPartial,
|
|
252
|
+
renderedFrames,
|
|
253
|
+
interactions: options.interactions,
|
|
254
|
+
checkpoints,
|
|
255
|
+
shimEvents: normalizedShimEvents(runtime, scenario, options.temporaryRoot),
|
|
256
|
+
settlement: { ...settlement, cleanup_outcome: "not_required" },
|
|
257
|
+
manifest,
|
|
258
|
+
});
|
|
257
259
|
};
|
|
258
260
|
/** Execute one authorized scenario and return bounded observations. */
|
|
259
261
|
const runProcessScenario = async (scenario, policy, signal) => {
|
|
260
262
|
const { temporaryRoot, runId, home, before } = await prepareProcessCapture(scenario, policy, signal);
|
|
261
263
|
const frames = [];
|
|
262
264
|
const samples = [];
|
|
263
|
-
let
|
|
264
|
-
let terminal;
|
|
265
|
-
let replay;
|
|
266
|
-
let started = 0;
|
|
267
|
-
let lastOutput = 0;
|
|
265
|
+
let runtime;
|
|
268
266
|
const timers = new Set();
|
|
269
267
|
let capture;
|
|
270
268
|
let executionFailure;
|
|
271
|
-
let framesTruncated = () => false;
|
|
272
269
|
let stopSampler = async () => ({ partial: false });
|
|
273
|
-
|
|
270
|
+
const interactions = [];
|
|
271
|
+
const dispatchedEventIndexes = new Set();
|
|
274
272
|
try {
|
|
275
|
-
|
|
276
|
-
started = Date.now();
|
|
277
|
-
lastOutput = started;
|
|
278
|
-
terminal = spawn(scenario.executable, [...scenario.arguments], {
|
|
279
|
-
cwd: scenario.working_directory,
|
|
280
|
-
env: makeEnvironment(scenario, home, replay, runId),
|
|
281
|
-
cols: 80,
|
|
282
|
-
rows: 24,
|
|
283
|
-
name: "xterm-256color",
|
|
284
|
-
});
|
|
285
|
-
const startEvents = startScenarioEvents(scenario, () => terminal, timers);
|
|
286
|
-
if (!eventsRequireReadiness(scenario))
|
|
287
|
-
startEvents();
|
|
288
|
-
framesTruncated = captureTerminalFrames({
|
|
289
|
-
terminal,
|
|
273
|
+
runtime = await startCaptureRuntime({
|
|
290
274
|
scenario,
|
|
291
|
-
|
|
292
|
-
started,
|
|
275
|
+
home,
|
|
293
276
|
temporaryRoot,
|
|
294
|
-
|
|
295
|
-
|
|
277
|
+
runId,
|
|
278
|
+
before,
|
|
279
|
+
frames,
|
|
280
|
+
samples,
|
|
281
|
+
interactions,
|
|
282
|
+
timers,
|
|
283
|
+
dispatchedEventIndexes,
|
|
284
|
+
...(signal === undefined ? {} : { signal }),
|
|
296
285
|
});
|
|
297
|
-
stopSampler =
|
|
286
|
+
stopSampler = runtime.stopSampler;
|
|
298
287
|
const exit = await awaitTerminalExit({
|
|
299
|
-
terminal,
|
|
288
|
+
terminal: runtime.terminal,
|
|
300
289
|
scenario,
|
|
301
|
-
started,
|
|
302
|
-
lastOutput:
|
|
290
|
+
started: runtime.started,
|
|
291
|
+
lastOutput: runtime.lastOutput,
|
|
303
292
|
signal,
|
|
304
293
|
timers,
|
|
294
|
+
interactions,
|
|
295
|
+
dispatchedEventIndexes,
|
|
305
296
|
});
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
const after = await snapshotRoots(scenario, signal);
|
|
313
|
-
truncated ||=
|
|
314
|
-
after.truncated ||
|
|
315
|
-
replay.truncated ||
|
|
316
|
-
framesTruncated() ||
|
|
317
|
-
samples.length >= scenario.limits.processes;
|
|
318
|
-
truncated ||= samplingPartial;
|
|
319
|
-
capture = buildCaptureResult({
|
|
297
|
+
capture = await completeCapture({
|
|
298
|
+
scenario,
|
|
299
|
+
runtime,
|
|
300
|
+
runId,
|
|
301
|
+
temporaryRoot,
|
|
302
|
+
before,
|
|
320
303
|
frames,
|
|
321
|
-
exit: { ...exit, reason: exitReason },
|
|
322
304
|
samples,
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
scenario,
|
|
328
|
-
rootPid: terminal.pid,
|
|
329
|
-
samplingPartial,
|
|
305
|
+
interactions,
|
|
306
|
+
exit,
|
|
307
|
+
initiallyTruncated: before.truncated,
|
|
308
|
+
...(signal === undefined ? {} : { signal }),
|
|
330
309
|
});
|
|
331
310
|
}
|
|
332
311
|
catch (cause) {
|
|
333
|
-
terminal
|
|
312
|
+
runtime?.terminal.kill("SIGKILL");
|
|
334
313
|
executionFailure = cause;
|
|
335
314
|
}
|
|
336
|
-
|
|
337
|
-
|
|
315
|
+
return finishProcessRun({
|
|
316
|
+
runtime,
|
|
338
317
|
timers,
|
|
339
|
-
replay,
|
|
340
|
-
terminal,
|
|
341
318
|
runId,
|
|
342
319
|
temporaryRoot,
|
|
320
|
+
samples,
|
|
321
|
+
stopSampler,
|
|
322
|
+
capture,
|
|
323
|
+
executionFailure,
|
|
343
324
|
});
|
|
344
|
-
return resolveProcessResult(capture, executionFailure, cleanupFailure);
|
|
345
325
|
};
|
|
346
326
|
/** Execute one scenario through a typed expected-failure channel. */
|
|
347
327
|
export const captureProcessScenario = async (scenario, policy, signal) => {
|
|
@@ -24,7 +24,12 @@ export const normalizeProcessText = (value, scenario, temporaryRoot, pid) => {
|
|
|
24
24
|
export const normalizeProcessSamples = (samples, scenario, rootPid) => {
|
|
25
25
|
const identifiers = [
|
|
26
26
|
rootPid,
|
|
27
|
-
...samples.flatMap((sample) => [
|
|
27
|
+
...samples.flatMap((sample) => [
|
|
28
|
+
sample.pid,
|
|
29
|
+
sample.parent_pid,
|
|
30
|
+
sample.process_group_id ?? 0,
|
|
31
|
+
sample.session_id ?? 0,
|
|
32
|
+
]),
|
|
28
33
|
];
|
|
29
34
|
const mapping = new Map();
|
|
30
35
|
for (const identifier of identifiers)
|
|
@@ -35,6 +40,10 @@ export const normalizeProcessSamples = (samples, scenario, rootPid) => {
|
|
|
35
40
|
scenario.normalization.time_bucket_ms,
|
|
36
41
|
pid: mapping.get(sample.pid) ?? 1,
|
|
37
42
|
parent_pid: mapping.get(sample.parent_pid) ?? 0,
|
|
43
|
+
process_group_id: sample.process_group_id === null
|
|
44
|
+
? null
|
|
45
|
+
: (mapping.get(sample.process_group_id) ?? 0),
|
|
46
|
+
session_id: sample.session_id === null ? null : (mapping.get(sample.session_id) ?? 0),
|
|
38
47
|
command: normalizeProcessText(sample.command, scenario, "<no-temporary-root>", rootPid),
|
|
39
48
|
}));
|
|
40
49
|
};
|