feat(process): add reactive capture scenarios
This commit is contained in:
parent
9df3fc4bbc
commit
69761e95b6
|
|
@ -115,6 +115,37 @@ redacted from recorded text. The command-shim directory is placed first in the
|
|||
captured process `PATH`; any remaining `PATH` must be explicitly supplied or
|
||||
inherited under operator policy.
|
||||
|
||||
## Reactive process scenarios
|
||||
|
||||
Set `reactive` to a Process Reactive Scenario v1 declaration when interaction
|
||||
must follow observed output instead of guessed delays. The graph declares an
|
||||
initial state, absolute scenario and state deadlines, explicitly prioritized
|
||||
transitions, bounded uses and visits, trigger predicates, ordered actions, and
|
||||
either a next state or `passed` outcome.
|
||||
|
||||
The admitted runtime slice matches decoded UTF-8 terminal literals and exact
|
||||
`terminal_raw`, `interaction`, `process`, `filesystem`, `http`, `websocket`, or
|
||||
`shim` journal events. Process events are sampled observations, not an
|
||||
event-complete lifecycle. Filesystem events identify named checkpoints; shim
|
||||
events identify a command and route. Live predicates receive the same normalized
|
||||
and redacted payload later persisted in Evidence. Predicates may compose with
|
||||
bounded `all`, `any`, `sequence`, and `repeat` nodes and may select a frontier at
|
||||
scenario start, state entry, a prior checkpoint, or an exact event ID. Actions
|
||||
can write PTY input, resize it, signal the root process, or capture a named
|
||||
filesystem checkpoint. `terminal_rendered`, `lifecycle`, and `replay_transition`
|
||||
event triggers and non-root signal selectors fail during preflight before the
|
||||
target launches.
|
||||
|
||||
Reactive results are recorded in `reactive_run`, including the terminal outcome,
|
||||
active state, matched event IDs, action evidence IDs, and deterministic
|
||||
transition journal. Terminal controls also commit the last admitted observation
|
||||
order, so later output cannot be moved ahead of a timeout, cancellation, or
|
||||
target loss during validation. If the process exits before the graph finishes,
|
||||
the outcome is `target_lost`; state/scenario deadlines remain distinct
|
||||
`predicate_timeout`/`scenario_deadline` outcomes. Timed `events` remain
|
||||
available for fixed schedules, but they are not silently translated into the
|
||||
reactive graph.
|
||||
|
||||
## Checkpoints and deterministic shims
|
||||
|
||||
Checkpoint triggers may use an elapsed time, a terminal literal and occurrence
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
import type { ProcessScenario } from "../domain/processCapture.js";
|
||||
import type { LoopbackReplay } from "./LoopbackReplay.js";
|
||||
import type { CommandShimReplay } from "./CommandShimReplay.js";
|
||||
|
||||
interface ProcessCaptureEnvironmentOptions {
|
||||
readonly scenario: ProcessScenario;
|
||||
readonly home: string;
|
||||
readonly replay: LoopbackReplay;
|
||||
readonly shimReplay: CommandShimReplay;
|
||||
readonly runId: string;
|
||||
}
|
||||
|
||||
/** Build the explicitly admitted environment for a captured process. */
|
||||
export const makeProcessCaptureEnvironment = (
|
||||
options: ProcessCaptureEnvironmentOptions,
|
||||
): Record<string, string> => {
|
||||
const { scenario, home, replay, shimReplay, runId } = options;
|
||||
const environment: Record<string, string> = {
|
||||
...scenario.environment,
|
||||
HOME: home,
|
||||
TERM: "xterm-256color",
|
||||
REA_PROCESS_RUN_ID: runId,
|
||||
};
|
||||
for (const name of scenario.inherit_environment) {
|
||||
const value = process.env[name];
|
||||
if (value !== undefined) environment[name] = value;
|
||||
}
|
||||
environment.REA_REPLAY_HTTP_URL = replay.httpUrl;
|
||||
environment.REA_REPLAY_WEBSOCKET_URL = replay.websocketUrl;
|
||||
environment.REA_SHIM_LEDGER_URL = shimReplay.url;
|
||||
// PATH is evidence-producing input. Inheriting the host PATH here would let
|
||||
// the authority and reconstruction probe different tools without recording
|
||||
// that difference. Scenarios must opt in through environment or
|
||||
// inherit_environment; deterministic shims always take precedence.
|
||||
environment.PATH = [shimReplay.binPath, environment.PATH ?? ""]
|
||||
.filter((part) => part.length > 0)
|
||||
.join(":");
|
||||
return environment;
|
||||
};
|
||||
|
|
@ -71,6 +71,7 @@ interface CaptureResultOptions {
|
|||
};
|
||||
readonly samples: readonly ProcessSample[];
|
||||
readonly replay: LoopbackReplay;
|
||||
readonly reactiveRun: ProcessCapture["reactive_run"];
|
||||
readonly before: SnapshotResult;
|
||||
readonly after: SnapshotResult;
|
||||
readonly truncated: boolean;
|
||||
|
|
@ -116,6 +117,7 @@ export const buildCaptureResult = (
|
|||
options.scenario,
|
||||
),
|
||||
replay_transitions: options.replay.transitions,
|
||||
reactive_run: options.reactiveRun,
|
||||
event_journal: options.eventJournal,
|
||||
files_before: options.before.files,
|
||||
files_after: options.after.files,
|
||||
|
|
|
|||
|
|
@ -106,6 +106,12 @@ export class ProcessCheckpoints {
|
|||
this.#scheduleCapture(name, this.#signal);
|
||||
}
|
||||
|
||||
/** Read one committed checkpoint for synchronous journal projection. */
|
||||
at(index: number): FilesystemCheckpoint | undefined {
|
||||
const checkpoint = this.#captures[index];
|
||||
return checkpoint === undefined ? undefined : structuredClone(checkpoint);
|
||||
}
|
||||
|
||||
#scheduleCapture(name: string, signal: AbortSignal | undefined): void {
|
||||
if (this.#captured.has(name)) return;
|
||||
this.#captured.add(name);
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import {
|
|||
ProcessCaptureError,
|
||||
processCaptureCancelled,
|
||||
} from "./ProcessCaptureError.js";
|
||||
|
||||
export { ProcessCaptureError } from "./ProcessCaptureError.js";
|
||||
import { startLoopbackReplay, type LoopbackReplay } from "./LoopbackReplay.js";
|
||||
import { startProcessSampler } from "./ProcessSampling.js";
|
||||
|
|
@ -26,7 +25,10 @@ import {
|
|||
startCommandShimReplay,
|
||||
type CommandShimReplay,
|
||||
} from "./CommandShimReplay.js";
|
||||
import { normalizeProcessText } from "./ProcessNormalization.js";
|
||||
import {
|
||||
normalizeProcessShimEvent,
|
||||
normalizeProcessText,
|
||||
} from "./ProcessNormalization.js";
|
||||
import { selectCapturedProcessGroupIds } from "../process/ProcessOwnership.js";
|
||||
import {
|
||||
awaitTerminalExit,
|
||||
|
|
@ -42,45 +44,17 @@ import { assertNotCancelled } from "./ProcessCaptureAuthority.js";
|
|||
import {
|
||||
createProcessCaptureJournal,
|
||||
scheduleScenarioInteractions,
|
||||
type ProcessCaptureJournal,
|
||||
} from "./ProcessCaptureJournal.js";
|
||||
|
||||
import {
|
||||
assertSupportedReactiveScenario,
|
||||
projectProcessReactiveRun,
|
||||
startProcessReactiveHarness,
|
||||
type ProcessReactiveHarness,
|
||||
} from "./ProcessReactiveHarness.js";
|
||||
import { makeProcessCaptureEnvironment } from "./ProcessCaptureEnvironment.js";
|
||||
export { probeProcessCaptureCapability } from "./ProcessCaptureCapability.js";
|
||||
|
||||
interface EnvironmentOptions {
|
||||
readonly scenario: ProcessScenario;
|
||||
readonly home: string;
|
||||
readonly replay: LoopbackReplay;
|
||||
readonly shimReplay: CommandShimReplay;
|
||||
readonly runId: string;
|
||||
}
|
||||
|
||||
const makeEnvironment = (
|
||||
options: EnvironmentOptions,
|
||||
): Record<string, string> => {
|
||||
const { scenario, home, replay, shimReplay, runId } = options;
|
||||
const environment: Record<string, string> = {
|
||||
...scenario.environment,
|
||||
HOME: home,
|
||||
TERM: "xterm-256color",
|
||||
REA_PROCESS_RUN_ID: runId,
|
||||
};
|
||||
for (const name of scenario.inherit_environment) {
|
||||
const value = process.env[name];
|
||||
if (value !== undefined) environment[name] = value;
|
||||
}
|
||||
environment.REA_REPLAY_HTTP_URL = replay.httpUrl;
|
||||
environment.REA_REPLAY_WEBSOCKET_URL = replay.websocketUrl;
|
||||
environment.REA_SHIM_LEDGER_URL = shimReplay.url;
|
||||
// PATH is evidence-producing input. Inheriting the host PATH here would let
|
||||
// the authority and reconstruction probe different tools without recording
|
||||
// that difference. Scenarios must opt in through environment or
|
||||
// inherit_environment; deterministic shims always take precedence.
|
||||
environment.PATH = [shimReplay.binPath, environment.PATH ?? ""]
|
||||
.filter((part) => part.length > 0)
|
||||
.join(":");
|
||||
return environment;
|
||||
};
|
||||
|
||||
interface StartedCaptureRuntime {
|
||||
readonly replay: LoopbackReplay;
|
||||
readonly renderer: TerminalRenderer;
|
||||
|
|
@ -92,6 +66,7 @@ interface StartedCaptureRuntime {
|
|||
readonly lastOutput: () => number;
|
||||
readonly framesTruncated: () => boolean;
|
||||
readonly stopSampler: () => Promise<{ readonly partial: boolean }>;
|
||||
readonly reactive: ProcessReactiveHarness | undefined;
|
||||
}
|
||||
|
||||
const cleanupFailedStartup = async (options: {
|
||||
|
|
@ -136,7 +111,7 @@ const createTerminalRenderer = (
|
|||
recordEvent,
|
||||
});
|
||||
|
||||
const startCaptureRuntime = async (options: {
|
||||
interface StartCaptureRuntimeOptions {
|
||||
readonly scenario: ProcessScenario;
|
||||
readonly home: string;
|
||||
readonly temporaryRoot: string;
|
||||
|
|
@ -148,14 +123,29 @@ const startCaptureRuntime = async (options: {
|
|||
readonly timers: Set<NodeJS.Timeout>;
|
||||
readonly dispatchedEventIndexes: Set<number>;
|
||||
readonly recordEvent: RecordProcessCaptureEvent;
|
||||
readonly journal: ProcessCaptureJournal;
|
||||
readonly signal?: AbortSignal;
|
||||
}): Promise<StartedCaptureRuntime> => {
|
||||
}
|
||||
|
||||
const reactiveCapture = (
|
||||
options: StartCaptureRuntimeOptions,
|
||||
protocolEvents: () => LoopbackReplay["events"],
|
||||
): Parameters<typeof startProcessReactiveHarness>[0]["capture"] => ({
|
||||
...options,
|
||||
processSamples: options.samples,
|
||||
protocolEvents,
|
||||
});
|
||||
|
||||
const startCaptureRuntime = async (
|
||||
options: StartCaptureRuntimeOptions,
|
||||
): Promise<StartedCaptureRuntime> => {
|
||||
const { scenario } = options;
|
||||
let replay: LoopbackReplay | undefined;
|
||||
let renderer: TerminalRenderer | undefined;
|
||||
let checkpoints: ProcessCheckpoints | undefined;
|
||||
let shimReplay: CommandShimReplay | undefined;
|
||||
let terminal: IPty | undefined;
|
||||
let reactive: ProcessReactiveHarness | undefined;
|
||||
try {
|
||||
const { spawn } = await import("@lydell/node-pty");
|
||||
replay = await startLoopbackReplay(scenario, options.recordEvent);
|
||||
|
|
@ -180,7 +170,7 @@ const startCaptureRuntime = async (options: {
|
|||
);
|
||||
terminal = spawn(scenario.executable, [...scenario.arguments], {
|
||||
cwd: scenario.working_directory,
|
||||
env: makeEnvironment({ ...options, replay, shimReplay }),
|
||||
env: makeProcessCaptureEnvironment({ ...options, replay, shimReplay }),
|
||||
cols: scenario.terminal.columns,
|
||||
rows: scenario.terminal.rows,
|
||||
name: "xterm-256color",
|
||||
|
|
@ -193,6 +183,15 @@ const startCaptureRuntime = async (options: {
|
|||
renderer,
|
||||
checkpoints,
|
||||
});
|
||||
reactive = startProcessReactiveHarness({
|
||||
capture: reactiveCapture(options, () => replay?.events ?? []),
|
||||
scenario,
|
||||
terminal: () => terminal,
|
||||
renderer,
|
||||
checkpoints,
|
||||
shimReplay,
|
||||
started,
|
||||
});
|
||||
scheduleScenarioInteractions({
|
||||
...options,
|
||||
getTerminal: () => terminal,
|
||||
|
|
@ -218,8 +217,11 @@ const startCaptureRuntime = async (options: {
|
|||
lastOutput: () => lastOutput,
|
||||
framesTruncated,
|
||||
stopSampler,
|
||||
reactive,
|
||||
};
|
||||
} catch (cause: unknown) {
|
||||
reactive?.unsubscribe();
|
||||
await reactive?.coordinator.close();
|
||||
return cleanupFailedStartup({
|
||||
cause,
|
||||
timers: options.timers,
|
||||
|
|
@ -239,23 +241,14 @@ const normalizedShimEvents = (
|
|||
scenario: ProcessScenario,
|
||||
temporaryRoot: string,
|
||||
): ProcessCapture["shim_events"] =>
|
||||
runtime.shimReplay.events.map((event) => ({
|
||||
...event,
|
||||
arguments: event.arguments.map((argument) =>
|
||||
normalizeProcessText(
|
||||
argument,
|
||||
scenario,
|
||||
temporaryRoot,
|
||||
runtime.terminal.pid,
|
||||
),
|
||||
),
|
||||
working_directory: normalizeProcessText(
|
||||
event.working_directory,
|
||||
runtime.shimReplay.events.map((event) =>
|
||||
normalizeProcessShimEvent(
|
||||
event,
|
||||
scenario,
|
||||
temporaryRoot,
|
||||
runtime.terminal.pid,
|
||||
),
|
||||
}));
|
||||
);
|
||||
|
||||
const finishProcessRun = async (options: {
|
||||
readonly runtime: StartedCaptureRuntime | undefined;
|
||||
|
|
@ -267,6 +260,7 @@ const finishProcessRun = async (options: {
|
|||
readonly capture: ProcessCapture | undefined;
|
||||
readonly executionFailure: unknown;
|
||||
}): Promise<ProcessCapture> => {
|
||||
options.runtime?.reactive?.unsubscribe();
|
||||
await options.stopSampler();
|
||||
const cleanupFailure = await releaseProcessResources({
|
||||
timers: options.timers,
|
||||
|
|
@ -285,10 +279,16 @@ const finishProcessRun = async (options: {
|
|||
options.samples,
|
||||
),
|
||||
});
|
||||
if (cleanupFailure !== undefined)
|
||||
await options.runtime?.reactive?.coordinator.submit({
|
||||
kind: "cleanup_failed",
|
||||
});
|
||||
await options.runtime?.reactive?.coordinator.close();
|
||||
let { capture, executionFailure } = options;
|
||||
if (capture !== undefined) {
|
||||
capture = {
|
||||
...capture,
|
||||
reactive_run: projectProcessReactiveRun(options.runtime?.reactive),
|
||||
settlement: {
|
||||
...capture.settlement,
|
||||
cleanup_outcome:
|
||||
|
|
@ -326,7 +326,17 @@ const completeCapture = async (options: {
|
|||
const { runtime, scenario } = options;
|
||||
runtime.checkpoints.trigger("root_exit");
|
||||
const { reason } = options.exit;
|
||||
if (reason === "cancelled") throw processCaptureCancelled();
|
||||
if (reason === "cancelled") {
|
||||
if (runtime.reactive !== undefined) {
|
||||
await runtime.reactive.coordinator.submit({ kind: "cancelled" });
|
||||
runtime.reactive.unsubscribe();
|
||||
}
|
||||
throw processCaptureCancelled();
|
||||
}
|
||||
if (runtime.reactive !== undefined) {
|
||||
await runtime.reactive.coordinator.submit({ kind: "target_lost" });
|
||||
runtime.reactive.unsubscribe();
|
||||
}
|
||||
const settlement = await observeSettlement(
|
||||
options.runId,
|
||||
selectCapturedProcessGroupIds(runtime.terminal.pid, options.samples),
|
||||
|
|
@ -359,6 +369,7 @@ const completeCapture = async (options: {
|
|||
exit: { ...options.exit, reason },
|
||||
samples: options.samples,
|
||||
replay: runtime.replay,
|
||||
reactiveRun: projectProcessReactiveRun(runtime.reactive),
|
||||
before: options.before,
|
||||
after,
|
||||
truncated,
|
||||
|
|
@ -381,6 +392,7 @@ const runProcessScenario = async (
|
|||
policy: ProcessExecutionPolicy,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ProcessCapture> => {
|
||||
assertSupportedReactiveScenario(scenario);
|
||||
const { temporaryRoot, runId, home, before } = await prepareProcessCapture(
|
||||
scenario,
|
||||
policy,
|
||||
|
|
@ -388,7 +400,8 @@ const runProcessScenario = async (
|
|||
);
|
||||
const frames: TerminalFrame[] = [];
|
||||
const samples: ProcessSample[] = [];
|
||||
const { entries: eventJournal, recordEvent } = createProcessCaptureJournal();
|
||||
const journal = createProcessCaptureJournal();
|
||||
const { entries: eventJournal, recordEvent } = journal;
|
||||
let runtime: StartedCaptureRuntime | undefined;
|
||||
const timers = new Set<NodeJS.Timeout>();
|
||||
let capture: ProcessCapture | undefined;
|
||||
|
|
@ -410,6 +423,7 @@ const runProcessScenario = async (
|
|||
timers,
|
||||
dispatchedEventIndexes,
|
||||
recordEvent,
|
||||
journal,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
stopSampler = runtime.stopSampler;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type {
|
|||
ProcessCapture,
|
||||
ProcessSample,
|
||||
ProcessScenario,
|
||||
ShimEvent,
|
||||
} from "../domain/processCapture.js";
|
||||
|
||||
/** Bucket one elapsed process-capture timestamp under scenario normalization. */
|
||||
|
|
@ -91,6 +92,25 @@ export const normalizeProcessSamples = (
|
|||
}));
|
||||
};
|
||||
|
||||
/** Normalize one shim observation identically for live matching and capture. */
|
||||
export const normalizeProcessShimEvent = (
|
||||
event: ShimEvent,
|
||||
scenario: ProcessScenario,
|
||||
temporaryRoot: string,
|
||||
rootPid: number,
|
||||
): ShimEvent => ({
|
||||
...event,
|
||||
arguments: event.arguments.map((argument) =>
|
||||
normalizeProcessText(argument, scenario, temporaryRoot, rootPid),
|
||||
),
|
||||
working_directory: normalizeProcessText(
|
||||
event.working_directory,
|
||||
scenario,
|
||||
temporaryRoot,
|
||||
rootPid,
|
||||
),
|
||||
});
|
||||
|
||||
const normalizePidTokens = (
|
||||
value: string,
|
||||
identifiers: readonly number[],
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ export class ProcessReactiveCoordinator {
|
|||
readonly #now: () => number;
|
||||
readonly #scenarioDeadlineAt: number;
|
||||
readonly #onDecision:
|
||||
| ((decision: ProcessReactiveDecision) => void)
|
||||
| ((decision: ProcessReactiveDecision, input: ProcessReactiveInput) => void)
|
||||
| undefined;
|
||||
#snapshot: ProcessReactiveSnapshot;
|
||||
readonly #pendingInputs: ProcessReactiveInput[] = [];
|
||||
|
|
@ -70,7 +70,10 @@ export class ProcessReactiveCoordinator {
|
|||
readonly executor: ProcessReactiveExecutor;
|
||||
readonly timerHost?: ProcessReactiveTimerHost;
|
||||
readonly now?: () => number;
|
||||
readonly onDecision?: (decision: ProcessReactiveDecision) => void;
|
||||
readonly onDecision?: (
|
||||
decision: ProcessReactiveDecision,
|
||||
input: ProcessReactiveInput,
|
||||
) => void;
|
||||
}) {
|
||||
this.#scenario = options.scenario;
|
||||
this.#executor = options.executor;
|
||||
|
|
@ -206,7 +209,7 @@ export class ProcessReactiveCoordinator {
|
|||
);
|
||||
}
|
||||
this.#snapshot = decision.snapshot;
|
||||
this.#onDecision?.(decision);
|
||||
this.#onDecision?.(decision, input);
|
||||
if (this.#snapshot.status === "finished") this.#clearTimers();
|
||||
else if (
|
||||
this.#accepting &&
|
||||
|
|
@ -216,18 +219,35 @@ export class ProcessReactiveCoordinator {
|
|||
)
|
||||
this.#scheduleStateDeadline();
|
||||
for (const result of effectResults)
|
||||
if (result.status === "succeeded")
|
||||
if (
|
||||
result.status === "succeeded" &&
|
||||
!this.#hasObservation(result.observation.event_id)
|
||||
)
|
||||
this.#enqueueOwned({
|
||||
kind: "observation",
|
||||
observation: result.observation,
|
||||
});
|
||||
}
|
||||
|
||||
#hasObservation(eventId: string): boolean {
|
||||
return (
|
||||
this.#snapshot.observations.some(
|
||||
(observation) => observation.event_id === eventId,
|
||||
) ||
|
||||
this.#pendingInputs.some(
|
||||
(input) =>
|
||||
input.kind === "observation" &&
|
||||
input.observation.event_id === eventId,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#interruptsEffects(input: ProcessReactiveInput): boolean {
|
||||
if (
|
||||
input.kind === "cancelled" ||
|
||||
input.kind === "cleanup_failed" ||
|
||||
input.kind === "scenario_deadline"
|
||||
input.kind === "scenario_deadline" ||
|
||||
input.kind === "target_lost"
|
||||
)
|
||||
return true;
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -184,9 +184,15 @@ const triggerSources = (
|
|||
): readonly string[] => {
|
||||
if (trigger.kind === "terminal_text") return [];
|
||||
if (trigger.kind === "event")
|
||||
return ["terminal_raw", "interaction", "filesystem"].includes(
|
||||
trigger.source,
|
||||
)
|
||||
return [
|
||||
"terminal_raw",
|
||||
"interaction",
|
||||
"process",
|
||||
"filesystem",
|
||||
"http",
|
||||
"websocket",
|
||||
"shim",
|
||||
].includes(trigger.source)
|
||||
? []
|
||||
: [trigger.source];
|
||||
return trigger.kind === "repeat"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,159 @@
|
|||
import type { IPty } from "@lydell/node-pty";
|
||||
|
||||
import type {
|
||||
InteractionEvent,
|
||||
ProcessCapture,
|
||||
ProcessReactiveControlRecord,
|
||||
ProcessSample,
|
||||
ProcessScenario,
|
||||
ProtocolEvent,
|
||||
TerminalFrame,
|
||||
} from "../domain/processCapture.js";
|
||||
import type { ProcessReactiveInput } from "../domain/processReactiveRuntime.js";
|
||||
import { ProcessCaptureError } from "./ProcessCaptureError.js";
|
||||
import type { ProcessCaptureJournal } from "./ProcessCaptureJournal.js";
|
||||
import type { ProcessCheckpoints } from "./ProcessCheckpoints.js";
|
||||
import type { CommandShimReplay } from "./CommandShimReplay.js";
|
||||
import { ProcessReactiveCoordinator } from "./ProcessReactiveCoordinator.js";
|
||||
import {
|
||||
executeProcessReactiveEffects,
|
||||
unsupportedProcessReactiveFeatures,
|
||||
} from "./ProcessReactiveEffects.js";
|
||||
import { subscribeProcessReactiveObservations } from "./ProcessReactiveObservations.js";
|
||||
import {
|
||||
normalizeProcessSamples,
|
||||
normalizeProcessShimEvent,
|
||||
redactProtocolEvents,
|
||||
} from "./ProcessNormalization.js";
|
||||
import type { TerminalRenderer } from "./TerminalRenderer.js";
|
||||
|
||||
/** Coordinator and subscription owned by one live process capture. */
|
||||
export interface ProcessReactiveHarness {
|
||||
readonly coordinator: ProcessReactiveCoordinator;
|
||||
readonly controls: readonly ProcessReactiveControlRecord[];
|
||||
readonly unsubscribe: () => void;
|
||||
}
|
||||
|
||||
const controlKind = (
|
||||
input: ProcessReactiveInput,
|
||||
): ProcessReactiveControlRecord["kind"] | undefined =>
|
||||
input.kind === "observation" ? undefined : input.kind;
|
||||
|
||||
/** Reject reactive declarations the current process adapter cannot execute. */
|
||||
export const assertSupportedReactiveScenario = (
|
||||
scenario: ProcessScenario,
|
||||
): void => {
|
||||
if (scenario.reactive === null) return;
|
||||
const unsupported = unsupportedProcessReactiveFeatures(scenario.reactive);
|
||||
if (unsupported.length === 0) return;
|
||||
throw new ProcessCaptureError(
|
||||
`reactive scenario requests unsupported features: ${unsupported.join(", ")}`,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Attach the serialized reactive runtime once the target PID is available.
|
||||
*
|
||||
* Subscription replays journal entries admitted during launcher startup before
|
||||
* observing new records, so PID-dependent normalization cannot drop them.
|
||||
*/
|
||||
export const startProcessReactiveHarness = (options: {
|
||||
readonly scenario: ProcessScenario;
|
||||
readonly terminal: () => IPty | undefined;
|
||||
readonly renderer: TerminalRenderer;
|
||||
readonly checkpoints: ProcessCheckpoints;
|
||||
readonly shimReplay: CommandShimReplay;
|
||||
readonly started: number;
|
||||
readonly capture: {
|
||||
readonly interactions: InteractionEvent[];
|
||||
readonly journal: ProcessCaptureJournal;
|
||||
readonly frames: readonly TerminalFrame[];
|
||||
readonly processSamples: readonly ProcessSample[];
|
||||
readonly protocolEvents: () => readonly ProtocolEvent[];
|
||||
readonly temporaryRoot: string;
|
||||
};
|
||||
}): ProcessReactiveHarness | undefined => {
|
||||
if (options.scenario.reactive === null) return undefined;
|
||||
const controls: ProcessReactiveControlRecord[] = [];
|
||||
const coordinator = new ProcessReactiveCoordinator({
|
||||
scenario: options.scenario.reactive,
|
||||
executor: {
|
||||
execute: (actions, signal) =>
|
||||
executeProcessReactiveEffects(
|
||||
{
|
||||
terminal: options.terminal,
|
||||
renderer: options.renderer,
|
||||
checkpoints: options.checkpoints,
|
||||
interactions: options.capture.interactions,
|
||||
journal: options.capture.journal,
|
||||
startedAtMs: options.started,
|
||||
now: Date.now,
|
||||
},
|
||||
actions,
|
||||
signal,
|
||||
),
|
||||
},
|
||||
onDecision: (decision, input) => {
|
||||
const kind = controlKind(input);
|
||||
if (kind === undefined || decision.kind !== "finished") return;
|
||||
controls.push({
|
||||
sequence: controls.length,
|
||||
kind,
|
||||
after_capture_order:
|
||||
decision.snapshot.observations.at(-1)?.capture_order ?? -1,
|
||||
});
|
||||
},
|
||||
});
|
||||
return {
|
||||
coordinator,
|
||||
controls,
|
||||
unsubscribe: subscribeProcessReactiveObservations({
|
||||
journal: options.capture.journal,
|
||||
frames: options.capture.frames,
|
||||
interactions: options.capture.interactions,
|
||||
checkpointAt: (index) => options.checkpoints.at(index),
|
||||
processSampleAt: (index) => {
|
||||
const rootPid = options.terminal()?.pid;
|
||||
return rootPid === undefined
|
||||
? undefined
|
||||
: normalizeProcessSamples(
|
||||
options.capture.processSamples,
|
||||
options.scenario,
|
||||
rootPid,
|
||||
)[index];
|
||||
},
|
||||
protocolEventAt: (index) =>
|
||||
redactProtocolEvents(
|
||||
options.capture.protocolEvents().slice(index, index + 1),
|
||||
options.scenario,
|
||||
)[0],
|
||||
shimEventAt: (index) => {
|
||||
const event = options.shimReplay.events[index];
|
||||
const rootPid = options.terminal()?.pid;
|
||||
return event === undefined || rootPid === undefined
|
||||
? undefined
|
||||
: normalizeProcessShimEvent(
|
||||
event,
|
||||
options.scenario,
|
||||
options.capture.temporaryRoot,
|
||||
rootPid,
|
||||
);
|
||||
},
|
||||
sink: coordinator,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
/** Project internal reducer state into the stable Process Capture shape. */
|
||||
export const projectProcessReactiveRun = (
|
||||
harness: ProcessReactiveHarness | undefined,
|
||||
): ProcessCapture["reactive_run"] =>
|
||||
harness === undefined
|
||||
? null
|
||||
: {
|
||||
status: harness.coordinator.snapshot.status,
|
||||
outcome: harness.coordinator.snapshot.outcome,
|
||||
active_state: harness.coordinator.snapshot.active_state,
|
||||
transitions: harness.coordinator.snapshot.transitions,
|
||||
controls: harness.controls,
|
||||
};
|
||||
|
|
@ -1,8 +1,17 @@
|
|||
import type {
|
||||
InteractionEvent,
|
||||
FilesystemCheckpoint,
|
||||
ProcessCaptureEventJournalEntry,
|
||||
ProcessSample,
|
||||
ProtocolEvent,
|
||||
ShimEvent,
|
||||
TerminalFrame,
|
||||
} from "../domain/processCapture.js";
|
||||
import { createProcessObservation } from "../domain/processObservation.js";
|
||||
import {
|
||||
createProcessObservation,
|
||||
processObservationSubject,
|
||||
type ProcessObservation,
|
||||
} from "../domain/processObservation.js";
|
||||
import type { ProcessReactiveInput } from "../domain/processReactiveRuntime.js";
|
||||
import type { ProcessCaptureJournal } from "./ProcessCaptureJournal.js";
|
||||
|
||||
|
|
@ -11,25 +20,145 @@ export interface ProcessReactiveObservationSink {
|
|||
enqueue(input: ProcessReactiveInput): void;
|
||||
}
|
||||
|
||||
/** Subscribe a reactive coordinator to normalized raw PTY journal records. */
|
||||
export const subscribeProcessReactiveTerminalObservations = (options: {
|
||||
interface ProcessReactiveObservationOptions {
|
||||
readonly journal: ProcessCaptureJournal;
|
||||
readonly frames: readonly TerminalFrame[];
|
||||
readonly interactions: readonly InteractionEvent[];
|
||||
readonly checkpointAt: (index: number) => FilesystemCheckpoint | undefined;
|
||||
readonly processSampleAt: (index: number) => ProcessSample | undefined;
|
||||
readonly protocolEventAt: (index: number) => ProtocolEvent | undefined;
|
||||
readonly shimEventAt: (index: number) => ShimEvent | undefined;
|
||||
readonly sink: ProcessReactiveObservationSink;
|
||||
}): (() => void) =>
|
||||
options.journal.subscribe((entry: ProcessCaptureEventJournalEntry) => {
|
||||
if (entry.collection !== "frames") return;
|
||||
const frame = options.frames[entry.index];
|
||||
if (frame === undefined) return;
|
||||
options.sink.enqueue({
|
||||
kind: "observation",
|
||||
observation: createProcessObservation({
|
||||
}
|
||||
|
||||
const terminalObservation = (
|
||||
options: ProcessReactiveObservationOptions,
|
||||
entry: ProcessCaptureEventJournalEntry,
|
||||
): ProcessObservation | undefined => {
|
||||
const value = options.frames[entry.index];
|
||||
return value === undefined
|
||||
? undefined
|
||||
: createProcessObservation({
|
||||
source: "terminal_raw",
|
||||
source_sequence: frame.sequence,
|
||||
captured_at_ms: frame.at_ms,
|
||||
source_sequence: value.sequence,
|
||||
captured_at_ms: value.at_ms,
|
||||
subject_id: null,
|
||||
location: entry,
|
||||
payload: frame,
|
||||
}),
|
||||
});
|
||||
payload: value,
|
||||
});
|
||||
};
|
||||
|
||||
const interactionObservation = (
|
||||
options: ProcessReactiveObservationOptions,
|
||||
entry: ProcessCaptureEventJournalEntry,
|
||||
): ProcessObservation | undefined => {
|
||||
const value = options.interactions[entry.index];
|
||||
return value === undefined
|
||||
? undefined
|
||||
: createProcessObservation({
|
||||
source: "interaction",
|
||||
source_sequence: value.sequence,
|
||||
captured_at_ms: value.dispatched_at_ms,
|
||||
subject_id: null,
|
||||
location: entry,
|
||||
payload: value,
|
||||
});
|
||||
};
|
||||
|
||||
const filesystemObservation = (
|
||||
options: ProcessReactiveObservationOptions,
|
||||
entry: ProcessCaptureEventJournalEntry,
|
||||
): ProcessObservation | undefined => {
|
||||
const value = options.checkpointAt(entry.index);
|
||||
return value === undefined
|
||||
? undefined
|
||||
: createProcessObservation({
|
||||
source: "filesystem",
|
||||
source_sequence: entry.index,
|
||||
captured_at_ms: value.at_ms,
|
||||
subject_id: processObservationSubject("filesystem", value),
|
||||
location: entry,
|
||||
payload: value,
|
||||
});
|
||||
};
|
||||
|
||||
const processObservation = (
|
||||
options: ProcessReactiveObservationOptions,
|
||||
entry: ProcessCaptureEventJournalEntry,
|
||||
): ProcessObservation | undefined => {
|
||||
const value = options.processSampleAt(entry.index);
|
||||
return value === undefined
|
||||
? undefined
|
||||
: createProcessObservation({
|
||||
source: "process",
|
||||
source_sequence: entry.index,
|
||||
captured_at_ms: value.at_ms,
|
||||
subject_id: processObservationSubject("process", value),
|
||||
location: entry,
|
||||
payload: value,
|
||||
});
|
||||
};
|
||||
|
||||
const protocolObservation = (
|
||||
options: ProcessReactiveObservationOptions,
|
||||
entry: ProcessCaptureEventJournalEntry,
|
||||
): ProcessObservation | undefined => {
|
||||
const value = options.protocolEventAt(entry.index);
|
||||
return value === undefined
|
||||
? undefined
|
||||
: createProcessObservation({
|
||||
source: value.protocol,
|
||||
source_sequence: value.sequence,
|
||||
captured_at_ms: value.at_ms,
|
||||
subject_id: processObservationSubject(value.protocol, value),
|
||||
location: entry,
|
||||
payload: value,
|
||||
});
|
||||
};
|
||||
|
||||
const shimObservation = (
|
||||
options: ProcessReactiveObservationOptions,
|
||||
entry: ProcessCaptureEventJournalEntry,
|
||||
): ProcessObservation | undefined => {
|
||||
const normalized = options.shimEventAt(entry.index);
|
||||
if (normalized === undefined) return undefined;
|
||||
return createProcessObservation({
|
||||
source: "shim",
|
||||
source_sequence: normalized.sequence,
|
||||
captured_at_ms: normalized.at_ms,
|
||||
subject_id: processObservationSubject("shim", normalized),
|
||||
location: entry,
|
||||
payload: normalized,
|
||||
});
|
||||
};
|
||||
|
||||
const observationFor = (
|
||||
options: ProcessReactiveObservationOptions,
|
||||
entry: ProcessCaptureEventJournalEntry,
|
||||
): ProcessObservation | undefined => {
|
||||
if (entry.collection === "frames") return terminalObservation(options, entry);
|
||||
if (entry.collection === "interaction_events")
|
||||
return interactionObservation(options, entry);
|
||||
if (entry.collection === "filesystem_checkpoints")
|
||||
return filesystemObservation(options, entry);
|
||||
if (entry.collection === "process_samples")
|
||||
return processObservation(options, entry);
|
||||
if (entry.collection === "protocol_events")
|
||||
return protocolObservation(options, entry);
|
||||
if (entry.collection === "shim_events")
|
||||
return shimObservation(options, entry);
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Subscribe the reactive coordinator to admitted normalized journal records. */
|
||||
export const subscribeProcessReactiveObservations = (
|
||||
options: ProcessReactiveObservationOptions,
|
||||
): (() => void) => {
|
||||
const enqueue = (entry: ProcessCaptureEventJournalEntry): void => {
|
||||
const observation = observationFor(options, entry);
|
||||
if (observation !== undefined)
|
||||
options.sink.enqueue({ kind: "observation", observation });
|
||||
};
|
||||
for (const entry of options.journal.entries) enqueue(entry);
|
||||
return options.journal.subscribe(enqueue);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@ import {
|
|||
LEGACY_PROCESS_CAPTURE_MESSAGE,
|
||||
normalizationSchema,
|
||||
} from "./processScenario.js";
|
||||
import type {
|
||||
ProcessReactiveOutcome,
|
||||
ProcessReactiveTransitionRecord,
|
||||
} from "./processReactiveRuntime.js";
|
||||
import { PROCESS_REACTIVE_LIMITS } from "./processReactiveScenario.js";
|
||||
import type { ReplayTransitionRecord } from "./replayMachineRuntime.js";
|
||||
|
||||
export * from "./processScenario.js";
|
||||
|
|
@ -133,6 +138,18 @@ export interface ProcessCaptureEventJournalEntry {
|
|||
readonly index: number;
|
||||
}
|
||||
|
||||
/** Ordered control input that terminated or superseded a reactive run. */
|
||||
export interface ProcessReactiveControlRecord {
|
||||
readonly sequence: number;
|
||||
readonly kind:
|
||||
| "state_deadline"
|
||||
| "scenario_deadline"
|
||||
| "target_lost"
|
||||
| "cancelled"
|
||||
| "cleanup_failed";
|
||||
readonly after_capture_order: number;
|
||||
}
|
||||
|
||||
/** Shared observation callback used by every process-capture producer. */
|
||||
export type RecordProcessCaptureEvent = (
|
||||
collection: ProcessCaptureEventCollection,
|
||||
|
|
@ -185,6 +202,13 @@ export interface ProcessCapture {
|
|||
readonly shim_events: readonly ShimEvent[];
|
||||
readonly protocol_events: readonly ProtocolEvent[];
|
||||
readonly replay_transitions: readonly ReplayTransitionRecord[];
|
||||
readonly reactive_run: {
|
||||
readonly status: "running" | "finished";
|
||||
readonly outcome: ProcessReactiveOutcome | null;
|
||||
readonly active_state: string;
|
||||
readonly transitions: readonly ProcessReactiveTransitionRecord[];
|
||||
readonly controls: readonly ProcessReactiveControlRecord[];
|
||||
} | null;
|
||||
/**
|
||||
* Global observation order across independently recorded collections.
|
||||
*
|
||||
|
|
@ -363,6 +387,75 @@ export const processCaptureSchema: z.ZodType<ProcessCapture> = z.object({
|
|||
}),
|
||||
)
|
||||
.default([]),
|
||||
reactive_run: z
|
||||
.object({
|
||||
status: z.enum(["running", "finished"]),
|
||||
outcome: z
|
||||
.enum([
|
||||
"passed",
|
||||
"predicate_timeout",
|
||||
"scenario_deadline",
|
||||
"ambiguous_match",
|
||||
"action_rejected",
|
||||
"target_lost",
|
||||
"capture_incomplete",
|
||||
"cancelled",
|
||||
"cleanup_failed",
|
||||
])
|
||||
.nullable(),
|
||||
active_state: z.string().min(1).max(64),
|
||||
controls: z
|
||||
.array(
|
||||
z.object({
|
||||
sequence: z.number().int().nonnegative(),
|
||||
kind: z.enum([
|
||||
"state_deadline",
|
||||
"scenario_deadline",
|
||||
"target_lost",
|
||||
"cancelled",
|
||||
"cleanup_failed",
|
||||
]),
|
||||
after_capture_order: z.number().int().min(-1),
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
transitions: z
|
||||
.array(
|
||||
z.object({
|
||||
sequence: z.number().int().nonnegative(),
|
||||
transition_id: z.string().min(1).max(64),
|
||||
state_before: z.string().min(1).max(64),
|
||||
state_after: z.string().min(1).max(64).nullable(),
|
||||
outcome: z
|
||||
.enum([
|
||||
"passed",
|
||||
"predicate_timeout",
|
||||
"scenario_deadline",
|
||||
"ambiguous_match",
|
||||
"action_rejected",
|
||||
"target_lost",
|
||||
"capture_incomplete",
|
||||
"cancelled",
|
||||
"cleanup_failed",
|
||||
])
|
||||
.nullable(),
|
||||
trigger_event_ids: z
|
||||
.array(z.string().min(1))
|
||||
.max(PROCESS_REACTIVE_LIMITS.predicates),
|
||||
action_event_ids: z
|
||||
.array(z.string().min(1))
|
||||
.max(PROCESS_REACTIVE_LIMITS.actionsPerTransition),
|
||||
action_types: z
|
||||
.array(
|
||||
z.enum(["send_input", "resize", "send_signal", "checkpoint"]),
|
||||
)
|
||||
.max(PROCESS_REACTIVE_LIMITS.actionsPerTransition),
|
||||
}),
|
||||
)
|
||||
.max(PROCESS_REACTIVE_LIMITS.transitions),
|
||||
})
|
||||
.nullable()
|
||||
.default(null),
|
||||
event_journal: z
|
||||
.array(
|
||||
z.object({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,273 @@
|
|||
import canonicalize from "canonicalize";
|
||||
|
||||
import type { ProcessCapture } from "./processCapture.js";
|
||||
import {
|
||||
projectProcessObservation,
|
||||
type ProcessObservation,
|
||||
} from "./processObservation.js";
|
||||
import {
|
||||
commitProcessReactiveProposal,
|
||||
createProcessReactiveSnapshot,
|
||||
reduceProcessReactiveScenario,
|
||||
type ProcessReactiveDecision,
|
||||
type ProcessReactiveInput,
|
||||
type ProcessReactiveSnapshot,
|
||||
} from "./processReactiveRuntime.js";
|
||||
import {
|
||||
processReactiveScenarioSchema,
|
||||
type ProcessReactiveScenario,
|
||||
} from "./processReactiveScenario.js";
|
||||
|
||||
type RequireInvariant = (
|
||||
condition: boolean,
|
||||
path: string,
|
||||
message: string,
|
||||
) => void;
|
||||
type ReactiveRun = NonNullable<ProcessCapture["reactive_run"]>;
|
||||
type ReactiveTransition = ReactiveRun["transitions"][number];
|
||||
|
||||
const admittedCollection = (
|
||||
collection: NonNullable<
|
||||
ProcessCapture["event_journal"]
|
||||
>[number]["collection"],
|
||||
): boolean =>
|
||||
[
|
||||
"frames",
|
||||
"interaction_events",
|
||||
"process_samples",
|
||||
"filesystem_checkpoints",
|
||||
"shim_events",
|
||||
"protocol_events",
|
||||
].includes(collection);
|
||||
|
||||
const reactiveObservations = (
|
||||
capture: ProcessCapture,
|
||||
): readonly ProcessObservation[] =>
|
||||
(capture.event_journal ?? []).flatMap((location) => {
|
||||
if (!admittedCollection(location.collection)) return [];
|
||||
const observation = projectProcessObservation(capture, location);
|
||||
return observation === null ? [] : [observation];
|
||||
});
|
||||
|
||||
const validateTransitionDeclaration = (
|
||||
transition: ReactiveTransition,
|
||||
next: ReactiveTransition | undefined,
|
||||
context: {
|
||||
readonly index: number;
|
||||
readonly scenario: ProcessReactiveScenario;
|
||||
readonly require: RequireInvariant;
|
||||
},
|
||||
): void => {
|
||||
const { index, scenario, require } = context;
|
||||
const path = `reactive_run.transitions[${String(index)}]`;
|
||||
const declared = scenario.states
|
||||
.find(({ id }) => id === transition.state_before)
|
||||
?.on.find(({ id }) => id === transition.transition_id);
|
||||
require(declared !==
|
||||
undefined, path, "reactive transition is not declared by its recorded source state");
|
||||
if (declared !== undefined) {
|
||||
const stateAfter =
|
||||
declared.target.kind === "goto" ? declared.target.state : null;
|
||||
const outcome =
|
||||
declared.target.kind === "finish" ? declared.target.outcome : null;
|
||||
require(transition.state_after === stateAfter &&
|
||||
transition.outcome ===
|
||||
outcome, path, "reactive transition target or outcome differs from the committed graph");
|
||||
require(canonicalize(transition.action_types) ===
|
||||
canonicalize(
|
||||
declared.actions.map(({ type }) => type),
|
||||
), `${path}.action_types`, "reactive transition actions differ from the committed graph");
|
||||
}
|
||||
require(transition.sequence ===
|
||||
index, `${path}.sequence`, "reactive transition sequence must be contiguous from zero");
|
||||
if (next !== undefined)
|
||||
require(transition.state_after ===
|
||||
next.state_before, `reactive_run.transitions[${String(index + 1)}].state_before`, "reactive transition states must be contiguous");
|
||||
};
|
||||
|
||||
const validateTransitionDeclarations = (
|
||||
run: ReactiveRun,
|
||||
scenario: ProcessReactiveScenario,
|
||||
require: RequireInvariant,
|
||||
): void => {
|
||||
require(run.transitions[0]?.state_before === scenario.initial_state ||
|
||||
run.transitions.length ===
|
||||
0, "reactive_run.transitions[0].state_before", "reactive transition journal must start at the declared initial state");
|
||||
for (const [index, transition] of run.transitions.entries())
|
||||
validateTransitionDeclaration(transition, run.transitions[index + 1], {
|
||||
index,
|
||||
scenario,
|
||||
require,
|
||||
});
|
||||
};
|
||||
|
||||
const commitRecordedEffects = (options: {
|
||||
readonly scenario: ProcessReactiveScenario;
|
||||
readonly proposal: Extract<
|
||||
ProcessReactiveDecision,
|
||||
{ readonly kind: "proposal" }
|
||||
>;
|
||||
readonly expected: ReactiveTransition | undefined;
|
||||
readonly observations: ReadonlyMap<string, ProcessObservation>;
|
||||
readonly recordedFailure: "action_rejected" | "target_lost" | undefined;
|
||||
}): ProcessReactiveDecision =>
|
||||
commitProcessReactiveProposal(
|
||||
options.scenario,
|
||||
options.proposal,
|
||||
options.recordedFailure === undefined
|
||||
? options.proposal.effects.map((_, index) => {
|
||||
const eventId = options.expected?.action_event_ids[index];
|
||||
const observation =
|
||||
eventId === undefined
|
||||
? undefined
|
||||
: options.observations.get(eventId);
|
||||
return observation === undefined
|
||||
? { status: "rejected" as const }
|
||||
: { status: "succeeded" as const, observation };
|
||||
})
|
||||
: [
|
||||
options.recordedFailure === "target_lost"
|
||||
? { status: "target_lost" as const }
|
||||
: { status: "rejected" as const },
|
||||
],
|
||||
{ committedSensitiveInputs: true },
|
||||
);
|
||||
|
||||
const replayTransitions = (
|
||||
scenario: ProcessReactiveScenario,
|
||||
run: ReactiveRun,
|
||||
observations: readonly ProcessObservation[],
|
||||
require: RequireInvariant,
|
||||
): ProcessReactiveSnapshot => {
|
||||
const observationsById = new Map(
|
||||
observations.map((observation) => [observation.event_id, observation]),
|
||||
);
|
||||
let snapshot = createProcessReactiveSnapshot(scenario);
|
||||
let transitionIndex = 0;
|
||||
const advance = (input: ProcessReactiveInput): void => {
|
||||
let decision = reduceProcessReactiveScenario(scenario, snapshot, input);
|
||||
if (decision.kind === "proposal") {
|
||||
const proposal = decision;
|
||||
decision = commitRecordedEffects({
|
||||
scenario,
|
||||
proposal: decision,
|
||||
expected: run.transitions[transitionIndex],
|
||||
observations: observationsById,
|
||||
recordedFailure:
|
||||
run.transitions[transitionIndex] === undefined &&
|
||||
decision.effects.length > 0 &&
|
||||
(run.outcome === "action_rejected" || run.outcome === "target_lost")
|
||||
? run.outcome
|
||||
: undefined,
|
||||
});
|
||||
require(decision.kind !== "finished" ||
|
||||
decision.outcome !== "action_rejected" ||
|
||||
run.outcome ===
|
||||
"action_rejected", `reactive_run.transitions[${String(transitionIndex)}].action_event_ids`, `recorded action observations do not match proposed effects: ${canonicalize(
|
||||
{
|
||||
effects: proposal.effects,
|
||||
observations: run.transitions[transitionIndex]?.action_event_ids.map(
|
||||
(eventId) => observationsById.get(eventId),
|
||||
),
|
||||
},
|
||||
)}`);
|
||||
}
|
||||
if (decision.kind === "transition") {
|
||||
const expected = run.transitions[transitionIndex];
|
||||
require(expected !== undefined &&
|
||||
canonicalize(decision.record) ===
|
||||
canonicalize(
|
||||
expected,
|
||||
), `reactive_run.transitions[${String(transitionIndex)}]`, "reactive transition differs from deterministic journal replay");
|
||||
transitionIndex += 1;
|
||||
}
|
||||
snapshot = decision.snapshot;
|
||||
};
|
||||
const advanceControl = (
|
||||
control: ReactiveRun["controls"][number],
|
||||
index: number,
|
||||
): void => {
|
||||
require(snapshot.status !== "finished" ||
|
||||
control.kind ===
|
||||
"cleanup_failed", `reactive_run.controls[${String(index)}]`, "reactive control cannot follow a finished run");
|
||||
advance(
|
||||
control.kind === "state_deadline"
|
||||
? {
|
||||
kind: control.kind,
|
||||
state_id: snapshot.active_state,
|
||||
state_entry_capture_order: snapshot.state_entry_capture_order,
|
||||
}
|
||||
: { kind: control.kind },
|
||||
);
|
||||
};
|
||||
let controlIndex = 0;
|
||||
for (const observation of observations) {
|
||||
while (
|
||||
(run.controls[controlIndex]?.after_capture_order ??
|
||||
Number.MAX_SAFE_INTEGER) < observation.capture_order
|
||||
) {
|
||||
const control = run.controls[controlIndex]!;
|
||||
advanceControl(control, controlIndex);
|
||||
controlIndex += 1;
|
||||
}
|
||||
advance({ kind: "observation", observation });
|
||||
}
|
||||
for (; controlIndex < run.controls.length; controlIndex += 1) {
|
||||
const control = run.controls[controlIndex]!;
|
||||
advanceControl(control, controlIndex);
|
||||
}
|
||||
require(transitionIndex ===
|
||||
run.transitions
|
||||
.length, "reactive_run.transitions", `reactive transition journal is not reproduced by deterministic replay (replayed ${String(transitionIndex)} of ${String(run.transitions.length)}; outcome ${snapshot.outcome})`);
|
||||
return snapshot;
|
||||
};
|
||||
|
||||
const validateControls = (
|
||||
run: ReactiveRun,
|
||||
observations: readonly ProcessObservation[],
|
||||
require: RequireInvariant,
|
||||
): void => {
|
||||
const admittedOrders = new Set([
|
||||
-1,
|
||||
...observations.map(({ capture_order }) => capture_order),
|
||||
]);
|
||||
for (const [index, control] of run.controls.entries()) {
|
||||
require(control.sequence ===
|
||||
index, `reactive_run.controls[${String(index)}].sequence`, "reactive control sequence must be contiguous");
|
||||
require(admittedOrders.has(
|
||||
control.after_capture_order,
|
||||
), `reactive_run.controls[${String(index)}].after_capture_order`, "reactive control cutoff must identify the last admitted observation");
|
||||
require(index === 0 ||
|
||||
control.after_capture_order >=
|
||||
run.controls[index - 1]!
|
||||
.after_capture_order, `reactive_run.controls[${String(index)}].after_capture_order`, "reactive control cutoffs must be ordered");
|
||||
}
|
||||
};
|
||||
|
||||
/** Validate one capture's reactive journal by replaying the production reducer. */
|
||||
export const validateProcessCaptureReactiveRun = (
|
||||
capture: ProcessCapture,
|
||||
require: RequireInvariant,
|
||||
): void => {
|
||||
const declared = capture.manifest.scenario["reactive"];
|
||||
const run = capture.reactive_run;
|
||||
const declaredReactive = declared !== undefined && declared !== null;
|
||||
require(declaredReactive ===
|
||||
(run !==
|
||||
null), "reactive_run", "reactive result presence must match the committed scenario");
|
||||
if (run === null) return;
|
||||
const parsed = processReactiveScenarioSchema.safeParse(declared);
|
||||
require(parsed.success, "manifest.scenario.reactive", "committed reactive scenario does not satisfy its declared schema");
|
||||
if (!parsed.success) return;
|
||||
require(run.status === "finished" &&
|
||||
run.outcome !==
|
||||
null, "reactive_run.outcome", "completed capture requires a finished reactive outcome");
|
||||
validateTransitionDeclarations(run, parsed.data, require);
|
||||
const observations = reactiveObservations(capture);
|
||||
validateControls(run, observations, require);
|
||||
const snapshot = replayTransitions(parsed.data, run, observations, require);
|
||||
require(snapshot.outcome ===
|
||||
run.outcome, "reactive_run.outcome", "reactive outcome differs from deterministic journal replay");
|
||||
require(snapshot.active_state ===
|
||||
run.active_state, "reactive_run.active_state", "reactive active state differs from deterministic journal replay");
|
||||
};
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { ProcessCapture } from "./processCapture.js";
|
||||
import { validateProcessCaptureReactiveRun } from "./processCaptureReactiveValidation.js";
|
||||
import { replayMachineSchema, type ReplayMachine } from "./replayMachine.js";
|
||||
import { digestProcessCommitment } from "./processScenario.js";
|
||||
|
||||
|
|
@ -314,5 +315,6 @@ export const validateProcessCapture = (
|
|||
validateLifecycle(capture, require);
|
||||
validateShimEvents(capture, require);
|
||||
validateReplayEvents(capture, require);
|
||||
validateProcessCaptureReactiveRun(capture, require);
|
||||
return issues;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -109,16 +109,29 @@ const atMilliseconds = (value: unknown): number | null => {
|
|||
return null;
|
||||
};
|
||||
|
||||
const subjectFor = (
|
||||
/** Derive the stable capture-local identity for one normalized observation. */
|
||||
export const processObservationSubject = (
|
||||
source: ProcessObservationSource,
|
||||
value: JsonValue,
|
||||
value: unknown,
|
||||
): string | null => {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value))
|
||||
return null;
|
||||
if (source === "process" && typeof value["pid"] === "number")
|
||||
return `process:${String(value["pid"])}`;
|
||||
if (source === "filesystem" && typeof value["name"] === "string")
|
||||
return `checkpoint:${value["name"]}`;
|
||||
if (source === "process" && "pid" in value && typeof value.pid === "number")
|
||||
return `process:${String(value.pid)}`;
|
||||
if (
|
||||
source === "filesystem" &&
|
||||
"name" in value &&
|
||||
typeof value.name === "string"
|
||||
)
|
||||
return `checkpoint:${value.name}`;
|
||||
if (
|
||||
source === "shim" &&
|
||||
"command" in value &&
|
||||
typeof value.command === "string"
|
||||
)
|
||||
return "route_index" in value && typeof value.route_index === "number"
|
||||
? `shim:${value.command}:route:${String(value.route_index)}`
|
||||
: `shim:${value.command}:unmatched`;
|
||||
return null;
|
||||
};
|
||||
|
||||
|
|
@ -188,7 +201,7 @@ const projectedRecord = (
|
|||
return {
|
||||
source: projected.source,
|
||||
captured_at_ms: atMilliseconds(payload),
|
||||
subject_id: subjectFor(projected.source, payload),
|
||||
subject_id: processObservationSubject(projected.source, payload),
|
||||
payload,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ export type ProcessReactiveInput =
|
|||
readonly state_entry_capture_order: number;
|
||||
}
|
||||
| { readonly kind: "scenario_deadline" }
|
||||
| { readonly kind: "target_lost" }
|
||||
| { readonly kind: "cancelled" }
|
||||
| { readonly kind: "cleanup_failed" };
|
||||
|
||||
|
|
@ -123,6 +124,33 @@ export type ProcessReactiveDecision =
|
|||
const counterValue = (counters: readonly Counter[], id: string): number =>
|
||||
counters.find((counter) => counter.id === id)?.count ?? 0;
|
||||
|
||||
type ObservationAdmission =
|
||||
| { readonly kind: "new"; readonly snapshot: ProcessReactiveSnapshot }
|
||||
| { readonly kind: "invalid" };
|
||||
|
||||
const observeInput = (
|
||||
snapshot: ProcessReactiveSnapshot,
|
||||
observation: ProcessObservation,
|
||||
): ObservationAdmission => {
|
||||
const duplicate = snapshot.observations.find(
|
||||
({ event_id }) => event_id === observation.event_id,
|
||||
);
|
||||
if (duplicate !== undefined) return { kind: "invalid" };
|
||||
const prior = snapshot.observations.at(-1);
|
||||
if (
|
||||
(prior !== undefined && observation.capture_order <= prior.capture_order) ||
|
||||
snapshot.observations.length >= PROCESS_REACTIVE_LIMITS.retainedObservations
|
||||
)
|
||||
return { kind: "invalid" };
|
||||
return {
|
||||
kind: "new",
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
observations: [...snapshot.observations, observation],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** Create the initial immutable reducer state for one validated scenario. */
|
||||
export const createProcessReactiveSnapshot = (
|
||||
scenario: ProcessReactiveScenario,
|
||||
|
|
@ -166,6 +194,8 @@ const reduceControlInput = (
|
|||
: { kind: "waiting", snapshot, evaluations: [] };
|
||||
if (input.kind === "scenario_deadline")
|
||||
return finishedDecision(snapshot, "scenario_deadline");
|
||||
if (input.kind === "target_lost")
|
||||
return finishedDecision(snapshot, "target_lost");
|
||||
return finishedDecision(snapshot, "cancelled");
|
||||
};
|
||||
|
||||
|
|
@ -179,23 +209,10 @@ export const reduceProcessReactiveScenario = (
|
|||
if (controlDecision !== null) return controlDecision;
|
||||
if (input.kind !== "observation")
|
||||
return finishedDecision(snapshot, "capture_incomplete");
|
||||
const prior = snapshot.observations.at(-1);
|
||||
if (
|
||||
(prior !== undefined &&
|
||||
input.observation.capture_order <= prior.capture_order) ||
|
||||
snapshot.observations.some(
|
||||
({ event_id }) => event_id === input.observation.event_id,
|
||||
)
|
||||
)
|
||||
const admitted = observeInput(snapshot, input.observation);
|
||||
if (admitted.kind === "invalid")
|
||||
return finishedDecision(snapshot, "capture_incomplete");
|
||||
if (
|
||||
snapshot.observations.length >= PROCESS_REACTIVE_LIMITS.retainedObservations
|
||||
)
|
||||
return finishedDecision(snapshot, "capture_incomplete");
|
||||
const observed = {
|
||||
...snapshot,
|
||||
observations: [...snapshot.observations, input.observation],
|
||||
};
|
||||
const observed = admitted.snapshot;
|
||||
const state = scenario.states.find(({ id }) => id === observed.active_state);
|
||||
if (state === undefined) return finishedDecision(observed, "target_lost");
|
||||
const budget = { remaining: PROCESS_REACTIVE_LIMITS.evaluationWork };
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@ const applyProcessReactiveTransition = (input: {
|
|||
const effectObservationMatchesAction = (
|
||||
action: ProcessReactiveScenario["states"][number]["on"][number]["actions"][number],
|
||||
observation: ProcessObservation,
|
||||
committedSensitiveInputs: boolean,
|
||||
): boolean => {
|
||||
const payload =
|
||||
typeof observation.payload === "object" &&
|
||||
|
|
@ -153,9 +154,11 @@ const effectObservationMatchesAction = (
|
|||
return (
|
||||
payload["type"] === "input" &&
|
||||
payload["data"] ===
|
||||
(action.sensitive
|
||||
? `<redacted-input:${String(Buffer.byteLength(action.data))}-bytes>`
|
||||
: action.data)
|
||||
expectedInputEvidence(
|
||||
action.data,
|
||||
action.sensitive,
|
||||
committedSensitiveInputs,
|
||||
)
|
||||
);
|
||||
if (action.type === "resize")
|
||||
return (
|
||||
|
|
@ -165,6 +168,16 @@ const effectObservationMatchesAction = (
|
|||
return payload["type"] === "signal" && payload["data"] === action.signal;
|
||||
};
|
||||
|
||||
const expectedInputEvidence = (
|
||||
data: string,
|
||||
sensitive: boolean,
|
||||
committedSensitiveInput: boolean,
|
||||
): string =>
|
||||
!sensitive ||
|
||||
(committedSensitiveInput && /^<redacted-input:\d+-bytes>$/.test(data))
|
||||
? data
|
||||
: `<redacted-input:${String(Buffer.byteLength(data))}-bytes>`;
|
||||
|
||||
const failedProposal = (
|
||||
proposed: Extract<ProcessReactiveDecision, { readonly kind: "proposal" }>,
|
||||
outcome: "action_rejected" | "target_lost",
|
||||
|
|
@ -180,6 +193,7 @@ export const commitProcessReactiveProposal = (
|
|||
scenario: ProcessReactiveScenario,
|
||||
proposed: Extract<ProcessReactiveDecision, { readonly kind: "proposal" }>,
|
||||
results: readonly ProcessReactiveEffectResult[],
|
||||
options: { readonly committedSensitiveInputs?: boolean } = {},
|
||||
): ProcessReactiveDecision => {
|
||||
if (results.some(({ status }) => status === "target_lost"))
|
||||
return failedProposal(proposed, "target_lost");
|
||||
|
|
@ -227,7 +241,11 @@ export const commitProcessReactiveProposal = (
|
|||
const effectObservation = result.observation;
|
||||
if (
|
||||
action === undefined ||
|
||||
!effectObservationMatchesAction(action, effectObservation) ||
|
||||
!effectObservationMatchesAction(
|
||||
action,
|
||||
effectObservation,
|
||||
options.committedSensitiveInputs ?? false,
|
||||
) ||
|
||||
!Number.isSafeInteger(effectObservation.capture_order) ||
|
||||
effectObservation.capture_order <= previousOrder ||
|
||||
knownEventIds.has(effectObservation.event_id)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import { createHash } from "node:crypto";
|
|||
import { resolve } from "node:path";
|
||||
import canonicalize from "canonicalize";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
processReactiveScenarioSchema,
|
||||
type ProcessReactiveScenario,
|
||||
} from "./processReactiveScenario.js";
|
||||
import { replayMachineSchema } from "./replayMachine.js";
|
||||
|
||||
const positiveBudget = z.number().int().positive();
|
||||
|
|
@ -51,6 +55,29 @@ export const digestProcessCommitment = (value: unknown): string => {
|
|||
return createHash("sha256").update(serialized).digest("hex");
|
||||
};
|
||||
|
||||
const reactiveScenarioCommitment = (
|
||||
scenario: ProcessReactiveScenario | null,
|
||||
): Readonly<Record<string, unknown>> | null =>
|
||||
scenario === null
|
||||
? null
|
||||
: {
|
||||
...scenario,
|
||||
states: scenario.states.map((state) => ({
|
||||
...state,
|
||||
on: state.on.map((transition) => ({
|
||||
...transition,
|
||||
actions: transition.actions.map((action) =>
|
||||
action.type === "send_input" && action.sensitive
|
||||
? {
|
||||
...action,
|
||||
data: `<redacted-input:${String(Buffer.byteLength(action.data))}-bytes>`,
|
||||
}
|
||||
: action,
|
||||
),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
|
||||
/** Build the secret-safe scenario projection committed by Process Capture v4. */
|
||||
export const processScenarioCommitment = (
|
||||
scenario: ProcessScenario,
|
||||
|
|
@ -63,6 +90,7 @@ export const processScenarioCommitment = (
|
|||
scenario.secret_aliases.includes(name) ? "<redacted-secret>" : value,
|
||||
]),
|
||||
),
|
||||
reactive: reactiveScenarioCommitment(scenario.reactive),
|
||||
unknown_registry_approved: scenario.unknown_registry_approved === true,
|
||||
executable_sha256: executableSha256 ?? null,
|
||||
});
|
||||
|
|
@ -86,6 +114,7 @@ export const processComparisonContract = (
|
|||
normalization: scenario.normalization,
|
||||
command_shims: scenario.command_shims,
|
||||
replay: scenario.replay,
|
||||
reactive: reactiveScenarioCommitment(scenario.reactive),
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -292,6 +321,7 @@ export const processScenarioSchema = z
|
|||
websocket_messages: [],
|
||||
websocket_connections: [],
|
||||
}),
|
||||
reactive: processReactiveScenarioSchema.nullable().default(null),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((scenario, context) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
|
||||
import { WebSocket } from "ws";
|
||||
|
||||
process.stdout.write("Ready\n");
|
||||
const worker = spawn(
|
||||
process.execPath,
|
||||
["-e", 'process.title = "rea-reactive-worker"; setTimeout(() => {}, 750);'],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
process.stdout.write("Collecting\n");
|
||||
spawnSync(process.argv[2], ["probe"], { stdio: "ignore" });
|
||||
await writeFile("reactive-result.txt", "created");
|
||||
await fetch(`${process.env.REA_REPLAY_HTTP_URL}/reactive`);
|
||||
await new Promise((resolve, reject) => {
|
||||
const socket = new WebSocket(process.env.REA_REPLAY_WEBSOCKET_URL);
|
||||
socket.once("open", () => socket.send("reactive-client"));
|
||||
socket.once("message", () => socket.close());
|
||||
socket.once("close", resolve);
|
||||
socket.once("error", reject);
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
worker.once("exit", resolve);
|
||||
worker.once("error", reject);
|
||||
});
|
||||
|
|
@ -103,6 +103,7 @@ const emptyCapture = (): ProcessCapture => {
|
|||
shim_events: [],
|
||||
protocol_events: [],
|
||||
replay_transitions: [],
|
||||
reactive_run: null,
|
||||
event_journal: [],
|
||||
files_before: [],
|
||||
files_after: [],
|
||||
|
|
@ -187,6 +188,7 @@ describe("process capture domain", () => {
|
|||
];
|
||||
const result = buildCaptureResult({
|
||||
frames: [],
|
||||
reactiveRun: null,
|
||||
exit: { exitCode: 0, reason: "exited" },
|
||||
samples: [],
|
||||
replay: {
|
||||
|
|
@ -452,6 +454,7 @@ describe("process capture domain", () => {
|
|||
shim_events: [],
|
||||
protocol_events: [],
|
||||
replay_transitions: [],
|
||||
reactive_run: null,
|
||||
files_before: [],
|
||||
files_after: [],
|
||||
filesystem_effects: [],
|
||||
|
|
@ -696,6 +699,7 @@ describe("process capture domain", () => {
|
|||
shim_events: [],
|
||||
protocol_events: [],
|
||||
replay_transitions: [],
|
||||
reactive_run: null,
|
||||
files_before: [],
|
||||
files_after: [],
|
||||
filesystem_effects: [],
|
||||
|
|
@ -756,6 +760,7 @@ describe("process capture domain", () => {
|
|||
shim_events: [],
|
||||
protocol_events: [],
|
||||
replay_transitions: [],
|
||||
reactive_run: null,
|
||||
files_before: [],
|
||||
files_after: [],
|
||||
filesystem_effects: [],
|
||||
|
|
@ -800,6 +805,7 @@ describe("process capture domain", () => {
|
|||
shim_events: [],
|
||||
protocol_events: [],
|
||||
replay_transitions: [],
|
||||
reactive_run: null,
|
||||
files_before: [],
|
||||
files_after: [],
|
||||
filesystem_effects: [],
|
||||
|
|
@ -873,6 +879,7 @@ describe("process capture domain", () => {
|
|||
shim_events: [],
|
||||
protocol_events: [],
|
||||
replay_transitions: [],
|
||||
reactive_run: null,
|
||||
files_before: [],
|
||||
files_after: [],
|
||||
filesystem_effects: [],
|
||||
|
|
@ -1507,6 +1514,407 @@ describe("process capture adapter", () => {
|
|||
}
|
||||
});
|
||||
|
||||
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 },
|
||||
],
|
||||
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.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: 2_000,
|
||||
states: [
|
||||
{
|
||||
id: "waiting",
|
||||
max_visits: 1,
|
||||
deadline_ms: 300,
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
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");
|
||||
|
|
@ -1529,7 +1937,6 @@ describe("process capture adapter", () => {
|
|||
executable: process.execPath,
|
||||
arguments: [script],
|
||||
working_directory: root,
|
||||
filesystem_roots: [root],
|
||||
checkpoints: [
|
||||
{
|
||||
name: "probe_seen",
|
||||
|
|
@ -1562,6 +1969,75 @@ describe("process capture adapter", () => {
|
|||
],
|
||||
},
|
||||
],
|
||||
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,
|
||||
|
|
@ -1591,12 +2067,300 @@ describe("process capture adapter", () => {
|
|||
]);
|
||||
expect(
|
||||
result.value.filesystem_checkpoints.map(({ name }) => name),
|
||||
).toEqual(["before", "probe_seen", "after_settlement"]);
|
||||
).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"));
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
|||
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js";
|
||||
|
||||
|
|
@ -106,6 +107,130 @@ describe("process-capture MCP elicitation", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it.each(["tampered", "expired"] as const)(
|
||||
"rejects %s signed state through the modern MCP client and server",
|
||||
async (failure) => {
|
||||
const startedAt = Date.parse("2026-07-23T00:00:00.000Z");
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(startedAt);
|
||||
const root = await createTestTempDirectory("rea-elicit-state-");
|
||||
const authority = new PermissionAuthority(
|
||||
createPermissionPolicy([
|
||||
{
|
||||
capability: "process_capture",
|
||||
roots: [root],
|
||||
executables: [process.execPath],
|
||||
environment_names: [],
|
||||
network: "external",
|
||||
mount: false,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const session = new BinarySession(() => ({
|
||||
execute: () => Promise.resolve(observed(null)),
|
||||
close: () => Promise.resolve(),
|
||||
}));
|
||||
const client = new Client(
|
||||
{ name: "process-elicit-state", version: "1" },
|
||||
{
|
||||
capabilities: { elicitation: { form: {} } },
|
||||
versionNegotiation: {
|
||||
mode: {
|
||||
pin: PROCESS_CAPTURE_ELICITATION_POLICY.protocolVersions[0],
|
||||
},
|
||||
},
|
||||
inputRequired: { autoFulfill: false, maxRounds: 3 },
|
||||
},
|
||||
);
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
const server = serveStdio(
|
||||
() =>
|
||||
createServer(session, session, {
|
||||
logger: silentLogger,
|
||||
permissionAuthority: authority,
|
||||
processPolicy: {
|
||||
enabled: true,
|
||||
executableRoots: [dirname(process.execPath)],
|
||||
workingRoots: [root],
|
||||
allowedEnvironment: [],
|
||||
allowExternalNetwork: true,
|
||||
},
|
||||
}),
|
||||
{ transport: serverTransport, legacy: "reject" },
|
||||
);
|
||||
const call = {
|
||||
name: "capture_process_scenario",
|
||||
arguments: {
|
||||
approved: true,
|
||||
executable: process.execPath,
|
||||
arguments: ["-e", "process.exit(0)"],
|
||||
working_directory: root,
|
||||
},
|
||||
};
|
||||
try {
|
||||
await client.connect(clientTransport);
|
||||
const required = await client.callTool(call, {
|
||||
allowInputRequired: true,
|
||||
});
|
||||
expect(isInputRequiredResult(required)).toBe(true);
|
||||
if (!isInputRequiredResult(required)) return;
|
||||
const requestState = required.requestState;
|
||||
expect(requestState).toEqual(expect.any(String));
|
||||
if (requestState === undefined) return;
|
||||
if (failure === "expired")
|
||||
vi.setSystemTime(
|
||||
startedAt +
|
||||
(PROCESS_CAPTURE_ELICITATION_POLICY.stateTtlSeconds + 1) * 1_000,
|
||||
);
|
||||
const echoedState =
|
||||
failure === "tampered"
|
||||
? `${requestState.slice(0, -1)}${requestState.endsWith("A") ? "B" : "A"}`
|
||||
: requestState;
|
||||
const retryMethod: string = "tools/call";
|
||||
await expect(
|
||||
client.request(
|
||||
{
|
||||
method: retryMethod,
|
||||
params: {
|
||||
...call,
|
||||
requestState: echoedState,
|
||||
inputResponses: {
|
||||
process_capture_grant: {
|
||||
action: "accept",
|
||||
content: { lifetime: "session" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
z.unknown(),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: -32_602,
|
||||
data: { reason: "invalid_request_state" },
|
||||
});
|
||||
expect(
|
||||
await authority.authorize(
|
||||
{
|
||||
capability: "process_capture",
|
||||
roots: [root],
|
||||
executables: [process.execPath],
|
||||
environment_names: [],
|
||||
network: "external",
|
||||
mount: false,
|
||||
operation_identity: `capture_process_scenario:${process.execPath}`,
|
||||
},
|
||||
"read",
|
||||
),
|
||||
).toMatchObject({ ok: false });
|
||||
} finally {
|
||||
await Promise.allSettled([client.close(), server.close()]);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
vi.useRealTimers();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("grants and reuses a signed session scope on the modern protocol", async () => {
|
||||
const authority = ceilingOnlyAuthority();
|
||||
let state: ProcessCaptureElicitationState | undefined;
|
||||
|
|
|
|||
|
|
@ -122,6 +122,10 @@ describe("process observation vocabulary", () => {
|
|||
captured_at_ms: 4,
|
||||
subject_id: "process:2",
|
||||
});
|
||||
expect(observations[7]).toMatchObject({
|
||||
event_id: "obs.shim_events.0",
|
||||
subject_id: "shim:helper:route:0",
|
||||
});
|
||||
expect(observations[3]?.captured_at_ms).toBeNull();
|
||||
expect(observations[4]?.captured_at_ms).toBeNull();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
unsupportedProcessReactiveFeatures,
|
||||
} from "../src/application/ProcessReactiveEffects.js";
|
||||
import { createProcessCaptureJournal } from "../src/application/ProcessCaptureJournal.js";
|
||||
import { subscribeProcessReactiveTerminalObservations } from "../src/application/ProcessReactiveObservations.js";
|
||||
import { subscribeProcessReactiveObservations } from "../src/application/ProcessReactiveObservations.js";
|
||||
import type {
|
||||
FilesystemCheckpoint,
|
||||
InteractionEvent,
|
||||
|
|
@ -149,9 +149,14 @@ const createHarness = (scenario: ProcessReactiveScenario) => {
|
|||
},
|
||||
timerHost: timers.host,
|
||||
});
|
||||
const unsubscribe = subscribeProcessReactiveTerminalObservations({
|
||||
const unsubscribe = subscribeProcessReactiveObservations({
|
||||
journal,
|
||||
frames,
|
||||
interactions,
|
||||
checkpointAt: (index) => checkpoints[index],
|
||||
processSampleAt: () => undefined,
|
||||
protocolEventAt: () => undefined,
|
||||
shimEventAt: () => undefined,
|
||||
sink: coordinator,
|
||||
});
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -194,6 +194,27 @@ describe("process reactive runtime", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("treats redaction-shaped live input as ordinary sensitive data", () => {
|
||||
const scenario = scenarioWith([
|
||||
finish("ready", terminalTrigger(), 100, [
|
||||
{
|
||||
type: "send_input",
|
||||
data: "<redacted-input:1-bytes>",
|
||||
sensitive: true,
|
||||
},
|
||||
]),
|
||||
]);
|
||||
const decision = offer(
|
||||
scenario,
|
||||
createProcessReactiveSnapshot(scenario),
|
||||
observation("terminal_raw", 0, { data: "Ready" }),
|
||||
);
|
||||
expect(decision).toMatchObject({
|
||||
kind: "transition",
|
||||
record: { outcome: "passed" },
|
||||
});
|
||||
});
|
||||
|
||||
it("reports only the minimal terminal chunk span containing the occurrence", () => {
|
||||
const scenario = scenarioWith([finish("ready", terminalTrigger())]);
|
||||
let snapshot = createProcessReactiveSnapshot(scenario);
|
||||
|
|
@ -638,5 +659,16 @@ describe("process reactive runtime", () => {
|
|||
kind: "finished",
|
||||
outcome: "capture_incomplete",
|
||||
});
|
||||
const duplicate = observation("shim", 1, { name: "later" });
|
||||
expect(offer(scenario, first.snapshot, duplicate)).toMatchObject({
|
||||
kind: "finished",
|
||||
outcome: "capture_incomplete",
|
||||
});
|
||||
expect(
|
||||
offer(scenario, first.snapshot, {
|
||||
...duplicate,
|
||||
payload: { name: "changed" },
|
||||
}),
|
||||
).toMatchObject({ kind: "finished", outcome: "capture_incomplete" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue