feat(process): add deterministic capture v3
This commit is contained in:
parent
a504342d2b
commit
5a361a6725
|
|
@ -13,6 +13,8 @@
|
|||
"@babel/types": "8.0.4",
|
||||
"@electron/asar": "4.2.0",
|
||||
"@modelcontextprotocol/server": "2.0.0-beta.3",
|
||||
"@xterm/addon-serialize": "0.14.0",
|
||||
"@xterm/headless": "6.0.0",
|
||||
"@zip.js/zip.js": "2.8.26",
|
||||
"canonicalize": "3.0.0",
|
||||
"diff": "9.0.0",
|
||||
|
|
@ -1883,6 +1885,21 @@
|
|||
"node": ">=14.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-serialize": {
|
||||
"version": "0.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0.tgz",
|
||||
"integrity": "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/headless": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz",
|
||||
"integrity": "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"addons/*"
|
||||
]
|
||||
},
|
||||
"node_modules/@yomguithereal/helpers": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@yomguithereal/helpers/-/helpers-1.1.1.tgz",
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@
|
|||
"install.sh",
|
||||
"bridge/hopper_bridge.py",
|
||||
"scripts/rea.mjs",
|
||||
"scripts/prepare-node-pty.mjs",
|
||||
"scripts/rebuild-native.mjs",
|
||||
"skills",
|
||||
"README.md",
|
||||
|
|
@ -59,6 +60,7 @@
|
|||
"scan:todos": "node scripts/scan-todos.mjs",
|
||||
"start": "node dist/main.js",
|
||||
"pretest": "npm run build",
|
||||
"postinstall": "node scripts/prepare-node-pty.mjs",
|
||||
"test": "vitest run",
|
||||
"pretest:coverage": "npm run build",
|
||||
"test:coverage": "vitest run --coverage.enabled --coverage.provider=v8",
|
||||
|
|
@ -86,6 +88,8 @@
|
|||
"@babel/types": "8.0.4",
|
||||
"@electron/asar": "4.2.0",
|
||||
"@modelcontextprotocol/server": "2.0.0-beta.3",
|
||||
"@xterm/addon-serialize": "0.14.0",
|
||||
"@xterm/headless": "6.0.0",
|
||||
"@zip.js/zip.js": "2.8.26",
|
||||
"canonicalize": "3.0.0",
|
||||
"diff": "9.0.0",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { chmod, lstat } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
const require = createRequire(import.meta.url);
|
||||
const packageRoot = dirname(
|
||||
require.resolve("node-pty/package.json", {
|
||||
paths: [dirname(fileURLToPath(import.meta.url))],
|
||||
}),
|
||||
);
|
||||
const helperPath = join(
|
||||
packageRoot,
|
||||
"prebuilds",
|
||||
`${process.platform}-${process.arch}`,
|
||||
"spawn-helper",
|
||||
);
|
||||
|
||||
try {
|
||||
const helper = await lstat(helperPath);
|
||||
if (!helper.isFile() || helper.isSymbolicLink()) {
|
||||
throw new Error("node-pty spawn helper is not a regular file");
|
||||
}
|
||||
await chmod(helperPath, helper.mode | 0o111);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
||||
// Source builds place their correctly-modeled helper under build/Release.
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
import { createServer, type Server } from "node:http";
|
||||
import { chmod, mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { ProcessScenario, ShimEvent } from "../domain/processCapture.js";
|
||||
|
||||
interface Invocation {
|
||||
readonly command: string;
|
||||
readonly arguments: readonly string[];
|
||||
readonly working_directory: string;
|
||||
}
|
||||
|
||||
/** Owned command-shim replay resources for one process scenario. */
|
||||
export interface CommandShimReplay {
|
||||
readonly binPath: string;
|
||||
readonly url: string;
|
||||
readonly events: readonly ShimEvent[];
|
||||
readonly truncated: boolean;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
const RUNNER = `const command = process.argv[2];
|
||||
const response = await fetch(process.env.REA_SHIM_LEDGER_URL, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ command, arguments: process.argv.slice(3), working_directory: process.cwd() }),
|
||||
});
|
||||
if (!response.ok) process.exit(127);
|
||||
const route = await response.json();
|
||||
let elapsed = 0;
|
||||
for (const output of route.outputs) {
|
||||
const wait = Math.max(0, output.at_ms - elapsed);
|
||||
if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
|
||||
elapsed = output.at_ms;
|
||||
(output.stream === "stdout" ? process.stdout : process.stderr).write(output.data);
|
||||
}
|
||||
if (route.termination.type === "signal") process.kill(process.pid, route.termination.signal);
|
||||
else process.exit(route.termination.code);
|
||||
`;
|
||||
|
||||
const shellQuote = (value: string): string =>
|
||||
`'${value.replaceAll("'", `'\\''`)}'`;
|
||||
|
||||
/** Start bounded executable replay wrappers and their private invocation ledger. */
|
||||
export const startCommandShimReplay = async (
|
||||
scenario: ProcessScenario,
|
||||
temporaryRoot: string,
|
||||
started: number,
|
||||
): Promise<CommandShimReplay> => {
|
||||
const binPath = join(temporaryRoot, "shims");
|
||||
await mkdir(binPath);
|
||||
const runnerPath = join(temporaryRoot, "shim-runner.mjs");
|
||||
await writeFile(runnerPath, RUNNER, { mode: 0o600, flag: "wx" });
|
||||
for (const shim of scenario.command_shims) {
|
||||
const path = join(binPath, shim.name);
|
||||
const wrapper = `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(runnerPath)} ${shellQuote(shim.name)} "$@"\n`;
|
||||
await writeFile(path, wrapper, { mode: 0o700, flag: "wx" });
|
||||
await chmod(path, 0o700);
|
||||
}
|
||||
|
||||
const events: ShimEvent[] = [];
|
||||
let truncated = false;
|
||||
const calls = new Map<string, number>();
|
||||
const server = createServer((request, response) => {
|
||||
if (request.method !== "POST" || request.url !== "/invoke") {
|
||||
response.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
readInvocation(request)
|
||||
.then((invocation) => {
|
||||
const shim = scenario.command_shims.find(
|
||||
({ name }) => name === invocation.command,
|
||||
);
|
||||
const routeIndex = shim?.routes.findIndex(
|
||||
({ arguments: expected }) =>
|
||||
JSON.stringify(expected) === JSON.stringify(invocation.arguments),
|
||||
);
|
||||
const key = `${invocation.command}:${String(routeIndex ?? -1)}`;
|
||||
const used = calls.get(key) ?? 0;
|
||||
const route =
|
||||
shim !== undefined && routeIndex !== undefined && routeIndex >= 0
|
||||
? shim.routes[routeIndex]
|
||||
: undefined;
|
||||
const outcome =
|
||||
route === undefined
|
||||
? "unmatched"
|
||||
: used >= route.max_calls
|
||||
? "exhausted"
|
||||
: "matched";
|
||||
if (events.length >= scenario.limits.protocol_events) truncated = true;
|
||||
else
|
||||
events.push({
|
||||
sequence: events.length,
|
||||
at_ms: Math.max(0, Date.now() - started),
|
||||
command: invocation.command,
|
||||
arguments: invocation.arguments,
|
||||
working_directory: invocation.working_directory,
|
||||
outcome,
|
||||
});
|
||||
if (outcome !== "matched" || route === undefined) {
|
||||
response.writeHead(409).end();
|
||||
return;
|
||||
}
|
||||
calls.set(key, used + 1);
|
||||
response
|
||||
.writeHead(200, { "content-type": "application/json" })
|
||||
.end(JSON.stringify(route));
|
||||
})
|
||||
.catch(() => response.writeHead(400).end());
|
||||
});
|
||||
await listen(server);
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string") {
|
||||
await closeServer(server);
|
||||
throw new Error("command shim ledger did not bind an IPv4 port");
|
||||
}
|
||||
return {
|
||||
binPath,
|
||||
url: `http://127.0.0.1:${String(address.port)}/invoke`,
|
||||
events,
|
||||
get truncated() {
|
||||
return truncated;
|
||||
},
|
||||
close: () => closeServer(server),
|
||||
};
|
||||
};
|
||||
|
||||
const readInvocation = async (
|
||||
request: import("node:http").IncomingMessage,
|
||||
): Promise<Invocation> => {
|
||||
const chunks: Buffer[] = [];
|
||||
let bytes = 0;
|
||||
for await (const chunk of request) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
bytes += buffer.length;
|
||||
if (bytes > 64 * 1024) throw new Error("shim invocation exceeds limit");
|
||||
chunks.push(buffer);
|
||||
}
|
||||
const value: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
if (
|
||||
typeof value !== "object" ||
|
||||
value === null ||
|
||||
!("command" in value) ||
|
||||
typeof value.command !== "string" ||
|
||||
!("arguments" in value) ||
|
||||
!Array.isArray(value.arguments) ||
|
||||
!value.arguments.every((item) => typeof item === "string") ||
|
||||
!("working_directory" in value) ||
|
||||
typeof value.working_directory !== "string"
|
||||
)
|
||||
throw new Error("invalid shim invocation");
|
||||
return {
|
||||
command: value.command,
|
||||
arguments: value.arguments,
|
||||
working_directory: value.working_directory,
|
||||
};
|
||||
};
|
||||
|
||||
const listen = (server: Server): Promise<void> =>
|
||||
new Promise((resolveListen, rejectListen) => {
|
||||
server.once("error", rejectListen);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.off("error", rejectListen);
|
||||
resolveListen();
|
||||
});
|
||||
});
|
||||
|
||||
const closeServer = (server: Server): Promise<void> =>
|
||||
new Promise((resolveClose, rejectClose) =>
|
||||
server.close((cause) =>
|
||||
cause === undefined ? resolveClose() : rejectClose(cause),
|
||||
),
|
||||
);
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
import type {
|
||||
FileState,
|
||||
FilesystemCheckpoint,
|
||||
ProcessScenario,
|
||||
} from "../domain/processCapture.js";
|
||||
import { snapshotRoots, type SnapshotResult } from "./FilesystemSnapshot.js";
|
||||
|
||||
/** Classify path-stable filesystem effects between two bounded states. */
|
||||
export const classifyFilesystemEffects = (
|
||||
before: readonly FileState[],
|
||||
after: readonly FileState[],
|
||||
): FilesystemCheckpoint["effects"] => {
|
||||
const beforeByPath = new Map(before.map((file) => [file.path, file]));
|
||||
const afterByPath = new Map(after.map((file) => [file.path, file]));
|
||||
return [...new Set([...beforeByPath.keys(), ...afterByPath.keys()])]
|
||||
.sort()
|
||||
.map((path) => {
|
||||
const beforeFile = beforeByPath.get(path) ?? null;
|
||||
const afterFile = afterByPath.get(path) ?? null;
|
||||
const status =
|
||||
beforeFile === null
|
||||
? "created"
|
||||
: afterFile === null
|
||||
? "deleted"
|
||||
: JSON.stringify(beforeFile) === JSON.stringify(afterFile)
|
||||
? "unchanged"
|
||||
: "modified";
|
||||
return { path, status, before: beforeFile, after: afterFile } as const;
|
||||
});
|
||||
};
|
||||
|
||||
/** Coordinates named lifecycle filesystem observations without overlapping scans. */
|
||||
export class ProcessCheckpoints {
|
||||
readonly #captures: FilesystemCheckpoint[] = [];
|
||||
readonly #captured = new Set<string>();
|
||||
readonly #terminalCounts = new Map<string, number>();
|
||||
readonly #terminalTails = new Map<string, string>();
|
||||
readonly #timers = new Set<NodeJS.Timeout>();
|
||||
#pending: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(
|
||||
private readonly scenario: ProcessScenario,
|
||||
private readonly started: number,
|
||||
before: SnapshotResult,
|
||||
private readonly signal: AbortSignal | undefined,
|
||||
) {
|
||||
this.#captures.push({
|
||||
name: "before",
|
||||
at_ms: 0,
|
||||
files: before.files,
|
||||
effects: [],
|
||||
truncated: before.truncated,
|
||||
});
|
||||
this.#captured.add("before");
|
||||
for (const checkpoint of scenario.checkpoints) {
|
||||
if (checkpoint.trigger.type !== "time") continue;
|
||||
const timer = setTimeout(
|
||||
() => this.capture(checkpoint.name),
|
||||
checkpoint.trigger.at_ms,
|
||||
);
|
||||
this.#timers.add(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** Observe normalized terminal text and fire matching literal checkpoints once. */
|
||||
observeTerminal(data: string): void {
|
||||
for (const checkpoint of this.scenario.checkpoints) {
|
||||
const trigger = checkpoint.trigger;
|
||||
if (
|
||||
trigger.type !== "terminal_literal" ||
|
||||
this.#captured.has(checkpoint.name)
|
||||
)
|
||||
continue;
|
||||
const combined = `${this.#terminalTails.get(checkpoint.name) ?? ""}${data}`;
|
||||
const count =
|
||||
(this.#terminalCounts.get(checkpoint.name) ?? 0) +
|
||||
countOccurrences(combined, trigger.value);
|
||||
this.#terminalCounts.set(checkpoint.name, count);
|
||||
this.#terminalTails.set(
|
||||
checkpoint.name,
|
||||
trigger.value.length === 1
|
||||
? ""
|
||||
: combined.slice(-(trigger.value.length - 1)),
|
||||
);
|
||||
if (count >= trigger.occurrence) this.capture(checkpoint.name);
|
||||
}
|
||||
}
|
||||
|
||||
/** Capture every checkpoint assigned to the specified lifecycle trigger. */
|
||||
trigger(type: "root_exit" | "settled"): void {
|
||||
for (const checkpoint of this.scenario.checkpoints)
|
||||
if (checkpoint.trigger.type === type) this.capture(checkpoint.name);
|
||||
}
|
||||
|
||||
/** Queue one idempotent named snapshot. */
|
||||
capture(name: string): void {
|
||||
if (this.#captured.has(name)) return;
|
||||
this.#captured.add(name);
|
||||
this.#pending = this.#pending.then(async () => {
|
||||
const snapshot = await snapshotRoots(this.scenario, this.signal);
|
||||
const previous = this.#captures.at(-1)?.files ?? [];
|
||||
this.#captures.push({
|
||||
name,
|
||||
at_ms: Math.max(0, Date.now() - this.started),
|
||||
files: snapshot.files,
|
||||
effects: classifyFilesystemEffects(previous, snapshot.files),
|
||||
truncated: snapshot.truncated,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Finish pending snapshots and return them in observation order. */
|
||||
async finish(
|
||||
after: SnapshotResult,
|
||||
): Promise<readonly FilesystemCheckpoint[]> {
|
||||
for (const timer of this.#timers) clearTimeout(timer);
|
||||
await this.#pending;
|
||||
const previous = this.#captures.at(-1)?.files ?? [];
|
||||
this.#captures.push({
|
||||
name: "after_settlement",
|
||||
at_ms: Math.max(0, Date.now() - this.started),
|
||||
files: after.files,
|
||||
effects: classifyFilesystemEffects(previous, after.files),
|
||||
truncated: after.truncated,
|
||||
});
|
||||
return this.#captures;
|
||||
}
|
||||
|
||||
/** Cancel scheduled captures and await any snapshot already in progress. */
|
||||
async dispose(): Promise<void> {
|
||||
for (const timer of this.#timers) clearTimeout(timer);
|
||||
this.#timers.clear();
|
||||
await this.#pending;
|
||||
}
|
||||
}
|
||||
|
||||
const countOccurrences = (text: string, value: string): number => {
|
||||
let count = 0;
|
||||
let offset = 0;
|
||||
while (offset <= text.length - value.length) {
|
||||
const found = text.indexOf(value, offset);
|
||||
if (found < 0) break;
|
||||
count += 1;
|
||||
offset = found + value.length;
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { parseConfig } from "../config.js";
|
||||
import { createEvidence, parseEvidence } from "../domain/evidence.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import {
|
||||
compareProcessCaptures,
|
||||
parseProcessScenario,
|
||||
processCaptureSchema,
|
||||
} from "../domain/processCapture.js";
|
||||
import { captureProcessScenario } from "./ProcessHarness.js";
|
||||
import {
|
||||
PROCESS_PROVIDER,
|
||||
createProcessCaptureEvidence,
|
||||
} from "./ProcessEvidence.js";
|
||||
|
||||
const MAX_INPUT_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
/** Capture one JSON scenario through the same policy and evidence contract as MCP. */
|
||||
export const captureProcessScenarioFile = async (path: string) => {
|
||||
const scenario = parseProcessScenario(await readJson(path));
|
||||
const config = parseConfig(process.env);
|
||||
if (!config.ok) throw config.error;
|
||||
const captured = await captureProcessScenario(
|
||||
scenario,
|
||||
config.value.processExecutionPolicy,
|
||||
);
|
||||
if (!captured.ok) throw captured.error;
|
||||
return createProcessCaptureEvidence(scenario, captured.value);
|
||||
};
|
||||
|
||||
/** Compare two capture Evidence files and emit derived comparison Evidence. */
|
||||
export const compareProcessEvidenceFiles = async (
|
||||
leftPath: string,
|
||||
rightPath: string,
|
||||
) => {
|
||||
const left = parseCaptureEvidence(await readJson(leftPath));
|
||||
const right = parseCaptureEvidence(await readJson(rightPath));
|
||||
const comparison = compareProcessCaptures(left.capture, right.capture);
|
||||
return createEvidence(undefined, PROCESS_PROVIDER, {
|
||||
predicateType: "rea.process-comparison/v2",
|
||||
operation: "compare_process_captures",
|
||||
parameters: {
|
||||
left_evidence_id: left.id,
|
||||
right_evidence_id: right.id,
|
||||
left_normalization: left.capture.normalization,
|
||||
right_normalization: right.capture.normalization,
|
||||
},
|
||||
result: jsonValueSchema.parse(comparison),
|
||||
confidence: "derived",
|
||||
authority: "analyst-inference",
|
||||
limitations: comparison.limitations,
|
||||
evidenceLinks: [left.id, right.id],
|
||||
});
|
||||
};
|
||||
|
||||
const parseCaptureEvidence = (input: unknown) => {
|
||||
const evidence = parseEvidence(input);
|
||||
if (
|
||||
evidence.operation !== "capture_process_scenario" ||
|
||||
evidence.predicate_type !== "rea.process-capture/v3" ||
|
||||
evidence.provider.id !== PROCESS_PROVIDER.id ||
|
||||
evidence.provider.version !== PROCESS_PROVIDER.version
|
||||
)
|
||||
throw new TypeError("Expected REA Process Capture v3 Evidence");
|
||||
return {
|
||||
id: evidence.evidence_id,
|
||||
capture: processCaptureSchema.parse(evidence.normalized_result),
|
||||
};
|
||||
};
|
||||
|
||||
const readJson = async (path: string): Promise<unknown> => {
|
||||
const bytes = await readFile(path);
|
||||
if (bytes.length > MAX_INPUT_BYTES)
|
||||
throw new TypeError("Process JSON input exceeds byte limit");
|
||||
return JSON.parse(bytes.toString("utf8")) as unknown;
|
||||
};
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import type {
|
||||
ProcessCapture,
|
||||
ProcessScenario,
|
||||
} from "../domain/processCapture.js";
|
||||
|
||||
/** Provider identity for controlled Process Capture v3 evidence. */
|
||||
export const PROCESS_PROVIDER = {
|
||||
id: "rea-process",
|
||||
name: "REA deterministic process harness",
|
||||
version: "2",
|
||||
} as const;
|
||||
|
||||
/** Project one process scenario into secret-free Evidence parameters. */
|
||||
const processEvidenceParameters = (
|
||||
scenario: ProcessScenario,
|
||||
): Readonly<Record<string, JsonValue>> => ({
|
||||
executable_name: scenario.executable.split("/").at(-1) ?? scenario.executable,
|
||||
argument_count: scenario.arguments.length,
|
||||
event_count: scenario.events.length,
|
||||
filesystem_root_count: scenario.filesystem_roots.length,
|
||||
checkpoint_count: scenario.checkpoints.length,
|
||||
command_shim_count: scenario.command_shims.length,
|
||||
normalization: scenario.normalization,
|
||||
});
|
||||
|
||||
/** Create one canonical observed Process Capture v3 Evidence record. */
|
||||
export const createProcessCaptureEvidence = (
|
||||
scenario: ProcessScenario,
|
||||
capture: ProcessCapture,
|
||||
) =>
|
||||
createEvidence(undefined, PROCESS_PROVIDER, {
|
||||
predicateType: "rea.process-capture/v3",
|
||||
operation: "capture_process_scenario",
|
||||
parameters: processEvidenceParameters(scenario),
|
||||
result: jsonValueSchema.parse(capture),
|
||||
confidence: "observed",
|
||||
authority: "controlled-replay",
|
||||
environment: {
|
||||
id: `${process.platform}-${process.arch}`,
|
||||
platform: process.platform,
|
||||
architecture: process.arch,
|
||||
isolation: "process",
|
||||
},
|
||||
limitations: capture.limitations,
|
||||
});
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, realpath, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { dirname, join } from "node:path";
|
||||
import { spawn, type IPty } from "node-pty";
|
||||
import type {
|
||||
FileState,
|
||||
FilesystemCheckpoint,
|
||||
InteractionEvent,
|
||||
ProcessCapture,
|
||||
ProcessExecutionPolicy,
|
||||
ProcessSample,
|
||||
|
|
@ -18,6 +19,15 @@ import { startLoopbackReplay, type LoopbackReplay } from "./LoopbackReplay.js";
|
|||
import { cleanupOwnedProcessGroup } from "./ProcessOwnership.js";
|
||||
import { startProcessSampler } from "./ProcessSampling.js";
|
||||
import { snapshotRoots, type SnapshotResult } from "./FilesystemSnapshot.js";
|
||||
import {
|
||||
classifyFilesystemEffects,
|
||||
ProcessCheckpoints,
|
||||
} from "./ProcessCheckpoints.js";
|
||||
import { TerminalRenderer } from "./TerminalRenderer.js";
|
||||
import {
|
||||
startCommandShimReplay,
|
||||
type CommandShimReplay,
|
||||
} from "./CommandShimReplay.js";
|
||||
import {
|
||||
normalizeProcessSamples,
|
||||
normalizeProcessText,
|
||||
|
|
@ -104,12 +114,18 @@ export const probeProcessCaptureCapability =
|
|||
}
|
||||
};
|
||||
|
||||
interface EnvironmentOptions {
|
||||
readonly scenario: ProcessScenario;
|
||||
readonly home: string;
|
||||
readonly replay: LoopbackReplay;
|
||||
readonly shimReplay: CommandShimReplay;
|
||||
readonly runId: string;
|
||||
}
|
||||
|
||||
const makeEnvironment = (
|
||||
scenario: ProcessScenario,
|
||||
home: string,
|
||||
replay: LoopbackReplay,
|
||||
runId: string,
|
||||
options: EnvironmentOptions,
|
||||
): Record<string, string> => {
|
||||
const { scenario, home, replay, shimReplay, runId } = options;
|
||||
const environment: Record<string, string> = {
|
||||
...scenario.environment,
|
||||
HOME: home,
|
||||
|
|
@ -122,36 +138,75 @@ const makeEnvironment = (
|
|||
}
|
||||
environment.REA_REPLAY_HTTP_URL = replay.httpUrl;
|
||||
environment.REA_REPLAY_WEBSOCKET_URL = replay.websocketUrl;
|
||||
environment.REA_SHIM_LEDGER_URL = shimReplay.url;
|
||||
environment.PATH = [shimReplay.binPath, environment.PATH ?? ""]
|
||||
.filter((part) => part.length > 0)
|
||||
.join(":");
|
||||
return environment;
|
||||
};
|
||||
|
||||
const scheduleScenarioEvents = (
|
||||
scenario: ProcessScenario,
|
||||
getTerminal: () => IPty | undefined,
|
||||
timers: Set<NodeJS.Timeout>,
|
||||
): void => {
|
||||
for (const event of scenario.events) {
|
||||
interface ScenarioEventOptions {
|
||||
readonly scenario: ProcessScenario;
|
||||
readonly getTerminal: () => IPty | undefined;
|
||||
readonly timers: Set<NodeJS.Timeout>;
|
||||
readonly interactions: InteractionEvent[];
|
||||
readonly renderer: TerminalRenderer;
|
||||
readonly started: number;
|
||||
readonly dispatchedEventIndexes: Set<number>;
|
||||
}
|
||||
|
||||
const scheduleScenarioEvents = (options: ScenarioEventOptions): void => {
|
||||
const { scenario, getTerminal, timers, interactions, renderer, started } =
|
||||
options;
|
||||
for (const [eventIndex, event] of scenario.events.entries()) {
|
||||
const timer = setTimeout(() => {
|
||||
options.dispatchedEventIndexes.add(eventIndex);
|
||||
const terminal = getTerminal();
|
||||
if (event.type === "input") terminal?.write(event.data);
|
||||
else if (event.type === "resize")
|
||||
terminal?.resize(event.columns, event.rows);
|
||||
else terminal?.kill(event.signal);
|
||||
const dispatchedAt = Math.max(0, Date.now() - started);
|
||||
let outcome: InteractionEvent["outcome"] = "dispatched";
|
||||
if (terminal === undefined) outcome = "target_exited";
|
||||
else {
|
||||
try {
|
||||
if (event.type === "input") terminal.write(event.data);
|
||||
else if (event.type === "resize") {
|
||||
terminal.resize(event.columns, event.rows);
|
||||
renderer.resize(event.columns, event.rows, dispatchedAt);
|
||||
} else terminal.kill(event.signal);
|
||||
} catch {
|
||||
outcome = "failed";
|
||||
}
|
||||
}
|
||||
interactions.push({
|
||||
sequence: interactions.length,
|
||||
scheduled_at_ms: event.at_ms,
|
||||
dispatched_at_ms: dispatchedAt,
|
||||
type: event.type,
|
||||
data:
|
||||
event.type === "input"
|
||||
? event.sensitive
|
||||
? `<redacted-input:${String(Buffer.byteLength(event.data))}-bytes>`
|
||||
: normalizeProcessText(
|
||||
event.data,
|
||||
scenario,
|
||||
"<no-temporary-root>",
|
||||
-1,
|
||||
)
|
||||
: event.type === "resize"
|
||||
? `${String(event.columns)}x${String(event.rows)}`
|
||||
: event.signal,
|
||||
outcome,
|
||||
});
|
||||
}, event.at_ms);
|
||||
timers.add(timer);
|
||||
}
|
||||
};
|
||||
|
||||
const startScenarioEvents = (
|
||||
scenario: ProcessScenario,
|
||||
getTerminal: () => IPty | undefined,
|
||||
timers: Set<NodeJS.Timeout>,
|
||||
): (() => void) => {
|
||||
let started = false;
|
||||
const startScenarioEvents = (options: ScenarioEventOptions): (() => void) => {
|
||||
let eventsStarted = false;
|
||||
return () => {
|
||||
if (started) return;
|
||||
started = true;
|
||||
scheduleScenarioEvents(scenario, getTerminal, timers);
|
||||
if (eventsStarted) return;
|
||||
eventsStarted = true;
|
||||
scheduleScenarioEvents(options);
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -167,6 +222,8 @@ interface TerminalExitOptions {
|
|||
readonly lastOutput: () => number;
|
||||
readonly signal: AbortSignal | undefined;
|
||||
readonly timers: Set<NodeJS.Timeout>;
|
||||
readonly interactions: InteractionEvent[];
|
||||
readonly dispatchedEventIndexes: ReadonlySet<number>;
|
||||
}
|
||||
|
||||
interface CaptureResultOptions {
|
||||
|
|
@ -184,36 +241,18 @@ interface CaptureResultOptions {
|
|||
readonly scenario: ProcessScenario;
|
||||
readonly rootPid: number;
|
||||
readonly samplingPartial: boolean;
|
||||
readonly renderedFrames: ProcessCapture["rendered_frames"];
|
||||
readonly interactions: readonly InteractionEvent[];
|
||||
readonly checkpoints: readonly FilesystemCheckpoint[];
|
||||
readonly shimEvents: ProcessCapture["shim_events"];
|
||||
}
|
||||
|
||||
const classifyFilesystemEffects = (
|
||||
before: readonly FileState[],
|
||||
after: readonly FileState[],
|
||||
): ProcessCapture["filesystem_effects"] => {
|
||||
const beforeByPath = new Map(before.map((file) => [file.path, file]));
|
||||
const afterByPath = new Map(after.map((file) => [file.path, file]));
|
||||
const paths = [
|
||||
...new Set([...beforeByPath.keys(), ...afterByPath.keys()]),
|
||||
].sort();
|
||||
return paths.map((path) => {
|
||||
const beforeFile = beforeByPath.get(path) ?? null;
|
||||
const afterFile = afterByPath.get(path) ?? null;
|
||||
const status =
|
||||
beforeFile === null
|
||||
? "created"
|
||||
: afterFile === null
|
||||
? "deleted"
|
||||
: JSON.stringify(beforeFile) === JSON.stringify(afterFile)
|
||||
? "unchanged"
|
||||
: "modified";
|
||||
return { path, status, before: beforeFile, after: afterFile };
|
||||
});
|
||||
};
|
||||
|
||||
const buildCaptureResult = (options: CaptureResultOptions): ProcessCapture => ({
|
||||
schema_version: 2,
|
||||
schema_version: 3,
|
||||
normalization: options.scenario.normalization,
|
||||
frames: options.frames,
|
||||
rendered_frames: options.renderedFrames,
|
||||
interaction_events: options.interactions,
|
||||
exit: {
|
||||
code: options.exit.exitCode < 0 ? null : options.exit.exitCode,
|
||||
signal: options.exit.signal ?? null,
|
||||
|
|
@ -224,6 +263,8 @@ const buildCaptureResult = (options: CaptureResultOptions): ProcessCapture => ({
|
|||
options.scenario,
|
||||
options.rootPid,
|
||||
),
|
||||
filesystem_checkpoints: options.checkpoints,
|
||||
shim_events: options.shimEvents,
|
||||
protocol_events: redactProtocolEvents(
|
||||
options.replay.events,
|
||||
options.scenario,
|
||||
|
|
@ -266,6 +307,8 @@ const awaitTerminalExit = async ({
|
|||
lastOutput,
|
||||
signal,
|
||||
timers,
|
||||
interactions,
|
||||
dispatchedEventIndexes,
|
||||
}: TerminalExitOptions): Promise<{
|
||||
exitCode: number;
|
||||
signal?: number;
|
||||
|
|
@ -274,6 +317,22 @@ const awaitTerminalExit = async ({
|
|||
new Promise((resolveExit) => {
|
||||
let reason: "exited" | "timeout" | "idle_timeout" | "cancelled" = "exited";
|
||||
terminal.onExit((exit) => {
|
||||
for (const [eventIndex, event] of scenario.events.entries()) {
|
||||
if (dispatchedEventIndexes.has(eventIndex)) continue;
|
||||
interactions.push({
|
||||
sequence: interactions.length,
|
||||
scheduled_at_ms: event.at_ms,
|
||||
dispatched_at_ms: Math.max(0, Date.now() - started),
|
||||
type: event.type,
|
||||
data:
|
||||
event.type === "input"
|
||||
? "<not-dispatched>"
|
||||
: event.type === "resize"
|
||||
? `${String(event.columns)}x${String(event.rows)}`
|
||||
: event.signal,
|
||||
outcome: "target_exited",
|
||||
});
|
||||
}
|
||||
for (const timer of timers) {
|
||||
clearTimeout(timer);
|
||||
clearInterval(timer);
|
||||
|
|
@ -300,8 +359,12 @@ const releaseProcessResources = async (options: {
|
|||
readonly timers: ReadonlySet<NodeJS.Timeout>;
|
||||
readonly replay: LoopbackReplay | undefined;
|
||||
readonly terminal: IPty | undefined;
|
||||
readonly renderer: TerminalRenderer | undefined;
|
||||
readonly shimReplay: CommandShimReplay | undefined;
|
||||
readonly checkpoints: ProcessCheckpoints | undefined;
|
||||
readonly runId: string;
|
||||
readonly temporaryRoot: string;
|
||||
readonly sampledProcessGroupIds: readonly number[];
|
||||
}): Promise<string | undefined> => {
|
||||
for (const timer of options.timers) clearTimeout(timer);
|
||||
let failure: string | undefined;
|
||||
|
|
@ -310,13 +373,34 @@ const releaseProcessResources = async (options: {
|
|||
} catch {
|
||||
failure = "loopback replay cleanup failed";
|
||||
}
|
||||
try {
|
||||
await options.shimReplay?.close();
|
||||
} catch {
|
||||
failure ??= "command shim replay cleanup failed";
|
||||
}
|
||||
try {
|
||||
await options.renderer?.dispose();
|
||||
} catch {
|
||||
failure ??= "terminal renderer cleanup failed";
|
||||
}
|
||||
try {
|
||||
await options.checkpoints?.dispose();
|
||||
} catch {
|
||||
failure ??= "filesystem checkpoint cleanup failed";
|
||||
}
|
||||
if (options.terminal !== undefined && process.platform !== "win32") {
|
||||
const cleaned = await cleanupOwnedProcessGroup({
|
||||
runId: options.runId,
|
||||
leaderPid: options.terminal.pid,
|
||||
processGroupId: options.terminal.pid,
|
||||
});
|
||||
if (!cleaned.cleaned) failure ??= cleaned.reason;
|
||||
const processGroupIds = new Set([
|
||||
options.terminal.pid,
|
||||
...options.sampledProcessGroupIds,
|
||||
]);
|
||||
for (const processGroupId of processGroupIds) {
|
||||
const cleaned = await cleanupOwnedProcessGroup({
|
||||
runId: options.runId,
|
||||
leaderPid: processGroupId,
|
||||
processGroupId,
|
||||
});
|
||||
if (!cleaned.cleaned) failure ??= cleaned.reason;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await rm(options.temporaryRoot, { recursive: true, force: true });
|
||||
|
|
@ -334,6 +418,8 @@ const captureTerminalFrames = (options: {
|
|||
readonly temporaryRoot: string;
|
||||
readonly onOutput: () => void;
|
||||
readonly onFirstOutput: () => void;
|
||||
readonly renderer: TerminalRenderer;
|
||||
readonly checkpoints: ProcessCheckpoints;
|
||||
}): (() => boolean) => {
|
||||
let outputBytes = 0;
|
||||
let truncated = false;
|
||||
|
|
@ -349,20 +435,24 @@ const captureTerminalFrames = (options: {
|
|||
return;
|
||||
}
|
||||
outputBytes += bytes;
|
||||
const atMs =
|
||||
Math.floor(
|
||||
(Date.now() - options.started) /
|
||||
options.scenario.normalization.time_bucket_ms,
|
||||
) * options.scenario.normalization.time_bucket_ms;
|
||||
const normalized = normalizeProcessText(
|
||||
data,
|
||||
options.scenario,
|
||||
options.temporaryRoot,
|
||||
options.terminal.pid,
|
||||
);
|
||||
options.frames.push({
|
||||
sequence: options.frames.length,
|
||||
at_ms:
|
||||
Math.floor(
|
||||
(Date.now() - options.started) /
|
||||
options.scenario.normalization.time_bucket_ms,
|
||||
) * options.scenario.normalization.time_bucket_ms,
|
||||
data: normalizeProcessText(
|
||||
data,
|
||||
options.scenario,
|
||||
options.temporaryRoot,
|
||||
options.terminal.pid,
|
||||
),
|
||||
at_ms: atMs,
|
||||
data: normalized,
|
||||
});
|
||||
options.renderer.write(data, atMs);
|
||||
options.checkpoints.observeTerminal(normalized);
|
||||
});
|
||||
return () => truncated;
|
||||
};
|
||||
|
|
@ -422,6 +512,9 @@ const runProcessScenario = async (
|
|||
let truncated = before.truncated;
|
||||
let terminal: IPty | undefined;
|
||||
let replay: LoopbackReplay | undefined;
|
||||
let renderer: TerminalRenderer | undefined;
|
||||
let checkpoints: ProcessCheckpoints | undefined;
|
||||
let shimReplay: CommandShimReplay | undefined;
|
||||
let started = 0;
|
||||
let lastOutput = 0;
|
||||
const timers = new Set<NodeJS.Timeout>();
|
||||
|
|
@ -430,19 +523,45 @@ const runProcessScenario = async (
|
|||
let framesTruncated = (): boolean => false;
|
||||
let stopSampler = async () => ({ partial: false });
|
||||
let samplingPartial = false;
|
||||
const interactions: InteractionEvent[] = [];
|
||||
const dispatchedEventIndexes = new Set<number>();
|
||||
|
||||
try {
|
||||
replay = await startLoopbackReplay(scenario);
|
||||
started = Date.now();
|
||||
lastOutput = started;
|
||||
renderer = new TerminalRenderer({
|
||||
columns: scenario.terminal.columns,
|
||||
rows: scenario.terminal.rows,
|
||||
scrollback: scenario.terminal.scrollback,
|
||||
maxFrames: scenario.limits.frames,
|
||||
maxBytes: scenario.limits.output_bytes,
|
||||
normalize: (value) =>
|
||||
normalizeProcessText(
|
||||
value,
|
||||
scenario,
|
||||
temporaryRoot,
|
||||
terminal?.pid ?? -1,
|
||||
),
|
||||
});
|
||||
checkpoints = new ProcessCheckpoints(scenario, started, before, signal);
|
||||
shimReplay = await startCommandShimReplay(scenario, temporaryRoot, started);
|
||||
terminal = spawn(scenario.executable, [...scenario.arguments], {
|
||||
cwd: scenario.working_directory,
|
||||
env: makeEnvironment(scenario, home, replay, runId),
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
env: makeEnvironment({ scenario, home, replay, shimReplay, runId }),
|
||||
cols: scenario.terminal.columns,
|
||||
rows: scenario.terminal.rows,
|
||||
name: "xterm-256color",
|
||||
});
|
||||
const startEvents = startScenarioEvents(scenario, () => terminal, timers);
|
||||
const startEvents = startScenarioEvents({
|
||||
scenario,
|
||||
getTerminal: () => terminal,
|
||||
timers,
|
||||
interactions,
|
||||
renderer,
|
||||
started,
|
||||
dispatchedEventIndexes,
|
||||
});
|
||||
if (!eventsRequireReadiness(scenario)) startEvents();
|
||||
framesTruncated = captureTerminalFrames({
|
||||
terminal,
|
||||
|
|
@ -452,6 +571,8 @@ const runProcessScenario = async (
|
|||
temporaryRoot,
|
||||
onOutput: () => (lastOutput = Date.now()),
|
||||
onFirstOutput: startEvents,
|
||||
renderer,
|
||||
checkpoints,
|
||||
});
|
||||
stopSampler = startProcessSampler(
|
||||
terminal.pid,
|
||||
|
|
@ -466,22 +587,30 @@ const runProcessScenario = async (
|
|||
lastOutput: () => lastOutput,
|
||||
signal,
|
||||
timers,
|
||||
interactions,
|
||||
dispatchedEventIndexes,
|
||||
});
|
||||
const exitReason = exit.reason;
|
||||
samplingPartial = (await stopSampler()).partial;
|
||||
checkpoints.trigger("root_exit");
|
||||
if (exitReason === "cancelled")
|
||||
throw new ProcessCaptureError("process capture was cancelled");
|
||||
await new Promise((resolveSettle) =>
|
||||
setTimeout(resolveSettle, scenario.settle_ms),
|
||||
);
|
||||
checkpoints.trigger("settled");
|
||||
samplingPartial = (await stopSampler()).partial;
|
||||
assertNotCancelled(signal);
|
||||
const after = await snapshotRoots(scenario, signal);
|
||||
const renderedFrames = await renderer.frames();
|
||||
const filesystemCheckpoints = await checkpoints.finish(after);
|
||||
truncated ||=
|
||||
after.truncated ||
|
||||
replay.truncated ||
|
||||
shimReplay.truncated ||
|
||||
framesTruncated() ||
|
||||
samples.length >= scenario.limits.processes;
|
||||
truncated ||= samplingPartial;
|
||||
filesystemCheckpoints.some(({ truncated: partial }) => partial) ||
|
||||
samplingPartial;
|
||||
truncated ||= renderer.truncated();
|
||||
capture = buildCaptureResult({
|
||||
frames,
|
||||
exit: { ...exit, reason: exitReason },
|
||||
|
|
@ -493,6 +622,26 @@ const runProcessScenario = async (
|
|||
scenario,
|
||||
rootPid: terminal.pid,
|
||||
samplingPartial,
|
||||
renderedFrames,
|
||||
interactions,
|
||||
checkpoints: filesystemCheckpoints,
|
||||
shimEvents: shimReplay.events.map((event) => ({
|
||||
...event,
|
||||
arguments: event.arguments.map((argument) =>
|
||||
normalizeProcessText(
|
||||
argument,
|
||||
scenario,
|
||||
temporaryRoot,
|
||||
terminal?.pid ?? -1,
|
||||
),
|
||||
),
|
||||
working_directory: normalizeProcessText(
|
||||
event.working_directory,
|
||||
scenario,
|
||||
temporaryRoot,
|
||||
terminal?.pid ?? -1,
|
||||
),
|
||||
})),
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
terminal?.kill("SIGKILL");
|
||||
|
|
@ -503,8 +652,14 @@ const runProcessScenario = async (
|
|||
timers,
|
||||
replay,
|
||||
terminal,
|
||||
renderer,
|
||||
shimReplay,
|
||||
checkpoints,
|
||||
runId,
|
||||
temporaryRoot,
|
||||
sampledProcessGroupIds: samples.flatMap(({ process_group_id }) =>
|
||||
process_group_id === null ? [] : [process_group_id],
|
||||
),
|
||||
});
|
||||
return resolveProcessResult(capture, executionFailure, cleanupFailure);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -46,7 +46,12 @@ export const normalizeProcessSamples = (
|
|||
): readonly ProcessSample[] => {
|
||||
const identifiers = [
|
||||
rootPid,
|
||||
...samples.flatMap((sample) => [sample.pid, sample.parent_pid]),
|
||||
...samples.flatMap((sample) => [
|
||||
sample.pid,
|
||||
sample.parent_pid,
|
||||
sample.process_group_id ?? 0,
|
||||
sample.session_id ?? 0,
|
||||
]),
|
||||
];
|
||||
const mapping = new Map<number, number>();
|
||||
for (const identifier of identifiers)
|
||||
|
|
@ -58,6 +63,12 @@ export const normalizeProcessSamples = (
|
|||
scenario.normalization.time_bucket_ms,
|
||||
pid: mapping.get(sample.pid) ?? 1,
|
||||
parent_pid: mapping.get(sample.parent_pid) ?? 0,
|
||||
process_group_id:
|
||||
sample.process_group_id === null
|
||||
? null
|
||||
: (mapping.get(sample.process_group_id) ?? 0),
|
||||
session_id:
|
||||
sample.session_id === null ? null : (mapping.get(sample.session_id) ?? 0),
|
||||
command: normalizeProcessText(
|
||||
sample.command,
|
||||
scenario,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ const PROC_READ_CONCURRENCY = 64;
|
|||
interface ProcessRow {
|
||||
readonly pid: number;
|
||||
readonly parent_pid: number;
|
||||
readonly process_group_id: number | null;
|
||||
readonly session_id: number | null;
|
||||
readonly command: string;
|
||||
readonly startTime: string | undefined;
|
||||
}
|
||||
|
|
@ -21,7 +23,6 @@ interface SampleProcessContext {
|
|||
readonly rootPid: number;
|
||||
readonly elapsedMs: number;
|
||||
readonly limit: number;
|
||||
readonly sampledPids: Set<number>;
|
||||
readonly identities: Map<number, string>;
|
||||
readonly signal: AbortSignal;
|
||||
}
|
||||
|
|
@ -51,12 +52,18 @@ const parseProcStat = (
|
|||
.split(/\s+/);
|
||||
const pid = Number(identifier);
|
||||
const parentPid = Number(fields[1]);
|
||||
const processGroupId = Number(fields[2]);
|
||||
const sessionId = Number(fields[3]);
|
||||
const startTime = fields[19];
|
||||
if (
|
||||
!Number.isSafeInteger(pid) ||
|
||||
pid <= 0 ||
|
||||
!Number.isSafeInteger(parentPid) ||
|
||||
parentPid < 0 ||
|
||||
!Number.isSafeInteger(processGroupId) ||
|
||||
processGroupId <= 0 ||
|
||||
!Number.isSafeInteger(sessionId) ||
|
||||
sessionId <= 0 ||
|
||||
startTime === undefined ||
|
||||
!/^\d+$/.test(startTime)
|
||||
)
|
||||
|
|
@ -64,6 +71,8 @@ const parseProcStat = (
|
|||
return {
|
||||
pid,
|
||||
parent_pid: parentPid,
|
||||
process_group_id: processGroupId,
|
||||
session_id: sessionId,
|
||||
command: "",
|
||||
startTime,
|
||||
};
|
||||
|
|
@ -153,6 +162,8 @@ const inspectProcess = async (
|
|||
return {
|
||||
pid: before.pid,
|
||||
parent_pid: before.parent_pid,
|
||||
process_group_id: before.process_group_id,
|
||||
session_id: before.session_id,
|
||||
startTime: before.startTime,
|
||||
command,
|
||||
children: children ?? [],
|
||||
|
|
@ -162,7 +173,7 @@ const inspectProcess = async (
|
|||
const sampleLinux = async (
|
||||
context: SampleProcessContext,
|
||||
): Promise<readonly ProcessRow[]> => {
|
||||
const { rootPid, limit, signal, sampledPids, identities } = context;
|
||||
const { rootPid, limit, signal, identities } = context;
|
||||
if (limit <= 0) return [];
|
||||
|
||||
const rootStat = await readProcessStat(rootPid, signal);
|
||||
|
|
@ -192,10 +203,8 @@ const sampleLinux = async (
|
|||
for (const node of batchResults) {
|
||||
if (node === undefined) continue;
|
||||
|
||||
if (!sampledPids.has(node.pid)) {
|
||||
if (rows.length >= limit) break;
|
||||
rows.push(node);
|
||||
}
|
||||
if (rows.length >= limit) break;
|
||||
rows.push(node);
|
||||
if (rows.length >= limit) break;
|
||||
|
||||
for (const child of node.children) {
|
||||
|
|
@ -217,17 +226,19 @@ const readPsRows = async (
|
|||
): Promise<readonly ProcessRow[]> => {
|
||||
const { stdout } = await execFileAsync(
|
||||
"ps",
|
||||
["-axo", "pid=,ppid=,command="],
|
||||
["-axo", "pid=,ppid=,pgid=,sess=,command="],
|
||||
{ signal },
|
||||
);
|
||||
return stdout
|
||||
.split("\n")
|
||||
.map((line) => /\s*(\d+)\s+(\d+)\s+(.*)/u.exec(line))
|
||||
.map((line) => /\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(.*)/u.exec(line))
|
||||
.filter((match): match is RegExpExecArray => match !== null)
|
||||
.map((match) => ({
|
||||
pid: Number(match[1]),
|
||||
parent_pid: Number(match[2]),
|
||||
command: match[3] ?? "",
|
||||
process_group_id: Number(match[3]),
|
||||
session_id: Number(match[4]),
|
||||
command: match[5] ?? "",
|
||||
startTime: undefined,
|
||||
}))
|
||||
.filter(
|
||||
|
|
@ -243,7 +254,7 @@ const readPsRows = async (
|
|||
const samplePs = async (
|
||||
context: SampleProcessContext,
|
||||
): Promise<readonly ProcessRow[]> => {
|
||||
const { rootPid, limit, signal, sampledPids } = context;
|
||||
const { rootPid, limit, signal } = context;
|
||||
if (limit <= 0) return [];
|
||||
|
||||
const rows = await readPsRows(signal);
|
||||
|
|
@ -268,9 +279,7 @@ const samplePs = async (
|
|||
visited.add(pid);
|
||||
|
||||
const row = rowByPid.get(pid);
|
||||
if (row !== undefined && !sampledPids.has(row.pid)) {
|
||||
result.push(row);
|
||||
}
|
||||
if (row !== undefined) result.push(row);
|
||||
if (result.length >= limit) break;
|
||||
|
||||
for (const child of childrenByParent.get(pid) ?? []) {
|
||||
|
|
@ -291,10 +300,9 @@ const sampleProcesses = async (
|
|||
? await sampleLinux(context)
|
||||
: await samplePs(context);
|
||||
|
||||
const { elapsedMs, sampledPids, identities } = context;
|
||||
const { elapsedMs, identities } = context;
|
||||
const samples: ProcessSample[] = [];
|
||||
for (const row of rows) {
|
||||
if (sampledPids.has(row.pid)) continue;
|
||||
if (row.startTime !== undefined) {
|
||||
const existing = identities.get(row.pid);
|
||||
if (existing !== undefined && existing !== row.startTime) continue;
|
||||
|
|
@ -305,6 +313,8 @@ const sampleProcesses = async (
|
|||
pid: row.pid,
|
||||
parent_pid: row.parent_pid,
|
||||
command: row.command,
|
||||
process_group_id: row.process_group_id,
|
||||
session_id: row.session_id,
|
||||
});
|
||||
}
|
||||
return samples;
|
||||
|
|
@ -318,30 +328,38 @@ export const startProcessSampler = (
|
|||
samples: ProcessSample[],
|
||||
): (() => Promise<{ readonly partial: boolean }>) => {
|
||||
const identities = new Map<number, string>();
|
||||
const sampledPids = new Set<number>(samples.map(({ pid }) => pid));
|
||||
const lastObservations = new Map<number, string>();
|
||||
let pending: Promise<void> | undefined;
|
||||
let stopped = false;
|
||||
let abortCurrent: (() => void) | undefined;
|
||||
let partial = false;
|
||||
|
||||
const sample = (): void => {
|
||||
if (stopped || pending !== undefined || sampledPids.size >= limit) return;
|
||||
if (stopped || pending !== undefined) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
abortCurrent = () => controller.abort();
|
||||
pending = sampleProcesses({
|
||||
rootPid,
|
||||
elapsedMs: Date.now() - started,
|
||||
limit: limit - sampledPids.size,
|
||||
sampledPids,
|
||||
limit: limit + 1,
|
||||
identities,
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((values) => {
|
||||
for (const value of values) {
|
||||
if (sampledPids.size >= limit) break;
|
||||
if (sampledPids.has(value.pid)) continue;
|
||||
sampledPids.add(value.pid);
|
||||
const observation = JSON.stringify({
|
||||
parent_pid: value.parent_pid,
|
||||
command: value.command,
|
||||
process_group_id: value.process_group_id,
|
||||
session_id: value.session_id,
|
||||
});
|
||||
if (lastObservations.get(value.pid) === observation) continue;
|
||||
if (samples.length >= limit) {
|
||||
partial = true;
|
||||
continue;
|
||||
}
|
||||
lastObservations.set(value.pid, observation);
|
||||
samples.push(value);
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,120 @@
|
|||
import { createRequire } from "node:module";
|
||||
|
||||
import type { RenderedTerminalFrame } from "../domain/processCapture.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
// SAFETY: both pinned xterm packages publish CommonJS at runtime and matching declarations.
|
||||
const HeadlessPackage =
|
||||
require("@xterm/headless") as typeof import("@xterm/headless");
|
||||
// SAFETY: the addon package is pinned with the compatible headless xterm release.
|
||||
const SerializePackage =
|
||||
require("@xterm/addon-serialize") as typeof import("@xterm/addon-serialize");
|
||||
|
||||
interface TerminalRendererOptions {
|
||||
readonly columns: number;
|
||||
readonly rows: number;
|
||||
readonly scrollback: number;
|
||||
readonly maxFrames: number;
|
||||
readonly maxBytes: number;
|
||||
readonly normalize: (value: string) => string;
|
||||
}
|
||||
|
||||
/** Owns one headless terminal and serializes writes into deterministic frames. */
|
||||
export class TerminalRenderer {
|
||||
readonly #terminal: InstanceType<typeof HeadlessPackage.Terminal>;
|
||||
readonly #serializeAddon = new SerializePackage.SerializeAddon();
|
||||
readonly #frames: RenderedTerminalFrame[] = [];
|
||||
#pending: Promise<void> = Promise.resolve();
|
||||
#capturedBytes = 0;
|
||||
#truncated = false;
|
||||
|
||||
constructor(private readonly options: TerminalRendererOptions) {
|
||||
this.#terminal = new HeadlessPackage.Terminal({
|
||||
allowProposedApi: true,
|
||||
cols: options.columns,
|
||||
rows: options.rows,
|
||||
scrollback: options.scrollback,
|
||||
});
|
||||
this.#terminal.loadAddon(this.#serializeAddon);
|
||||
}
|
||||
|
||||
/** Queue one PTY chunk and capture state only after xterm has parsed it. */
|
||||
write(data: string, atMs: number): void {
|
||||
this.#pending = this.#pending.then(
|
||||
() =>
|
||||
new Promise<void>((resolveWrite) => {
|
||||
this.#terminal.write(data, () => {
|
||||
this.#capture(atMs);
|
||||
resolveWrite();
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Queue a terminal resize after every preceding write. */
|
||||
resize(columns: number, rows: number, atMs: number): void {
|
||||
this.#pending = this.#pending.then(() => {
|
||||
this.#terminal.resize(columns, rows);
|
||||
this.#capture(atMs);
|
||||
});
|
||||
}
|
||||
|
||||
/** Await all queued parsing and return immutable rendered observations. */
|
||||
async frames(): Promise<readonly RenderedTerminalFrame[]> {
|
||||
await this.#pending;
|
||||
return this.#frames;
|
||||
}
|
||||
|
||||
/** Whether a rendered observation exceeded its independent capture budget. */
|
||||
truncated(): boolean {
|
||||
return this.#truncated;
|
||||
}
|
||||
|
||||
/** Release addon and terminal resources after all writes settle. */
|
||||
async dispose(): Promise<void> {
|
||||
await this.#pending;
|
||||
this.#serializeAddon.dispose();
|
||||
this.#terminal.dispose();
|
||||
}
|
||||
|
||||
#capture(atMs: number): void {
|
||||
const buffer = this.#terminal.buffer.active;
|
||||
const lines: string[] = [];
|
||||
for (let row = 0; row < this.#terminal.rows; row += 1) {
|
||||
const line = buffer.getLine(buffer.viewportY + row);
|
||||
lines.push(
|
||||
this.options.normalize(
|
||||
(line?.translateToString(false, 0, this.#terminal.cols) ?? "").padEnd(
|
||||
this.#terminal.cols,
|
||||
" ",
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
const serializedState = this.options.normalize(
|
||||
this.#serializeAddon.serialize(),
|
||||
);
|
||||
const bytes =
|
||||
Buffer.byteLength(serializedState) +
|
||||
lines.reduce((total, line) => total + Buffer.byteLength(line), 0);
|
||||
if (
|
||||
this.#frames.length >= this.options.maxFrames ||
|
||||
this.#capturedBytes + bytes > this.options.maxBytes
|
||||
) {
|
||||
this.#truncated = true;
|
||||
return;
|
||||
}
|
||||
this.#capturedBytes += bytes;
|
||||
this.#frames.push({
|
||||
sequence: this.#frames.length,
|
||||
at_ms: atMs,
|
||||
columns: this.#terminal.cols,
|
||||
rows: this.#terminal.rows,
|
||||
cursor_x: buffer.cursorX,
|
||||
cursor_y: buffer.cursorY,
|
||||
active_buffer: buffer.type,
|
||||
lines,
|
||||
serialized_state: serializedState,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import { logCliCommand } from "./cliLogging.js";
|
|||
import { parseConfig } from "./config.js";
|
||||
import { importReferenceSource } from "./application/ReferenceSourceImport.js";
|
||||
import { registerEvidenceCommands } from "./cliEvidenceCommands.js";
|
||||
import { registerProcessCommands } from "./cliProcessCommands.js";
|
||||
|
||||
/**
|
||||
* Build the one-shot Incur CLI without starting Hopper at import time.
|
||||
|
|
@ -60,6 +61,7 @@ export const createCli = (): ReturnType<typeof Cli.create> => {
|
|||
registerArtifactCommands(cli, logger);
|
||||
registerEvidenceCommands(cli, logger);
|
||||
registerReferenceSourceCommand(cli, logger);
|
||||
registerProcessCommands(cli, logger);
|
||||
return cli;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
import { Cli, z } from "incur";
|
||||
|
||||
import {
|
||||
captureProcessScenarioFile,
|
||||
compareProcessEvidenceFiles,
|
||||
} from "./application/ProcessCli.js";
|
||||
import { logCliCommand } from "./cliLogging.js";
|
||||
import type { Logger } from "./logger.js";
|
||||
|
||||
/** Register Process Capture v3 one-shot commands through shared application services. */
|
||||
export const registerProcessCommands = (
|
||||
cli: ReturnType<typeof Cli.create>,
|
||||
logger: Logger,
|
||||
): void => {
|
||||
cli.command("capture-process", {
|
||||
description: "Capture one approved Process Capture v3 JSON scenario",
|
||||
args: z.object({ scenario: z.string().describe("Scenario JSON path") }),
|
||||
run: ({ args }) =>
|
||||
logCliCommand(logger, "capture-process", () =>
|
||||
captureProcessScenarioFile(args.scenario),
|
||||
),
|
||||
});
|
||||
cli.command("compare-process-captures", {
|
||||
description: "Compare two Process Capture v3 Evidence JSON files",
|
||||
args: z.object({
|
||||
left: z.string().describe("Left capture Evidence JSON path"),
|
||||
right: z.string().describe("Right capture Evidence JSON path"),
|
||||
}),
|
||||
run: ({ args }) =>
|
||||
logCliCommand(logger, "compare-process-captures", () =>
|
||||
compareProcessEvidenceFiles(args.left, args.right),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
|
@ -45,12 +45,12 @@ export const FUNCTION_COMPARISON_EVIDENCE = createEvidence(
|
|||
const PROCESS_PROVIDER = {
|
||||
id: "rea-process",
|
||||
name: "REA deterministic process harness",
|
||||
version: "1",
|
||||
version: "2",
|
||||
} as const;
|
||||
const capture = processCaptureSchema.parse(EMPTY_PROCESS_CAPTURE_EXAMPLE);
|
||||
const captureEvidence = (scenario: string) =>
|
||||
createEvidence(undefined, PROCESS_PROVIDER, {
|
||||
predicateType: "rea.process-capture/v2",
|
||||
predicateType: "rea.process-capture/v3",
|
||||
operation: "capture_process_scenario",
|
||||
parameters: { scenario },
|
||||
result: jsonValueSchema.parse(capture),
|
||||
|
|
@ -69,7 +69,7 @@ export const PROCESS_COMPARISON_EVIDENCE = createEvidence(
|
|||
undefined,
|
||||
PROCESS_PROVIDER,
|
||||
{
|
||||
predicateType: "rea.process-comparison/v1",
|
||||
predicateType: "rea.process-comparison/v2",
|
||||
operation: "compare_process_captures",
|
||||
parameters: {
|
||||
left_evidence_id: PROCESS_CAPTURE_REFERENCE.evidence_id,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { JsonValue } from "../domain/jsonValue.js";
|
|||
|
||||
/** Minimal valid process capture used only in public contract examples. */
|
||||
export const EMPTY_PROCESS_CAPTURE_EXAMPLE = {
|
||||
schema_version: 2,
|
||||
schema_version: 3,
|
||||
normalization: {
|
||||
paths: true,
|
||||
pids: true,
|
||||
|
|
@ -11,8 +11,12 @@ export const EMPTY_PROCESS_CAPTURE_EXAMPLE = {
|
|||
patterns: [],
|
||||
},
|
||||
frames: [],
|
||||
rendered_frames: [],
|
||||
interaction_events: [],
|
||||
exit: { code: 0, signal: null, reason: "exited" },
|
||||
process_samples: [],
|
||||
filesystem_checkpoints: [],
|
||||
shim_events: [],
|
||||
protocol_events: [],
|
||||
files_before: [],
|
||||
files_after: [],
|
||||
|
|
|
|||
|
|
@ -432,12 +432,12 @@ export const SESSION_TOOL_CONTRACTS = [
|
|||
),
|
||||
session(
|
||||
"capture_process_scenario",
|
||||
"Run one bounded process under a PTY using operator-approved executable and working roots. Requires approved: true; unknown_registry_approved: true separately records capture residuals. Captures normalized terminal frames, descendants, filesystem snapshots, and loopback replay. Disabled unless operator policy enables it; not a security sandbox.",
|
||||
"Run one bounded process under a PTY using operator-approved executable and working roots. Requires approved: true; unknown_registry_approved: true separately records capture residuals. Captures raw and xterm-rendered terminal frames, scripted interactions, lifecycle filesystem checkpoints, process ownership, declarative command shims, and loopback replay. Disabled unless operator policy enables it; not a security sandbox.",
|
||||
processScenarioSchema,
|
||||
),
|
||||
session(
|
||||
"compare_process_captures",
|
||||
"Compare two bounded process captures across terminal, exit, sampled process, filesystem, HTTP, and WebSocket evidence. Missing or truncated observations are never treated as equivalent.",
|
||||
"Compare two Process Capture v3 observations across terminal, interaction, exit, process, filesystem, command-shim, HTTP, and WebSocket evidence, returning the first bounded divergence. Missing or truncated observations are never treated as equivalent.",
|
||||
z.object({
|
||||
left_evidence_id: z.string().regex(/^ev_[a-f0-9]{64}$/u),
|
||||
left: processCaptureSchema,
|
||||
|
|
|
|||
|
|
@ -64,10 +64,10 @@ type RuntimeStatus = z.infer<typeof processCaptureComparisonSchema>["status"];
|
|||
|
||||
const EXPECTED_PROVIDERS = {
|
||||
compare_process_captures: {
|
||||
predicate: "rea.process-comparison/v1",
|
||||
predicate: "rea.process-comparison/v2",
|
||||
id: "rea-process",
|
||||
name: "REA deterministic process harness",
|
||||
version: "1",
|
||||
version: "2",
|
||||
},
|
||||
compare_artifacts: {
|
||||
predicate: "rea.artifact-comparison/v1",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const reservedEnvironment = new Set([
|
|||
"REA_PROCESS_RUN_ID",
|
||||
"REA_REPLAY_HTTP_URL",
|
||||
"REA_REPLAY_WEBSOCKET_URL",
|
||||
"REA_SHIM_LEDGER_URL",
|
||||
]);
|
||||
const normalizationSchema = z.object({
|
||||
paths: z.boolean(),
|
||||
|
|
@ -23,6 +24,17 @@ const normalizationSchema = z.object({
|
|||
}),
|
||||
),
|
||||
});
|
||||
const checkpointNameSchema = z
|
||||
.string()
|
||||
.regex(/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/u);
|
||||
const commandNameSchema = z
|
||||
.string()
|
||||
.regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u);
|
||||
const outputChunkSchema = z.object({
|
||||
at_ms: z.number().int().nonnegative().max(300_000),
|
||||
stream: z.enum(["stdout", "stderr"]),
|
||||
data: z.string().max(1_000_000),
|
||||
});
|
||||
|
||||
/** Exact boundary schema for bounded dynamic process scenarios. */
|
||||
export const processScenarioSchema = z
|
||||
|
|
@ -44,6 +56,62 @@ export const processScenarioSchema = z
|
|||
secret_aliases: z.array(environmentName).max(64).default([]),
|
||||
network_access: z.literal("host").default("host"),
|
||||
filesystem_roots: z.array(z.string().startsWith("/")).max(16).default([]),
|
||||
terminal: z
|
||||
.object({
|
||||
columns: z.number().int().min(1).max(1_000).default(80),
|
||||
rows: z.number().int().min(1).max(1_000).default(24),
|
||||
scrollback: z.number().int().min(0).max(10_000).default(1_000),
|
||||
})
|
||||
.default({ columns: 80, rows: 24, scrollback: 1_000 }),
|
||||
checkpoints: z
|
||||
.array(
|
||||
z.object({
|
||||
name: checkpointNameSchema,
|
||||
trigger: z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("time"),
|
||||
at_ms: z.number().int().nonnegative().max(300_000),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("terminal_literal"),
|
||||
value: z.string().min(1).max(10_000),
|
||||
occurrence: z.number().int().positive().max(1_000).default(1),
|
||||
}),
|
||||
z.object({ type: z.literal("root_exit") }),
|
||||
z.object({ type: z.literal("settled") }),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
.max(64)
|
||||
.default([]),
|
||||
command_shims: z
|
||||
.array(
|
||||
z.object({
|
||||
name: commandNameSchema,
|
||||
routes: z
|
||||
.array(
|
||||
z.object({
|
||||
arguments: z.array(z.string()).max(256),
|
||||
outputs: z.array(outputChunkSchema).max(1_000).default([]),
|
||||
termination: z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("exit"),
|
||||
code: z.number().int().min(0).max(255),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("signal"),
|
||||
signal: z.enum(["SIGINT", "SIGTERM", "SIGKILL"]),
|
||||
}),
|
||||
]),
|
||||
max_calls: z.number().int().positive().max(100).default(1),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(100),
|
||||
}),
|
||||
)
|
||||
.max(32)
|
||||
.default([]),
|
||||
events: z
|
||||
.array(
|
||||
z.discriminatedUnion("type", [
|
||||
|
|
@ -51,6 +119,7 @@ export const processScenarioSchema = z
|
|||
...timedEventBase,
|
||||
type: z.literal("input"),
|
||||
data: z.string(),
|
||||
sensitive: z.boolean().default(false),
|
||||
}),
|
||||
z.object({
|
||||
...timedEventBase,
|
||||
|
|
@ -216,6 +285,28 @@ export const processScenarioSchema = z
|
|||
});
|
||||
}
|
||||
}
|
||||
const checkpointNames = scenario.checkpoints.map(({ name }) => name);
|
||||
for (const [index, name] of checkpointNames.entries()) {
|
||||
if (name === "before" || name === "after_settlement")
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "checkpoint name is reserved by the capture lifecycle",
|
||||
path: ["checkpoints", index, "name"],
|
||||
});
|
||||
}
|
||||
if (new Set(checkpointNames).size !== checkpointNames.length)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "checkpoint names must be unique",
|
||||
path: ["checkpoints"],
|
||||
});
|
||||
const shimNames = scenario.command_shims.map(({ name }) => name);
|
||||
if (new Set(shimNames).size !== shimNames.length)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "command shim names must be unique",
|
||||
path: ["command_shims"],
|
||||
});
|
||||
});
|
||||
|
||||
/** A parsed, bounded dynamic process observation scenario. */
|
||||
|
|
@ -304,6 +395,29 @@ export interface TerminalFrame {
|
|||
readonly data: string;
|
||||
}
|
||||
|
||||
/** One rendered terminal state after xterm has parsed a PTY chunk or resize. */
|
||||
export interface RenderedTerminalFrame {
|
||||
readonly sequence: number;
|
||||
readonly at_ms: number;
|
||||
readonly columns: number;
|
||||
readonly rows: number;
|
||||
readonly cursor_x: number;
|
||||
readonly cursor_y: number;
|
||||
readonly active_buffer: "normal" | "alternate";
|
||||
readonly lines: readonly string[];
|
||||
readonly serialized_state: string;
|
||||
}
|
||||
|
||||
/** One attempted scripted interaction with the PTY. */
|
||||
export interface InteractionEvent {
|
||||
readonly sequence: number;
|
||||
readonly scheduled_at_ms: number;
|
||||
readonly dispatched_at_ms: number;
|
||||
readonly type: "input" | "resize" | "signal";
|
||||
readonly data: string;
|
||||
readonly outcome: "dispatched" | "target_exited" | "failed";
|
||||
}
|
||||
|
||||
/** One filesystem state used for before/after comparison. */
|
||||
export interface FileState {
|
||||
readonly path: string;
|
||||
|
|
@ -327,6 +441,27 @@ export interface ProcessSample {
|
|||
readonly pid: number;
|
||||
readonly parent_pid: number;
|
||||
readonly command: string;
|
||||
readonly process_group_id: number | null;
|
||||
readonly session_id: number | null;
|
||||
}
|
||||
|
||||
/** A named filesystem observation captured during a process lifecycle. */
|
||||
export interface FilesystemCheckpoint {
|
||||
readonly name: string;
|
||||
readonly at_ms: number;
|
||||
readonly files: readonly FileState[];
|
||||
readonly effects: readonly FileEffect[];
|
||||
readonly truncated: boolean;
|
||||
}
|
||||
|
||||
/** One invocation observed by the declarative command-shim replay adapter. */
|
||||
export interface ShimEvent {
|
||||
readonly sequence: number;
|
||||
readonly at_ms: number;
|
||||
readonly command: string;
|
||||
readonly arguments: readonly string[];
|
||||
readonly working_directory: string;
|
||||
readonly outcome: "matched" | "unmatched" | "exhausted";
|
||||
}
|
||||
|
||||
/** A bounded loopback replay observation. */
|
||||
|
|
@ -347,15 +482,19 @@ export interface ProtocolEvent {
|
|||
|
||||
/** A bounded process observation with explicit incompleteness metadata. */
|
||||
export interface ProcessCapture {
|
||||
readonly schema_version: 2;
|
||||
readonly schema_version: 3;
|
||||
readonly normalization: z.infer<typeof normalizationSchema>;
|
||||
readonly frames: readonly TerminalFrame[];
|
||||
readonly rendered_frames: readonly RenderedTerminalFrame[];
|
||||
readonly interaction_events: readonly InteractionEvent[];
|
||||
readonly exit: {
|
||||
readonly code: number | null;
|
||||
readonly signal: number | null;
|
||||
readonly reason: "exited" | "timeout" | "idle_timeout";
|
||||
};
|
||||
readonly process_samples: readonly ProcessSample[];
|
||||
readonly filesystem_checkpoints: readonly FilesystemCheckpoint[];
|
||||
readonly shim_events: readonly ShimEvent[];
|
||||
readonly protocol_events: readonly ProtocolEvent[];
|
||||
readonly files_before: readonly FileState[];
|
||||
readonly files_after: readonly FileState[];
|
||||
|
|
@ -379,9 +518,27 @@ export interface ProcessCapture {
|
|||
};
|
||||
}
|
||||
|
||||
const fileStateSchema = z.object({
|
||||
path: z.string(),
|
||||
type: z.enum(["file", "directory", "symlink", "other"]),
|
||||
mode: z.number().int().nonnegative(),
|
||||
size: z.number().int().nonnegative(),
|
||||
sha256: z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{64}$/u)
|
||||
.nullable(),
|
||||
symlink_target: z.string().nullable(),
|
||||
});
|
||||
const fileEffectSchema = z.object({
|
||||
path: z.string(),
|
||||
status: z.enum(["created", "modified", "deleted", "unchanged"]),
|
||||
before: fileStateSchema.nullable(),
|
||||
after: fileStateSchema.nullable(),
|
||||
});
|
||||
|
||||
/** Exact serialized shape of a bounded process capture. */
|
||||
export const processCaptureSchema: z.ZodType<ProcessCapture> = z.object({
|
||||
schema_version: z.literal(2),
|
||||
schema_version: z.literal(3),
|
||||
normalization: normalizationSchema,
|
||||
frames: z.array(
|
||||
z.object({
|
||||
|
|
@ -390,6 +547,29 @@ export const processCaptureSchema: z.ZodType<ProcessCapture> = z.object({
|
|||
data: z.string(),
|
||||
}),
|
||||
),
|
||||
rendered_frames: z.array(
|
||||
z.object({
|
||||
sequence: z.number().int().nonnegative(),
|
||||
at_ms: z.number().int().nonnegative(),
|
||||
columns: z.number().int().positive(),
|
||||
rows: z.number().int().positive(),
|
||||
cursor_x: z.number().int().nonnegative(),
|
||||
cursor_y: z.number().int().nonnegative(),
|
||||
active_buffer: z.enum(["normal", "alternate"]),
|
||||
lines: z.array(z.string()),
|
||||
serialized_state: z.string(),
|
||||
}),
|
||||
),
|
||||
interaction_events: z.array(
|
||||
z.object({
|
||||
sequence: z.number().int().nonnegative(),
|
||||
scheduled_at_ms: z.number().int().nonnegative(),
|
||||
dispatched_at_ms: z.number().int().nonnegative(),
|
||||
type: z.enum(["input", "resize", "signal"]),
|
||||
data: z.string(),
|
||||
outcome: z.enum(["dispatched", "target_exited", "failed"]),
|
||||
}),
|
||||
),
|
||||
exit: z.object({
|
||||
code: z.number().int().nullable(),
|
||||
signal: z.number().int().nullable(),
|
||||
|
|
@ -401,6 +581,27 @@ export const processCaptureSchema: z.ZodType<ProcessCapture> = z.object({
|
|||
pid: z.number().int().positive(),
|
||||
parent_pid: z.number().int().nonnegative(),
|
||||
command: z.string(),
|
||||
process_group_id: z.number().int().positive().nullable(),
|
||||
session_id: z.number().int().nonnegative().nullable(),
|
||||
}),
|
||||
),
|
||||
filesystem_checkpoints: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
at_ms: z.number().int().nonnegative(),
|
||||
files: z.array(fileStateSchema),
|
||||
effects: z.array(fileEffectSchema),
|
||||
truncated: z.boolean(),
|
||||
}),
|
||||
),
|
||||
shim_events: z.array(
|
||||
z.object({
|
||||
sequence: z.number().int().nonnegative(),
|
||||
at_ms: z.number().int().nonnegative(),
|
||||
command: z.string(),
|
||||
arguments: z.array(z.string()),
|
||||
working_directory: z.string(),
|
||||
outcome: z.enum(["matched", "unmatched", "exhausted"]),
|
||||
}),
|
||||
),
|
||||
protocol_events: z.array(
|
||||
|
|
@ -420,64 +621,9 @@ export const processCaptureSchema: z.ZodType<ProcessCapture> = z.object({
|
|||
]),
|
||||
}),
|
||||
),
|
||||
files_before: z.array(
|
||||
z.object({
|
||||
path: z.string(),
|
||||
type: z.enum(["file", "directory", "symlink", "other"]),
|
||||
mode: z.number().int().nonnegative(),
|
||||
size: z.number().int().nonnegative(),
|
||||
sha256: z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{64}$/u)
|
||||
.nullable(),
|
||||
symlink_target: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
files_after: z.array(
|
||||
z.object({
|
||||
path: z.string(),
|
||||
type: z.enum(["file", "directory", "symlink", "other"]),
|
||||
mode: z.number().int().nonnegative(),
|
||||
size: z.number().int().nonnegative(),
|
||||
sha256: z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{64}$/u)
|
||||
.nullable(),
|
||||
symlink_target: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
filesystem_effects: z.array(
|
||||
z.object({
|
||||
path: z.string(),
|
||||
status: z.enum(["created", "modified", "deleted", "unchanged"]),
|
||||
before: z
|
||||
.object({
|
||||
path: z.string(),
|
||||
type: z.enum(["file", "directory", "symlink", "other"]),
|
||||
mode: z.number().int().nonnegative(),
|
||||
size: z.number().int().nonnegative(),
|
||||
sha256: z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{64}$/u)
|
||||
.nullable(),
|
||||
symlink_target: z.string().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
after: z
|
||||
.object({
|
||||
path: z.string(),
|
||||
type: z.enum(["file", "directory", "symlink", "other"]),
|
||||
mode: z.number().int().nonnegative(),
|
||||
size: z.number().int().nonnegative(),
|
||||
sha256: z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{64}$/u)
|
||||
.nullable(),
|
||||
symlink_target: z.string().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
}),
|
||||
),
|
||||
files_before: z.array(fileStateSchema),
|
||||
files_after: z.array(fileStateSchema),
|
||||
filesystem_effects: z.array(fileEffectSchema),
|
||||
truncated: z.boolean(),
|
||||
limitations: z.array(z.string()),
|
||||
residual_unknowns: z.array(
|
||||
|
|
|
|||
|
|
@ -18,19 +18,44 @@ export const processCaptureComparisonSchema = z
|
|||
.object({
|
||||
status: comparisonStatusSchema,
|
||||
terminal: comparisonStatusSchema,
|
||||
interaction: comparisonStatusSchema,
|
||||
exit: comparisonStatusSchema,
|
||||
filesystem: comparisonStatusSchema,
|
||||
protocol: comparisonStatusSchema,
|
||||
process: comparisonStatusSchema,
|
||||
shim: comparisonStatusSchema,
|
||||
first_divergence: z.discriminatedUnion("status", [
|
||||
z.object({ status: z.literal("none") }),
|
||||
z.object({ status: z.literal("unknown"), reason: z.string() }),
|
||||
z.object({
|
||||
status: z.literal("found"),
|
||||
dimension: z.enum([
|
||||
"terminal",
|
||||
"interaction",
|
||||
"exit",
|
||||
"filesystem",
|
||||
"protocol",
|
||||
"process",
|
||||
"shim",
|
||||
]),
|
||||
index: z.number().int().nonnegative(),
|
||||
left_at_ms: z.number().int().nonnegative().nullable(),
|
||||
right_at_ms: z.number().int().nonnegative().nullable(),
|
||||
left: z.unknown().nullable(),
|
||||
right: z.unknown().nullable(),
|
||||
}),
|
||||
]),
|
||||
limitations: z.array(z.string()),
|
||||
})
|
||||
.superRefine((comparison, context) => {
|
||||
const dimensions = [
|
||||
comparison.terminal,
|
||||
comparison.interaction,
|
||||
comparison.exit,
|
||||
comparison.filesystem,
|
||||
comparison.protocol,
|
||||
comparison.process,
|
||||
comparison.shim,
|
||||
];
|
||||
const expected = dimensions.includes("truncated")
|
||||
? "truncated"
|
||||
|
|
@ -50,12 +75,30 @@ export const processCaptureComparisonSchema = z
|
|||
});
|
||||
type ProcessCaptureComparison = z.infer<typeof processCaptureComparisonSchema>;
|
||||
|
||||
const stableFiles = (capture: ProcessCapture): string =>
|
||||
JSON.stringify(
|
||||
[...capture.files_after].sort((left, right) =>
|
||||
left.path.localeCompare(right.path),
|
||||
),
|
||||
);
|
||||
const terminalObservations = (capture: ProcessCapture): readonly unknown[] =>
|
||||
[
|
||||
...capture.frames.map((frame) => ({ kind: "raw" as const, ...frame })),
|
||||
...capture.rendered_frames.map((frame) => ({
|
||||
kind: "rendered" as const,
|
||||
...frame,
|
||||
})),
|
||||
].sort((left, right) => {
|
||||
const time = left.at_ms - right.at_ms;
|
||||
if (time !== 0) return time;
|
||||
if (left.kind !== right.kind) return left.kind === "raw" ? -1 : 1;
|
||||
return left.sequence - right.sequence;
|
||||
});
|
||||
|
||||
const filesystemObservations = (
|
||||
capture: ProcessCapture,
|
||||
): readonly unknown[] => [
|
||||
...capture.filesystem_checkpoints,
|
||||
{
|
||||
name: "final",
|
||||
files: capture.files_after,
|
||||
effects: capture.filesystem_effects,
|
||||
},
|
||||
];
|
||||
|
||||
const classifyCollection = (
|
||||
left: readonly unknown[],
|
||||
|
|
@ -84,6 +127,75 @@ const comparisonStatus = (
|
|||
return dimensions.includes("unknown") ? "unknown" : "unchanged";
|
||||
};
|
||||
|
||||
type DivergenceDimension = Exclude<
|
||||
ProcessCaptureComparison["first_divergence"],
|
||||
{ readonly status: "none" | "unknown" }
|
||||
>["dimension"];
|
||||
|
||||
const eventTime = (value: unknown): number | null => {
|
||||
if (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"at_ms" in value &&
|
||||
typeof value.at_ms === "number" &&
|
||||
Number.isSafeInteger(value.at_ms) &&
|
||||
value.at_ms >= 0
|
||||
)
|
||||
return value.at_ms;
|
||||
return null;
|
||||
};
|
||||
|
||||
const firstCollectionDivergence = (
|
||||
dimension: DivergenceDimension,
|
||||
left: readonly unknown[],
|
||||
right: readonly unknown[],
|
||||
): Extract<
|
||||
ProcessCaptureComparison["first_divergence"],
|
||||
{ readonly status: "found" }
|
||||
> | null => {
|
||||
const length = Math.max(left.length, right.length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const leftValue = left[index] ?? null;
|
||||
const rightValue = right[index] ?? null;
|
||||
if (JSON.stringify(leftValue) === JSON.stringify(rightValue)) continue;
|
||||
return {
|
||||
status: "found",
|
||||
dimension,
|
||||
index,
|
||||
left_at_ms: eventTime(leftValue),
|
||||
right_at_ms: eventTime(rightValue),
|
||||
left: leftValue,
|
||||
right: rightValue,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const chooseFirstDivergence = (
|
||||
candidates: ReadonlyArray<Extract<
|
||||
ProcessCaptureComparison["first_divergence"],
|
||||
{ readonly status: "found" }
|
||||
> | null>,
|
||||
): ProcessCaptureComparison["first_divergence"] => {
|
||||
const found = candidates.filter(
|
||||
(candidate): candidate is NonNullable<typeof candidate> =>
|
||||
candidate !== null,
|
||||
);
|
||||
return (
|
||||
found.sort((left, right) => {
|
||||
const leftTime = Math.min(
|
||||
left.left_at_ms ?? Number.MAX_SAFE_INTEGER,
|
||||
left.right_at_ms ?? Number.MAX_SAFE_INTEGER,
|
||||
);
|
||||
const rightTime = Math.min(
|
||||
right.left_at_ms ?? Number.MAX_SAFE_INTEGER,
|
||||
right.right_at_ms ?? Number.MAX_SAFE_INTEGER,
|
||||
);
|
||||
return leftTime - rightTime;
|
||||
})[0] ?? { status: "none" }
|
||||
);
|
||||
};
|
||||
|
||||
/** Compare bounded captures without equating missing or incompatible evidence. */
|
||||
export const compareProcessCaptures = (
|
||||
left: ProcessCapture,
|
||||
|
|
@ -93,10 +205,16 @@ export const compareProcessCaptures = (
|
|||
return {
|
||||
status: "truncated",
|
||||
terminal: "truncated",
|
||||
interaction: "truncated",
|
||||
exit: "truncated",
|
||||
filesystem: "truncated",
|
||||
protocol: "truncated",
|
||||
process: "truncated",
|
||||
shim: "truncated",
|
||||
first_divergence: {
|
||||
status: "unknown",
|
||||
reason: "At least one capture is truncated.",
|
||||
},
|
||||
limitations: ["At least one capture is truncated."],
|
||||
};
|
||||
}
|
||||
|
|
@ -112,8 +230,13 @@ export const compareProcessCaptures = (
|
|||
: classifyCollection(leftValues, rightValues);
|
||||
const terminal = classify(
|
||||
"terminal",
|
||||
left.frames.map(({ data }) => data),
|
||||
right.frames.map(({ data }) => data),
|
||||
terminalObservations(left),
|
||||
terminalObservations(right),
|
||||
);
|
||||
const interaction = classify(
|
||||
"terminal",
|
||||
left.interaction_events,
|
||||
right.interaction_events,
|
||||
);
|
||||
const exit =
|
||||
hasUnknown(left, "exit") || hasUnknown(right, "exit")
|
||||
|
|
@ -121,17 +244,11 @@ export const compareProcessCaptures = (
|
|||
: JSON.stringify(left.exit) === JSON.stringify(right.exit)
|
||||
? "unchanged"
|
||||
: "changed";
|
||||
const filesystem =
|
||||
!hasUnknown(left, "filesystem") &&
|
||||
!hasUnknown(right, "filesystem") &&
|
||||
sameNormalization &&
|
||||
stableFiles(left) === stableFiles(right)
|
||||
? "unchanged"
|
||||
: classify(
|
||||
"filesystem",
|
||||
left.filesystem_effects,
|
||||
right.filesystem_effects,
|
||||
);
|
||||
const filesystem = classify(
|
||||
"filesystem",
|
||||
filesystemObservations(left),
|
||||
filesystemObservations(right),
|
||||
);
|
||||
const protocol = classify(
|
||||
"protocol",
|
||||
left.protocol_events,
|
||||
|
|
@ -142,13 +259,68 @@ export const compareProcessCaptures = (
|
|||
left.process_samples,
|
||||
right.process_samples,
|
||||
);
|
||||
const shim = sameNormalization
|
||||
? classifyCollection(left.shim_events, right.shim_events)
|
||||
: "unknown";
|
||||
const observedFirstDivergence = chooseFirstDivergence([
|
||||
firstCollectionDivergence(
|
||||
"terminal",
|
||||
terminalObservations(left),
|
||||
terminalObservations(right),
|
||||
),
|
||||
firstCollectionDivergence(
|
||||
"interaction",
|
||||
left.interaction_events,
|
||||
right.interaction_events,
|
||||
),
|
||||
firstCollectionDivergence("exit", [left.exit], [right.exit]),
|
||||
firstCollectionDivergence(
|
||||
"filesystem",
|
||||
filesystemObservations(left),
|
||||
filesystemObservations(right),
|
||||
),
|
||||
firstCollectionDivergence(
|
||||
"protocol",
|
||||
left.protocol_events,
|
||||
right.protocol_events,
|
||||
),
|
||||
firstCollectionDivergence(
|
||||
"process",
|
||||
left.process_samples,
|
||||
right.process_samples,
|
||||
),
|
||||
firstCollectionDivergence("shim", left.shim_events, right.shim_events),
|
||||
]);
|
||||
const firstDivergence =
|
||||
observedFirstDivergence.status === "found"
|
||||
? observedFirstDivergence
|
||||
: !sameNormalization ||
|
||||
left.residual_unknowns.length > 0 ||
|
||||
right.residual_unknowns.length > 0
|
||||
? ({
|
||||
status: "unknown",
|
||||
reason:
|
||||
"Residual unknowns prevent proving that no divergence occurred.",
|
||||
} as const)
|
||||
: observedFirstDivergence;
|
||||
return {
|
||||
status: comparisonStatus([terminal, exit, filesystem, protocol, process]),
|
||||
status: comparisonStatus([
|
||||
terminal,
|
||||
interaction,
|
||||
exit,
|
||||
filesystem,
|
||||
protocol,
|
||||
process,
|
||||
shim,
|
||||
]),
|
||||
terminal,
|
||||
interaction,
|
||||
exit,
|
||||
filesystem,
|
||||
protocol,
|
||||
process,
|
||||
shim,
|
||||
first_divergence: firstDivergence,
|
||||
limitations: [
|
||||
...left.limitations,
|
||||
...right.limitations,
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ const MAX_CANONICAL_BYTES = 32 * 1024 * 1024;
|
|||
const providers = {
|
||||
behavioral: {
|
||||
operation: "compare_process_captures",
|
||||
predicate: "rea.process-comparison/v1",
|
||||
predicate: "rea.process-comparison/v2",
|
||||
id: "rea-process",
|
||||
name: "REA deterministic process harness",
|
||||
},
|
||||
|
|
@ -199,7 +199,7 @@ const validateSourceKinds = (
|
|||
): void => {
|
||||
const expected =
|
||||
claim.kind === "behavioral"
|
||||
? ["capture_process_scenario", "rea.process-capture/v2"]
|
||||
? ["capture_process_scenario", "rea.process-capture/v3"]
|
||||
: claim.kind === "structural-function"
|
||||
? ["analyze_function", "rea.analysis/v2"]
|
||||
: ["inventory_artifact", "rea.analysis/v2"];
|
||||
|
|
@ -230,7 +230,7 @@ const validateComparisonIdentity = (claim: Claim, evidence: Evidence): void => {
|
|||
evidence.predicate_type !== expected.predicate ||
|
||||
evidence.provider.id !== expected.id ||
|
||||
evidence.provider.name !== expected.name ||
|
||||
evidence.provider.version !== "1" ||
|
||||
evidence.provider.version !== (claim.kind === "behavioral" ? "2" : "1") ||
|
||||
evidence.confidence !== "derived" ||
|
||||
evidence.authority !== "analyst-inference" ||
|
||||
evidence.subject !== null
|
||||
|
|
|
|||
|
|
@ -158,7 +158,7 @@ const EXPECTED = {
|
|||
"REA function comparison",
|
||||
],
|
||||
compare_process_captures: [
|
||||
"rea.process-comparison/v1",
|
||||
"rea.process-comparison/v2",
|
||||
"rea-process",
|
||||
"REA deterministic process harness",
|
||||
],
|
||||
|
|
@ -402,7 +402,8 @@ const assertComparisonIdentity = (evidence: Evidence): void => {
|
|||
evidence.predicate_type !== expected[0] ||
|
||||
evidence.provider.id !== expected[1] ||
|
||||
evidence.provider.name !== expected[2] ||
|
||||
evidence.provider.version !== "1" ||
|
||||
evidence.provider.version !==
|
||||
(evidence.operation === "compare_process_captures" ? "2" : "1") ||
|
||||
evidence.confidence !== "derived" ||
|
||||
evidence.authority !== "analyst-inference" ||
|
||||
evidence.subject !== null ||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export const registerProcessComparisonTool = (
|
|||
if (!validated.ok) return toCallToolResult(validated, contract);
|
||||
const comparison = compareProcessCaptures(parsed.left, parsed.right);
|
||||
const evidence = createEvidence(undefined, PROCESS_PROVIDER, {
|
||||
predicateType: "rea.process-comparison/v1",
|
||||
predicateType: "rea.process-comparison/v2",
|
||||
operation: contract.name,
|
||||
parameters: {
|
||||
left_evidence_id: parsed.left_evidence_id,
|
||||
|
|
@ -85,7 +85,7 @@ const validateCaptureSources = (
|
|||
if (
|
||||
evidence === undefined ||
|
||||
evidence.operation !== "capture_process_scenario" ||
|
||||
evidence.predicate_type !== "rea.process-capture/v2" ||
|
||||
evidence.predicate_type !== "rea.process-capture/v3" ||
|
||||
evidence.provider.id !== PROCESS_PROVIDER.id ||
|
||||
evidence.provider.name !== PROCESS_PROVIDER.name ||
|
||||
evidence.provider.version !== PROCESS_PROVIDER.version ||
|
||||
|
|
|
|||
|
|
@ -14,8 +14,7 @@ import type {
|
|||
} from "../domain/processCapture.js";
|
||||
import { processScenarioSchema } from "../domain/processCapture.js";
|
||||
import { captureProcessScenario } from "../application/ProcessHarness.js";
|
||||
import { createEvidence, type Evidence } from "../domain/evidence.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import {
|
||||
recordUnknownInputSchema,
|
||||
updateUnknownInputSchema,
|
||||
|
|
@ -29,8 +28,8 @@ import { UnknownRegistryError, type AnalysisError } from "../domain/errors.js";
|
|||
import {
|
||||
DENY_EVIDENCE_FILE_POLICY,
|
||||
DENY_PROCESS_POLICY,
|
||||
PROCESS_PROVIDER,
|
||||
} from "./sessionToolPolicies.js";
|
||||
import { createProcessCaptureEvidence } from "../application/ProcessEvidence.js";
|
||||
import { registerProcessComparisonTool } from "./registerProcessComparisonTool.js";
|
||||
import { registerArtifactComparisonTool } from "./registerArtifactComparisonTool.js";
|
||||
import { registerFunctionComparisonTool } from "./registerFunctionComparisonTool.js";
|
||||
|
|
@ -76,16 +75,6 @@ const recordProcessResidualUnknowns = (
|
|||
return ok(null);
|
||||
};
|
||||
|
||||
const processEvidenceParameters = (
|
||||
scenario: ProcessScenario,
|
||||
): Readonly<Record<string, JsonValue>> => ({
|
||||
executable_name: scenario.executable.split("/").at(-1) ?? scenario.executable,
|
||||
argument_count: scenario.arguments.length,
|
||||
event_count: scenario.events.length,
|
||||
filesystem_root_count: scenario.filesystem_roots.length,
|
||||
normalization: scenario.normalization,
|
||||
});
|
||||
|
||||
interface ProcessToolRegistration {
|
||||
readonly server: McpServer;
|
||||
readonly session: BinarySessionPort;
|
||||
|
|
@ -122,21 +111,7 @@ const registerProcessTools = ({
|
|||
),
|
||||
);
|
||||
if (!captured.ok) return toCallToolResult(captured, captureContract);
|
||||
const evidence = createEvidence(undefined, PROCESS_PROVIDER, {
|
||||
predicateType: "rea.process-capture/v2",
|
||||
operation: captureContract.name,
|
||||
parameters: processEvidenceParameters(scenario),
|
||||
result: jsonValueSchema.parse(captured.value),
|
||||
confidence: "observed",
|
||||
authority: "controlled-replay",
|
||||
environment: {
|
||||
id: `${process.platform}-${process.arch}`,
|
||||
platform: process.platform,
|
||||
architecture: process.arch,
|
||||
isolation: "process",
|
||||
},
|
||||
limitations: captured.value.limitations,
|
||||
});
|
||||
const evidence = createProcessCaptureEvidence(scenario, captured.value);
|
||||
const recorded = session.recordEvidence(evidence);
|
||||
if (!recorded.ok) return toCallToolResult(recorded, captureContract);
|
||||
const unknowns = recordProcessResidualUnknowns(
|
||||
|
|
|
|||
|
|
@ -19,12 +19,7 @@ export const DENY_EVIDENCE_FILE_POLICY: EvidenceFilePolicy = {
|
|||
maxNodes: 1_000_000,
|
||||
};
|
||||
|
||||
/** Provider identity for controlled process-capture evidence. */
|
||||
export const PROCESS_PROVIDER = {
|
||||
id: "rea-process",
|
||||
name: "REA deterministic process harness",
|
||||
version: "1",
|
||||
} as const;
|
||||
export { PROCESS_PROVIDER } from "../application/ProcessEvidence.js";
|
||||
|
||||
/** Provider identity for deterministic cross-artifact comparisons. */
|
||||
export const ARTIFACT_COMPARISON_PROVIDER = {
|
||||
|
|
|
|||
|
|
@ -33,11 +33,11 @@ const comparison = (
|
|||
name: process
|
||||
? "REA deterministic process harness"
|
||||
: "REA artifact comparison",
|
||||
version: "1",
|
||||
version: operation === "compare_process_captures" ? "2" : "1",
|
||||
},
|
||||
{
|
||||
predicateType: process
|
||||
? "rea.process-comparison/v1"
|
||||
? "rea.process-comparison/v2"
|
||||
: "rea.artifact-comparison/v1",
|
||||
operation,
|
||||
parameters: {},
|
||||
|
|
@ -58,10 +58,26 @@ const processResult = (
|
|||
): JsonValue => ({
|
||||
status: overrides.status ?? "unchanged",
|
||||
terminal: overrides.terminal ?? "unchanged",
|
||||
interaction: "unchanged",
|
||||
exit: overrides.exit ?? "unchanged",
|
||||
filesystem: "unchanged",
|
||||
protocol: "unchanged",
|
||||
process: "unchanged",
|
||||
shim: "unchanged",
|
||||
first_divergence:
|
||||
overrides.status === "changed"
|
||||
? {
|
||||
status: "found",
|
||||
dimension: "terminal",
|
||||
index: 0,
|
||||
left_at_ms: 0,
|
||||
right_at_ms: 0,
|
||||
left: "left",
|
||||
right: "right",
|
||||
}
|
||||
: overrides.status === "truncated" || overrides.status === "unknown"
|
||||
? { status: "unknown", reason: "Incomplete fixture." }
|
||||
: { status: "none" },
|
||||
limitations: [],
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -283,7 +283,7 @@ describe("full MCP integration with multi-tool sequences", () => {
|
|||
});
|
||||
const captureEvidence = (capture: typeof left) =>
|
||||
createEvidence(undefined, PROCESS_PROVIDER, {
|
||||
predicateType: "rea.process-capture/v2",
|
||||
predicateType: "rea.process-capture/v3",
|
||||
operation: "capture_process_scenario",
|
||||
parameters: {},
|
||||
result: jsonValueSchema.parse(capture),
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ describe("process capture domain", () => {
|
|||
|
||||
it("never considers truncated captures equivalent", () => {
|
||||
const capture = {
|
||||
schema_version: 2 as const,
|
||||
schema_version: 3 as const,
|
||||
normalization: {
|
||||
paths: true,
|
||||
pids: true,
|
||||
|
|
@ -116,8 +116,12 @@ describe("process capture domain", () => {
|
|||
patterns: [],
|
||||
},
|
||||
frames: [],
|
||||
rendered_frames: [],
|
||||
interaction_events: [],
|
||||
exit: { code: 0, signal: null, reason: "exited" as const },
|
||||
process_samples: [],
|
||||
filesystem_checkpoints: [],
|
||||
shim_events: [],
|
||||
protocol_events: [],
|
||||
files_before: [],
|
||||
files_after: [],
|
||||
|
|
@ -135,7 +139,7 @@ describe("process capture domain", () => {
|
|||
|
||||
it("classifies missing observations as unknown and one-sided evidence as added", () => {
|
||||
const base = {
|
||||
schema_version: 2 as const,
|
||||
schema_version: 3 as const,
|
||||
normalization: {
|
||||
paths: true,
|
||||
pids: true,
|
||||
|
|
@ -144,8 +148,12 @@ describe("process capture domain", () => {
|
|||
patterns: [],
|
||||
},
|
||||
frames: [],
|
||||
rendered_frames: [],
|
||||
interaction_events: [],
|
||||
exit: { code: 0, signal: null, reason: "exited" as const },
|
||||
process_samples: [],
|
||||
filesystem_checkpoints: [],
|
||||
shim_events: [],
|
||||
protocol_events: [],
|
||||
files_before: [],
|
||||
files_after: [],
|
||||
|
|
@ -161,6 +169,19 @@ describe("process capture domain", () => {
|
|||
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");
|
||||
|
|
@ -169,12 +190,54 @@ describe("process capture domain", () => {
|
|||
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: 3 as const,
|
||||
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: [],
|
||||
shim_events: [],
|
||||
protocol_events: [],
|
||||
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: 2 as const,
|
||||
schema_version: 3 as const,
|
||||
normalization: {
|
||||
paths: true,
|
||||
pids: true,
|
||||
|
|
@ -183,8 +246,12 @@ describe("process capture domain", () => {
|
|||
patterns: [],
|
||||
},
|
||||
frames: [],
|
||||
rendered_frames: [],
|
||||
interaction_events: [],
|
||||
exit: { code: 0, signal: null, reason: "exited" as const },
|
||||
process_samples: [],
|
||||
filesystem_checkpoints: [],
|
||||
shim_events: [],
|
||||
protocol_events: [],
|
||||
files_before: [],
|
||||
files_after: [],
|
||||
|
|
@ -203,11 +270,27 @@ describe("process capture domain", () => {
|
|||
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: "during_run",
|
||||
at_ms: 10,
|
||||
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: 2 as const,
|
||||
schema_version: 3 as const,
|
||||
normalization: {
|
||||
paths: true,
|
||||
pids: true,
|
||||
|
|
@ -216,10 +299,21 @@ describe("process capture domain", () => {
|
|||
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, command: "worker" },
|
||||
{
|
||||
at_ms: 10,
|
||||
pid: 1,
|
||||
parent_pid: 0,
|
||||
process_group_id: 1,
|
||||
session_id: 1,
|
||||
command: "worker",
|
||||
},
|
||||
],
|
||||
filesystem_checkpoints: [],
|
||||
shim_events: [],
|
||||
protocol_events: [],
|
||||
files_before: [],
|
||||
files_after: [],
|
||||
|
|
@ -235,7 +329,14 @@ describe("process capture domain", () => {
|
|||
const changed = {
|
||||
...capture,
|
||||
process_samples: [
|
||||
{ at_ms: 20, pid: 1, parent_pid: 2, command: "worker" },
|
||||
{
|
||||
at_ms: 20,
|
||||
pid: 1,
|
||||
parent_pid: 2,
|
||||
process_group_id: 1,
|
||||
session_id: 1,
|
||||
command: "worker",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
|
@ -451,6 +552,96 @@ describe("process capture adapter", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("renders terminal state, records shim invocations, and captures literal checkpoints", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "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,
|
||||
filesystem_roots: [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 },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
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(3);
|
||||
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(["before", "probe_seen", "after_settlement"]);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not follow or disclose symlink targets outside declared roots", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-symlink-test-"));
|
||||
await symlink("/etc/passwd", join(root, "escape"));
|
||||
|
|
@ -460,13 +651,13 @@ describe("process capture adapter", () => {
|
|||
const result = await captureProcessScenario(
|
||||
parseProcessScenario({
|
||||
approved: true,
|
||||
executable: "/bin/true",
|
||||
executable: "/usr/bin/true",
|
||||
working_directory: root,
|
||||
filesystem_roots: [root],
|
||||
}),
|
||||
{
|
||||
enabled: true,
|
||||
executableRoots: ["/bin"],
|
||||
executableRoots: ["/usr/bin"],
|
||||
workingRoots: [root],
|
||||
allowedEnvironment: [],
|
||||
allowExternalNetwork: true,
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ const source = (
|
|||
{
|
||||
predicateType:
|
||||
authority === "controlled-replay"
|
||||
? "rea.process-capture/v2"
|
||||
? "rea.process-capture/v3"
|
||||
: "rea.analysis/v2",
|
||||
operation:
|
||||
authority === "controlled-replay"
|
||||
|
|
@ -55,10 +55,26 @@ const processResult = (
|
|||
): JsonValue => ({
|
||||
status: terminal === "changed" ? "changed" : terminal,
|
||||
terminal,
|
||||
interaction: "unchanged",
|
||||
exit: "unchanged",
|
||||
filesystem: "unchanged",
|
||||
protocol: "unchanged",
|
||||
process: "unchanged",
|
||||
shim: "unchanged",
|
||||
first_divergence:
|
||||
terminal === "changed"
|
||||
? {
|
||||
status: "found",
|
||||
dimension: "terminal",
|
||||
index: 0,
|
||||
left_at_ms: 0,
|
||||
right_at_ms: 0,
|
||||
left: "left",
|
||||
right: "right",
|
||||
}
|
||||
: terminal === "unknown"
|
||||
? { status: "unknown", reason: "Incomplete fixture." }
|
||||
: { status: "none" },
|
||||
limitations: [],
|
||||
});
|
||||
|
||||
|
|
@ -73,10 +89,10 @@ const processComparison = (
|
|||
{
|
||||
id: "rea-process",
|
||||
name: "REA deterministic process harness",
|
||||
version: "1",
|
||||
version: "2",
|
||||
},
|
||||
{
|
||||
predicateType: "rea.process-comparison/v1",
|
||||
predicateType: "rea.process-comparison/v2",
|
||||
operation: "compare_process_captures",
|
||||
parameters: {
|
||||
left_evidence_id: left.evidence_id,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const capture = (digit: string): Evidence =>
|
|||
undefined,
|
||||
{ id: "fixture", name: "Fixture", version: "1" },
|
||||
{
|
||||
predicateType: "rea.process-capture/v2",
|
||||
predicateType: "rea.process-capture/v3",
|
||||
operation: "capture_process_scenario",
|
||||
parameters: {},
|
||||
result: { digit },
|
||||
|
|
@ -41,10 +41,26 @@ const processComparison = (
|
|||
const result = {
|
||||
status: terminal,
|
||||
terminal,
|
||||
interaction: terminal === "truncated" ? "truncated" : "unchanged",
|
||||
exit: terminal === "truncated" ? "truncated" : "unchanged",
|
||||
filesystem: terminal === "truncated" ? "truncated" : "unchanged",
|
||||
protocol: terminal === "truncated" ? "truncated" : "unchanged",
|
||||
process: terminal === "truncated" ? "truncated" : "unchanged",
|
||||
shim: terminal === "truncated" ? "truncated" : "unchanged",
|
||||
first_divergence:
|
||||
terminal === "changed"
|
||||
? {
|
||||
status: "found",
|
||||
dimension: "terminal",
|
||||
index: 0,
|
||||
left_at_ms: 0,
|
||||
right_at_ms: 0,
|
||||
left: "left",
|
||||
right: "right",
|
||||
}
|
||||
: terminal === "truncated" || terminal === "unknown"
|
||||
? { status: "unknown", reason: "Incomplete fixture." }
|
||||
: { status: "none" },
|
||||
limitations: terminal === "unknown" ? ["Terminal unavailable."] : [],
|
||||
} satisfies JsonValue;
|
||||
return createEvidence(
|
||||
|
|
@ -52,10 +68,10 @@ const processComparison = (
|
|||
{
|
||||
id: "rea-process",
|
||||
name: "REA deterministic process harness",
|
||||
version: "1",
|
||||
version: "2",
|
||||
},
|
||||
{
|
||||
predicateType: "rea.process-comparison/v1",
|
||||
predicateType: "rea.process-comparison/v2",
|
||||
operation: "compare_process_captures",
|
||||
parameters: {
|
||||
left_evidence_id: leftCapture.evidence_id,
|
||||
|
|
|
|||
Loading…
Reference in New Issue