Files
rea/tests/processCapture.test.ts
T

2594 lines
82 KiB
TypeScript

import { readFile, rm, symlink, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { describe, expect, it } from "vitest";
import WebSocket from "ws";
import { fileURLToPath } from "node:url";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js";
import { startLoopbackReplay } from "../src/application/LoopbackReplay.js";
import {
captureProcessScenario,
probeProcessCaptureCapability,
ProcessCaptureError,
} from "../src/application/ProcessHarness.js";
import { snapshotRoots } from "../src/application/FilesystemSnapshot.js";
import {
buildCaptureResult,
prepareProcessCapture,
type ProcessPreparationHost,
} from "../src/application/ProcessCaptureLifecycle.js";
import { ProcessCheckpoints } from "../src/application/ProcessCheckpoints.js";
import { createProcessCaptureJournal } from "../src/application/ProcessCaptureJournal.js";
import { normalizeProcessSamples } from "../src/application/ProcessNormalization.js";
import {
isInitializedPtyRoot,
readLinuxChildren,
} from "../src/application/ProcessSampling.js";
import { TerminalRenderer } from "../src/application/TerminalRenderer.js";
import {
authorizeProcessScenario,
compareProcessCaptures,
digestProcessCommitment,
LEGACY_PROCESS_CAPTURE_MESSAGE,
parseProcessCapture,
parseProcessScenario,
processCaptureSchema,
validateProcessCapture,
type ProcessCapture,
type ProcessCaptureEventJournalEntry,
type ProcessExecutionPolicy,
} from "../src/domain/processCapture.js";
const processFixture = fileURLToPath(
new URL("./fixtures/processFidelity.mjs", import.meta.url),
);
const execFileAsync = promisify(execFile);
const emptyCapture = (): ProcessCapture => {
const normalization = {
paths: true,
pids: true,
ports: true,
time_bucket_ms: 10,
patterns: [],
};
const scenario = { executable_sha256: "0".repeat(64) };
const comparisonContract = {};
const shimPlan: readonly unknown[] = [];
const replayPlan = {};
return {
schema_version: 4,
manifest: {
rea_version: "test",
provider_version: "3",
platform: process.platform,
architecture: process.arch,
pty_backend: "node-pty",
started_at: "2026-01-01T00:00:00.000Z",
completed_at: "2026-01-01T00:00:00.001Z",
scenario,
comparison_contract: comparisonContract,
shim_plan: shimPlan,
replay_plan: replayPlan,
full_scenario_sha256: digestProcessCommitment(scenario),
comparison_contract_sha256: digestProcessCommitment(comparisonContract),
executable_sha256: "0".repeat(64),
normalization_sha256: digestProcessCommitment(normalization),
shim_plan_sha256: digestProcessCommitment(shimPlan),
replay_plan_sha256: digestProcessCommitment(replayPlan),
},
normalization,
frames: [],
rendered_frames: [],
interaction_events: [],
exit: { code: 0, signal: null, reason: "exited" },
settlement: {
state: "quiesced",
elapsed_ms: 50,
cleanup_outcome: "not_required",
},
process_samples: [],
filesystem_checkpoints: [
{ name: "before", at_ms: 0, files: [], effects: [], truncated: false },
{
name: "after_settlement",
at_ms: 50,
files: [],
effects: [],
truncated: false,
},
],
shim_events: [],
protocol_events: [],
replay_transitions: [],
reactive_run: null,
event_journal: [],
files_before: [],
files_after: [],
filesystem_effects: [],
truncated: false,
limitations: [],
residual_unknowns: [],
cleanup: {
owned_process_group: "verified",
temporary_root: "removed",
},
};
};
describe("process capture domain", () => {
const base = {
approved: true as const,
executable: "/bin/sh",
working_directory: "/tmp",
};
it("returns detached terminal and filesystem checkpoint observations", async () => {
const observed: string[] = [];
const renderer = new TerminalRenderer({
columns: 40,
rows: 12,
scrollback: 100,
maxFrames: 10,
maxBytes: 10_000,
normalize: (value) => value,
recordEvent: (collection, index) =>
observed.push(`${collection}:${String(index)}`),
});
renderer.write("A", 20);
renderer.resize(40, 12, 10);
const frames = await renderer.frames();
expect(frames.map(({ at_ms }) => at_ms)).toEqual([20, 10]);
expect(observed).toEqual(["rendered_frames:0", "rendered_frames:1"]);
if (frames[0] !== undefined) Reflect.set(frames[0], "cursor_x", 999);
expect((await renderer.frames())[0]?.cursor_x).not.toBe(999);
await renderer.dispose();
const scenario = parseProcessScenario(base);
const checkpoints = new ProcessCheckpoints(
scenario,
Date.now(),
{ files: [], truncated: false },
{ signal: undefined },
);
const first = await checkpoints.finish({ files: [], truncated: false });
if (first[0] !== undefined) Reflect.set(first[0], "name", "tampered");
const second = await checkpoints.finish({ files: [], truncated: false });
expect(second[0]?.name).toBe("before");
await checkpoints.dispose();
});
it("preserves rendered observation order instead of timestamp sorting", () => {
const capture = emptyCapture();
const renderedFrames: ProcessCapture["rendered_frames"] = [
{
sequence: 0,
at_ms: 20,
columns: 1,
rows: 1,
cursor_x: 0,
cursor_y: 0,
active_buffer: "normal",
lines: ["first"],
serialized_state: "first",
},
{
sequence: 1,
at_ms: 10,
columns: 1,
rows: 1,
cursor_x: 0,
cursor_y: 0,
active_buffer: "normal",
lines: ["second"],
serialized_state: "second",
},
];
const result = buildCaptureResult({
frames: [],
reactiveRun: null,
exit: { exitCode: 0, reason: "exited" },
samples: [],
replay: {
httpUrl: "http://127.0.0.1",
websocketUrl: "ws://127.0.0.1/ws",
events: [],
transitions: [],
truncated: false,
close: () => Promise.resolve(),
},
before: { files: [], truncated: false },
after: { files: [], truncated: false },
truncated: false,
scenario: parseProcessScenario(base),
rootPid: 1,
samplingPartial: false,
renderedFrames,
interactions: [],
checkpoints: capture.filesystem_checkpoints,
shimEvents: [],
settlement: capture.settlement,
manifest: capture.manifest,
eventJournal: [],
});
expect(result.rendered_frames).toEqual(renderedFrames);
});
it("cleans the temporary root when capture home creation fails", async () => {
const cleaned: string[] = [];
const host: ProcessPreparationHost = {
createTemporaryRoot: () => Promise.resolve("/tmp/rea-process-fixture"),
createHome: () => Promise.reject(new Error("mkdir failed")),
cleanup: (path) => {
cleaned.push(path);
return Promise.resolve();
},
};
await expect(
prepareProcessCapture(
parseProcessScenario(base),
{
enabled: true,
executableRoots: ["/bin"],
workingRoots: ["/tmp"],
allowedEnvironment: [],
allowExternalNetwork: true,
},
undefined,
host,
),
).rejects.toThrow("mkdir failed");
expect(cleaned).toEqual(["/tmp/rea-process-fixture"]);
});
it("normalizes every sampled process identifier in command text", () => {
const samples = normalizeProcessSamples(
[
{
at_ms: 0,
pid: 101,
parent_pid: 0,
process_group_id: 101,
session_id: 101,
command: "root 101",
},
{
at_ms: 10,
pid: 202,
parent_pid: 101,
process_group_id: 101,
session_id: 101,
command: "child 202 peer=101 unrelated 1202",
},
],
parseProcessScenario(base),
101,
);
expect(samples[1]?.command).toBe("child <pid> peer=<pid> unrelated 1202");
});
it("collects and deduplicates children from every Linux thread", async () => {
const signal = new AbortController().signal;
expect(
await readLinuxChildren(100, signal, {
taskIds: () => Promise.resolve([100, 101, 102]),
children: (_pid, taskId) =>
Promise.resolve(
taskId === 100 ? "201 202" : taskId === 101 ? "202 203" : "",
),
}),
).toEqual([201, 202, 203]);
});
it("admits PTY samples only after stable session and token setup", () => {
const initialized = {
pid: 100,
parent_pid: 10,
process_group_id: 100,
session_id: 100,
startTime: "200",
};
expect(
isInitializedPtyRoot({
rootPid: 100,
expectedRunId: "run-token",
before: { ...initialized, process_group_id: 10, session_id: 10 },
observedRunId: undefined,
after: initialized,
}),
).toBe(false);
expect(
isInitializedPtyRoot({
rootPid: 100,
expectedRunId: "run-token",
before: initialized,
observedRunId: "run-token",
after: { ...initialized, startTime: "201" },
}),
).toBe(false);
expect(
isInitializedPtyRoot({
rootPid: 100,
expectedRunId: "run-token",
before: initialized,
observedRunId: "run-token",
after: initialized,
}),
).toBe(true);
expect(
isInitializedPtyRoot({
rootPid: 100,
expectedRunId: "run-token",
before: { ...initialized, session_id: null },
observedRunId: "run-token",
after: { ...initialized, session_id: null },
}),
).toBe(true);
});
it("keeps interaction and shim residual uncertainty in separate scopes", () => {
const baseCapture = emptyCapture();
const interaction = parseProcessCapture({
...baseCapture,
residual_unknowns: [
{ scope: "interaction", reason: "Interaction capture was partial." },
],
});
const shim = parseProcessCapture({
...baseCapture,
residual_unknowns: [
{ scope: "shim", reason: "Shim capture was partial." },
],
});
expect(compareProcessCaptures(interaction, baseCapture)).toMatchObject({
status: "unknown",
terminal: "unchanged",
interaction: "unknown",
shim: "unchanged",
});
expect(compareProcessCaptures(shim, baseCapture)).toMatchObject({
status: "unknown",
terminal: "unchanged",
interaction: "unchanged",
shim: "unknown",
});
});
it("cancels filesystem snapshots before traversing declared roots", async () => {
const controller = new AbortController();
controller.abort();
await expect(
snapshotRoots(
parseProcessScenario({
...base,
filesystem_roots: ["/tmp"],
}),
controller.signal,
),
).rejects.toMatchObject({ name: "AbortError" });
});
it("parses bounded scenarios and rejects unordered events", () => {
expect(parseProcessScenario(base).timeout_ms).toBe(30_000);
expect(() =>
parseProcessScenario({
...base,
events: [
{ type: "input", at_ms: 2, data: "a" },
{ type: "input", at_ms: 1, data: "b" },
],
}),
).toThrow(/ordered/);
expect(() =>
parseProcessScenario({
...base,
environment: { HOME: "/unsafe" },
}),
).toThrow(/reserved/);
expect(() =>
parseProcessScenario({
...base,
events: [{ type: "input", at_ms: 31_000, data: "late" }],
}),
).toThrow(/after the scenario timeout/);
});
it("requires explicit operator approval for host network access", () => {
expect(
authorizeProcessScenario(parseProcessScenario(base), {
enabled: true,
executableRoots: ["/bin"],
workingRoots: ["/tmp"],
allowedEnvironment: [],
allowExternalNetwork: false,
}),
).toEqual({
allowed: false,
reason: "host network access is not approved by operator policy",
});
});
it("refuses paths and environment outside operator policy", () => {
const scenario = parseProcessScenario({
...base,
environment: { TOKEN: "secret" },
});
expect(
authorizeProcessScenario(scenario, {
enabled: true,
executableRoots: ["/bin"],
workingRoots: ["/tmp"],
allowedEnvironment: [],
allowExternalNetwork: true,
}),
).toEqual({
allowed: false,
reason: "scenario requests an environment variable not allowed by policy",
});
});
it("never considers truncated captures equivalent", () => {
const capture = {
schema_version: 4 as const,
manifest: emptyCapture().manifest,
settlement: emptyCapture().settlement,
normalization: {
paths: true,
pids: true,
ports: true,
time_bucket_ms: 10,
patterns: [],
},
frames: [],
rendered_frames: [],
interaction_events: [],
exit: { code: 0, signal: null, reason: "exited" as const },
process_samples: [],
filesystem_checkpoints: emptyCapture().filesystem_checkpoints,
shim_events: [],
protocol_events: [],
replay_transitions: [],
reactive_run: null,
files_before: [],
files_after: [],
filesystem_effects: [],
truncated: true,
limitations: [],
residual_unknowns: [],
cleanup: {
owned_process_group: "verified" as const,
temporary_root: "removed" as const,
},
};
expect(compareProcessCaptures(capture, capture).status).toBe("truncated");
});
it("rejects altered v4 commitments and accepts canonical key reordering", () => {
const capture = emptyCapture();
expect(parseProcessCapture(capture)).toEqual(capture);
expect(digestProcessCommitment({ second: 2, first: 1 })).toBe(
digestProcessCommitment({ first: 1, second: 2 }),
);
expect(() =>
parseProcessCapture({
...capture,
manifest: {
...capture.manifest,
normalization_sha256: "f".repeat(64),
},
}),
).toThrow("normalization_sha256");
expect(() =>
parseProcessCapture({
...capture,
manifest: {
...capture.manifest,
executable_sha256: "f".repeat(64),
},
}),
).toThrow("executable_sha256");
});
it("accepts old captures without a journal and validates complete journals", () => {
const journal = createProcessCaptureJournal();
const observed: ProcessCaptureEventJournalEntry[] = [];
const unsubscribe = journal.subscribe((entry) => observed.push(entry));
journal.recordEvent("lifecycle", 0);
const settlementEntry = journal.record("lifecycle", 1);
unsubscribe();
journal.recordEvent("frames", 0);
expect(journal.entries).toEqual([
{ capture_order: 0, collection: "lifecycle", index: 0 },
{ capture_order: 1, collection: "lifecycle", index: 1 },
{ capture_order: 2, collection: "frames", index: 0 },
]);
expect(observed).toEqual(journal.entries.slice(0, 2));
expect(settlementEntry).toEqual(journal.entries[1]);
const nestedJournal = createProcessCaptureJournal();
const notifications: string[] = [];
nestedJournal.subscribe((entry) => {
notifications.push(`a:${String(entry.capture_order)}`);
if (entry.capture_order === 0) nestedJournal.record("frames", 1);
});
nestedJournal.subscribe((entry) =>
notifications.push(`b:${String(entry.capture_order)}`),
);
nestedJournal.record("frames", 0);
expect(notifications).toEqual(["a:0", "b:0", "a:1", "b:1"]);
const capture = emptyCapture();
const { event_journal: _eventJournal, ...oldCapture } = capture;
expect(parseProcessCapture(oldCapture).event_journal).toEqual([]);
const eventJournal = [
{ capture_order: 0, collection: "filesystem_checkpoints", index: 0 },
{ capture_order: 1, collection: "lifecycle", index: 0 },
{ capture_order: 2, collection: "lifecycle", index: 1 },
{ capture_order: 3, collection: "filesystem_checkpoints", index: 1 },
] as const;
expect(
validateProcessCapture({ ...capture, event_journal: eventJournal }),
).toEqual([]);
for (const [candidate, message] of [
[
eventJournal.map((entry, index) =>
index === 1 ? { ...entry, capture_order: 2 } : entry,
),
"contiguous",
],
[
eventJournal.map((entry, index) =>
index === 3
? { ...entry, collection: "lifecycle" as const, index: 1 }
: entry,
),
"unique",
],
[eventJournal.slice(0, 3), "every captured observation"],
[
eventJournal.map((entry, index) =>
index === 3 ? { ...entry, index: 2 } : entry,
),
"outside",
],
] as const) {
expect(
validateProcessCapture({ ...capture, event_journal: candidate }),
).toEqual(
expect.arrayContaining([
expect.objectContaining({
message: expect.stringContaining(message),
}),
]),
);
}
});
it.each([
["machine limit", 1, 5, 10],
["transition use limit", 5, 1, 10],
["state visit limit", 5, 5, 1],
])(
"rejects replay journals that exceed the declared %s",
(_name, maxTransitions, maxUses, maxVisits) => {
const replayPlan = {
machine: {
initial_state: "active",
states: [
{ name: "active", max_visits: maxVisits },
{ name: "complete", terminal: true },
],
transitions: [
{
id: "again",
from: "active",
to: "active",
trigger: {
protocol: "websocket_message",
path: "/ws",
body: "again",
},
actions: [{ type: "websocket_send", data: "again" }],
max_uses: maxUses,
},
{
id: "finish",
from: "active",
to: "complete",
trigger: {
protocol: "websocket_message",
path: "/ws",
body: "finish",
},
actions: [{ type: "websocket_send", data: "done" }],
max_uses: 1,
},
],
max_transitions: maxTransitions,
},
};
const capture = emptyCapture();
const candidate: ProcessCapture = {
...capture,
manifest: {
...capture.manifest,
replay_plan: replayPlan,
replay_plan_sha256: digestProcessCommitment(replayPlan),
},
replay_transitions: [0, 1].map((sequence) => ({
sequence,
at_ms: sequence,
transition_id: "again",
state_before: "active",
state_after: "active",
sensitive_aliases: [],
})),
};
expect(validateProcessCapture(candidate)).toEqual(
expect.arrayContaining([
expect.objectContaining({
message: expect.stringContaining("limit"),
}),
]),
);
},
);
it("tells agents and users to recapture unsupported v3 evidence", () => {
const legacy = { ...emptyCapture(), schema_version: 3 };
expect(() => parseProcessCapture(legacy)).toThrow(
LEGACY_PROCESS_CAPTURE_MESSAGE,
);
const parsed = processCaptureSchema.safeParse(legacy);
expect(parsed.success).toBe(false);
if (parsed.success) throw new Error("expected legacy capture rejection");
expect(parsed.error.issues[0]?.message).toBe(
LEGACY_PROCESS_CAPTURE_MESSAGE,
);
});
it("requires compatible contracts and enforces capture age through a clock seam", () => {
const capture = emptyCapture();
expect(() =>
compareProcessCaptures(capture, {
...capture,
manifest: {
...capture.manifest,
comparison_contract: { changed: true },
comparison_contract_sha256: digestProcessCommitment({
changed: true,
}),
},
}),
).toThrow("incompatible comparison contracts");
expect(() =>
compareProcessCaptures(capture, capture, {
maxCaptureAgeMs: 1,
now: () => Date.parse("2026-01-01T00:00:01.000Z"),
}),
).toThrow("max_capture_age_ms");
});
it("classifies missing observations as unknown and one-sided evidence as added", () => {
const base = {
schema_version: 4 as const,
manifest: emptyCapture().manifest,
settlement: emptyCapture().settlement,
normalization: {
paths: true,
pids: true,
ports: true,
time_bucket_ms: 10,
patterns: [],
},
frames: [],
rendered_frames: [],
interaction_events: [],
exit: { code: 0, signal: null, reason: "exited" as const },
process_samples: [],
filesystem_checkpoints: emptyCapture().filesystem_checkpoints,
shim_events: [],
protocol_events: [],
replay_transitions: [],
reactive_run: null,
files_before: [],
files_after: [],
filesystem_effects: [],
truncated: false,
limitations: [],
residual_unknowns: [],
cleanup: {
owned_process_group: "verified" as const,
temporary_root: "removed" as const,
},
};
const added = compareProcessCaptures(base, {
...base,
frames: [{ sequence: 0, at_ms: 0, data: "new" }],
rendered_frames: [
{
sequence: 0,
at_ms: 0,
columns: 3,
rows: 1,
cursor_x: 3,
cursor_y: 0,
active_buffer: "normal",
lines: ["new"],
serialized_state: "new",
},
],
});
expect(added.terminal).toBe("added");
expect(added.status).toBe("changed");
const unknown = compareProcessCaptures(
{ ...base, residual_unknowns: [{ scope: "process", reason: "sampled" }] },
base,
);
expect(unknown.process).toBe("unknown");
expect(unknown.shim).toBe("unchanged");
expect(unknown.status).toBe("unknown");
});
it("compares raw terminal chunks even when rendered states agree", () => {
const capture = {
schema_version: 4 as const,
manifest: emptyCapture().manifest,
settlement: emptyCapture().settlement,
normalization: {
paths: true,
pids: true,
ports: true,
time_bucket_ms: 10,
patterns: [],
},
frames: [{ sequence: 0, at_ms: 0, data: "bar" }],
rendered_frames: [],
interaction_events: [],
exit: { code: 0, signal: null, reason: "exited" as const },
process_samples: [],
filesystem_checkpoints: emptyCapture().filesystem_checkpoints,
shim_events: [],
protocol_events: [],
replay_transitions: [],
reactive_run: null,
files_before: [],
files_after: [],
filesystem_effects: [],
truncated: false,
limitations: [],
residual_unknowns: [],
cleanup: {
owned_process_group: "verified" as const,
temporary_root: "removed" as const,
},
};
const comparison = compareProcessCaptures(capture, {
...capture,
frames: [{ sequence: 0, at_ms: 0, data: "foo\rbar" }],
});
expect(comparison.terminal).toBe("changed");
expect(comparison.first_divergence).toMatchObject({
status: "found",
dimension: "terminal",
});
});
it("keeps filesystem evidence unknown when stable snapshots match", () => {
const capture = {
schema_version: 4 as const,
manifest: emptyCapture().manifest,
settlement: emptyCapture().settlement,
normalization: {
paths: true,
pids: true,
ports: true,
time_bucket_ms: 10,
patterns: [],
},
frames: [],
rendered_frames: [],
interaction_events: [],
exit: { code: 0, signal: null, reason: "exited" as const },
process_samples: [],
filesystem_checkpoints: emptyCapture().filesystem_checkpoints,
shim_events: [],
protocol_events: [],
replay_transitions: [],
reactive_run: null,
files_before: [],
files_after: [],
filesystem_effects: [],
truncated: false,
limitations: [],
residual_unknowns: [
{ scope: "filesystem" as const, reason: "watcher unavailable" },
],
cleanup: {
owned_process_group: "verified" as const,
temporary_root: "removed" as const,
},
};
const comparison = compareProcessCaptures(capture, capture);
expect(comparison.filesystem).toBe("unknown");
expect(comparison.status).toBe("unknown");
const complete = { ...capture, residual_unknowns: [] };
const transient = compareProcessCaptures(complete, {
...complete,
filesystem_checkpoints: [
{ name: "before", at_ms: 0, files: [], effects: [], truncated: false },
{
name: "during_run",
at_ms: 10,
files: [],
effects: [],
truncated: false,
},
{
name: "after_settlement",
at_ms: 50,
files: [],
effects: [],
truncated: false,
},
],
});
expect(transient.filesystem).toBe("changed");
expect(transient.status).toBe("changed");
});
it("detects changes in normalized process sample metadata", () => {
const capture = {
schema_version: 4 as const,
manifest: emptyCapture().manifest,
settlement: emptyCapture().settlement,
normalization: {
paths: true,
pids: true,
ports: true,
time_bucket_ms: 10,
patterns: [],
},
frames: [],
rendered_frames: [],
interaction_events: [],
exit: { code: 0, signal: null, reason: "exited" as const },
process_samples: [
{
at_ms: 10,
pid: 1,
parent_pid: 0,
process_group_id: 1,
session_id: 1,
command: "worker",
},
],
filesystem_checkpoints: emptyCapture().filesystem_checkpoints,
shim_events: [],
protocol_events: [],
replay_transitions: [],
reactive_run: null,
files_before: [],
files_after: [],
filesystem_effects: [],
truncated: false,
limitations: [],
residual_unknowns: [],
cleanup: {
owned_process_group: "verified" as const,
temporary_root: "removed" as const,
},
};
const changed = {
...capture,
process_samples: [
{
at_ms: 20,
pid: 1,
parent_pid: 2,
process_group_id: 1,
session_id: 1,
command: "worker",
},
],
};
const comparison = compareProcessCaptures(capture, changed);
expect(comparison.process).toBe("changed");
expect(comparison.status).toBe("changed");
});
it("compares replay state transitions as protocol evidence", () => {
const replayPlan = {
machine: {
initial_state: "anonymous",
states: [
{ name: "anonymous" },
{ name: "authenticated", terminal: true },
],
transitions: [
{
id: "login",
from: "anonymous",
to: "authenticated",
trigger: { protocol: "http", method: "POST", path: "/login" },
captures: [
{
variable: "token",
value: { source: "request_json", path: ["token"] },
sensitive: true,
},
],
actions: [{ type: "http_response", status: 204, body: "" }],
max_uses: 1,
},
],
max_transitions: 1,
},
};
const empty = emptyCapture();
const baseline = {
...empty,
manifest: {
...empty.manifest,
replay_plan: replayPlan,
replay_plan_sha256: digestProcessCommitment(replayPlan),
},
};
const transitioned = {
...baseline,
replay_transitions: [
{
sequence: 0,
at_ms: 0,
transition_id: "login",
state_before: "anonymous",
state_after: "authenticated",
sensitive_aliases: ["token"],
},
],
};
expect(compareProcessCaptures(baseline, transitioned)).toMatchObject({
status: "changed",
protocol: "added",
});
});
});
describe("process capture adapter", () => {
it("serves bounded HTTP and WebSocket replay on loopback", async () => {
const scenario = parseProcessScenario({
approved: true,
executable: "/bin/sh",
working_directory: "/tmp",
replay: {
http: [{ method: "GET", path: "/ready", status: 201, body: "ready" }],
websocket_messages: ["welcome"],
},
});
const replay = await startLoopbackReplay(scenario);
try {
const response = await fetch(`${replay.httpUrl}/ready`);
expect(response.status).toBe(201);
expect(await response.text()).toBe("ready");
const websocketMessage = await new Promise<string>(
(resolveMessage, rejectMessage) => {
const socket = new WebSocket(replay.websocketUrl);
socket.once("message", (value) => {
resolveMessage(value.toString());
socket.close();
});
socket.once("error", rejectMessage);
},
);
expect(websocketMessage).toBe("welcome");
expect(replay.events.map((event) => event.protocol)).toContain(
"websocket",
);
} finally {
await replay.close();
}
});
it("matches bounded HTTP scripts without persisting request secrets", async () => {
const scenario = parseProcessScenario({
approved: true,
executable: "/bin/sh",
working_directory: "/tmp",
replay: {
http: [
{
method: "POST",
path: "/callback",
request_headers: { authorization: "Bearer fixture-secret" },
request_body: "credential-body",
status: 302,
response_headers: { location: "/done" },
body: "redirecting",
max_calls: 1,
},
{
method: "GET",
path: "/disconnect",
status: 200,
body: "",
disconnect: true,
},
],
},
});
const replay = await startLoopbackReplay(scenario);
try {
const request = () =>
fetch(`${replay.httpUrl}/callback`, {
method: "POST",
headers: { authorization: "Bearer fixture-secret" },
body: "credential-body",
redirect: "manual",
});
const matched = await request();
expect(matched.status).toBe(302);
expect(matched.headers.get("location")).toBe("/done");
expect((await request()).status).toBe(409);
expect((await fetch(`${replay.httpUrl}/missing`)).status).toBe(404);
await expect(fetch(`${replay.httpUrl}/disconnect`)).rejects.toThrow();
expect(replay.events.map(({ outcome }) => outcome)).toEqual(
expect.arrayContaining([
"matched",
"script_exhausted",
"unmatched",
"disconnected",
]),
);
expect(JSON.stringify(replay.events)).not.toContain("fixture-secret");
expect(JSON.stringify(replay.events)).not.toContain("credential-body");
} finally {
await replay.close();
}
});
it("consumes ordered WebSocket reconnect scripts and reports exhaustion", async () => {
const scenario = parseProcessScenario({
approved: true,
executable: "/bin/sh",
working_directory: "/tmp",
replay: {
websocket_connections: [
{
messages: [{ data: "first", delay_ms: 1 }],
disconnect_after: true,
},
{
messages: [{ data: "second" }],
disconnect_after: true,
},
],
},
});
const replay = await startLoopbackReplay(scenario);
const receive = () =>
new Promise<string>((resolveMessage, rejectMessage) => {
const socket = new WebSocket(replay.websocketUrl);
socket.once("message", (value) => resolveMessage(value.toString()));
socket.once("error", rejectMessage);
});
try {
await expect(receive()).resolves.toBe("first");
await expect(receive()).resolves.toBe("second");
await new Promise<void>((resolveClose, rejectClose) => {
const socket = new WebSocket(replay.websocketUrl);
socket.once("close", () => resolveClose());
socket.once("error", rejectClose);
});
expect(replay.events.at(-1)?.outcome).toBe("script_exhausted");
} finally {
await replay.close();
}
});
it("serializes WebSocket transition actions across rapid messages", async () => {
const scenario = parseProcessScenario({
approved: true,
executable: "/bin/sh",
working_directory: "/tmp",
replay: {
machine: {
initial_state: "connecting",
states: [
{ name: "connecting" },
{ name: "first" },
{ name: "second" },
{ name: "complete", terminal: true },
],
transitions: [
{
id: "connect",
from: "connecting",
to: "first",
trigger: { protocol: "websocket_connect", path: "/ws" },
actions: [{ type: "websocket_send", data: "ready" }],
max_uses: 1,
},
{
id: "first",
from: "first",
to: "second",
trigger: {
protocol: "websocket_message",
path: "/ws",
body: "first",
},
actions: [
{ type: "delay", duration_ms: 30 },
{ type: "websocket_send", data: "first-done" },
],
max_uses: 1,
},
{
id: "second",
from: "second",
to: "complete",
trigger: {
protocol: "websocket_message",
path: "/ws",
body: "second",
},
actions: [{ type: "websocket_send", data: "second-done" }],
max_uses: 1,
},
],
max_transitions: 3,
},
},
});
const replay = await startLoopbackReplay(scenario);
try {
const messages = await new Promise<string[]>((resolve, reject) => {
const received: string[] = [];
const socket = new WebSocket(replay.websocketUrl);
socket.on("message", (value) => {
received.push(value.toString());
if (received.length === 1) {
socket.send("first");
socket.send("second");
}
if (received.length === 3) {
socket.close();
resolve(received);
}
});
socket.once("error", reject);
});
expect(messages).toEqual(["ready", "first-done", "second-done"]);
} finally {
await replay.close();
}
});
it("drains queued machine work before exposing finalized snapshots", async () => {
const scenario = parseProcessScenario({
approved: true,
executable: "/bin/sh",
working_directory: "/tmp",
replay: {
machine: {
initial_state: "connecting",
states: [
{ name: "connecting" },
{ name: "first" },
{ name: "second" },
{ name: "complete", terminal: true },
],
transitions: [
{
id: "connect",
from: "connecting",
to: "first",
trigger: { protocol: "websocket_connect", path: "/ws" },
actions: [{ type: "websocket_send", data: "ready" }],
max_uses: 1,
},
{
id: "first",
from: "first",
to: "second",
trigger: {
protocol: "websocket_message",
path: "/ws",
body: "first",
},
actions: [
{ type: "delay", duration_ms: 30 },
{ type: "disconnect" },
],
max_uses: 1,
},
{
id: "second",
from: "second",
to: "complete",
trigger: {
protocol: "websocket_message",
path: "/ws",
body: "second",
},
actions: [{ type: "websocket_send", data: "done" }],
max_uses: 1,
},
],
max_transitions: 3,
},
},
});
const replay = await startLoopbackReplay(scenario);
await new Promise<void>((resolve, reject) => {
const socket = new WebSocket(replay.websocketUrl);
socket.once("message", () => {
socket.send("first");
socket.send("second");
setTimeout(resolve, 5);
});
socket.once("error", reject);
});
await replay.close();
const finalized = replay.transitions;
expect(finalized.map(({ transition_id }) => transition_id)).toEqual([
"connect",
"first",
"second",
]);
await new Promise((resolve) => setTimeout(resolve, 40));
expect(replay.transitions).toEqual(finalized);
});
it("enforces replay duration before normalizing recorded timestamps", async () => {
const scenario = parseProcessScenario({
approved: true,
executable: "/bin/sh",
working_directory: "/tmp",
normalization: { time_bucket_ms: 60_000 },
replay: {
machine: {
initial_state: "waiting",
states: [{ name: "waiting" }, { name: "complete", terminal: true }],
transitions: [
{
id: "late",
from: "waiting",
to: "complete",
trigger: { protocol: "http", method: "GET", path: "/late" },
actions: [{ type: "http_response", status: 200, body: "late" }],
max_uses: 1,
},
],
max_transitions: 1,
limits: { duration_ms: 5 },
},
},
});
const replay = await startLoopbackReplay(scenario);
try {
await new Promise((resolve) => setTimeout(resolve, 20));
const response = await fetch(`${replay.httpUrl}/late`);
expect(response.status).toBe(409);
expect(replay.events.at(-1)).toMatchObject({
at_ms: 0,
outcome: "limit_exhausted",
});
} finally {
await replay.close();
}
});
it("runs a login and reconnect replay machine inside process capture", async () => {
const root = await createTestTempDirectory("rea-replay-machine-test-");
const script = join(root, "client.mjs");
await writeFile(
script,
[
'const token = "machine-secret";',
'await fetch(`${process.env.REA_REPLAY_HTTP_URL}/login`, { method: "POST", body: JSON.stringify({ token }) });',
"const api = await fetch(`${process.env.REA_REPLAY_HTTP_URL}/api`, { headers: { authorization: token } });",
'if (await api.text() !== "connect") throw new Error("bad API response");',
"const connect = (acknowledge) => new Promise((resolve, reject) => {",
" const socket = new WebSocket(process.env.REA_REPLAY_WEBSOCKET_URL);",
" socket.addEventListener('error', reject);",
" socket.addEventListener('message', (event) => {",
" if (acknowledge) socket.send('ack');",
" else { console.log(`reply:${event.data}`); socket.close(); resolve(); }",
" });",
" if (acknowledge) socket.addEventListener('close', () => resolve());",
"});",
"await connect(true);",
"await connect(false);",
].join("\n"),
);
const scenario = parseProcessScenario({
approved: true,
executable: process.execPath,
arguments: [script],
working_directory: root,
replay: {
machine: {
initial_state: "login",
states: [
{ name: "login" },
{ name: "api" },
{ name: "socket" },
{ name: "ack" },
{ name: "reconnect" },
{ name: "complete", terminal: true },
],
transitions: [
{
id: "login",
from: "login",
to: "api",
trigger: { protocol: "http", method: "POST", path: "/login" },
captures: [
{
variable: "token",
value: { source: "request_json", path: ["token"] },
sensitive: true,
},
],
actions: [{ type: "http_response", status: 204, body: "" }],
max_uses: 1,
},
{
id: "api",
from: "api",
to: "socket",
trigger: { protocol: "http", method: "GET", path: "/api" },
guards: [
{
variable: "token",
value: { source: "request_header", name: "authorization" },
},
],
actions: [
{ type: "http_response", status: 200, body: "connect" },
],
max_uses: 1,
},
{
id: "initial_socket",
from: "socket",
to: "ack",
trigger: { protocol: "websocket_connect", path: "/ws" },
actions: [{ type: "websocket_send", data: "welcome" }],
max_uses: 1,
},
{
id: "ack",
from: "ack",
to: "reconnect",
trigger: {
protocol: "websocket_message",
path: "/ws",
body: "ack",
},
actions: [{ type: "disconnect" }],
max_uses: 1,
},
{
id: "reconnect",
from: "reconnect",
to: "complete",
trigger: { protocol: "websocket_connect", path: "/ws" },
actions: [{ type: "websocket_send", data: "done" }],
max_uses: 1,
},
],
max_transitions: 5,
},
},
});
try {
const capability = await probeProcessCaptureCapability();
if (!capability.available) return;
const capture = await captureProcessScenario(scenario, {
enabled: true,
executableRoots: [dirname(process.execPath)],
workingRoots: [root],
allowedEnvironment: [],
allowExternalNetwork: true,
});
expect(capture.ok).toBe(true);
if (!capture.ok) throw capture.error;
expect(
capture.value.replay_transitions.map(
({ transition_id }) => transition_id,
),
).toEqual(["login", "api", "initial_socket", "ack", "reconnect"]);
expect(capture.value.frames.map(({ data }) => data).join("")).toContain(
"reply:done",
);
expect(JSON.stringify(capture.value)).not.toContain("machine-secret");
expect(capture.value.manifest.replay_plan_sha256).toBe(
digestProcessCommitment(scenario.replay),
);
expect(
capture.value.event_journal?.filter(
({ collection }) => collection === "replay_transitions",
),
).toHaveLength(capture.value.replay_transitions.length);
for (const [index, entry] of (
capture.value.event_journal ?? []
).entries())
if (entry.collection === "replay_transitions")
expect(capture.value.event_journal?.[index - 1]?.collection).toBe(
"protocol_events",
);
} finally {
await rm(root, { recursive: true, force: true });
}
}, 20_000);
it("captures PTY, filesystem, descendants, HTTP replay, and redacts environment", async () => {
const root = await createTestTempDirectory("rea-harness-test-");
const script = join(root, "fixture.mjs");
await writeFile(
script,
[
'import { writeFile } from "node:fs/promises";',
'import { spawn } from "node:child_process";',
'await writeFile(new URL("result.txt", `file://${process.cwd()}/`), "created");',
"const response = await fetch(`${process.env.REA_REPLAY_HTTP_URL}/probe`);",
"console.log(`reply:${await response.text()}`);",
"console.log(`sensitive:${process.env.SECRET}`);",
'const child = spawn(process.execPath, ["-e", "setTimeout(()=>{}, 150)"], { stdio: "ignore" });',
"await new Promise((resolve) => setTimeout(resolve, 80));",
"child.kill();",
].join("\n"),
);
const policy: ProcessExecutionPolicy = {
enabled: true,
executableRoots: [dirname(process.execPath)],
workingRoots: [root],
allowedEnvironment: ["SECRET"],
allowExternalNetwork: true,
};
const scenario = parseProcessScenario({
approved: true,
executable: process.execPath,
arguments: [script],
working_directory: root,
filesystem_roots: [root],
environment: { SECRET: "do-not-record" },
secret_aliases: ["SECRET"],
replay: {
http: [{ method: "GET", path: "/probe", status: 200, body: "ok" }],
},
});
try {
const capability = await probeProcessCaptureCapability();
if (!capability.available) {
expect(capability.reason).toMatch(/native PTY/);
return;
}
const capture = await captureProcessScenario(scenario, policy);
expect(capture.ok).toBe(true);
if (!capture.ok) throw capture.error;
expect(
capture.value.frames.map((frame) => frame.data).join(""),
).toContain("reply:ok");
expect(
capture.value.frames.map((frame) => frame.data).join(""),
).toContain("sensitive:<redacted>");
expect(
capture.value.files_after.some((file) =>
file.path.endsWith("result.txt"),
),
).toBe(true);
expect(
capture.value.filesystem_effects.some(
(effect) =>
effect.path.endsWith(":result.txt") && effect.status === "created",
),
).toBe(true);
expect(
capture.value.protocol_events.some(
(event) => event.protocol === "http" && event.path === "/probe",
),
).toBe(true);
expect(JSON.stringify(capture.value)).not.toContain("do-not-record");
expect(await readFile(join(root, "result.txt"), "utf8")).toBe("created");
expect(JSON.stringify(capture.value.files_after)).not.toContain(root);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("drives a process from terminal observations and retains the reactive transition journal", async () => {
const root = await createTestTempDirectory("rea-reactive-harness-test-");
const script = join(root, "reactive.mjs");
await writeFile(
script,
[
'import { createInterface } from "node:readline";',
"const input = createInterface({ input: process.stdin, terminal: false });",
'process.stdout.write("Ready\\n");',
'input.once("line", () => { process.stdout.write("Done\\n"); input.close(); });',
].join("\n"),
);
const scenario = parseProcessScenario({
approved: true,
executable: process.execPath,
arguments: [script],
working_directory: root,
reactive: {
version: 1,
initial_state: "waiting",
deadline_ms: 5_000,
states: [
{
id: "waiting",
max_visits: 1,
deadline_ms: 2_000,
on: [
{
id: "answer",
priority: 0,
max_uses: 1,
when: {
kind: "terminal_text",
view: "decoded",
encoding: "utf8",
literal: "Ready",
case_sensitive: true,
control_sequences: "include",
occurrence: 1,
since: { kind: "scenario_start" },
consume: true,
},
actions: [
{ type: "send_input", data: "accepted\n", sensitive: true },
{ type: "close_stdin" },
],
target: { kind: "goto", state: "finishing" },
},
],
},
{
id: "finishing",
max_visits: 1,
deadline_ms: 2_000,
on: [
{
id: "complete",
priority: 0,
max_uses: 1,
when: {
kind: "terminal_text",
view: "decoded",
encoding: "utf8",
literal: "Done",
case_sensitive: true,
control_sequences: "include",
occurrence: 1,
since: { kind: "state_entry" },
consume: true,
},
actions: [],
target: { kind: "finish", outcome: "passed" },
},
],
},
],
},
});
try {
const capability = await probeProcessCaptureCapability();
if (!capability.available) return;
const result = await captureProcessScenario(scenario, {
enabled: true,
executableRoots: [dirname(process.execPath)],
workingRoots: [root],
allowedEnvironment: [],
allowExternalNetwork: true,
});
if (!result.ok) throw result.error;
expect(result.ok).toBe(true);
expect(result.value.reactive_run).toMatchObject({
status: "finished",
outcome: "passed",
active_state: "finishing",
});
expect(
result.value.reactive_run?.transitions.map(
({ transition_id }) => transition_id,
),
).toEqual(["answer", "complete"]);
expect(result.value.interaction_events).toContainEqual(
expect.objectContaining({
type: "input",
data: "<redacted-input:9-bytes>",
outcome: "dispatched",
}),
);
expect(result.value.interaction_events).toContainEqual(
expect.objectContaining({
type: "stdin_close",
data: "",
outcome: "dispatched",
}),
);
expect(result.value.frames.map(({ data }) => data).join("")).toContain(
"Done",
);
expect(result.value.manifest.comparison_contract).toHaveProperty(
"reactive",
);
expect(
JSON.stringify(result.value.manifest.comparison_contract),
).not.toContain("accepted");
expect(JSON.stringify(result.value.manifest.scenario)).not.toContain(
"accepted",
);
const reactiveRun = result.value.reactive_run;
if (reactiveRun === null) throw new Error("reactive result missing");
const lastReactiveOrder = result.value.event_journal
?.filter(({ collection }) =>
[
"frames",
"interaction_events",
"filesystem_checkpoints",
"shim_events",
].includes(collection),
)
.at(-1)?.capture_order;
if (lastReactiveOrder === undefined)
throw new Error("reactive observation order missing");
expect(
validateProcessCapture({
...result.value,
reactive_run: {
...reactiveRun,
controls: [
...reactiveRun.controls,
{
sequence: reactiveRun.controls.length,
kind: "target_lost",
after_capture_order: lastReactiveOrder,
},
],
},
}),
).toContainEqual(
expect.objectContaining({
path: `reactive_run.controls[${String(reactiveRun.controls.length)}]`,
message: "reactive control cannot follow a finished run",
}),
);
expect(
validateProcessCapture({
...result.value,
reactive_run: {
...reactiveRun,
transitions: reactiveRun.transitions.map((transition, index) =>
index === 0
? { ...transition, transition_id: "fabricated" }
: transition,
),
},
}),
).toContainEqual(
expect.objectContaining({
path: "reactive_run.transitions[0]",
message:
"reactive transition is not declared by its recorded source state",
}),
);
expect(
validateProcessCapture({
...result.value,
reactive_run: {
status: "finished",
outcome: "passed",
active_state: "waiting",
transitions: [],
controls: [],
},
}),
).toContainEqual(
expect.objectContaining({
path: "reactive_run.outcome",
message: "reactive outcome differs from deterministic journal replay",
}),
);
const suffix = reactiveRun.transitions
.slice(1)
.map((transition, index) => ({
...transition,
sequence: index,
}));
expect(
validateProcessCapture({
...result.value,
reactive_run: { ...reactiveRun, transitions: suffix },
}),
).toContainEqual(
expect.objectContaining({
path: "reactive_run.transitions[0].state_before",
message:
"reactive transition journal must start at the declared initial state",
}),
);
expect(
validateProcessCapture({
...result.value,
reactive_run: {
...reactiveRun,
transitions: reactiveRun.transitions.map((transition, index) =>
index === 0
? {
...transition,
trigger_event_ids: [],
action_event_ids: [],
}
: transition,
),
},
}),
).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: "reactive_run.transitions",
message: expect.stringContaining(
"reactive transition journal is not reproduced",
),
}),
expect.objectContaining({
path: "reactive_run.transitions[0].action_event_ids",
message: expect.stringContaining(
"recorded action observations do not match proposed effects",
),
}),
]),
);
expect(
validateProcessCapture({
...result.value,
reactive_run: {
...reactiveRun,
status: "running",
outcome: null,
},
}),
).toContainEqual(
expect.objectContaining({
message: "completed capture requires a finished reactive outcome",
}),
);
expect(
validateProcessCapture({
...result.value,
reactive_run: {
...reactiveRun,
transitions: [
reactiveRun.transitions[0]!,
{ ...reactiveRun.transitions[0]!, sequence: 1 },
],
},
}),
).toEqual(
expect.arrayContaining([
expect.objectContaining({
message: "reactive transition states must be contiguous",
}),
expect.objectContaining({
message:
"reactive transition differs from deterministic journal replay",
}),
]),
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("records target loss before post-exit settlement can win the deadline race", async () => {
const root = await createTestTempDirectory(
"rea-reactive-target-loss-test-",
);
const script = join(root, "exit.mjs");
await writeFile(script, 'process.stdout.write("exiting\\n");\n');
const scenario = parseProcessScenario({
approved: true,
executable: process.execPath,
arguments: [script],
working_directory: root,
settle_ms: 500,
reactive: {
version: 1,
initial_state: "waiting",
deadline_ms: 5_000,
states: [
{
id: "waiting",
max_visits: 1,
// Leave process startup scheduling headroom; this test targets
// post-exit settlement ordering, not a 300ms startup deadline.
deadline_ms: 2_000,
on: [
{
id: "unreachable",
priority: 0,
max_uses: 1,
when: {
kind: "terminal_text",
view: "decoded",
encoding: "utf8",
literal: "never-produced",
case_sensitive: true,
control_sequences: "include",
occurrence: 1,
since: { kind: "scenario_start" },
consume: true,
},
actions: [],
target: { kind: "finish", outcome: "passed" },
},
],
},
],
},
});
try {
const capability = await probeProcessCaptureCapability();
if (!capability.available) return;
const result = await captureProcessScenario(scenario, {
enabled: true,
executableRoots: [dirname(process.execPath)],
workingRoots: [root],
allowedEnvironment: [],
allowExternalNetwork: true,
});
expect(result.ok).toBe(true);
if (!result.ok) throw result.error;
expect(result.value.reactive_run).toMatchObject({
status: "finished",
outcome: "target_lost",
active_state: "waiting",
transitions: [],
controls: [
expect.objectContaining({
kind: "target_lost",
}),
],
});
const frame = result.value.frames.at(-1);
const journal = result.value.event_journal;
const reactiveRun = result.value.reactive_run;
if (frame === undefined || journal === undefined || reactiveRun === null)
throw new Error("ordered reactive evidence missing");
const laterCaptureOrder = journal.length;
const withLaterMatch = {
...result.value,
frames: [
...result.value.frames,
{
...frame,
sequence: result.value.frames.length,
at_ms: frame.at_ms + 1,
data: "never-produced",
},
],
event_journal: [
...journal,
{
capture_order: laterCaptureOrder,
collection: "frames" as const,
index: result.value.frames.length,
},
],
};
expect(
validateProcessCapture(withLaterMatch).filter(({ path }) =>
path.startsWith("reactive_run"),
),
).toEqual([]);
expect(
validateProcessCapture({
...withLaterMatch,
reactive_run: {
...reactiveRun,
controls: reactiveRun.controls.map((control) => ({
...control,
after_capture_order: laterCaptureOrder,
})),
},
}),
).toContainEqual(
expect.objectContaining({
path: "reactive_run.outcome",
message: "reactive outcome differs from deterministic journal replay",
}),
);
} finally {
await rm(root, { recursive: true, force: true });
}
}, 10_000);
it("renders terminal state, records shim invocations, and captures literal checkpoints", async () => {
const root = await createTestTempDirectory("rea-v3-test-");
const script = join(root, "scenario.mjs");
await writeFile(
script,
[
'import { spawnSync } from "node:child_process";',
'const result = spawnSync("codex", ["--version"], { encoding: "utf8" });',
'const node = spawnSync("node", ["--version"], { encoding: "utf8" });',
"process.stdout.write(`probe:${result.stdout}`);",
"process.stdout.write(`runtime:${node.stdout}`);",
].join("\n"),
);
try {
const capability = await probeProcessCaptureCapability();
if (!capability.available) return;
const result = await captureProcessScenario(
parseProcessScenario({
approved: true,
executable: process.execPath,
arguments: [script],
working_directory: root,
checkpoints: [
{
name: "probe_seen",
trigger: { type: "terminal_literal", value: "probe:codex 1.2.3" },
},
],
command_shims: [
{
name: "codex",
routes: [
{
arguments: ["--version"],
outputs: [
{ at_ms: 0, stream: "stdout", data: "codex 1.2.3\n" },
],
termination: { type: "exit", code: 0 },
},
],
},
{
name: "node",
routes: [
{
arguments: ["--version"],
outputs: [
{ at_ms: 0, stream: "stdout", data: "node 9.8.7\n" },
],
termination: { type: "exit", code: 0 },
},
],
},
],
reactive: {
version: 1,
initial_state: "waiting_for_shim",
deadline_ms: 5_000,
states: [
{
id: "waiting_for_shim",
max_visits: 1,
deadline_ms: 2_000,
on: [
{
id: "record_shim",
priority: 0,
max_uses: 1,
when: {
kind: "event",
source: "shim",
exact: {
command: "codex",
route_index: 0,
arguments: ["--version"],
working_directory: "<working-directory>",
outcome: "matched",
},
ignore_fields: ["sequence", "at_ms"],
since: { kind: "scenario_start" },
consume: true,
cardinality: { min: 1, max: 1 },
},
actions: [
{ type: "checkpoint", name: "reactive_shim_seen" },
],
target: {
kind: "goto",
state: "waiting_for_checkpoint",
},
},
],
},
{
id: "waiting_for_checkpoint",
max_visits: 1,
deadline_ms: 2_000,
on: [
{
id: "finish_checkpoint",
priority: 0,
max_uses: 1,
when: {
kind: "event",
source: "filesystem",
exact: {
name: "reactive_shim_seen",
files: [],
effects: [],
truncated: false,
},
ignore_fields: ["at_ms"],
since: { kind: "scenario_start" },
consume: true,
cardinality: { min: 1, max: 1 },
},
actions: [],
target: { kind: "finish", outcome: "passed" },
},
],
},
],
},
}),
{
enabled: true,
executableRoots: [dirname(process.execPath)],
workingRoots: [root],
allowedEnvironment: [],
allowExternalNetwork: true,
},
);
expect(result.ok).toBe(true);
if (!result.ok) throw result.error;
expect(result.value.schema_version).toBe(4);
expect(result.value.rendered_frames.at(-1)?.lines.join("\n")).toContain(
"probe:codex 1.2.3",
);
expect(result.value.shim_events).toEqual([
expect.objectContaining({
command: "codex",
arguments: ["--version"],
outcome: "matched",
}),
expect.objectContaining({
command: "node",
arguments: ["--version"],
outcome: "matched",
}),
]);
expect(
result.value.filesystem_checkpoints.map(({ name }) => name),
).toEqual(
expect.arrayContaining([
"before",
"reactive_shim_seen",
"probe_seen",
"after_settlement",
]),
);
expect(result.value.reactive_run).toMatchObject({
status: "finished",
outcome: "passed",
transitions: [
expect.objectContaining({
transition_id: "record_shim",
trigger_event_ids: ["obs.shim_events.0"],
action_event_ids: [
expect.stringMatching(/^obs\.filesystem_checkpoints\./u),
],
}),
expect.objectContaining({
transition_id: "finish_checkpoint",
trigger_event_ids: [
expect.stringMatching(/^obs\.filesystem_checkpoints\./u),
],
}),
],
});
} finally {
await rm(root, { recursive: true, force: true });
}
}, 20_000);
it("runs the committed multi-source reactive fixture deterministically", async () => {
const root = await createTestTempDirectory("rea-reactive-e2e-");
const script = fileURLToPath(
new URL("./fixtures/processReactiveScenario.mjs", import.meta.url),
);
const event = (
source: "process" | "filesystem" | "http" | "websocket" | "shim",
exact: Record<string, unknown>,
ignoreFields: readonly string[],
) => ({
kind: "event" as const,
source,
exact,
ignore_fields: ignoreFields,
since: { kind: "scenario_start" as const },
consume: true,
cardinality: { min: 1, max: 1 },
});
const run = () =>
captureProcessScenario(
parseProcessScenario({
approved: true,
executable: process.execPath,
arguments: [script, "codex"],
working_directory: root,
replay: {
http: [
{
method: "GET",
path: "/reactive",
status: 200,
body: "ok",
},
],
websocket_messages: ["reactive-server"],
},
command_shims: [
{
name: "codex",
routes: [
{
arguments: ["probe"],
outputs: [],
termination: { type: "exit", code: 0 },
},
],
},
],
reactive: {
version: 1,
initial_state: "ready",
deadline_ms: 8_000,
states: [
{
id: "ready",
max_visits: 1,
deadline_ms: 3_000,
on: [
{
id: "checkpoint_ready",
priority: 0,
max_uses: 1,
when: {
kind: "terminal_text",
view: "decoded",
encoding: "utf8",
literal: "Ready",
case_sensitive: true,
control_sequences: "include",
occurrence: 1,
since: { kind: "scenario_start" },
consume: true,
},
actions: [{ type: "checkpoint", name: "ready_snapshot" }],
target: { kind: "goto", state: "collecting" },
},
],
},
{
id: "collecting",
max_visits: 1,
deadline_ms: 5_000,
on: [
{
id: "finish_all_sources",
priority: 0,
max_uses: 1,
when: {
kind: "all",
triggers: [
{
kind: "any",
triggers: [
{
kind: "terminal_text",
view: "decoded",
encoding: "utf8",
literal: "Collecting",
case_sensitive: true,
control_sequences: "include",
occurrence: 1,
since: { kind: "scenario_start" },
consume: false,
},
{
kind: "terminal_text",
view: "decoded",
encoding: "utf8",
literal: "unreachable-alternative",
case_sensitive: true,
control_sequences: "include",
occurrence: 1,
since: { kind: "scenario_start" },
consume: false,
},
],
},
event(
"process",
{
pid: 3,
parent_pid: 1,
command: "rea-reactive-worker",
process_group_id: 1,
session_id: 1,
},
["at_ms"],
),
event(
"filesystem",
{
name: "ready_snapshot",
files: [],
effects: [],
truncated: false,
},
["at_ms"],
),
event(
"shim",
{
command: "codex",
route_index: 0,
arguments: ["probe"],
working_directory: "<working-directory>",
outcome: "matched",
},
["sequence", "at_ms"],
),
{
kind: "sequence",
triggers: [
event(
"http",
{
protocol: "http",
direction: "request",
method: "GET",
path: "/reactive",
data: "",
outcome: "matched",
},
["sequence", "at_ms"],
),
event(
"websocket",
{
protocol: "websocket",
direction: "received",
method: null,
path: "/ws",
data: "reactive-client",
outcome: "matched",
},
["sequence", "at_ms"],
),
],
},
],
},
actions: [],
target: { kind: "finish", outcome: "passed" },
},
],
},
],
},
}),
{
enabled: true,
executableRoots: [dirname(process.execPath)],
workingRoots: [root],
allowedEnvironment: [],
allowExternalNetwork: true,
},
);
try {
const capability = await probeProcessCaptureCapability();
if (!capability.available) return;
const first = await run();
const second = await run();
if (!first.ok) throw first.error;
if (!second.ok) throw second.error;
expect(first.ok).toBe(true);
expect(second.ok).toBe(true);
const trace = (capture: ProcessCapture) =>
capture.reactive_run?.transitions.map((transition) => ({
transition_id: transition.transition_id,
from: transition.state_before,
to: transition.state_after,
outcome: transition.outcome,
trigger_collections: transition.trigger_event_ids.map(
(id) => id.split(".")[1],
),
}));
expect(
first.value.reactive_run?.outcome,
JSON.stringify({
run: first.value.reactive_run,
processes: first.value.process_samples,
checkpoints: first.value.filesystem_checkpoints,
shims: first.value.shim_events,
protocols: first.value.protocol_events,
}),
).toBe("passed");
expect(trace(first.value)).toEqual(trace(second.value));
expect(trace(first.value)).toEqual([
expect.objectContaining({ transition_id: "checkpoint_ready" }),
expect.objectContaining({
transition_id: "finish_all_sources",
trigger_collections: expect.arrayContaining([
"frames",
"process_samples",
"filesystem_checkpoints",
"shim_events",
"protocol_events",
]),
}),
]);
for (const outcome of ["action_rejected", "target_lost"] as const) {
const failedActionCapture = {
...first.value,
reactive_run: {
status: "finished" as const,
outcome,
active_state: "ready",
transitions: [],
controls: [],
},
};
expect(
validateProcessCapture(failedActionCapture).filter(({ path }) =>
path.startsWith("reactive_run"),
),
).toEqual([]);
}
expect(first.value.truncated).toBe(false);
} finally {
await rm(root, { recursive: true, force: true });
}
}, 30_000);
it("does not follow or disclose symlink targets outside declared roots", async () => {
const root = await createTestTempDirectory("rea-symlink-test-");
await symlink("/etc/passwd", join(root, "escape"));
try {
const capability = await probeProcessCaptureCapability();
if (!capability.available) return;
const result = await captureProcessScenario(
parseProcessScenario({
approved: true,
executable: "/usr/bin/true",
working_directory: root,
filesystem_roots: [root],
}),
{
enabled: true,
executableRoots: ["/usr/bin"],
workingRoots: [root],
allowedEnvironment: [],
allowExternalNetwork: true,
},
);
expect(result.ok).toBe(true);
if (!result.ok) throw result.error;
const escaped = result.value.files_after.find((file) =>
file.path.endsWith(":escape"),
);
expect(escaped?.symlink_target).toBe("<outside-declared-root>");
expect(result.value.truncated).toBe(true);
expect(JSON.stringify(result.value.files_after)).not.toContain(root);
expect(JSON.stringify(result.value.files_after)).not.toContain(
"/etc/passwd",
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("does not launch when policy denies capture", async () => {
const scenario = parseProcessScenario({
approved: true,
executable: "/bin/sh",
working_directory: "/tmp",
});
const result = await captureProcessScenario(scenario, {
enabled: false,
executableRoots: [],
workingRoots: [],
allowedEnvironment: [],
allowExternalNetwork: true,
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected policy refusal");
expect(result.error).toBeInstanceOf(ProcessCaptureError);
});
it("distinguishes timeout from cancellation and cleans both runs", async () => {
const capability = await probeProcessCaptureCapability();
if (!capability.available) return;
const policy: ProcessExecutionPolicy = {
enabled: true,
executableRoots: [dirname(process.execPath)],
workingRoots: [dirname(processFixture)],
allowedEnvironment: [],
allowExternalNetwork: true,
};
const timedOut = await captureProcessScenario(
parseProcessScenario({
approved: true,
executable: process.execPath,
arguments: [processFixture, "hang"],
working_directory: dirname(processFixture),
timeout_ms: 50,
idle_timeout_ms: 5_000,
}),
policy,
);
expect(timedOut.ok).toBe(true);
if (!timedOut.ok) throw timedOut.error;
expect(timedOut.value.exit.reason).toBe("timeout");
expect(timedOut.value.cleanup).toEqual({
owned_process_group: "verified",
temporary_root: "removed",
});
const controller = new AbortController();
setTimeout(() => controller.abort(), 50);
const cancelled = await captureProcessScenario(
parseProcessScenario({
approved: true,
executable: process.execPath,
arguments: [processFixture, "hang"],
working_directory: dirname(processFixture),
timeout_ms: 5_000,
idle_timeout_ms: 5_000,
}),
policy,
controller.signal,
);
expect(cancelled.ok).toBe(false);
if (cancelled.ok) throw new Error("expected cancellation");
expect(cancelled.error.message).toContain("cancelled");
});
it("captures source-owned interactive, resize, Unicode, and signal behavior", async () => {
const capability = await probeProcessCaptureCapability();
if (!capability.available) return;
const result = await captureProcessScenario(
parseProcessScenario({
approved: true,
executable: process.execPath,
arguments: [processFixture, "interactive"],
working_directory: dirname(processFixture),
events: [
{ type: "input", at_ms: 100, data: "answer" },
{ type: "resize", at_ms: 300, columns: 100, rows: 40 },
{ type: "signal", at_ms: 700, signal: "SIGINT" },
],
normalization: { time_bucket_ms: 60_000 },
timeout_ms: 2_000,
idle_timeout_ms: 2_000,
}),
{
enabled: true,
executableRoots: [dirname(process.execPath)],
workingRoots: [dirname(processFixture)],
allowedEnvironment: [],
allowExternalNetwork: true,
},
);
if (!result.ok) throw result.error;
expect(result.ok).toBe(true);
const output = result.value.frames.map(({ data }) => data).join("");
expect(output).toContain("prompt>");
expect(output).toContain("input:answer unicode:雪");
expect(output).toContain("resize:100x40");
expect(output).toContain("signal:SIGINT");
expect(result.value.exit.code).toBe(0);
});
it("dispatches scheduled events before a silent PTY produces output", async () => {
const capability = await probeProcessCaptureCapability();
if (!capability.available) return;
const result = await captureProcessScenario(
parseProcessScenario({
approved: true,
executable: process.execPath,
arguments: [processFixture, "silent-interactive"],
working_directory: dirname(processFixture),
events: [
{ type: "resize", at_ms: 25, columns: 100, rows: 40 },
{ type: "input", at_ms: 50, data: "answer" },
],
timeout_ms: 2_000,
idle_timeout_ms: 2_000,
}),
{
enabled: true,
executableRoots: [
join(dirname(process.execPath), "missing"),
dirname(process.execPath),
],
workingRoots: [
join(dirname(processFixture), "missing"),
dirname(processFixture),
],
allowedEnvironment: [],
allowExternalNetwork: true,
},
);
expect(result.ok).toBe(true);
if (!result.ok) throw result.error;
expect(result.value.frames.map(({ data }) => data).join("")).toContain(
"input:answer",
);
expect(result.value.interaction_events).toMatchObject([
{ type: "resize", outcome: "dispatched" },
{ type: "input", outcome: "dispatched" },
]);
});
it("samples and cleans a source-owned child and grandchild process tree", async () => {
const capability = await probeProcessCaptureCapability();
if (!capability.available) return;
const result = await captureProcessScenario(
parseProcessScenario({
approved: true,
executable: process.execPath,
arguments: [processFixture, "tree"],
working_directory: dirname(processFixture),
timeout_ms: 2_000,
idle_timeout_ms: 2_000,
}),
{
enabled: true,
executableRoots: [dirname(process.execPath)],
workingRoots: [dirname(processFixture)],
allowedEnvironment: [],
allowExternalNetwork: true,
},
);
expect(result.ok).toBe(true);
if (!result.ok) throw result.error;
const commands = result.value.process_samples.map(({ command }) => command);
expect(commands.some((command) => command.includes("tree-child"))).toBe(
true,
);
expect(commands.some((command) => command.includes("forks.js"))).toBe(
false,
);
expect(
commands.some((command) => command.includes("tree-grandchild")),
).toBe(true);
expect(JSON.stringify(result.value.process_samples)).not.toContain(
dirname(processFixture),
);
const { stdout } = await execFileAsync("ps", ["-axo", "command="]);
expect(stdout).not.toContain(`${processFixture} tree-child`);
expect(stdout).not.toContain(`${processFixture} tree-grandchild`);
}, 20_000);
});