fix(process): preserve partial evidence and verify detached cleanup (#1011)

* test(ci): allow transport delivery in source identity regression

* fix(process): preserve partial evidence and verify detached cleanup

* fix(process): scope Linux token sweeps by stable process identity

* fix(process): retain environment permission failure codes

* fix(process): wait for signaled processes before verifying cleanup

* fix(process): verify sampled detached groups during cleanup

* fix(electron): preserve prelaunch identities through cleanup

* fix(process): retry growing Darwin process table snapshots

* fix(process): retain compatibility with saved v3 capture evidence

* fix(process): migrate saved v3 captures during reconstruction verification

* fix(process): preserve cancellation during Darwin helper compilation

* fix(process): propagate cancellation through ownership baselines

* perf(javascript): cache canonical export comparison sort keys

* fix(process): cancel provider ownership preparation before spawn

* fix(process): isolate native compiler waiter cancellation
This commit is contained in:
morluto authored and GitHub committed 2026-10-08 04:46:46 +08:00
1 parent 63ba7424c0
commit 63cf28fd8f
51 files changed
+5506 -316

No files matched your search

+503 -35
View File
@@ -1,6 +1,6 @@
import { createHash, randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { constants, statSync, type BigIntStats } from "node:fs";
import { open, mkdtemp, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { IPty } from "@lydell/node-pty";
@@ -12,6 +12,11 @@ import type {
UnverifiedProcessCapture,
ProcessSample,
ProcessSettlement,
ProcessCaptureCleanupReport,
PartialProcessCaptureObservation,
IncompleteProcessCaptureObservations,
PartialFilesystemSnapshot,
PartialProcessObservationField,
ProcessScenario,
ProcessCaptureEventJournalEntry,
RecordProcessCaptureEvent,
@@ -19,6 +24,8 @@ import type {
} from "../../domain/process/processCapture.js";
import {
digestProcessCommitment,
parseProcessCapture,
partialProcessCaptureObservationSchema,
processComparisonContract,
processScenarioCommitment,
} from "../../domain/process/processCapture.js";
@@ -26,9 +33,21 @@ import { PRODUCT_IDENTITY } from "../../identity.js";
import type { SnapshotResult } from "./FilesystemSnapshot.js";
import { snapshotRoots } from "./FilesystemSnapshot.js";
import { classifyFilesystemEffects } from "./ProcessFilesystemEffects.js";
import { cleanupOwnedProcessGroup } from "../ProcessOwnership.js";
import { observeOwnedProcessGroup } from "../ProcessOwnershipObservation.js";
import { ProcessCaptureError } from "./ProcessCaptureError.js";
import {
cleanupOwnedProcessGroup,
verifyNoTokenOwnedProcesses,
type ProcessCleanupResult,
type ProcessOwnershipBaseline,
} from "../ProcessOwnership.js";
import {
observeOwnedProcessGroup,
prepareProcessOwnershipInspection,
systemProcessOwnershipHost,
} from "../ProcessOwnershipObservation.js";
import {
describeProcessCaptureExecutionFailure,
ProcessCaptureError,
} from "./ProcessCaptureError.js";
import { assertNotCancelled } from "./ProcessScenarioRuntimeValidation.js";
import {
normalizeProcessElapsedTime,
@@ -92,6 +111,107 @@ export type PendingProcessCapture = Omit<
readonly settlement: ObservedProcessSettlement;
};
/** Mutable collection of facts available if completion stops before a capture exists. */
export interface ProcessCaptureObservationBuffer {
target_pid: PartialProcessObservationField<number>;
frames: PartialProcessObservationField<readonly TerminalFrame[]>;
rendered_frames: PartialProcessObservationField<
UnverifiedProcessCapture["rendered_frames"]
>;
interaction_events: PartialProcessObservationField<
UnverifiedProcessCapture["interaction_events"]
>;
exit: IncompleteProcessCaptureObservations["exit"];
settlement: IncompleteProcessCaptureObservations["settlement"];
process_samples: PartialProcessObservationField<readonly ProcessSample[]>;
filesystem_snapshots: {
before: PartialProcessObservationField<PartialFilesystemSnapshot>;
after: PartialProcessObservationField<PartialFilesystemSnapshot>;
};
event_journal: PartialProcessObservationField<
readonly ProcessCaptureEventJournalEntry[]
>;
manifest: IncompleteProcessCaptureObservations["manifest"];
}
/** Seed explicit observation availability before capture completion begins. */
export const createProcessCaptureObservationBuffer = (options: {
readonly frames: readonly TerminalFrame[];
readonly interactions: readonly InteractionEvent[];
readonly samples: readonly ProcessSample[];
readonly eventJournal: readonly ProcessCaptureEventJournalEntry[];
readonly before: SnapshotResult;
}): ProcessCaptureObservationBuffer => ({
target_pid: {
state: "unavailable",
reason: "No PTY process was spawned before the run failed.",
},
frames: { state: "available", value: options.frames },
rendered_frames: {
state: "unavailable",
reason:
"Rendered terminal frames were not collected before capture completion finished.",
},
interaction_events: { state: "available", value: options.interactions },
exit: {
state: "unavailable",
reason: "Terminal exit was not observed before the run failed.",
},
settlement: {
state: "unavailable",
reason:
"Process settlement was not observed before capture completion finished.",
},
process_samples: { state: "available", value: options.samples },
filesystem_snapshots: {
before: {
state: "available",
value: {
files: options.before.files,
truncated: options.before.truncated,
},
},
after: {
state: "unavailable",
reason:
"Final filesystem snapshot was not collected before capture completion finished.",
},
},
event_journal: { state: "available", value: options.eventJournal },
manifest: {
state: "unavailable",
reason:
"Capture manifest was not produced before capture completion finished.",
},
});
const normalizePartialProcessObservations = (
observations: IncompleteProcessCaptureObservations,
context: { readonly scenario: ProcessScenario; readonly rootPid?: number },
): IncompleteProcessCaptureObservations => {
const targetPid = observations.target_pid;
const target_pid =
targetPid.state === "available" && context.rootPid !== undefined
? {
state: "available" as const,
value: context.scenario.normalization.pids ? 1 : context.rootPid,
}
: targetPid;
const samples = observations.process_samples;
const process_samples =
samples.state === "available" && context.rootPid !== undefined
? {
state: "available" as const,
value: normalizeProcessSamples(
samples.value,
context.scenario,
context.rootPid,
),
}
: samples;
return { ...observations, target_pid, process_samples };
};
/** Wait for trailing PTY callbacks before the capture becomes immutable. */
export const settleProcessCaptureJournal = async (
journal: readonly ProcessCaptureEventJournalEntry[],
@@ -152,6 +272,7 @@ export const buildCaptureResult = (
),
truncated: options.truncated,
limitations: [
"The executable digest is a prelaunch file sample; matching path metadata immediately after spawn does not prove an atomic operating-system image binding.",
"Process trees are sampled and may omit short-lived descendants.",
...(options.samplingPartial
? ["Process-tree sampling ended with an incomplete observation."]
@@ -167,6 +288,11 @@ export const buildCaptureResult = (
reason:
"Process trees are sampled and may omit short-lived descendants.",
},
{
scope: "process",
reason:
"The executable digest is a prelaunch file sample; operating-system image binding is not atomically observed.",
},
{
scope: "environment",
reason: "Inherited host environment variables are not recorded.",
@@ -190,16 +316,163 @@ export const buildCaptureResult = (
};
};
const hashFile = async (path: string): Promise<string> => {
const hash = createHash("sha256");
for await (const chunk of createReadStream(path)) hash.update(chunk);
return hash.digest("hex");
interface ExecutableFileIdentity {
readonly dev: bigint;
readonly ino: bigint;
readonly mode: bigint;
readonly size: bigint;
readonly mtimeNs: bigint;
readonly ctimeNs: bigint;
}
export interface SelectedExecutableObservation {
readonly sha256: string | null;
readonly identity: ExecutableFileIdentity | null;
readonly reason: string | null;
}
export interface LaunchedExecutableObservation {
readonly selectedSha256: string | null;
readonly sha256: string | null;
readonly state: "path_metadata_unchanged" | "unknown";
readonly reason: string | null;
}
const identityFromStats = (stats: BigIntStats): ExecutableFileIdentity => ({
dev: stats.dev,
ino: stats.ino,
mode: stats.mode,
size: stats.size,
mtimeNs: stats.mtimeNs,
ctimeNs: stats.ctimeNs,
});
const sameExecutableIdentity = (
left: ExecutableFileIdentity,
right: ExecutableFileIdentity,
): boolean =>
left.dev === right.dev &&
left.ino === right.ino &&
left.mode === right.mode &&
left.size === right.size &&
left.mtimeNs === right.mtimeNs &&
left.ctimeNs === right.ctimeNs;
const filesystemFailure = (cause: unknown): string | undefined => {
if (!(cause instanceof Error) || !("code" in cause) || !("syscall" in cause))
return undefined;
return `Executable file observation failed: ${cause.message}`;
};
/** Sample the caller-selected executable contents and stable file identity before launch. */
export const observeSelectedExecutable = async (
path: string,
signal?: AbortSignal,
): Promise<SelectedExecutableObservation> => {
assertNotCancelled(signal);
let handle: Awaited<ReturnType<typeof open>> | undefined;
try {
handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
const openedStats = await handle.stat({ bigint: true });
if (!openedStats.isFile())
return {
sha256: null,
identity: null,
reason: "Selected executable is not a regular file.",
};
const openedIdentity = identityFromStats(openedStats);
const selectedPathIdentity = identityFromStats(
await stat(path, { bigint: true }),
);
if (!sameExecutableIdentity(openedIdentity, selectedPathIdentity))
return {
sha256: null,
identity: null,
reason: "Selected executable path changed while it was being opened.",
};
const hash = createHash("sha256");
for await (const chunk of handle.createReadStream({
autoClose: false,
signal,
})) {
assertNotCancelled(signal);
hash.update(chunk);
}
assertNotCancelled(signal);
const finalIdentity = identityFromStats(
await handle.stat({ bigint: true }),
);
if (!sameExecutableIdentity(openedIdentity, finalIdentity))
return {
sha256: null,
identity: null,
reason: "Selected executable changed while its contents were sampled.",
};
return {
sha256: hash.digest("hex"),
identity: openedIdentity,
reason: null,
};
} catch (cause: unknown) {
assertNotCancelled(signal);
const reason = filesystemFailure(cause);
if (reason !== undefined) return { sha256: null, identity: null, reason };
throw cause;
} finally {
await handle?.close();
}
};
/** Compare the selected file metadata with the path immediately after PTY spawn. */
export const observeLaunchedExecutable = (
path: string,
selected: SelectedExecutableObservation,
): LaunchedExecutableObservation => {
if (selected.identity === null || selected.sha256 === null)
return {
selectedSha256: selected.sha256,
sha256: null,
state: "unknown",
reason: selected.reason ?? "Selected executable identity is unavailable.",
};
try {
const currentStats = statSync(path, { bigint: true });
const currentIdentity = identityFromStats(currentStats);
if (
currentStats.isFile() &&
sameExecutableIdentity(selected.identity, currentIdentity)
)
return {
selectedSha256: selected.sha256,
sha256: selected.sha256,
state: "path_metadata_unchanged",
reason: null,
};
return {
selectedSha256: selected.sha256,
sha256: null,
state: "unknown",
reason: "Executable path metadata changed between sampling and spawn.",
};
} catch (cause: unknown) {
const reason = filesystemFailure(cause);
if (reason !== undefined)
return {
selectedSha256: selected.sha256,
sha256: null,
state: "unknown",
reason,
};
throw cause;
}
};
export const createRunManifest = async (
scenario: ProcessScenario,
startedAt: Date,
completedAt: Date,
executable: LaunchedExecutableObservation,
host: {
readonly platform: NodeJS.Platform;
readonly architecture: NodeJS.Architecture;
@@ -208,10 +481,9 @@ export const createRunManifest = async (
architecture: process.arch,
},
): Promise<UnverifiedProcessCapture["manifest"]> => {
const executableSha256 = await hashFile(scenario.executable);
const scenarioCommitment = processScenarioCommitment(
scenario,
executableSha256,
executable.selectedSha256 ?? undefined,
);
const comparisonContract = processComparisonContract(scenario);
return {
@@ -226,7 +498,12 @@ export const createRunManifest = async (
comparison_contract: comparisonContract,
full_scenario_sha256: digestProcessCommitment(scenarioCommitment),
comparison_contract_sha256: digestProcessCommitment(comparisonContract),
executable_sha256: executableSha256,
selected_executable_sha256: executable.selectedSha256,
executable_sha256: executable.sha256,
executable_identity: {
state: executable.state,
reason: executable.reason,
},
normalization_sha256: digestProcessCommitment(scenario.normalization),
};
};
@@ -339,50 +616,142 @@ export const awaitTerminalExit = async ({
export interface ProcessCaptureCleanupHost {
readonly platform: NodeJS.Platform;
readonly cleanupProcessGroup: typeof cleanupOwnedProcessGroup;
readonly verifyTokenOwnedProcesses: typeof verifyNoTokenOwnedProcesses;
readonly removeTemporaryRoot: (path: string) => Promise<void>;
}
const processCaptureCleanupHost: ProcessCaptureCleanupHost = {
platform: process.platform,
cleanupProcessGroup: cleanupOwnedProcessGroup,
verifyTokenOwnedProcesses: verifyNoTokenOwnedProcesses,
removeTemporaryRoot: (path) => rm(path, { recursive: true, force: true }),
};
const PROCESS_CLEANUP_VERIFICATION_GRACE_MS = 1_000;
const PROCESS_CLEANUP_VERIFICATION_INTERVAL_MS = 25;
const verifyTokenOwnedProcessesUntilSettled = async (
verify: () => Promise<ProcessCleanupResult>,
): Promise<ProcessCleanupResult> => {
const deadline = Date.now() + PROCESS_CLEANUP_VERIFICATION_GRACE_MS;
let result = await verify();
while (!result.cleaned) {
const remaining = deadline - Date.now();
if (remaining <= 0) return result;
await new Promise<void>((resolve) =>
setTimeout(
resolve,
Math.min(PROCESS_CLEANUP_VERIFICATION_INTERVAL_MS, remaining),
),
);
result = await verify();
}
return result;
};
export const releaseProcessResources = async (options: {
readonly timers: ReadonlySet<ProcessTimer>;
readonly terminal: IPty | undefined;
readonly terminal: Pick<IPty, "pid"> | undefined;
readonly renderer: TerminalRenderer | undefined;
readonly runId: string;
readonly temporaryRoot: string;
readonly capturedProcessGroupIds: readonly number[];
readonly captureBaseline?: ProcessOwnershipBaseline;
readonly sampledProcessGroupIds?: readonly number[];
readonly host?: ProcessCaptureCleanupHost;
}): Promise<string | undefined> => {
}): Promise<ProcessCaptureCleanupReport> => {
const host = options.host ?? processCaptureCleanupHost;
for (const timer of options.timers) timer.cancel();
let failure: string | undefined;
let terminalRenderer: ProcessCaptureCleanupReport["terminal_renderer"] = {
state: options.renderer === undefined ? "not_required" : "cleaned",
reason: null,
};
try {
await options.renderer?.dispose();
} catch (cause: unknown) {
void cause;
failure ??= "terminal renderer cleanup failed";
terminalRenderer = {
state: "failed",
reason:
cause instanceof Error
? cause.message || cause.name || "renderer dispose failed"
: "renderer dispose failed",
};
}
if (options.terminal !== undefined && host.platform !== "win32") {
for (const processGroupId of new Set(options.capturedProcessGroupIds)) {
let ownedProcessGroup: ProcessCaptureCleanupReport["owned_process_group"] = {
state: options.terminal === undefined ? "not_required" : "cleaned",
reason: null,
};
if (options.terminal !== undefined) {
if (host.platform === "win32") {
ownedProcessGroup = {
state: "unverified",
reason:
"owned process cleanup is unverifiable on Windows without process-job authority",
};
} else {
const cleaned = await host.cleanupProcessGroup({
runId: options.runId,
leaderPid: processGroupId,
processGroupId,
leaderPid: options.terminal.pid,
processGroupId: options.terminal.pid,
sweepTokenOwnedProcesses: true,
...(options.sampledProcessGroupIds === undefined
? {}
: { sampledProcessGroupIds: options.sampledProcessGroupIds }),
...(options.captureBaseline === undefined
? {}
: { captureBaseline: options.captureBaseline }),
});
if (!cleaned.cleaned) failure ??= cleaned.reason;
if (!cleaned.cleaned) {
ownedProcessGroup = { state: "unverified", reason: cleaned.reason };
} else {
const verified = await verifyTokenOwnedProcessesUntilSettled(() =>
host.verifyTokenOwnedProcesses(
options.runId,
undefined,
options.captureBaseline,
),
);
if (!verified.cleaned)
ownedProcessGroup = { state: "unverified", reason: verified.reason };
}
}
}
let temporaryRoot: ProcessCaptureCleanupReport["temporary_root"] = {
state: "cleaned",
reason: null,
};
try {
await host.removeTemporaryRoot(options.temporaryRoot);
} catch (cause: unknown) {
void cause;
failure ??= "temporary process root cleanup failed";
temporaryRoot = {
state: "failed",
reason:
cause instanceof Error
? cause.message || cause.name || "temporary root removal failed"
: "temporary root removal failed",
};
}
return failure;
return {
owned_process_group: ownedProcessGroup,
terminal_renderer: terminalRenderer,
temporary_root: temporaryRoot,
};
};
export const cleanupReportFailure = (
report: ProcessCaptureCleanupReport,
): string | undefined => {
const failures = Object.entries(report).filter(
([, outcome]) =>
outcome.state === "failed" || outcome.state === "unverified",
);
return failures.length === 0
? undefined
: failures
.map(
([resource, outcome]) =>
`${resource}: ${outcome.reason ?? outcome.state}`,
)
.join("; ");
};
export const captureTerminalFrames = (options: {
@@ -428,22 +797,106 @@ export const captureTerminalFrames = (options: {
};
export const resolveProcessResult = (
capture: ProcessCapture | undefined,
capture: PendingProcessCapture | ProcessCapture | undefined,
executionFailure: unknown,
cleanupFailure: string | undefined,
cleanup: ProcessCaptureCleanupReport,
observations?: IncompleteProcessCaptureObservations,
partialContext?: {
readonly scenario: ProcessScenario;
readonly rootPid?: number;
},
): ProcessCapture => {
const cleanupFailure = cleanupReportFailure(cleanup);
if (cleanupFailure !== undefined) {
const cleanupResources = Object.entries(cleanup)
.filter(
([, outcome]) =>
outcome.state === "failed" || outcome.state === "unverified",
)
.map(([resource]) => resource);
const executionFailureReason =
describeProcessCaptureExecutionFailure(executionFailure);
let partialObservation: PartialProcessCaptureObservation | undefined;
if (capture !== undefined || observations !== undefined) {
const normalizedObservations =
observations === undefined || partialContext === undefined
? observations
: normalizePartialProcessObservations(observations, partialContext);
const partialPayload =
capture === undefined
? { observations: normalizedObservations }
: { capture };
const pendingCapture =
capture === undefined
? undefined
: {
...capture,
settlement: {
...capture.settlement,
cleanup_outcome: "failed" as const,
},
};
const partialResult = partialProcessCaptureObservationSchema.safeParse({
...partialPayload,
...(pendingCapture === undefined
? {}
: {
capture: Object.fromEntries(
Object.entries(pendingCapture).filter(
([key]) => key !== "cleanup",
),
),
}),
cleanup,
execution_failure: executionFailureReason ?? null,
});
if (partialResult.success) partialObservation = partialResult.data;
}
throw new ProcessCaptureError(cleanupFailure, {
cause: executionFailure,
reason: "cleanup_incomplete",
cleanupResources,
...(executionFailureReason === undefined
? {}
: { executionFailure: executionFailureReason }),
...(partialObservation === undefined ? {} : { partialObservation }),
cleanupReport: cleanup,
});
}
if (executionFailure instanceof ProcessCaptureError) throw executionFailure;
if (executionFailure !== undefined)
if (executionFailure !== undefined) {
const executionFailureReason =
describeProcessCaptureExecutionFailure(executionFailure);
throw new ProcessCaptureError("process capture failed", {
cause: executionFailure,
...(executionFailureReason === undefined
? {}
: { executionFailure: executionFailureReason }),
});
if (cleanupFailure !== undefined)
throw new ProcessCaptureError(cleanupFailure, {
reason: "cleanup_incomplete",
});
}
if (capture === undefined)
throw new ProcessCaptureError("process capture produced no result");
return capture;
if ("cleanup" in capture) return capture;
const settlement: UnverifiedProcessCapture["settlement"] =
capture.settlement.state === "quiesced"
? { ...capture.settlement, cleanup_outcome: "not_required" }
: { ...capture.settlement, cleanup_outcome: "cleaned" };
try {
return parseProcessCapture({
...capture,
settlement,
cleanup: {
owned_process_group: "verified",
temporary_root: "removed",
},
});
} catch (cause: unknown) {
throw new ProcessCaptureError(
`process capture validation failed${cause instanceof Error ? `: ${cause.message}` : ""}`,
{ cause },
);
}
};
export const prepareProcessCapture = async (
@@ -454,14 +907,21 @@ export const prepareProcessCapture = async (
): Promise<{
readonly temporaryRoot: string;
readonly runId: string;
readonly ownershipBaseline: ProcessOwnershipBaseline;
readonly before: SnapshotResult;
}> => {
assertNotCancelled(signal);
await host.prepareOwnershipInspector?.(signal);
assertNotCancelled(signal);
const before = await captureSnapshot(scenario, signal);
const temporaryRoot = await host.createTemporaryRoot();
try {
assertNotCancelled(signal);
const runId = randomUUID();
return { temporaryRoot, runId, before };
const ownershipBaseline =
(await host.captureOwnershipBaseline?.(signal)) ?? [];
assertNotCancelled(signal);
return { temporaryRoot, runId, ownershipBaseline, before };
} catch (cause: unknown) {
await host.cleanup(temporaryRoot);
throw cause;
@@ -470,11 +930,19 @@ export const prepareProcessCapture = async (
/** Filesystem seam for allocating and cleaning a capture's temporary root. */
export interface ProcessPreparationHost {
prepareOwnershipInspector?(signal?: AbortSignal): Promise<void>;
/** Capture the process baseline with the caller's startup cancellation. */
captureOwnershipBaseline?(
signal?: AbortSignal,
): Promise<ProcessOwnershipBaseline>;
createTemporaryRoot(): Promise<string>;
cleanup(path: string): Promise<void>;
}
const systemProcessPreparationHost: ProcessPreparationHost = {
prepareOwnershipInspector: prepareProcessOwnershipInspection,
captureOwnershipBaseline: (signal) =>
systemProcessOwnershipHost.captureBaseline?.(signal) ?? Promise.resolve([]),
createTemporaryRoot: () => mkdtemp(join(tmpdir(), "rea-process-")),
cleanup: (path) => rm(path, { recursive: true, force: true }),
};