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
2026-10-08 04:46:46 +08:00
committed by GitHub
parent 63ba7424c0
commit 63cf28fd8f
51 changed files with 5506 additions and 316 deletions
+263
View File
@@ -0,0 +1,263 @@
import Darwin
import Foundation
struct RunTokenObservation: Encodable {
let pid: Int32
let state: String
let run_id: String?
let reason: String?
}
struct ProcessIdentityObservation: Encodable {
let pid: Int32
let state: String
let identity: String?
let reason: String?
}
private struct ProcessMetadata {
let identity: String
let pointerSize: Int
}
struct ProcessSnapshot {
fileprivate let metadata: [Int32: ProcessMetadata]
let failure: String?
}
typealias ProcessTableSysctlCall = (
_ mib: inout [Int32],
_ output: UnsafeMutableRawPointer?,
_ size: inout Int
) -> Int32
private func readProcessSnapshot() -> ProcessSnapshot {
readProcessSnapshot { mib, output, size in
sysctl(&mib, u_int(mib.count), output, &size, nil, 0)
}
}
func readProcessSnapshot(
sysctlCall: ProcessTableSysctlCall
) -> ProcessSnapshot {
for _ in 0..<3 {
var mib = [CTL_KERN, KERN_PROC, KERN_PROC_ALL, 0]
var size = 0
guard sysctlCall(&mib, nil, &size) == 0 else {
let failure = errno
return ProcessSnapshot(metadata: [:], failure: "process_table_failed_\(failure)")
}
guard size >= MemoryLayout<kinfo_proc>.size,
size % MemoryLayout<kinfo_proc>.stride == 0 else {
return ProcessSnapshot(metadata: [:], failure: "process_table_failed_\(errno)")
}
var processes = [kinfo_proc](
repeating: kinfo_proc(),
count: size / MemoryLayout<kinfo_proc>.stride
)
let result = processes.withUnsafeMutableBufferPointer { buffer in
let output = buffer.baseAddress.map { UnsafeMutableRawPointer($0) }
return sysctlCall(&mib, output, &size)
}
guard result == 0 else {
let failure = errno
if failure == ENOMEM { continue }
return ProcessSnapshot(metadata: [:], failure: "process_table_failed_\(failure)")
}
guard size % MemoryLayout<kinfo_proc>.stride == 0 else {
return ProcessSnapshot(metadata: [:], failure: "process_table_failed_\(errno)")
}
let processCount = size / MemoryLayout<kinfo_proc>.stride
let metadata = Dictionary(
processes.prefix(processCount).map { process in
let start = process.kp_proc.p_starttime
return (
process.kp_proc.p_pid,
ProcessMetadata(
identity: "\(start.tv_sec):\(start.tv_usec)",
pointerSize: process.kp_proc.p_flag & P_LP64 != 0 ? 8 : 4
)
)
},
uniquingKeysWith: { first, _ in first }
)
return ProcessSnapshot(metadata: metadata, failure: nil)
}
return ProcessSnapshot(metadata: [:], failure: "process_table_failed_\(ENOMEM)")
}
func readProcessIdentities(pids: [Int32]) -> [ProcessIdentityObservation] {
let snapshot = readProcessSnapshot()
return pids.map { pid in
guard let metadata = snapshot.metadata[pid] else {
return unavailableIdentity(
pid: pid,
reason: snapshot.failure ?? "process_not_in_snapshot"
)
}
return ProcessIdentityObservation(pid: pid, state: "readable", identity: metadata.identity, reason: nil)
}
}
private func unavailableIdentity(pid: Int32, reason: String) -> ProcessIdentityObservation {
ProcessIdentityObservation(pid: pid, state: "unavailable", identity: nil, reason: reason)
}
enum RunTokenReadError: Error {
case invalidBuffer
case duplicateToken
case invalidTokenEncoding
case environmentUnavailable
case appleVectorUnavailable
case ambiguousEnvironmentBoundary
}
private func readRunToken(pid: Int32, pointerSize: Int) -> RunTokenObservation {
var mib = [CTL_KERN, KERN_PROCARGS2, Int32(pid)]
var size = 0
guard sysctl(&mib, u_int(mib.count), nil, &size, nil, 0) == 0, size >= MemoryLayout<Int32>.size else {
return unavailable(pid: pid, reason: "sysctl_failed_\(errno)")
}
var bytes = [UInt8](repeating: 0, count: size)
let result = bytes.withUnsafeMutableBytes { buffer in
sysctl(&mib, u_int(mib.count), buffer.baseAddress, &size, nil, 0)
}
guard result == 0 else {
return unavailable(pid: pid, reason: "sysctl_failed_\(errno)")
}
bytes = Array(bytes.prefix(size))
do {
return RunTokenObservation(pid: pid, state: "readable", run_id: try token(in: bytes, pointerSize: pointerSize), reason: nil)
} catch RunTokenReadError.duplicateToken {
return unavailable(pid: pid, reason: "duplicate_run_token")
} catch RunTokenReadError.invalidTokenEncoding {
return unavailable(pid: pid, reason: "invalid_run_token_encoding")
} catch RunTokenReadError.environmentUnavailable {
return unavailable(pid: pid, reason: "environment_unavailable")
} catch RunTokenReadError.appleVectorUnavailable {
return unavailable(pid: pid, reason: "apple_vector_unavailable")
} catch RunTokenReadError.ambiguousEnvironmentBoundary {
return unavailable(pid: pid, reason: "ambiguous_environment_boundary")
} catch {
return unavailable(pid: pid, reason: "malformed_procargs")
}
}
func readRunTokens(pids: [Int32]) -> [RunTokenObservation] {
let before = readProcessSnapshot()
let observations = pids.map { pid -> RunTokenObservation in
guard let metadata = before.metadata[pid] else {
return unavailable(pid: pid, reason: before.failure ?? "process_not_in_snapshot")
}
return readRunToken(pid: pid, pointerSize: metadata.pointerSize)
}
let after = readProcessSnapshot()
return zip(pids, observations).map { pid, observation in
guard let initial = before.metadata[pid], let current = after.metadata[pid] else {
return unavailable(pid: pid, reason: after.failure ?? "process_not_in_snapshot")
}
guard initial.identity == current.identity,
initial.pointerSize == current.pointerSize else {
return unavailable(pid: pid, reason: "process_identity_changed_during_token_read")
}
return observation
}
}
func token(in bytes: [UInt8], pointerSize: Int = MemoryLayout<UnsafeRawPointer>.size) throws -> String? {
guard bytes.count >= MemoryLayout<Int32>.size else { throw RunTokenReadError.invalidBuffer }
let argc = bytes.withUnsafeBytes { $0.loadUnaligned(as: Int32.self).littleEndian }
guard argc >= 0, Int(argc) <= bytes.count else { throw RunTokenReadError.invalidBuffer }
var offset = MemoryLayout<Int32>.size
guard let executablePath = readString(in: bytes, offset: &offset) else {
throw RunTokenReadError.invalidBuffer
}
guard pointerSize == 4 || pointerSize == 8 else { throw RunTokenReadError.invalidBuffer }
let pathRecordSize = Array("executable_path=".utf8).count + executablePath.count + 1
let padding = (pointerSize - pathRecordSize % pointerSize) % pointerSize
guard offset + padding <= bytes.count,
bytes[offset..<(offset + padding)].allSatisfy({ $0 == 0 }) else {
throw RunTokenReadError.invalidBuffer
}
offset += padding
for _ in 0..<argc {
guard readString(in: bytes, offset: &offset, allowEmpty: true) != nil else {
throw RunTokenReadError.invalidBuffer
}
}
guard bytes.last == 0 else { throw RunTokenReadError.invalidBuffer }
if offset == bytes.count {
throw RunTokenReadError.environmentUnavailable
}
var trailingEntries: [ArraySlice<UInt8>] = []
var zeroedAppleVectorStart: Int?
while offset < bytes.count {
if bytes[offset] == 0 {
let start = offset
while offset < bytes.count, bytes[offset] == 0 {
offset += 1
}
if offset - start > 1 {
zeroedAppleVectorStart = zeroedAppleVectorStart ?? trailingEntries.count
}
continue
}
guard let entry = readString(in: bytes, offset: &offset) else {
throw RunTokenReadError.invalidBuffer
}
trailingEntries.append(entry)
}
// XNU appends its Apple vector after the environment. libc clears the
// initial `pfz=` record after startup, leaving a multi-NUL run in its place.
// A caller can also set `pfz`, so prefer the final intact sentinel when it
// remains available and otherwise stop at the cleared record.
let intactAppleVectorStart = trailingEntries.lastIndex(where: {
$0.starts(with: Array("pfz=".utf8))
})
let preferZeroedBoundary = zeroedAppleVectorStart.flatMap { zeroedStart in
intactAppleVectorStart.map { intactStart in intactStart < zeroedStart } ?? true
} ?? false
let appleVectorStart = preferZeroedBoundary
? zeroedAppleVectorStart
: intactAppleVectorStart
guard let appleVectorStart else {
throw RunTokenReadError.appleVectorUnavailable
}
if trailingEntries[appleVectorStart...].contains(where: {
$0.starts(with: Array("REA_PROCESS_RUN_ID=".utf8))
}) {
throw RunTokenReadError.ambiguousEnvironmentBoundary
}
let environmentEntries = trailingEntries[..<appleVectorStart]
guard !environmentEntries.isEmpty else {
throw RunTokenReadError.environmentUnavailable
}
var found: String?
for entry in environmentEntries {
if entry.starts(with: Array("REA_PROCESS_RUN_ID=".utf8)) {
guard found == nil else { throw RunTokenReadError.duplicateToken }
let value = entry.dropFirst("REA_PROCESS_RUN_ID=".utf8.count)
guard let decoded = String(bytes: value, encoding: .utf8) else {
throw RunTokenReadError.invalidTokenEncoding
}
found = decoded
}
}
return found
}
private func readString(
in bytes: [UInt8],
offset: inout Int,
allowEmpty: Bool = false
) -> ArraySlice<UInt8>? {
guard offset < bytes.count, let end = bytes[offset...].firstIndex(of: 0),
allowEmpty || end > offset else { return nil }
let string = bytes[offset..<end]
offset = end + 1
return string
}
private func unavailable(pid: Int32, reason: String) -> RunTokenObservation {
RunTokenObservation(pid: pid, state: "unavailable", run_id: nil, reason: reason)
}
@@ -0,0 +1,35 @@
import Foundation
struct RunTokenResponse: Encodable {
let results: [RunTokenObservation]
}
struct ProcessIdentityResponse: Encodable {
let results: [ProcessIdentityObservation]
}
@main
struct ProcessRunTokenReaderMain {
static func main() {
let arguments = Array(CommandLine.arguments.dropFirst())
let identityMode = arguments.first == "--identities"
let pidArguments = identityMode ? Array(arguments.dropFirst()) : arguments
let pids = pidArguments.compactMap(Int32.init)
guard pids.count == pidArguments.count else {
fputs("invalid process ID input\n", stderr)
exit(64)
}
do {
let output: Data
if identityMode {
output = try JSONEncoder().encode(ProcessIdentityResponse(results: readProcessIdentities(pids: pids)))
} else {
output = try JSONEncoder().encode(RunTokenResponse(results: readRunTokens(pids: pids)))
}
FileHandle.standardOutput.write(output)
} catch {
fputs("could not encode process token observations\n", stderr)
exit(70)
}
}
}
+8
View File
@@ -30,6 +30,14 @@ different recovery: reinstall REA for the active platform, architecture, and
Node.js version with optional dependencies enabled. Capability diagnostics
distinguish this from the Windows capture-adapter limitation.
macOS also requires Apple's Swift compiler through `xcrun` for process
ownership inspection. REA compiles its packaged, narrow process-inspection
helper into a private temporary directory before a capture or owned provider
process starts; the same prerequisite applies to owned provider-process
supervision on macOS. REA removes that directory when it exits. REA does not
install Xcode, Command Line Tools, or other software. Capability checks prepare
this helper before reporting macOS capture as available.
## Capture a command
Write a JSON scenario and pass its path to the CLI:
+2 -2
View File
@@ -675,9 +675,9 @@
"mcp_prompts": 6
},
"digests": {
"tools_sha256": "4baede40da9b3bb792e6c35b9b461745ac302973931281be26c8ad7a99f72872",
"tools_sha256": "a493c05cd1618b5626b7f5ef3dfa214886917ac1b04716054ab9de100bddd468",
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
"combined_sha256": "ed0b788dd5a6bf9446f840e388822db94b54589a5e294816a3e13bf2bec9f48c",
"combined_sha256": "8c32372537b3d12e1f0befc51b8b9a5d083d37041eac3ceb1ba9d0a3f1853b2c",
"providers_sha256": "aea15f517c822d80918ccaba57a4fa06557f484cf27b8acbaed4e1a660fac896"
}
}
@@ -1,5 +1,5 @@
{
"manifest_id": "ecm_4d1cf621854fe1f40698ef60f340fb99263edb0e99218c865b09cb26112d2547",
"manifest_id": "ecm_c139eb1f5cdc61ec60c8a73c9c0e50c7d9c78937a5677bf4bd392bb6269cf23e",
"verifier": {
"id": "rea-managed-conformance",
"version": "1"
@@ -13,7 +13,7 @@
"skill_digests": [
{
"skill_id": "reverse-engineer-anything",
"sha256": "1d816388b86e50fff47db599200968788a4bf2272f40d5bdd3bb51aae7f5a9b4"
"sha256": "4ee0e4f65a6d7643536da971f3da5532bd0b5de65dbc4ee01623259609c3196c"
}
],
"claims": [
+2
View File
@@ -44,6 +44,8 @@
"bridge/hopper_bridge.py",
"bridge/native/NativeUIChildren.swift",
"bridge/native/ReaNativeUI.swift",
"bridge/process/ProcessRunTokenReader.swift",
"bridge/process/ProcessRunTokenReaderMain.swift",
"bridge/ghidra/ReaGhidraBridge.java",
"bridge/ghidra/ReaGhidraExtensions.java",
"bridge/ghidra/ReaGhidraPrepareCom.java",
+1 -1
View File
@@ -4,7 +4,7 @@ description: Reverse engineer native, managed, Electron/JavaScript, packaged, fi
metadata:
version: "27"
tool_count: 133
catalog_digest: "ed0b788dd5a6bf9446f840e388822db94b54589a5e294816a3e13bf2bec9f48c"
catalog_digest: "8c32372537b3d12e1f0befc51b8b9a5d083d37041eac3ceb1ba9d0a3f1853b2c"
---
# REA
+1
View File
@@ -316,6 +316,7 @@ export class JadxProvider implements AndroidAnalysisPort {
],
cwd: root.path,
hostEnvironment: this.environment,
signal,
},
this.launcher,
);
@@ -2,6 +2,14 @@ import { describe, expect, it } from "vitest";
import { createEvidence } from "../domain/evidence.js";
import { createEvidenceBundle } from "../domain/evidenceBundle.js";
import { emptyUnverifiedProcessCapture } from "../domain/process/processCapture.fixture.js";
import { parseProcessCapture } from "../domain/process/processCapture.js";
import {
digestProcessCommitment,
parseProcessScenario,
processComparisonContract,
processScenarioCommitment,
} from "../domain/process/processScenario.js";
import {
APPLICATION_GRAPH_DIGESTS,
artifactEvidence,
@@ -14,6 +22,7 @@ import {
} from "../domain/javascript/javascriptApplicationGraph.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { MANAGED_WORKFLOW_PROVIDER } from "./InvestigationProviders.js";
import { createProcessCaptureEvidence } from "./process/ProcessEvidence.js";
import { deriveReconstructionObligationCandidates } from "./ReconstructionObligationCandidates.js";
const applicationWithUnknowns = (keys: readonly string[]) => {
@@ -124,4 +133,41 @@ describe("reconstruction obligation candidates", () => {
),
).toBe(false);
});
it("does not bind an unknown process image to a packaged artifact obligation", () => {
const scenario = parseProcessScenario({
executable: "/path/whose-launch-identity-is-unknown",
});
const base = emptyUnverifiedProcessCapture();
const scenarioProjection = processScenarioCommitment(scenario);
const comparisonContract = processComparisonContract(scenario);
const capture = parseProcessCapture({
...base,
normalization: scenario.normalization,
manifest: {
...base.manifest,
scenario: scenarioProjection,
comparison_contract: comparisonContract,
full_scenario_sha256: digestProcessCommitment(scenarioProjection),
comparison_contract_sha256: digestProcessCommitment(comparisonContract),
selected_executable_sha256: null,
executable_sha256: null,
executable_identity: {
state: "unknown",
reason: "Selected executable could not be inspected.",
},
normalization_sha256: digestProcessCommitment(scenario.normalization),
},
});
const evidence = createProcessCaptureEvidence(scenario, capture);
const generated = deriveReconstructionObligationCandidates(
createEvidenceBundle([evidence]),
[],
);
expect(generated.candidates).toEqual([]);
expect(generated.limitations).toContain(
`Process capture ${evidence.evidence_id} has no validated executable artifact identity; packaged-process lifecycle obligation was not bound to an artifact.`,
);
});
});
@@ -176,6 +176,12 @@ const deriveProcessCandidate = (
]
: []),
];
if (capture.manifest.executable_sha256 === null) {
limitations.add(
`Process capture ${evidence.evidence_id} has no validated executable artifact identity; packaged-process lifecycle obligation was not bound to an artifact.`,
);
return;
}
addCandidate(candidates, {
obligation_id: obligationId(
`process:${capture.manifest.executable_sha256}:${capture.manifest.full_scenario_sha256}`,
@@ -27,7 +27,11 @@ import {
type ProcessCleanupResult,
type ProcessLineageObservation,
} from "../process/ProcessOwnership.js";
import { observeOwnedProcessLineage } from "../process/ProcessOwnershipObservation.js";
import {
observeOwnedProcessLineage,
prepareProcessOwnershipInspection,
systemProcessOwnershipHost,
} from "../process/ProcessOwnershipObservation.js";
import { selectCapturedProcessGroupIds } from "../process/ProcessOwnershipProcessTree.js";
import {
runElectronActions,
@@ -157,8 +161,13 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
try {
if (options.signal?.aborted === true)
throw new BrowserObservationError(OPERATION, "cancelled");
await prepareProcessOwnershipInspection(options.signal);
const startupDeadline = Date.now() + STARTUP_TIMEOUT_MS;
const paths = await canonicalPaths(input);
const captureBaseline =
await systemProcessOwnershipHost.captureBaseline?.(options.signal);
if (options.signal?.aborted)
throw new BrowserObservationError(OPERATION, "cancelled");
application = await electron.launch({
executablePath: paths.executable,
cwd: paths.root,
@@ -183,6 +192,7 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
expectedParentPid: process.pid,
expectedCommand: paths.executable,
sweepTokenOwnedProcesses: true,
...(captureBaseline === undefined ? {} : { captureBaseline }),
};
const actions = await runElectronActions(application, input, options);
const state = await runWithExecutionLimits(
@@ -191,7 +201,13 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
);
outcome = ok(createResult(paths, input, actions, state));
} catch (cause: unknown) {
outcome = err(providerError(cause));
outcome = err(
options.signal?.aborted === true &&
(cause === options.signal.reason ||
(cause instanceof Error && cause.name === "AbortError"))
? new BrowserObservationError(OPERATION, "cancelled", { cause })
: providerError(cause),
);
}
if (application !== undefined) {
if (ownership !== undefined)
@@ -277,7 +293,11 @@ const cleanupElectronProcesses = async (
if (!result.cleaned) return result;
signaled ||= result.signaled;
}
const remaining = await verifyNoTokenOwnedProcesses(ownership.runId);
const remaining = await verifyNoTokenOwnedProcesses(
ownership.runId,
undefined,
ownership.captureBaseline,
);
if (!remaining.cleaned) return remaining;
return { cleaned: true, signaled };
};
+8
View File
@@ -35,4 +35,12 @@ export abstract class AnalysisError extends Error {
readonly userCategory: "cancelled" | undefined = undefined;
readonly cleanupIncomplete: boolean = false;
readonly cleanupResources: readonly string[] = [];
readonly executionFailure: string | undefined = undefined;
readonly partialObservation: PartialProcessCaptureObservation | undefined =
undefined;
readonly cleanupReport: ProcessCaptureCleanupReport | undefined = undefined;
}
import type {
PartialProcessCaptureObservation,
ProcessCaptureCleanupReport,
} from "./process/processCapture.js";
+19 -1
View File
@@ -1,4 +1,4 @@
import type { JsonValue } from "./jsonValue.js";
import { jsonValueSchema, type JsonValue } from "./jsonValue.js";
import {
analysisErrorCategory,
@@ -336,12 +336,30 @@ const lifecycleErrorDetails = (
return {
cleanup: "incomplete",
resources: [...error.cleanupResources],
...(error.cleanupReport === undefined
? {}
: { cleanup_report: jsonValueSchema.parse(error.cleanupReport) }),
...(error.executionFailure === undefined
? {}
: { execution_failure: error.executionFailure }),
...(error.partialObservation === undefined
? {}
: {
partial_observation: jsonValueSchema.parse(
error.partialObservation,
),
}),
};
if (
error._tag === "ProcessCaptureError" &&
error.userCategory === "cancelled"
)
return { operation: "process_capture", cleanup: "complete" };
if (
error._tag === "ProcessCaptureError" &&
error.executionFailure !== undefined
)
return { execution_failure: error.executionFailure };
if (error instanceof BinaryTargetError)
return {
path: error.path,
+368 -107
View File
@@ -132,6 +132,14 @@ export type RecordProcessCaptureEvent = (
/** Observed process-tree settlement and the cleanup required by that state. */
export type ProcessSettlement =
| VerifiedProcessSettlement
| {
readonly state: "quiesced";
readonly elapsed_ms: number;
readonly cleanup_outcome: "failed";
};
export type VerifiedProcessSettlement =
| {
readonly state: "quiesced";
readonly elapsed_ms: number;
@@ -162,7 +170,19 @@ export interface UnverifiedProcessCapture {
readonly comparison_contract: Readonly<Record<string, unknown>>;
readonly full_scenario_sha256: string;
readonly comparison_contract_sha256: string;
readonly executable_sha256: string;
/** Digest of the selected file sampled before spawn, when readable. */
readonly selected_executable_sha256: string | null;
/** Digest associated with the launch only when path metadata stayed stable across spawn. */
readonly executable_sha256: string | null;
/**
* Digest recorded by the original v3 format before selected and launch
* executable digests were distinguished. Kept only on migrated captures.
*/
readonly legacy_executable_sha256?: string | undefined;
readonly executable_identity: {
readonly state: "path_metadata_unchanged" | "unknown";
readonly reason: string | null;
};
readonly normalization_sha256: string;
};
readonly normalization: z.infer<typeof normalizationSchema>;
@@ -174,7 +194,7 @@ export interface UnverifiedProcessCapture {
readonly signal: number | null;
readonly reason: "exited" | "timeout" | "idle_timeout";
};
readonly settlement: ProcessSettlement;
readonly settlement: VerifiedProcessSettlement;
readonly process_samples: readonly ProcessSample[];
readonly filesystem_checkpoints: readonly FilesystemCheckpoint[];
/**
@@ -211,6 +231,66 @@ const fileStateShape = {
mode: z.number().int().nonnegative(),
size: z.number().int().nonnegative(),
};
/** A resource cleanup result retained when observations cannot be verified. */
export interface ProcessCaptureResourceCleanup {
readonly state: "cleaned" | "failed" | "unverified" | "not_required";
readonly reason: string | null;
}
export interface ProcessCaptureCleanupReport {
readonly owned_process_group: ProcessCaptureResourceCleanup;
readonly terminal_renderer: ProcessCaptureResourceCleanup;
readonly temporary_root: ProcessCaptureResourceCleanup;
}
/** One observation field that may be unavailable when capture completion fails. */
export type PartialProcessObservationField<T> =
| { readonly state: "available"; readonly value: T }
| { readonly state: "unavailable"; readonly reason: string };
/** Snapshot facts available before filesystem comparison can be completed. */
/** Snapshot facts available before filesystem comparison can be completed. */
export interface PartialFilesystemSnapshot {
readonly files: readonly FileState[];
readonly truncated: boolean;
}
/** Available and unavailable fields when a run never becomes a full capture. */
/** Available and unavailable fields when a run never becomes a full capture. */
export interface IncompleteProcessCaptureObservations {
readonly target_pid: PartialProcessObservationField<number>;
readonly frames: PartialProcessObservationField<readonly TerminalFrame[]>;
readonly rendered_frames: PartialProcessObservationField<
readonly RenderedTerminalFrame[]
>;
readonly interaction_events: PartialProcessObservationField<
readonly InteractionEvent[]
>;
readonly exit: PartialProcessObservationField<{
readonly code: number | null;
readonly signal: number | null;
readonly reason: "exited" | "timeout" | "idle_timeout" | "cancelled";
}>;
readonly settlement: PartialProcessObservationField<{
readonly state: "quiesced" | "alive_at_deadline" | "unverifiable";
readonly elapsed_ms: number;
}>;
readonly process_samples: PartialProcessObservationField<
readonly ProcessSample[]
>;
readonly filesystem_snapshots: {
readonly before: PartialProcessObservationField<PartialFilesystemSnapshot>;
readonly after: PartialProcessObservationField<PartialFilesystemSnapshot>;
};
readonly event_journal: PartialProcessObservationField<
readonly ProcessCaptureEventJournalEntry[]
>;
readonly manifest: PartialProcessObservationField<
UnverifiedProcessCapture["manifest"]
>;
}
const fileStateSchema = z.discriminatedUnion("type", [
z.object({
...fileStateShape,
@@ -255,128 +335,309 @@ const fileEffectSchema = z.discriminatedUnion("status", [
}),
]);
/** Exact serialized shape of a process capture. */
const processCaptureShapeSchema: z.ZodType<UnverifiedProcessCapture> =
z.strictObject({
manifest: z.strictObject({
rea_version: z.string().min(1),
provider_version: z.string().min(1),
platform: z.string().min(1),
architecture: z.string().min(1),
pty_backend: z.literal("node-pty"),
started_at: z.iso.datetime(),
completed_at: z.iso.datetime(),
scenario: z.record(z.string(), jsonValueSchema),
comparison_contract: z.record(z.string(), jsonValueSchema),
full_scenario_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
comparison_contract_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
executable_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
normalization_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
const processSettlementSchema = z.discriminatedUnion("state", [
z.object({
state: z.literal("quiesced"),
elapsed_ms: z.number().int().nonnegative(),
cleanup_outcome: z.enum(["not_required", "failed"]),
}),
z.object({
state: z.enum(["alive_at_deadline", "unverifiable"]),
elapsed_ms: z.number().int().nonnegative(),
cleanup_outcome: z.enum(["cleaned", "failed"]),
}),
]);
const processCaptureShapeSchema = z.strictObject({
manifest: z.strictObject({
rea_version: z.string().min(1),
provider_version: z.string().min(1),
platform: z.string().min(1),
architecture: z.string().min(1),
pty_backend: z.literal("node-pty"),
started_at: z.iso.datetime(),
completed_at: z.iso.datetime(),
scenario: z.record(z.string(), jsonValueSchema),
comparison_contract: z.record(z.string(), jsonValueSchema),
full_scenario_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
comparison_contract_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
selected_executable_sha256: z
.string()
.regex(/^[a-f0-9]{64}$/u)
.nullable(),
executable_sha256: z
.string()
.regex(/^[a-f0-9]{64}$/u)
.nullable(),
legacy_executable_sha256: z
.string()
.regex(/^[a-f0-9]{64}$/u)
.optional(),
executable_identity: z.strictObject({
state: z.enum(["path_metadata_unchanged", "unknown"]),
reason: z.string().nullable(),
}),
normalization: normalizationSchema,
frames: z.array(
normalization_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
}),
normalization: normalizationSchema,
frames: z.array(
z.object({
sequence: z.number().int().nonnegative(),
at_ms: z.number().int().nonnegative(),
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(),
reason: z.enum(["exited", "timeout", "idle_timeout"]),
}),
settlement: z.discriminatedUnion("state", [
z.object({
state: z.literal("quiesced"),
elapsed_ms: z.number().int().nonnegative(),
cleanup_outcome: z.literal("not_required"),
}),
z.object({
state: z.enum(["alive_at_deadline", "unverifiable"]),
elapsed_ms: z.number().int().nonnegative(),
cleanup_outcome: z.enum(["cleaned", "failed"]),
}),
]),
process_samples: z.array(
z.object({
at_ms: z.number().int().nonnegative(),
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.enum(["before", "after_settlement"]),
at_ms: z.number().int().nonnegative(),
files: z.array(fileStateSchema),
effects: z.array(fileEffectSchema),
truncated: z.boolean(),
}),
),
event_journal: z
.array(
z.object({
sequence: z.number().int().nonnegative(),
at_ms: z.number().int().nonnegative(),
data: z.string(),
capture_order: z.number().int().nonnegative(),
collection: z.enum(PROCESS_CAPTURE_EVENT_COLLECTIONS),
index: z.number().int().nonnegative(),
}),
),
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({
)
.default([]),
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(
z.object({
scope: z.enum([
"terminal",
"interaction",
"exit",
"process",
"filesystem",
"cleanup",
"network",
"environment",
]),
reason: z.string(),
}),
),
cleanup: z.object({
owned_process_group: z.literal("verified"),
temporary_root: z.literal("removed"),
}),
});
const processCleanupResourceSchema = z.strictObject({
state: z.enum(["cleaned", "failed", "unverified", "not_required"]),
reason: z.string().nullable(),
});
const partialObservationFieldSchema = <Schema extends z.ZodType>(
value: Schema,
) =>
z.discriminatedUnion("state", [
z.strictObject({ state: z.literal("available"), value }),
z.strictObject({ state: z.literal("unavailable"), reason: z.string() }),
]);
const incompleteProcessCaptureObservationsSchema = z.strictObject({
target_pid: partialObservationFieldSchema(z.number().int().positive()),
frames: partialObservationFieldSchema(processCaptureShapeSchema.shape.frames),
rendered_frames: partialObservationFieldSchema(
processCaptureShapeSchema.shape.rendered_frames,
),
interaction_events: partialObservationFieldSchema(
processCaptureShapeSchema.shape.interaction_events,
),
exit: partialObservationFieldSchema(
z.strictObject({
code: z.number().int().nullable(),
signal: z.number().int().nullable(),
reason: z.enum(["exited", "timeout", "idle_timeout"]),
reason: z.enum(["exited", "timeout", "idle_timeout", "cancelled"]),
}),
settlement: z.discriminatedUnion("state", [
z.object({
state: z.literal("quiesced"),
elapsed_ms: z.number().int().nonnegative(),
cleanup_outcome: z.literal("not_required"),
}),
z.object({
state: z.enum(["alive_at_deadline", "unverifiable"]),
elapsed_ms: z.number().int().nonnegative(),
cleanup_outcome: z.enum(["cleaned", "failed"]),
}),
]),
process_samples: z.array(
z.object({
at_ms: z.number().int().nonnegative(),
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.enum(["before", "after_settlement"]),
at_ms: z.number().int().nonnegative(),
),
settlement: partialObservationFieldSchema(
z.strictObject({
state: z.enum(["quiesced", "alive_at_deadline", "unverifiable"]),
elapsed_ms: z.number().int().nonnegative(),
}),
),
process_samples: partialObservationFieldSchema(
processCaptureShapeSchema.shape.process_samples,
),
filesystem_snapshots: z.strictObject({
before: partialObservationFieldSchema(
z.strictObject({
files: z.array(fileStateSchema),
effects: z.array(fileEffectSchema),
truncated: z.boolean(),
}),
),
event_journal: z
.array(
z.object({
capture_order: z.number().int().nonnegative(),
collection: z.enum(PROCESS_CAPTURE_EVENT_COLLECTIONS),
index: z.number().int().nonnegative(),
}),
)
.default([]),
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(
z.object({
scope: z.enum([
"terminal",
"interaction",
"exit",
"process",
"filesystem",
"cleanup",
"network",
"environment",
]),
reason: z.string(),
after: partialObservationFieldSchema(
z.strictObject({
files: z.array(fileStateSchema),
truncated: z.boolean(),
}),
),
cleanup: z.object({
owned_process_group: z.literal("verified"),
temporary_root: z.literal("removed"),
}),
}),
event_journal: partialObservationFieldSchema(
processCaptureShapeSchema.shape.event_journal,
),
manifest: partialObservationFieldSchema(
processCaptureShapeSchema.shape.manifest,
),
});
const partialCleanupReportSchema = z.strictObject({
owned_process_group: processCleanupResourceSchema,
terminal_renderer: processCleanupResourceSchema,
temporary_root: processCleanupResourceSchema,
});
const partialObservationMetadataSchema = z.strictObject({
cleanup: partialCleanupReportSchema,
execution_failure: z.string().nullable(),
});
const completedCaptureObservationSchema = z.strictObject({
capture: processCaptureShapeSchema
.omit({ cleanup: true })
.extend({ settlement: processSettlementSchema }),
});
const incompleteCaptureObservationSchema = z.strictObject({
observations: incompleteProcessCaptureObservationsSchema,
});
/** Validated, non-comparable process observations attached to cleanup errors. */
export const partialProcessCaptureObservationSchema = z
.union([
completedCaptureObservationSchema.extend(
partialObservationMetadataSchema.shape,
),
incompleteCaptureObservationSchema.extend(
partialObservationMetadataSchema.shape,
),
])
.superRefine((partial, context) => {
if (
!Object.values(partial.cleanup).some(
({ state }) => state === "failed" || state === "unverified",
)
)
context.addIssue({
code: "custom",
path: ["cleanup"],
message: "partial observations require incomplete resource cleanup",
});
if ("observations" in partial) {
const { observations } = partial;
if (
![
observations.frames,
observations.interaction_events,
observations.process_samples,
observations.filesystem_snapshots.before,
observations.event_journal,
].some(({ state }) => state === "available")
)
context.addIssue({
code: "custom",
path: ["observations"],
message: "incomplete observations contain no collected evidence",
});
return;
}
const { capture } = partial;
const settlement =
capture.settlement.state === "quiesced"
? {
...capture.settlement,
cleanup_outcome: "not_required" as const,
}
: { ...capture.settlement, cleanup_outcome: "cleaned" as const };
const validationCapture: UnverifiedProcessCapture = {
...capture,
settlement,
cleanup: {
owned_process_group: "verified",
temporary_root: "removed",
},
};
for (const issue of collectProcessCaptureIssues(validationCapture))
context.addIssue({
code: "custom",
path: ["capture", ...issue.path.split(".")],
message: issue.message,
});
});
/** One collected payload: completed capture data or explicit unavailable fields. */
export type PartialProcessCaptureObservation = z.infer<
typeof partialProcessCaptureObservationSchema
>;
/** Exact serialized shape plus all process-capture semantic invariants. */
export const processCaptureSchema = processCaptureShapeSchema
.superRefine((capture, context) => {
if (capture.settlement.cleanup_outcome === "failed")
context.addIssue({
code: "custom",
path: ["settlement", "cleanup_outcome"],
message: "verified captures cannot contain failed cleanup",
});
for (const issue of collectProcessCaptureIssues(capture))
context.addIssue({
code: "custom",
@@ -65,7 +65,7 @@ it("rejects altered v4 commitments and accepts canonical key reordering", () =>
executable_sha256: "f".repeat(64),
},
}),
).toThrow("executable_sha256");
).toThrow("executable_identity");
});
it("rejects settlement and cleanup combinations that cannot occur", () => {
@@ -142,6 +142,92 @@ it("accepts old captures without a journal and validates complete journals", ()
}
});
it("migrates persisted v3 executable digests without claiming a selected digest", () => {
const capture = emptyCapture();
const {
selected_executable_sha256: _selectedExecutableSha256,
executable_identity: _executableIdentity,
executable_sha256,
...legacyManifest
} = capture.manifest;
const legacyCapture = {
...capture,
manifest: { ...legacyManifest, executable_sha256 },
};
const migrated = parseProcessCapture(legacyCapture);
expect(migrated.manifest).toMatchObject({
selected_executable_sha256: null,
executable_sha256: null,
executable_identity: {
state: "unknown",
reason: expect.stringContaining("did not distinguish"),
},
legacy_executable_sha256: executable_sha256,
scenario: capture.manifest.scenario,
full_scenario_sha256: capture.manifest.full_scenario_sha256,
});
expect(compareProcessCaptures(migrated, migrated)).toMatchObject({
status: "unchanged",
});
const exported = JSON.stringify(migrated);
expect(parseProcessCapture(JSON.parse(exported))).toEqual(migrated);
expect(() =>
parseProcessCapture({
...legacyCapture,
manifest: {
...legacyCapture.manifest,
executable_sha256: "f".repeat(64),
},
}),
).toThrow("legacy_executable_sha256");
const nullDigestScenario = {
...legacyCapture.manifest.scenario,
executable_sha256: null,
};
const malformedLegacyBase = {
...legacyCapture,
manifest: {
...legacyCapture.manifest,
executable_sha256: null,
scenario: nullDigestScenario,
full_scenario_sha256: digestProcessCommitment(nullDigestScenario),
},
};
expect(() => parseProcessCapture(malformedLegacyBase)).toThrow();
expect(() =>
parseProcessCapture({
...malformedLegacyBase,
manifest: {
...malformedLegacyBase.manifest,
executable_sha256: "not-a-digest",
},
}),
).toThrow();
const {
executable_sha256: _missingLegacyDigest,
...manifestWithoutLegacyDigest
} = malformedLegacyBase.manifest;
expect(() =>
parseProcessCapture({
...malformedLegacyBase,
manifest: manifestWithoutLegacyDigest,
}),
).toThrow();
const { executable_identity: _missingIdentity, ...incompleteModernManifest } =
capture.manifest;
expect(() =>
parseProcessCapture({
...capture,
manifest: incompleteModernManifest,
}),
).toThrow();
});
it("requires compatible contracts and enforces capture age through a clock seam", () => {
const capture = emptyCapture();
expect(() =>
@@ -25,7 +25,12 @@ export const EMPTY_PROCESS_CAPTURE_EXAMPLE = {
comparison_contract: comparisonContract,
full_scenario_sha256: digestProcessCommitment(scenario),
comparison_contract_sha256: digestProcessCommitment(comparisonContract),
selected_executable_sha256: "0".repeat(64),
executable_sha256: "0".repeat(64),
executable_identity: {
state: "path_metadata_unchanged",
reason: null,
},
normalization_sha256: digestProcessCommitment(normalization),
},
normalization,
+40 -1
View File
@@ -2,6 +2,7 @@ import {
processCaptureSchema,
type UnverifiedProcessCapture,
} from "./processCapture.js";
import { z } from "zod";
declare class ProcessCaptureProof {
private readonly verified: never;
@@ -22,9 +23,47 @@ const attachProof = (capture: UnverifiedProcessCapture): ProcessCapture => {
throw new TypeError("Process Capture proof ownership failed");
};
const recordSchema = z.record(z.string(), z.unknown());
const LEGACY_EXECUTABLE_IDENTITY_REASON =
"Legacy v3 capture did not distinguish selected and launch executable digests";
const migrateLegacyV3Capture = (input: unknown): unknown => {
const capture = recordSchema.safeParse(input);
if (!capture.success) return input;
const manifest = recordSchema.safeParse(capture.data.manifest);
if (!manifest.success) return input;
if (
manifest.data.provider_version !== "3" ||
"selected_executable_sha256" in manifest.data ||
"executable_identity" in manifest.data ||
"legacy_executable_sha256" in manifest.data
)
return input;
const { executable_sha256 } = manifest.data;
if (
typeof executable_sha256 !== "string" ||
!/^[a-f0-9]{64}$/u.test(executable_sha256)
)
return input;
return {
...capture.data,
manifest: {
...manifest.data,
selected_executable_sha256: null,
executable_sha256: null,
executable_identity: {
state: "unknown",
reason: LEGACY_EXECUTABLE_IDENTITY_REASON,
},
legacy_executable_sha256: executable_sha256,
},
};
};
/** Parse unknown input and reject invalid commitments or semantics. */
export const parseProcessCapture = (input: unknown): ProcessCapture => {
const parsed = processCaptureSchema.safeParse(input);
const parsed = processCaptureSchema.safeParse(migrateLegacyV3Capture(input));
if (parsed.success) return attachProof(parsed.data);
const issues = parsed.error.issues.flatMap((issue) =>
issue.code === "custom"
+17 -2
View File
@@ -26,8 +26,23 @@ const validateCommitments = (
require: RequireInvariant,
): void => {
const { manifest } = capture;
require(manifest.scenario.executable_sha256 ===
manifest.executable_sha256, "manifest.executable_sha256", "executable commitment does not match the scenario projection");
if (manifest.legacy_executable_sha256 !== undefined) {
require(manifest.selected_executable_sha256 === null &&
manifest.executable_sha256 === null &&
manifest.executable_identity.state === "unknown" &&
manifest.executable_identity.reason !== null &&
manifest.scenario.executable_sha256 ===
manifest.legacy_executable_sha256, "manifest.legacy_executable_sha256", "legacy executable digest must remain distinct from unknown selected and launch digests");
} else {
require(manifest.scenario.executable_sha256 ===
manifest.selected_executable_sha256, "manifest.selected_executable_sha256", "selected executable commitment does not match the scenario projection");
}
require(manifest.executable_identity.state === "path_metadata_unchanged"
? manifest.executable_sha256 === manifest.selected_executable_sha256 &&
manifest.executable_identity.reason === null
: manifest.executable_sha256 === null &&
manifest.executable_identity.reason !==
null, "manifest.executable_identity", "launch identity and executable digest do not agree");
for (const [field, value] of [
["full_scenario_sha256", manifest.scenario],
["comparison_contract_sha256", manifest.comparison_contract],
+56 -4
View File
@@ -23,6 +23,7 @@ const source = (
digit: string,
authority: "controlled-replay" | "shipped-artifact",
confidence: "observed" | "derived" = "observed",
captureResult: JsonValue = EMPTY_PROCESS_CAPTURE_EXAMPLE,
): Evidence =>
createEvidence(
{
@@ -41,16 +42,26 @@ const source = (
? "capture_process_scenario"
: "inventory_artifact",
parameters: {},
result:
authority === "controlled-replay"
? EMPTY_PROCESS_CAPTURE_EXAMPLE
: { digit },
result: authority === "controlled-replay" ? captureResult : { digit },
confidence,
authority,
environment: authority === "controlled-replay" ? environment : null,
},
);
const legacyV3Capture = () => {
const {
selected_executable_sha256: _selectedDigest,
executable_identity: _executableIdentity,
executable_sha256,
...legacyManifest
} = EMPTY_PROCESS_CAPTURE_EXAMPLE.manifest;
return {
...EMPTY_PROCESS_CAPTURE_EXAMPLE,
manifest: { ...legacyManifest, executable_sha256 },
};
};
const processResult = (
terminal: "unchanged" | "changed" | "unknown" = "unchanged",
): JsonValue => ({
@@ -275,7 +286,48 @@ describe("reconstruction verification", () => {
]),
);
});
});
describe("saved v3 reconstruction capture compatibility", () => {
it("verifies saved v3 process captures and rejects a malformed legacy digest", () => {
const legacy = legacyV3Capture();
const left = source("1", "controlled-replay", "observed", legacy);
const right = source("2", "controlled-replay");
const comparison = processComparison(left, right, processResult());
const bundle = createEvidenceBundle([comparison, left, right]);
expect(
verifyReconstruction(behavioralSpec(comparison), bundle),
).toMatchObject({
status: "pass",
summary: { total: 1, passed: 1, failed: 0, unknown: 0 },
});
const malformedLegacy = {
...legacy,
manifest: { ...legacy.manifest, executable_sha256: null },
};
const malformedLeft = source(
"3",
"controlled-replay",
"observed",
malformedLegacy,
);
const malformedComparison = processComparison(
malformedLeft,
right,
processResult(),
);
expect(() =>
verifyReconstruction(
behavioralSpec(malformedComparison),
createEvidenceBundle([malformedComparison, malformedLeft, right]),
),
).toThrow();
});
});
describe("reconstruction verification outcomes", () => {
it("fails an authoritative observed disagreement", () => {
const left = source("1", "controlled-replay");
const right = source("2", "controlled-replay");
+2 -2
View File
@@ -18,7 +18,7 @@ import {
deriveProcessComparisonStatus,
PROCESS_COMPARISON_DIMENSIONS,
processCaptureComparisonSchema,
processCaptureSchema,
parseProcessCapture,
} from "./process/processCapture.js";
import {
reconstructionClaimResultSchema,
@@ -199,7 +199,7 @@ const validateSourceKinds = (
);
for (const source of sources) {
if (claim.kind === "behavioral")
processCaptureSchema.parse(source.normalized_result);
parseProcessCapture(source.normalized_result);
else if (claim.kind === "structural-function")
functionDossierSchema.parse(source.normalized_result);
else artifactInventoryResultSchema.parse(source.normalized_result);
+1
View File
@@ -122,6 +122,7 @@ export const runFirmwareCommand = async (context: {
runId,
cwd: context.cwd,
hostEnvironment: context.environment,
signal,
env: {
TMPDIR: context.cwd,
OMP_NUM_THREADS: "1",
+1
View File
@@ -147,6 +147,7 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
platform,
command.command,
),
...(options.signal === undefined ? {} : { signal: options.signal }),
});
await writeGhidraRuntimeFile(
paths.ownershipPath,
+2
View File
@@ -336,6 +336,7 @@ const launchHopperProcess = async (input: {
arguments: arguments_,
runId: input.session.runId,
expectedCommand: ownershipCommand,
...(input.signal === undefined ? {} : { signal: input.signal }),
});
if (input.linuxDemo !== undefined)
return spawnOwnedProviderProcess({
@@ -343,6 +344,7 @@ const launchHopperProcess = async (input: {
arguments: input.linuxDemo.args,
runId: input.session.runId,
expectedCommand: ownershipCommand,
...(input.signal === undefined ? {} : { signal: input.signal }),
});
return spawn(input.argumentsForTarget);
};
+8 -3
View File
@@ -20,6 +20,9 @@ const PYTHON_PATH = "/usr/bin/python3";
const UNSHARE_PATH = "/usr/bin/unshare";
const DEFAULT_PROBE_TIMEOUT_MS = 5_000;
const isAborted = (signal: AbortSignal | undefined): boolean =>
signal?.aborted === true;
export type LinuxPrivateDisplayRunnableStrategy = Exclude<
HopperPrivateDisplayStrategy,
"unavailable"
@@ -101,8 +104,7 @@ export const selectLinuxPrivateDisplayStrategy = async (options: {
/** Run one helper probe in an owned process group and retain its full diagnostics. */
export const runLinuxPrivateDisplayProbe: LinuxPrivateDisplayProbeRunner =
async (strategy, options) => {
if (options.signal?.aborted === true)
return emptyProcessResult("cancelled");
if (isAborted(options.signal)) return emptyProcessResult("cancelled");
const command = privateDisplayProbeCommand(options.helperPath, strategy);
let started: Awaited<ReturnType<typeof spawnOwnedProviderProcess>>;
try {
@@ -111,11 +113,14 @@ export const runLinuxPrivateDisplayProbe: LinuxPrivateDisplayProbeRunner =
arguments: command.arguments,
runId: randomUUID(),
expectedCommand: null,
...(options.signal === undefined ? {} : { signal: options.signal }),
});
} catch (cause: unknown) {
// best-effort cleanup: probe spawn failure is reported as launch-failed.
void cause;
return emptyProcessResult("launch-failed");
return emptyProcessResult(
isAborted(options.signal) ? "cancelled" : "launch-failed",
);
}
const launch: ProviderProcessLaunch = {
process: started.process,
+3
View File
@@ -101,8 +101,11 @@ export const runWakaruCommand = async (context: WakaruCommandContext) => {
runId,
cwd: context.cwd,
hostEnvironment: context.environment,
...(context.signal === undefined ? {} : { signal: context.signal }),
env: { TMPDIR: context.cwd, RAYON_NUM_THREADS: "1", NO_COLOR: "1" },
}).catch((cause: unknown) => {
if (context.signal?.aborted === true)
throw new AnalysisCancelledError(OPERATION);
throw new ProviderAdapterError("wakaru", OPERATION, {
cause,
diagnostics: {
@@ -120,6 +120,7 @@ class OwnedCodec {
runId: this.#runId,
cwd: this.#runtime.path,
hostEnvironment: environment,
signal: this.deadline.signal,
});
this.#supervisor = new ProviderProcessSupervisor(
{
+361
View File
@@ -0,0 +1,361 @@
import { mkdtemp, rm } from "node:fs/promises";
import { rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { z } from "zod";
import { safeParseJson } from "../domain/safeJson.js";
import { execFileOutput } from "./ExecFileOutput.js";
import type {
ProcessIdentityObservation,
ProcessRunTokenObservation,
ProcessTableEntry,
} from "./ProcessOwnership.js";
const responseSchema = z.strictObject({
results: z.array(
z.discriminatedUnion("state", [
z.strictObject({
pid: z.number().int().positive(),
state: z.literal("readable"),
run_id: z.string().optional(),
reason: z.null().optional(),
}),
z.strictObject({
pid: z.number().int().positive(),
state: z.literal("unavailable"),
run_id: z.union([z.string(), z.null()]).optional(),
reason: z.string().min(1),
}),
]),
),
});
const identityResponseSchema = z.strictObject({
results: z.array(
z.discriminatedUnion("state", [
z.strictObject({
pid: z.number().int().positive(),
state: z.literal("readable"),
identity: z.string().min(1),
reason: z.null().optional(),
}),
z.strictObject({
pid: z.number().int().positive(),
state: z.literal("unavailable"),
identity: z.null().optional(),
reason: z.string().min(1),
}),
]),
),
});
const sourceFiles = [
fileURLToPath(
new URL(
"../../bridge/process/ProcessRunTokenReader.swift",
import.meta.url,
),
),
fileURLToPath(
new URL(
"../../bridge/process/ProcessRunTokenReaderMain.swift",
import.meta.url,
),
),
];
/** The native Darwin process-ownership reader could not be prepared. */
export class DarwinProcessOwnershipInspectionError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "DarwinProcessOwnershipInspectionError";
}
}
interface ProcessReaderCompilation {
readonly controller: AbortController;
promise: Promise<string>;
waiters: number;
aborting: boolean;
settled: boolean;
}
const abortReason = (signal: AbortSignal): unknown => {
if (signal.reason !== undefined) return signal.reason;
const error = new Error("The operation was aborted");
error.name = "AbortError";
return error;
};
const isExpectedAbort = (cause: unknown, signal: AbortSignal): boolean =>
signal.aborted &&
(cause === signal.reason ||
(cause instanceof Error && cause.name === "AbortError"));
const waitForCompilation = async (
promise: Promise<string>,
signal: AbortSignal | undefined,
): Promise<string> => {
if (signal === undefined) return promise;
if (signal.aborted) throw abortReason(signal);
return new Promise((resolve, reject) => {
let completed = false;
const finish = (callback: () => void) => {
if (completed) return;
completed = true;
signal.removeEventListener("abort", onAbort);
callback();
};
const onAbort = () => finish(() => reject(abortReason(signal)));
signal.addEventListener("abort", onAbort, { once: true });
promise.then(
(value) => finish(() => resolve(value)),
(cause: unknown) => finish(() => reject(cause)),
);
if (signal.aborted) onAbort();
});
};
/** Compile and retain the narrow Darwin sysctl reader for this REA process. */
export const createDarwinProcessRunTokenReader = (
options: { readonly xcrun?: string } = {},
) => {
let root: string | undefined;
let executable: string | undefined;
let compilation: ProcessReaderCompilation | undefined;
let closePromise: Promise<void> | undefined;
let closed = false;
let exitCleanupInstalled = false;
const cleanupAtExit = () => {
if (root !== undefined) rmSync(root, { recursive: true, force: true });
};
const cleanupRoot = async (target = root): Promise<void> => {
if (target === undefined) return;
await rm(target, { recursive: true, force: true });
if (root !== target) return;
root = undefined;
executable = undefined;
if (exitCleanupInstalled) process.removeListener("exit", cleanupAtExit);
exitCleanupInstalled = false;
};
const runCompilation = async (signal: AbortSignal): Promise<string> => {
let operationRoot: string | undefined;
try {
operationRoot = await mkdtemp(join(tmpdir(), "rea-process-token-"));
root = operationRoot;
process.once("exit", cleanupAtExit);
exitCleanupInstalled = true;
if (signal.aborted || closed) throw abortReason(signal);
const output = join(operationRoot, "reader");
try {
await execFileOutput(
options.xcrun ?? "/usr/bin/xcrun",
[
"swiftc",
"-module-cache-path",
join(operationRoot, "modules"),
...sourceFiles,
"-o",
output,
],
{
timeout: 60_000,
maxBuffer: 1024 * 1024,
signal,
},
);
} catch (cause: unknown) {
if (isExpectedAbort(cause, signal)) throw cause;
const code =
cause instanceof Error && "code" in cause
? String(cause.code)
: "unknown";
throw new DarwinProcessOwnershipInspectionError(
`macOS process ownership inspection requires the Apple Swift compiler via xcrun (compiler result: ${code})`,
{ cause },
);
}
if (signal.aborted || closed) throw abortReason(signal);
executable = output;
return output;
} catch (cause: unknown) {
const ownsRoot = operationRoot !== undefined && root === operationRoot;
if (operationRoot !== undefined) {
if (ownsRoot) await cleanupRoot(operationRoot);
else await rm(operationRoot, { recursive: true, force: true });
}
throw cause;
}
};
const beginCompilation = (): ProcessReaderCompilation => {
const controller = new AbortController();
const operation: ProcessReaderCompilation = {
controller,
promise: runCompilation(controller.signal).finally(() => {
operation.settled = true;
if (compilation === operation) compilation = undefined;
}),
waiters: 0,
aborting: false,
settled: false,
};
compilation = operation;
return operation;
};
const compile = async (signal?: AbortSignal): Promise<string> => {
if (closed) throw new Error("Darwin process token reader is closed");
signal?.throwIfAborted();
while (true) {
if (closed) throw new Error("Darwin process token reader is closed");
signal?.throwIfAborted();
if (executable !== undefined) return executable;
if (compilation === undefined && root !== undefined) {
await cleanupRoot();
continue;
}
let operation = compilation;
if (operation?.aborting === true) {
try {
await waitForCompilation(operation.promise, signal);
} catch (cause: unknown) {
if (signal !== undefined && isExpectedAbort(cause, signal))
throw cause;
}
continue;
}
operation ??= beginCompilation();
operation.waiters += 1;
try {
return await waitForCompilation(operation.promise, signal);
} finally {
operation.waiters -= 1;
if (
operation.waiters === 0 &&
!operation.settled &&
!operation.aborting
) {
operation.aborting = true;
operation.controller.abort();
await operation.promise.catch(() => undefined);
}
}
}
};
const read = async (
processes: readonly ProcessTableEntry[],
): Promise<ReadonlyMap<number, ProcessRunTokenObservation>> => {
if (processes.length === 0) return new Map();
const binary = await compile();
const pids = processes.map(({ pid }) => String(pid));
const { stdout } = await execFileOutput(binary, pids, { timeout: 15_000 });
const decoded = safeParseJson(stdout);
if (!decoded.ok)
throw new Error(
`macOS process token helper returned invalid JSON: ${decoded.error}`,
{ cause: decoded.cause },
);
const parsed = responseSchema.safeParse(decoded.value);
if (!parsed.success)
throw new Error(
"macOS process token helper returned an invalid response",
{
cause: parsed.error,
},
);
const requested = new Set(processes.map(({ pid }) => pid));
const observations = new Map<number, ProcessRunTokenObservation>();
for (const result of parsed.data.results) {
if (!requested.has(result.pid) || observations.has(result.pid))
throw new Error(
"macOS process token helper returned an unexpected PID set",
);
observations.set(
result.pid,
result.state === "readable"
? { state: "readable", runId: result.run_id }
: {
state: "unavailable",
reason:
result.reason ?? "process token observation is unavailable",
},
);
}
if (observations.size !== requested.size)
throw new Error(
"macOS process token helper returned an incomplete PID set",
);
return observations;
};
const identities = async (
processes: readonly ProcessTableEntry[],
signal?: AbortSignal,
): Promise<ReadonlyMap<number, ProcessIdentityObservation>> => {
if (processes.length === 0) return new Map();
const binary = await compile(signal);
const pids = processes.map(({ pid }) => String(pid));
const { stdout } = await execFileOutput(binary, ["--identities", ...pids], {
timeout: 15_000,
...(signal === undefined ? {} : { signal }),
});
const decoded = safeParseJson(stdout);
if (!decoded.ok)
throw new Error(
`macOS process identity helper returned invalid JSON: ${decoded.error}`,
{ cause: decoded.cause },
);
const parsed = identityResponseSchema.safeParse(decoded.value);
if (!parsed.success)
throw new Error(
"macOS process identity helper returned an invalid response",
{
cause: parsed.error,
},
);
const requested = new Set(processes.map(({ pid }) => pid));
const observations = new Map<number, ProcessIdentityObservation>();
for (const result of parsed.data.results) {
if (!requested.has(result.pid) || observations.has(result.pid))
throw new Error(
"macOS process identity helper returned an unexpected PID set",
);
observations.set(
result.pid,
result.state === "readable"
? { state: "readable", identity: result.identity }
: { state: "unavailable", reason: result.reason },
);
}
if (observations.size !== requested.size)
throw new Error(
"macOS process identity helper returned an incomplete PID set",
);
return observations;
};
const close = (): Promise<void> => {
if (closePromise !== undefined) return closePromise;
closed = true;
const pendingClose = (async () => {
const operation = compilation;
if (operation !== undefined && !operation.settled) {
operation.aborting = true;
operation.controller.abort();
await operation.promise.catch(() => undefined);
}
await cleanupRoot();
})().catch((cause: unknown) => {
if (closePromise === pendingClose) closePromise = undefined;
throw cause;
});
closePromise = pendingClose;
return closePromise;
};
return { read, identities, prepare: compile, close };
};
@@ -0,0 +1,481 @@
import { describe, expect, it, vi } from "vitest";
import {
cleanupOwnedProcessGroup,
verifyNoTokenOwnedProcesses,
type ProcessOwnershipHost,
type ProcessTableEntry,
} from "./ProcessOwnership.js";
import {
createSystemProcessOwnershipHost,
parseLinuxProcessStartTime,
} from "./ProcessOwnershipObservation.js";
const itOnLinux = process.platform === "linux" ? it : it.skip;
describe("Linux process start identities", () => {
itOnLinux("reads Linux process start identities from proc stat", async () => {
const host = createSystemProcessOwnershipHost("linux", {});
expect(host.processIdentities).toBeDefined();
expect(host.captureBaseline).toBeDefined();
const observedProcess = {
pid: process.pid,
parentPid: process.ppid,
processGroupId: process.pid,
state: "S",
command: process.execPath,
} satisfies ProcessTableEntry;
const identities = await host.processIdentities?.([observedProcess]);
expect(identities?.get(observedProcess.pid)).toMatchObject({
state: "readable",
identity: expect.stringMatching(/^linux-starttime:\d+$/u),
});
const baseline = await host.captureBaseline?.();
expect(baseline).toContainEqual({
pid: observedProcess.pid,
identity: expect.stringMatching(/^linux-starttime:\d+$/u),
});
});
it("parses stat start time after a command name containing parentheses", () => {
const fields = Array.from({ length: 20 }, (_, index) =>
index === 0 ? "S" : index === 19 ? "987654" : "0",
);
expect(
parseLinuxProcessStartTime(
`42 (worker ) old ) name) ${fields.join(" ")}`,
),
).toBe("987654");
expect(
parseLinuxProcessStartTime("42 (worker) S too-short"),
).toBeUndefined();
});
});
describe("capture-local process ownership", () => {
it("validates and cleans an exact-token owner without a capture baseline", async () => {
const process: ProcessTableEntry = {
pid: 904,
parentPid: 1,
processGroupId: 904,
state: "S",
command: "generic-darwin-owner",
};
const signalGroup = vi.fn();
const host: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve([process]),
environment: () => Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
runTokens: (entries) =>
Promise.resolve(
new Map(
entries.map(({ pid }) => [
pid,
{ state: "readable", runId: "run-token" } as const,
]),
),
),
processIdentities: (entries) =>
Promise.resolve(
new Map(
entries.map(({ pid }) => [
pid,
{ state: "readable", identity: "owner-start" } as const,
]),
),
),
signalGroup,
};
await expect(
cleanupOwnedProcessGroup(
{
runId: "run-token",
leaderPid: process.pid,
processGroupId: process.processGroupId,
sweepTokenOwnedProcesses: true,
},
host,
),
).resolves.toEqual({ cleaned: true, signaled: true });
expect(signalGroup).toHaveBeenCalledWith(process.processGroupId, "SIGKILL");
});
it("cleans verified owners before reporting an unrelated opaque process", async () => {
const owned: ProcessTableEntry = {
pid: 905,
parentPid: 1,
processGroupId: 905,
state: "S",
command: "owned-detached-child",
};
const opaque: ProcessTableEntry = {
pid: 906,
parentPid: 1,
processGroupId: 906,
state: "S",
command: "unrelated-process",
};
const malformed: ProcessTableEntry = {
...opaque,
pid: 907,
processGroupId: 907,
command: "malformed-procargs-process",
};
const sysctlDenied: ProcessTableEntry = {
...opaque,
pid: 908,
processGroupId: 908,
command: "sysctl-denied-process",
};
const signalGroup = vi.fn();
const host: ProcessOwnershipHost = {
listProcesses: () =>
Promise.resolve([owned, opaque, malformed, sysctlDenied]),
environment: (pid) =>
pid === owned.pid
? Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" })
: Promise.reject(new Error("permission denied")),
runTokens: (entries) =>
Promise.resolve(
new Map(
entries.map(({ pid }) => [
pid,
pid === owned.pid
? { state: "readable", runId: "run-token" as const }
: {
state: "unavailable",
reason:
pid === malformed.pid
? ("malformed_procargs" as const)
: pid === sysctlDenied.pid
? ("sysctl_failed_1" as const)
: ("environment_unavailable" as const),
},
]),
),
),
signalGroup,
};
await expect(
cleanupOwnedProcessGroup(
{
runId: "run-token",
leaderPid: 100,
processGroupId: 100,
sweepTokenOwnedProcesses: true,
},
host,
),
).resolves.toMatchObject({
cleaned: false,
reason:
"process ownership token could not be read for 3 live process(es): environment_unavailable=1, malformed_procargs=1, sysctl_errno_1=1",
});
expect(signalGroup).toHaveBeenCalledWith(owned.processGroupId, "SIGKILL");
expect(signalGroup).not.toHaveBeenCalledWith(
opaque.processGroupId,
"SIGKILL",
);
expect(signalGroup).not.toHaveBeenCalledWith(
malformed.processGroupId,
"SIGKILL",
);
expect(signalGroup).not.toHaveBeenCalledWith(
sysctlDenied.processGroupId,
"SIGKILL",
);
});
});
describe("capture-local process identity baselines", () => {
it("checks reused and unknown PIDs while skipping unchanged identities", async () => {
const tokenReads: number[][] = [];
const processes: readonly ProcessTableEntry[] = [
{
pid: 899,
parentPid: 1,
processGroupId: 899,
state: "S",
command: "stable",
},
{
pid: 900,
parentPid: 1,
processGroupId: 900,
state: "S",
command: "reused",
},
{
pid: 902,
parentPid: 1,
processGroupId: 902,
state: "S",
command: "unknown",
},
];
const host: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve(processes),
environment: () => Promise.resolve({}),
processIdentities: (entries) =>
Promise.resolve(
new Map(
entries.map(({ pid }) => [
pid,
pid === 902
? { state: "unavailable", reason: "fixture" }
: {
state: "readable",
identity: pid === 899 ? "stable-start" : "reused-start",
},
]),
),
),
runTokens: (entries) => {
tokenReads.push(entries.map(({ pid }) => pid));
return Promise.resolve(
new Map(
entries.map(({ pid }) => [
pid,
{
state: "readable",
runId: pid === 900 ? "run-token" : undefined,
},
]),
),
);
},
signalGroup: vi.fn(),
};
await expect(
verifyNoTokenOwnedProcesses("run-token", host, [
{ pid: 899, identity: "stable-start" },
{ pid: 900, identity: "old-start" },
{ pid: 902, identity: null },
]),
).resolves.toEqual({
cleaned: false,
reason: "process identity could not be revalidated",
failures: [
{
pid: 902,
reason: "process-identity-unavailable",
diagnostic: "process identity was unavailable during token scan",
},
],
});
expect(tokenReads).toEqual([[900]]);
});
it("revalidates fallback environment owners before signaling after PID reuse", async () => {
let identityReads = 0;
const process: ProcessTableEntry = {
pid: 910,
parentPid: 1,
processGroupId: 910,
state: "S",
command: "fallback-environment-owner",
};
const signalGroup = vi.fn();
const host: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve([process]),
environment: () => Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
processIdentities: (entries) => {
identityReads += 1;
const identity = identityReads < 3 ? "owned-start" : "reused-start";
return Promise.resolve(
new Map(
entries.map(({ pid }) => [
pid,
{ state: "readable", identity } as const,
]),
),
);
},
signalGroup,
};
await expect(
cleanupOwnedProcessGroup(
{
runId: "run-token",
leaderPid: 100,
processGroupId: 100,
sweepTokenOwnedProcesses: true,
captureBaseline: [{ pid: process.pid, identity: "old-start" }],
},
host,
),
).resolves.toMatchObject({
cleaned: false,
reason: "process identity could not be revalidated",
});
expect(signalGroup).not.toHaveBeenCalled();
expect(identityReads).toBeGreaterThanOrEqual(3);
});
});
describe("process identity during token validation", () => {
it("revalidates identity after the final environment token read", async () => {
let environmentReads = 0;
let reusedDuringFinalRead = false;
const process: ProcessTableEntry = {
pid: 912,
parentPid: 1,
processGroupId: 912,
state: "S",
command: "reused-during-final-token-read",
};
const signalGroup = vi.fn();
const host: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve([process]),
environment: () => {
environmentReads += 1;
if (environmentReads === 3) reusedDuringFinalRead = true;
return Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" });
},
processIdentities: (entries) => {
const identity = reusedDuringFinalRead ? "reused-start" : "owned-start";
return Promise.resolve(
new Map(
entries.map(({ pid }) => [
pid,
{ state: "readable", identity } as const,
]),
),
);
},
signalGroup,
};
await expect(
cleanupOwnedProcessGroup(
{
runId: "run-token",
leaderPid: 100,
processGroupId: 100,
sweepTokenOwnedProcesses: true,
captureBaseline: [{ pid: process.pid, identity: "old-start" }],
},
host,
),
).resolves.toMatchObject({
cleaned: false,
reason: "process identity could not be revalidated",
});
expect(signalGroup).not.toHaveBeenCalled();
expect(reusedDuringFinalRead).toBe(true);
});
it("fails closed when a candidate PID changes identity during token reading", async () => {
let identityReads = 0;
const process: ProcessTableEntry = {
pid: 911,
parentPid: 1,
processGroupId: 911,
state: "S",
command: "reused-during-token-read",
};
const host: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve([process]),
environment: () => Promise.resolve({}),
processIdentities: (entries) => {
identityReads += 1;
const identity = identityReads === 1 ? "new-start" : "reused-start";
return Promise.resolve(
new Map(
entries.map(({ pid }) => [
pid,
{ state: "readable", identity } as const,
]),
),
);
},
runTokens: (entries) =>
Promise.resolve(
new Map(
entries.map(({ pid }) => [
pid,
{ state: "readable", runId: undefined } as const,
]),
),
),
signalGroup: vi.fn(),
};
await expect(
verifyNoTokenOwnedProcesses("run-token", host, [
{ pid: process.pid, identity: "old-start" },
]),
).resolves.toEqual({
cleaned: false,
reason: "process identity could not be revalidated",
failures: [
{
pid: process.pid,
reason: "process-identity-unavailable",
diagnostic:
"process identity changed or became unavailable during token validation",
},
],
});
});
});
describe("process identity before signaling", () => {
it("refuses to signal when PID identity changes after token validation", async () => {
const signalGroup = vi.fn();
let identityReads = 0;
const process: ProcessTableEntry = {
pid: 903,
parentPid: 1,
processGroupId: 903,
state: "S",
command: "reused-before-signal",
};
const host: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve([process]),
environment: () => Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
processIdentities: (entries) => {
identityReads += 1;
const identity = identityReads < 3 ? "owned-start" : "reused-start";
return Promise.resolve(
new Map(
entries.map(({ pid }) => [
pid,
{ state: "readable", identity } as const,
]),
),
);
},
runTokens: (entries) =>
Promise.resolve(
new Map(
entries.map(({ pid }) => [
pid,
{ state: "readable", runId: "run-token" } as const,
]),
),
),
signalGroup,
};
await expect(
cleanupOwnedProcessGroup(
{
runId: "run-token",
leaderPid: 100,
processGroupId: 100,
sweepTokenOwnedProcesses: true,
captureBaseline: [{ pid: process.pid, identity: "old-start" }],
},
host,
),
).resolves.toMatchObject({
cleaned: false,
reason: "process identity could not be revalidated",
});
expect(signalGroup).not.toHaveBeenCalled();
expect(identityReads).toBeGreaterThanOrEqual(3);
});
});
@@ -180,3 +180,38 @@ describe("owned process-group cleanup discovery", () => {
});
});
});
describe("process environment permission diagnostics", () => {
it.each(["EACCES", "EPERM"])(
"preserves %s while refusing to signal an unverifiable live process",
async (code) => {
const signalGroup = vi.fn();
const diagnostic = `${code}: permission denied, open '/proc/900/environ'`;
const adapter: ProcessOwnershipHost = {
listProcesses: () =>
Promise.resolve([
{
pid: 900,
parentPid: 1,
processGroupId: 900,
state: "S",
command: "unverifiable-process",
},
]),
environment: () => Promise.reject(new Error(diagnostic)),
signalGroup,
};
await expect(
cleanupOwnedProcessGroup(
{ ...ownership, sweepTokenOwnedProcesses: true },
adapter,
),
).resolves.toMatchObject({
cleaned: false,
reason: `process ownership token could not be read for 1 live process(es): environment_errno_${code}=1`,
failures: [{ pid: 900, reason: "environment-unreadable", diagnostic }],
});
expect(signalGroup).not.toHaveBeenCalled();
},
);
});
+171
View File
@@ -64,6 +64,177 @@ describe("owned process-group cleanup discovery", () => {
});
expect(signalGroup).toHaveBeenCalledWith(100, "SIGKILL");
});
});
describe("opaque process neighbors during cleanup", () => {
it("cleans verified run-owned groups while reporting an opaque neighbor", async () => {
const processes = [
{ pid: 100, parentPid: 1, processGroupId: 100, command: "capture" },
{
pid: 200,
parentPid: 1,
processGroupId: 200,
command: "detached-child",
},
{
pid: 900,
parentPid: 1,
processGroupId: 900,
command: "opaque-neighbor",
},
].map((process) => ({ ...process, state: "S" }));
const signalGroup = vi.fn();
const identities = new Map(
processes.map(({ pid }) => [
pid,
{ state: "readable" as const, identity: `start-${String(pid)}` },
]),
);
const adapter: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve(processes),
environment: (pid) =>
Promise.resolve({
REA_PROCESS_RUN_ID: pid === 900 ? "unowned" : "run-token",
}),
processIdentities: () => Promise.resolve(identities),
runTokens: (members) =>
Promise.resolve(
new Map(
members.map(({ pid }) => [
pid,
pid === 900
? {
state: "unavailable" as const,
reason: "environment_unavailable",
}
: { state: "readable" as const, runId: "run-token" },
]),
),
),
signalGroup,
};
const result = await cleanupOwnedProcessGroup(
{
...ownership,
sweepTokenOwnedProcesses: true,
captureBaseline: [],
},
adapter,
);
expect(result).toMatchObject({
cleaned: false,
reason: expect.stringContaining("environment_unavailable=1"),
});
expect(signalGroup.mock.calls).toEqual([
[100, "SIGKILL"],
[200, "SIGKILL"],
]);
expect(signalGroup).not.toHaveBeenCalledWith(900, "SIGKILL");
});
});
describe("sampled detached process groups during cleanup", () => {
it("signals only token-authenticated sampled groups and reports an unowned sample", async () => {
const processes = [
{
pid: 100,
parentPid: 1,
processGroupId: 100,
state: "S",
command: "capture",
},
{
pid: 200,
parentPid: 1,
processGroupId: 200,
state: "S",
command: "sanitized-detached-child",
},
];
const signalGroup = vi.fn();
const identities = new Map([
[100, { state: "readable" as const, identity: "start-100" }],
[200, { state: "readable" as const, identity: "start-200" }],
]);
const adapter: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve(processes),
environment: (pid) =>
Promise.resolve(pid === 100 ? { REA_PROCESS_RUN_ID: "run-token" } : {}),
processIdentities: () => Promise.resolve(identities),
runTokens: (members) =>
Promise.resolve(
new Map(
members.map(({ pid }) => [
pid,
{
state: "readable" as const,
runId: pid === 100 ? "run-token" : undefined,
},
]),
),
),
signalGroup,
};
const result = await cleanupOwnedProcessGroup(
{
...ownership,
sweepTokenOwnedProcesses: true,
sampledProcessGroupIds: [200],
},
adapter,
);
expect(result).toMatchObject({
cleaned: false,
reason: "process tree contains an unowned or PID-reused process",
failures: [{ pid: 200, reason: "run-token-mismatch" }],
});
expect(signalGroup).toHaveBeenCalledTimes(1);
expect(signalGroup).toHaveBeenCalledWith(100, "SIGKILL");
expect(signalGroup).not.toHaveBeenCalledWith(200, "SIGKILL");
});
it("reports a live sampled group after the launcher has exited without signaling it", async () => {
const process = {
pid: 200,
parentPid: 1,
processGroupId: 200,
state: "S",
command: "sanitized-detached-child",
};
const signalGroup = vi.fn();
const adapter: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve([process]),
environment: () => Promise.resolve({}),
runTokens: () =>
Promise.resolve(
new Map([[200, { state: "readable" as const, runId: undefined }]]),
),
signalGroup,
};
const result = await cleanupOwnedProcessGroup(
{
...ownership,
sweepTokenOwnedProcesses: true,
sampledProcessGroupIds: [200],
},
adapter,
);
expect(result).toMatchObject({
cleaned: false,
reason: "process tree contains an unowned or PID-reused process",
failures: [{ pid: 200, reason: "run-token-mismatch" }],
});
expect(signalGroup).not.toHaveBeenCalled();
});
});
describe("rooted process-group cleanup", () => {
it("validates and signals rooted descendant groups once, root first", async () => {
const processes = [
{ pid: 100, parentPid: 1, processGroupId: 100 },
+369 -35
View File
@@ -2,8 +2,8 @@ import { execFile } from "node:child_process";
import { promisify } from "node:util";
import {
createSystemProcessOwnershipHost,
errorMessage,
systemProcessOwnershipHost as systemHost,
} from "./ProcessOwnershipObservation.js";
import { launcherIdentityFailure } from "./ProcessOwnershipIdentity.js";
import { descendantsOf, liveProcesses } from "./ProcessOwnershipProcessTree.js";
@@ -21,8 +21,21 @@ export interface OwnedProcessGroup {
readonly expectedParentPid?: number;
/** Scan all live processes for this run token during cleanup. */
readonly sweepTokenOwnedProcesses?: boolean;
/** Prelaunch process identities for this capture; absent on generic callers. */
readonly captureBaseline?: ProcessOwnershipBaseline;
/**
* Groups observed in capture samples. Observation adds cleanup candidates but
* does not authorize signaling; every live member still needs token proof.
*/
readonly sampledProcessGroupIds?: readonly number[];
}
/** Capture-local baseline; null means the PID identity could not be established. */
export type ProcessOwnershipBaseline = readonly {
readonly pid: number;
readonly identity: string | null;
}[];
/** One entry from an operating-system process-table snapshot. */
export interface ProcessTableEntry {
readonly pid: number;
@@ -32,6 +45,11 @@ export interface ProcessTableEntry {
readonly command: string;
}
/** Native process start identity; unavailable identities must never be skipped. */
export type ProcessIdentityObservation =
| { readonly state: "readable"; readonly identity: string }
| { readonly state: "unavailable"; readonly reason: string };
/** Token-verified process lineage retained for one owned provider run. */
export interface OwnedProcessLineage {
readonly runId: string;
@@ -64,11 +82,32 @@ export type ProcessLineageObservation =
/** Narrow operating-system seam used to inspect processes and signal groups. */
export interface ProcessOwnershipHost {
readonly platform?: NodeJS.Platform;
listProcesses(): Promise<readonly ProcessTableEntry[]>;
/** Prepare required native identity inspection before launching a child. */
prepare?(signal?: AbortSignal): Promise<void>;
/** Release owned native inspection helpers and their temporary files. */
close?(): Promise<void>;
/** List current processes; startup inspection may be cancelled by the caller. */
listProcesses(signal?: AbortSignal): Promise<readonly ProcessTableEntry[]>;
environment(pid: number): Promise<Readonly<Record<string, string>>>;
/** Read only run-token values in one host operation when the OS supports it. */
runTokens?(
processes: readonly ProcessTableEntry[],
): Promise<ReadonlyMap<number, ProcessRunTokenObservation>>;
/** Read stable identities; omit the signal during cleanup so it can finish. */
processIdentities?(
processes: readonly ProcessTableEntry[],
signal?: AbortSignal,
): Promise<ReadonlyMap<number, ProcessIdentityObservation>>;
/** Snapshot identities before launch, honoring startup cancellation. */
captureBaseline?(signal?: AbortSignal): Promise<ProcessOwnershipBaseline>;
signalGroup(processGroupId: number, signal: NodeJS.Signals): void;
}
/** Whether one live process exposed its ownership token to the host. */
export type ProcessRunTokenObservation =
| { readonly state: "readable"; readonly runId: string | undefined }
| { readonly state: "unavailable"; readonly reason: string };
/** Narrow Windows P0 seam for bounded process-tree termination. */
export interface WindowsProcessTreeHost {
terminateTree(rootPid: number): Promise<"terminated" | "missing">;
@@ -77,7 +116,10 @@ export interface WindowsProcessTreeHost {
/** Per-member reason that token-verified cleanup failed closed. */
interface ProcessOwnershipValidationFailure {
readonly pid: number;
readonly reason: "environment-unreadable" | "run-token-mismatch";
readonly reason:
| "environment-unreadable"
| "run-token-mismatch"
| "process-identity-unavailable";
readonly diagnostic?: string;
}
@@ -89,7 +131,10 @@ export type ProcessCleanupResult =
readonly reason: string;
readonly failures?: readonly {
readonly pid: number;
readonly reason: "environment-unreadable" | "run-token-mismatch";
readonly reason:
| "environment-unreadable"
| "run-token-mismatch"
| "process-identity-unavailable";
readonly diagnostic?: string;
}[];
};
@@ -128,8 +173,6 @@ export const cleanupWindowsProcessTree = async (
}
};
const systemHost = createSystemProcessOwnershipHost();
const systemWindowsProcessTreeHost: WindowsProcessTreeHost = {
async terminateTree(rootPid) {
try {
@@ -181,6 +224,7 @@ export const cleanupOwnedProcessGroup = async (
processGroupId,
ownership,
host,
plan.tokenOwnedIdentities,
);
if ("cleaned" in revalidation) return revalidation;
if (revalidation.empty) continue;
@@ -197,6 +241,8 @@ export const cleanupOwnedProcessGroup = async (
};
}
}
if (plan.unresolved.length > 0)
return cleanupValidationFailure(plan.unresolved);
return { cleaned: true, signaled };
};
@@ -204,6 +250,7 @@ export const cleanupOwnedProcessGroup = async (
export const verifyNoTokenOwnedProcesses = async (
runId: string,
host: ProcessOwnershipHost = systemHost,
captureBaseline?: ProcessOwnershipBaseline,
): Promise<ProcessCleanupResult> => {
const processTable = await readLiveProcessTable(host);
if (!processTable.available)
@@ -215,6 +262,7 @@ export const verifyNoTokenOwnedProcesses = async (
processTable.processes,
runId,
host,
captureBaseline,
);
if (scan.failures.length > 0) return cleanupValidationFailure(scan.failures);
return scan.owned.length === 0
@@ -243,6 +291,8 @@ const readLiveProcessTable = async (
interface OwnedCleanupPlan {
readonly signalOrder: readonly number[];
readonly tokenOwnedIdentities: ReadonlyMap<number, string | null>;
readonly unresolved: readonly ProcessOwnershipValidationFailure[];
}
const createOwnedCleanupPlan = async (
@@ -250,12 +300,21 @@ const createOwnedCleanupPlan = async (
processes: readonly ProcessTableEntry[],
host: ProcessOwnershipHost,
): Promise<OwnedCleanupPlan | ProcessCleanupResult> => {
const sampledProcessGroupIds = new Set(
(ownership.sampledProcessGroupIds ?? []).filter(
(processGroupId) =>
Number.isSafeInteger(processGroupId) && processGroupId > 0,
),
);
const tokenOwned =
ownership.sweepTokenOwnedProcesses === true
? await scanTokenOwnedProcesses(processes, ownership.runId, host)
: { owned: [], failures: [] };
if (tokenOwned.failures.length > 0)
return cleanupValidationFailure(tokenOwned.failures);
? await scanTokenOwnedProcesses(
processes,
ownership.runId,
host,
ownership.captureBaseline,
)
: { owned: [], failures: [], identities: new Map() };
const tokenOwnedGroupIds = new Set(
tokenOwned.owned.map(({ processGroupId }) => processGroupId),
);
@@ -266,9 +325,18 @@ const createOwnedCleanupPlan = async (
if (
launcher === undefined &&
rootMembers.length === 0 &&
tokenOwned.owned.length === 0
tokenOwned.owned.length === 0 &&
!processes.some(({ processGroupId }) =>
sampledProcessGroupIds.has(processGroupId),
)
)
return { cleaned: true, signaled: false };
return tokenOwned.failures.length > 0
? {
signalOrder: [],
tokenOwnedIdentities: tokenOwned.identities,
unresolved: tokenOwned.failures,
}
: { cleaned: true, signaled: false };
let descendants: readonly ProcessTableEntry[] = [];
if (launcher !== undefined) {
const identityFailure = launcherIdentityFailure(launcher, ownership);
@@ -282,6 +350,11 @@ const createOwnedCleanupPlan = async (
processGroupIds.add(descendant.processGroupId);
for (const process of tokenOwned.owned)
processGroupIds.add(process.processGroupId);
for (const processGroupId of sampledProcessGroupIds)
processGroupIds.add(processGroupId);
const signalableGroups = new Set<number>();
const unresolved = [...tokenOwned.failures];
const unverifiableSampledGroups = new Set<number>();
for (const processGroupId of processGroupIds) {
if (processGroupId === ownership.processGroupId) continue;
const groupLeader = processes.find(
@@ -290,29 +363,69 @@ const createOwnedCleanupPlan = async (
);
if (
(groupLeader === undefined || !descendantPids.has(groupLeader.pid)) &&
!tokenOwnedGroupIds.has(processGroupId)
!tokenOwnedGroupIds.has(processGroupId) &&
!sampledProcessGroupIds.has(processGroupId)
)
return {
cleaned: false,
reason:
"descendant process-group leader identity could not be verified",
};
if (
(groupLeader === undefined || !descendantPids.has(groupLeader.pid)) &&
!tokenOwnedGroupIds.has(processGroupId)
)
unverifiableSampledGroups.add(processGroupId);
}
const liveMembers = processes.filter(({ processGroupId }) =>
processGroupIds.has(processGroupId),
);
const failures = await processOwnershipFailures(
liveMembers,
ownership.runId,
host,
);
if (failures.length > 0) return cleanupValidationFailure(failures);
for (const processGroupId of processGroupIds) {
const members = liveMembers.filter(
({ processGroupId: observedGroupId }) =>
observedGroupId === processGroupId,
);
if (members.length === 0) continue;
const failures = await processOwnershipFailures(
members,
ownership.runId,
host,
tokenOwned.identities,
);
if (failures.length === 0 && unverifiableSampledGroups.has(processGroupId))
unresolved.push(
...members.map(({ pid }) => ({
pid,
reason: "process-identity-unavailable" as const,
diagnostic:
"sampled process-group leader identity could not be verified",
})),
);
const scanFailures = tokenOwned.failures.filter(({ pid }) =>
members.some((member) => member.pid === pid),
);
if (failures.length > 0) unresolved.push(...failures);
if (
failures.length === 0 &&
scanFailures.length === 0 &&
!unverifiableSampledGroups.has(processGroupId)
)
signalableGroups.add(processGroupId);
}
if (ownership.sweepTokenOwnedProcesses !== true && unresolved.length > 0)
return cleanupValidationFailure(unresolved);
return {
signalOrder: [ownership.processGroupId].concat(
[...processGroupIds]
.filter((processGroupId) => processGroupId !== ownership.processGroupId)
.sort((left, right) => left - right),
),
signalOrder: [...signalableGroups]
.filter((groupId) => processGroupIds.has(groupId))
.sort((left, right) =>
left === ownership.processGroupId
? -1
: right === ownership.processGroupId
? 1
: left - right,
),
tokenOwnedIdentities: tokenOwned.identities,
unresolved,
};
};
@@ -320,6 +433,7 @@ const revalidateOwnedProcessGroup = async (
processGroupId: number,
ownership: OwnedProcessGroup,
host: ProcessOwnershipHost,
expectedIdentities: ReadonlyMap<number, string | null>,
): Promise<{ readonly empty: boolean } | ProcessCleanupResult> => {
let members: readonly ProcessTableEntry[];
try {
@@ -337,6 +451,7 @@ const revalidateOwnedProcessGroup = async (
members,
ownership.runId,
host,
expectedIdentities,
);
if (failures.length > 0) return cleanupValidationFailure(failures);
if (processGroupId === ownership.processGroupId) {
@@ -363,20 +478,102 @@ const revalidateOwnedProcessGroup = async (
const cleanupValidationFailure = (
failures: readonly ProcessOwnershipValidationFailure[],
): ProcessCleanupResult => ({
cleaned: false,
reason: failures.some(({ reason }) => reason === "run-token-mismatch")
? "process tree contains an unowned or PID-reused process"
: "process ownership could not be revalidated",
failures,
});
): ProcessCleanupResult => {
const unreadable = failures.filter(
({ reason }) => reason === "environment-unreadable",
);
if (failures.some(({ reason }) => reason === "run-token-mismatch"))
return {
cleaned: false,
reason: "process tree contains an unowned or PID-reused process",
failures,
};
if (failures.some(({ reason }) => reason === "process-identity-unavailable"))
return {
cleaned: false,
reason: "process identity could not be revalidated",
failures,
};
if (unreadable.length > 0) {
const counts = new Map<string, number>();
for (const { diagnostic } of unreadable) {
const category = sanitizedTokenReadFailure(diagnostic);
counts.set(category, (counts.get(category) ?? 0) + 1);
}
const breakdown = [...counts]
.sort(([left], [right]) => left.localeCompare(right))
.map(([category, count]) => `${category}=${String(count)}`)
.join(", ");
return {
cleaned: false,
reason: `process ownership token could not be read for ${String(unreadable.length)} live process(es): ${breakdown}`,
failures,
};
}
return {
cleaned: false,
reason: "process ownership could not be revalidated",
failures,
};
};
const sanitizedTokenReadFailure = (diagnostic: string | undefined): string => {
if (diagnostic === "environment_unavailable") return diagnostic;
const environmentErrno = /^(EACCES|EPERM|ENOENT|ESRCH):/u.exec(
diagnostic ?? "",
);
if (environmentErrno?.[1] !== undefined)
return `environment_errno_${environmentErrno[1]}`;
if (diagnostic === "apple_vector_unavailable") return diagnostic;
if (diagnostic === "ambiguous_environment_boundary") return diagnostic;
if (diagnostic === "malformed_procargs") return diagnostic;
if (diagnostic === "process_identity_changed_during_token_read")
return diagnostic;
if (diagnostic === "process_not_in_snapshot") return diagnostic;
const processTableFailure = /^process_table_failed_(\d+)$/u.exec(
diagnostic ?? "",
);
if (processTableFailure?.[1] !== undefined)
return `process_table_errno_${processTableFailure[1]}`;
const sysctlFailure = /^sysctl_failed_(\d+)$/u.exec(diagnostic ?? "");
if (sysctlFailure?.[1] !== undefined)
return `sysctl_errno_${sysctlFailure[1]}`;
if (diagnostic === "reader_failure") return diagnostic;
if (diagnostic === "duplicate_run_token") return diagnostic;
if (diagnostic === "invalid_run_token_encoding") return diagnostic;
return "other_unavailable";
};
const processOwnershipFailures = async (
members: readonly ProcessTableEntry[],
runId: string,
host: ProcessOwnershipHost,
expectedIdentities: ReadonlyMap<number, string | null> = new Map(),
): Promise<readonly ProcessOwnershipValidationFailure[]> => {
const failures: ProcessOwnershipValidationFailure[] = [];
if (expectedIdentities.size > 0 && host.processIdentities !== undefined) {
const expectedMembers = members.filter(({ pid }) =>
expectedIdentities.has(pid),
);
const observed = await host.processIdentities(expectedMembers);
for (const member of expectedMembers) {
const expected = expectedIdentities.get(member.pid);
const current = observed.get(member.pid);
if (
expected === null ||
current?.state !== "readable" ||
current.identity !== expected
) {
if (await processIsGone(host, member.pid)) continue;
failures.push({
pid: member.pid,
reason: "process-identity-unavailable",
diagnostic:
"process identity changed or became unavailable before signal",
});
}
}
}
for (const member of members) {
try {
if ((await host.environment(member.pid)).REA_PROCESS_RUN_ID !== runId)
@@ -400,25 +597,123 @@ const processOwnershipFailures = async (
});
}
}
if (
failures.length === 0 &&
expectedIdentities.size > 0 &&
host.processIdentities !== undefined
) {
const expectedMembers = members.filter(({ pid }) =>
expectedIdentities.has(pid),
);
const observed = await host.processIdentities(expectedMembers);
for (const member of expectedMembers) {
const expected = expectedIdentities.get(member.pid);
const current = observed.get(member.pid);
if (
expected !== null &&
current?.state === "readable" &&
current.identity === expected
)
continue;
if (await processIsGone(host, member.pid)) continue;
failures.push({
pid: member.pid,
reason: "process-identity-unavailable",
diagnostic:
"process identity changed or became unavailable during token validation",
});
}
}
return failures;
};
interface TokenOwnedProcessScan {
readonly owned: readonly ProcessTableEntry[];
readonly failures: readonly ProcessOwnershipValidationFailure[];
readonly identities: ReadonlyMap<number, string | null>;
}
const scanTokenOwnedProcesses = async (
processes: readonly ProcessTableEntry[],
runId: string,
host: ProcessOwnershipHost,
captureBaseline?: ProcessOwnershipBaseline,
): Promise<TokenOwnedProcessScan> => {
const owned: ProcessTableEntry[] = [];
let candidates = processes;
let candidateIdentities: ReadonlyMap<number, ProcessIdentityObservation> =
new Map();
if (host.processIdentities !== undefined) {
const current = await host.processIdentities(processes);
candidateIdentities = current;
if (captureBaseline !== undefined) {
const baseline = new Map(
captureBaseline.map(({ pid, identity }) => [pid, identity]),
);
candidates = processes.filter((process) => {
const before = baseline.get(process.pid);
const now = current.get(process.pid);
return !(
before !== undefined &&
before !== null &&
now?.state === "readable" &&
now.identity === before
);
});
}
}
const failures: ProcessOwnershipValidationFailure[] = [];
for (const process of processes) {
if (captureBaseline !== undefined && host.processIdentities !== undefined) {
const stableCandidates: ProcessTableEntry[] = [];
for (const process of candidates) {
if (candidateIdentities.get(process.pid)?.state === "readable") {
stableCandidates.push(process);
continue;
}
if (!(await processIsGone(host, process.pid)))
failures.push({
pid: process.pid,
reason: "process-identity-unavailable",
diagnostic: "process identity was unavailable during token scan",
});
}
candidates = stableCandidates;
}
let bulkTokens: ReadonlyMap<number, ProcessRunTokenObservation> | undefined;
if (host.runTokens !== undefined)
bulkTokens = await host.runTokens(candidates);
const owned: ProcessTableEntry[] = [];
const ownedIdentities = new Map<number, string | null>();
for (const process of candidates) {
const bulkToken = bulkTokens?.get(process.pid);
if (bulkToken !== undefined) {
if (bulkToken.state === "readable") {
if (bulkToken.runId === runId) {
owned.push(process);
const identity = candidateIdentities.get(process.pid);
ownedIdentities.set(
process.pid,
identity?.state === "readable" ? identity.identity : null,
);
}
continue;
}
if (!(await processIsGone(host, process.pid)))
failures.push({
pid: process.pid,
reason: "environment-unreadable",
diagnostic: bulkToken.reason,
});
continue;
}
try {
if ((await host.environment(process.pid)).REA_PROCESS_RUN_ID === runId)
if ((await host.environment(process.pid)).REA_PROCESS_RUN_ID === runId) {
owned.push(process);
const identity = candidateIdentities.get(process.pid);
ownedIdentities.set(
process.pid,
identity?.state === "readable" ? identity.identity : null,
);
}
} catch (cause: unknown) {
try {
const live = liveProcesses(await host.listProcesses());
@@ -438,5 +733,44 @@ const scanTokenOwnedProcesses = async (
});
}
}
return { owned, failures };
if (candidates.length > 0 && host.processIdentities !== undefined) {
const afterRead = await host.processIdentities(candidates);
const stillOwned: ProcessTableEntry[] = [];
const ownedPids = new Set(owned.map(({ pid }) => pid));
for (const process of candidates) {
const before = candidateIdentities.get(process.pid);
const current = afterRead.get(process.pid);
if (
before?.state === "readable" &&
current?.state === "readable" &&
current.identity === before.identity
) {
if (ownedPids.has(process.pid)) stillOwned.push(process);
continue;
}
if (await processIsGone(host, process.pid)) continue;
failures.push({
pid: process.pid,
reason: "process-identity-unavailable",
diagnostic:
"process identity changed or became unavailable during token validation",
});
}
return { owned: stillOwned, failures, identities: ownedIdentities };
}
return { owned, failures, identities: ownedIdentities };
};
const processIsGone = async (
host: ProcessOwnershipHost,
pid: number,
): Promise<boolean> => {
try {
return !liveProcesses(await host.listProcesses()).some(
(process) => process.pid === pid,
);
} catch (cause: unknown) {
void cause;
return false;
}
};
+204 -26
View File
@@ -3,11 +3,14 @@ import { readFile } from "node:fs/promises";
import { launcherIdentityFailure } from "./ProcessOwnershipIdentity.js";
import { descendantsOf, liveProcesses } from "./ProcessOwnershipProcessTree.js";
import { execFileOutput } from "./ExecFileOutput.js";
import { createDarwinProcessRunTokenReader } from "./DarwinProcessRunTokenReader.js";
import type {
OwnedProcessGroup,
ProcessGroupObservation,
ProcessLineageObservation,
ProcessOwnershipHost,
ProcessIdentityObservation,
ProcessOwnershipBaseline,
ProcessTableEntry,
} from "./ProcessOwnership.js";
@@ -144,18 +147,53 @@ export const parseProcessEnvironment = (
}),
);
/** Read Linux stat field 22, tolerating spaces and closing parentheses in comm. */
export const parseLinuxProcessStartTime = (
value: string,
): string | undefined => {
const commandEnd = value.lastIndexOf(")");
if (commandEnd < 0) return undefined;
const fields = value
.slice(commandEnd + 1)
.trim()
.split(/\s+/u);
const startTime = fields[19];
return startTime !== undefined && /^\d+$/u.test(startTime)
? startTime
: undefined;
};
const isExpectedAbort = (
cause: unknown,
signal: AbortSignal | undefined,
): boolean =>
signal?.aborted === true &&
(cause === signal.reason ||
(cause instanceof Error && cause.name === "AbortError"));
/** Create the operating-system process inspector for an explicit host context. */
export const createSystemProcessOwnershipHost = (
platform: NodeJS.Platform = process.platform,
hostEnvironment: NodeJS.ProcessEnv = process.env,
): ProcessOwnershipHost => ({
platform,
async listProcesses() {
options: {
/** Override the compiler executable used by the Darwin native reader. */
readonly darwinXcrun?: string;
} = {},
): ProcessOwnershipHost => {
const darwinTokens =
platform === "darwin"
? createDarwinProcessRunTokenReader(
options.darwinXcrun === undefined
? {}
: { xcrun: options.darwinXcrun },
)
: undefined;
const listProcesses = async (signal?: AbortSignal) => {
if (platform === "win32") return [];
const { stdout } = await execFileOutput(
"ps",
["-axo", "pid=,ppid=,pgid=,stat=,command="],
{ env: hostEnvironment },
{ env: hostEnvironment, ...(signal === undefined ? {} : { signal }) },
);
return stdout
.split("\n")
@@ -168,35 +206,175 @@ export const createSystemProcessOwnershipHost = (
state: match[4] ?? "",
command: match[5] ?? "",
}));
},
async environment(pid) {
};
const processIdentities: NonNullable<
ProcessOwnershipHost["processIdentities"]
> = async (processes, signal) => {
if (processes.length === 0) return new Map();
if (platform === "linux")
return parseProcessEnvironment(
await readFile(`/proc/${pid}/environ`, "utf8"),
return new Map<number, ProcessIdentityObservation>(
await Promise.all(
processes.map(async ({ pid }) => {
try {
const stat = await readFile(`/proc/${String(pid)}/stat`, {
encoding: "utf8",
...(signal === undefined ? {} : { signal }),
});
const identity = parseLinuxProcessStartTime(stat);
return [
pid,
identity === undefined
? ({
state: "unavailable",
reason: "malformed_proc_stat",
} as const)
: ({
state: "readable",
identity: `linux-starttime:${identity}`,
} as const),
] as const;
} catch (cause: unknown) {
if (isExpectedAbort(cause, signal)) throw cause;
return [
pid,
{ state: "unavailable", reason: errorMessage(cause) } as const,
] as const;
}
}),
),
);
if (darwinTokens === undefined) return new Map();
try {
return await darwinTokens.identities(processes, signal);
} catch (cause: unknown) {
if (isExpectedAbort(cause, signal)) throw cause;
const reason = errorMessage(cause);
return new Map(
processes.map(({ pid }) => [
pid,
{ state: "unavailable", reason } as const,
]),
);
const { stdout } = await execFileOutput("ps", ["eww", "-p", String(pid)], {
env: hostEnvironment,
});
const observedEnvironment: Record<string, string> = {};
for (const match of stdout.matchAll(
/(?:^|\s)([A-Za-z_][A-Za-z0-9_]*)=([^\s]*)/gu,
)) {
const name = match[1];
if (name !== undefined) observedEnvironment[name] = match[2] ?? "";
}
return observedEnvironment;
},
signalGroup(processGroupId, signal) {
process.kill(-processGroupId, signal);
},
});
};
const captureBaseline = async (
signal?: AbortSignal,
): Promise<ProcessOwnershipBaseline> => {
if (platform !== "linux" && darwinTokens === undefined) return [];
const processes = liveProcesses(await listProcesses(signal));
let identities = await processIdentities(processes, signal);
const unreadable = processes.filter(
({ pid }) => identities.get(pid)?.state !== "readable",
);
if (unreadable.length > 0) {
const stillLive = liveProcesses(await listProcesses(signal)).filter(
({ pid }) => unreadable.some((entry) => entry.pid === pid),
);
const retried = await processIdentities(stillLive, signal);
identities = new Map([...identities, ...retried]);
const unresolved = stillLive.filter(
({ pid }) => identities.get(pid)?.state !== "readable",
);
const liveAfterRetry = liveProcesses(await listProcesses(signal));
const stillUnresolved = unresolved.filter(({ pid }) =>
liveAfterRetry.some((entry) => entry.pid === pid),
);
if (stillUnresolved.length > 0)
throw new Error(
`process identity snapshot is unavailable for ${String(stillUnresolved.length)} live processes`,
);
}
return processes.map(({ pid }) => {
const observation = identities.get(pid);
return {
pid,
identity:
observation?.state === "readable" ? observation.identity : null,
};
});
};
return {
platform,
prepare: async (signal) => {
await darwinTokens?.prepare(signal);
},
listProcesses,
async environment(pid) {
if (platform === "linux")
return parseProcessEnvironment(
await readFile(`/proc/${pid}/environ`, "utf8"),
);
if (platform === "darwin") {
const process = (await listProcesses()).find(
(entry) => entry.pid === pid,
);
if (process === undefined)
throw new Error(`process ${String(pid)} is not live`);
const observation = (await darwinTokens?.read([process]))?.get(pid);
if (observation === undefined || observation.state === "unavailable")
throw new Error(
observation?.reason ?? "process run token could not be read",
);
return observation.runId === undefined
? {}
: { REA_PROCESS_RUN_ID: observation.runId };
}
const { stdout } = await execFileOutput(
"ps",
["eww", "-p", String(pid)],
{
env: hostEnvironment,
},
);
const observedEnvironment: Record<string, string> = {};
for (const match of stdout.matchAll(
/(?:^|\s)([A-Za-z_][A-Za-z0-9_]*)=([^\s]*)/gu,
)) {
const name = match[1];
if (name !== undefined) observedEnvironment[name] = match[2] ?? "";
}
return observedEnvironment;
},
async runTokens(processes) {
if (platform !== "darwin" || processes.length === 0) return new Map();
try {
return (await darwinTokens?.read(processes)) ?? new Map();
} catch (cause: unknown) {
void cause;
return new Map(
processes.map(({ pid }) => [
pid,
{ state: "unavailable", reason: "reader_failure" } as const,
]),
);
}
},
...(platform !== "linux" && darwinTokens === undefined
? {}
: {
processIdentities,
captureBaseline,
...(darwinTokens === undefined ? {} : { close: darwinTokens.close }),
}),
signalGroup(processGroupId, signal) {
process.kill(-processGroupId, signal);
},
};
};
const systemHost = createSystemProcessOwnershipHost();
export const systemProcessOwnershipHost = createSystemProcessOwnershipHost();
/** Prepare native token inspection before REA launches a captured child. */
export const prepareProcessOwnershipInspection = async (
signal?: AbortSignal,
): Promise<void> => {
await systemProcessOwnershipHost.prepare?.(signal);
};
/** Observe one group without signaling it, failing closed on identity doubt. */
export const observeOwnedProcessGroup = async (
ownership: OwnedProcessGroup,
host: ProcessOwnershipHost = systemHost,
host: ProcessOwnershipHost = systemProcessOwnershipHost,
): Promise<ProcessGroupObservation> =>
observeOwnedProcessGroupWithHost(ownership, host);
@@ -209,7 +387,7 @@ export const observeOwnedProcessGroup = async (
*/
export const observeOwnedProcessLineage = async (
ownership: OwnedProcessGroup,
host: ProcessOwnershipHost = systemHost,
host: ProcessOwnershipHost = systemProcessOwnershipHost,
): Promise<ProcessLineageObservation> =>
observeOwnedProcessLineageWithHost(ownership, host);
+6
View File
@@ -8,6 +8,7 @@ import type {
OwnedProcessGroup,
ProcessCleanupResult,
} from "./ProcessOwnership.js";
import { prepareProcessOwnershipInspection } from "./ProcessOwnershipObservation.js";
const DEFAULT_TERMINATION_GRACE_MS = 250;
const DEFAULT_KILL_GRACE_MS = 1_000;
@@ -29,6 +30,8 @@ export interface OwnedProviderProcessSpawnOptions {
readonly hostEnvironment?: NodeJS.ProcessEnv;
/** Opt into a writable protocol stream; other providers retain ignored stdin. */
readonly stdin?: "pipe";
/** Cancels native ownership preparation before provider process creation. */
readonly signal?: AbortSignal;
}
/** Spawned process paired with the identity proof required for group cleanup. */
@@ -132,6 +135,9 @@ export type ProviderProcessStopResult =
export const spawnOwnedProviderProcess = async (
options: OwnedProviderProcessSpawnOptions,
): Promise<SpawnedOwnedProviderProcess> => {
options.signal?.throwIfAborted();
await prepareProcessOwnershipInspection(options.signal);
options.signal?.throwIfAborted();
const platform = options.platform ?? process.platform;
const hostEnvironment = options.hostEnvironment ?? process.env;
const environment = {
@@ -1,4 +1,5 @@
import { tmpdir } from "node:os";
import { prepareProcessOwnershipInspection } from "../ProcessOwnershipObservation.js";
export type ProcessCaptureCapability =
| { readonly available: true; readonly backend: "node-pty" }
@@ -20,6 +21,7 @@ export const processCaptureOwnershipUnavailableReason = (
export interface ProcessCaptureProbeOptions {
readonly platform?: NodeJS.Platform;
readonly loadPty?: () => Promise<typeof import("@lydell/node-pty")>;
readonly prepareOwnershipInspector?: () => Promise<void>;
}
/** Probe the native PTY seam and explain loader failures at their source. */
@@ -56,6 +58,22 @@ export const probeProcessCaptureCapability = async (
backend: "node-pty",
reason: ownershipReason,
};
if (platform === "darwin") {
try {
await (
options.prepareOwnershipInspector ?? prepareProcessOwnershipInspection
)();
} catch (cause: unknown) {
return {
available: false,
backend: "node-pty",
reason:
cause instanceof Error
? cause.message
: "macOS process ownership inspection could not be prepared",
};
}
}
try {
const { spawn } = await (
options.loadPty ?? (() => import("@lydell/node-pty"))
@@ -1,10 +1,17 @@
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import type {
PartialProcessCaptureObservation,
ProcessCaptureCleanupReport,
} from "../../domain/process/processCapture.js";
interface ProcessCaptureErrorOptions extends ErrorOptions {
readonly userMessage?: string;
readonly userCategory?: "cancelled";
readonly reason?: "capture_failed" | "cleanup_incomplete" | "cancelled";
readonly cleanupResources?: readonly string[];
readonly executionFailure?: string;
readonly partialObservation?: PartialProcessCaptureObservation;
readonly cleanupReport?: ProcessCaptureCleanupReport;
}
/** Typed application failure produced by controlled process capture. */
@@ -16,6 +23,11 @@ export class ProcessCaptureError extends AnalysisError {
readonly reason: NonNullable<ProcessCaptureErrorOptions["reason"]>;
override readonly cleanupIncomplete: boolean;
override readonly cleanupResources: readonly string[];
override readonly executionFailure: string | undefined;
override readonly partialObservation:
| PartialProcessCaptureObservation
| undefined;
override readonly cleanupReport: ProcessCaptureCleanupReport | undefined;
constructor(message: string, options?: ProcessCaptureErrorOptions) {
super(message, options);
@@ -26,9 +38,22 @@ export class ProcessCaptureError extends AnalysisError {
(options?.userCategory === "cancelled" ? "cancelled" : "capture_failed");
this.cleanupIncomplete = this.reason === "cleanup_incomplete";
this.cleanupResources = options?.cleanupResources ?? [];
this.executionFailure = options?.executionFailure;
this.partialObservation = options?.partialObservation;
this.cleanupReport = options?.cleanupReport;
}
}
/** Return one non-recursive, domain-neutral summary of an execution failure. */
export const describeProcessCaptureExecutionFailure = (
failure: unknown,
): string | undefined => {
if (failure instanceof Error) return failure.message || failure.name;
if (typeof failure === "string") return failure;
if (failure === undefined) return undefined;
return "Process capture failed with a non-error rejection.";
};
/** Caller-visible cancellation without exposing capture implementation state. */
export const processCaptureCancelled = (): ProcessCaptureError =>
new ProcessCaptureError("process capture was cancelled", {
+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 }),
};
+293 -3
View File
@@ -1,11 +1,27 @@
import { expect, it } from "vitest";
import { expect, it, vi } from "vitest";
import { processScenarioSchema } from "../../domain/process/processScenario.js";
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
import { captureProcessScenario } from "./ProcessHarness.js";
import {
captureProcessScenario,
normalizeCaptureFailure,
ProcessCaptureError,
} from "./ProcessHarness.js";
import { settleProcessCaptureJournal } from "./ProcessCaptureLifecycle.js";
import { processCaptureSchema } from "../../domain/process/processCapture.js";
import {
parseProcessCapture,
partialProcessCaptureObservationSchema,
processCaptureSchema,
} from "../../domain/process/processCapture.js";
import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../../domain/process/processCapture.fixture.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { emptyProcessCapture } from "../../domain/process/processCapture.fixture.js";
import { analysisErrorProjectionSchema } from "../../contracts/errorSchemas.js";
import {
releaseProcessResources,
resolveProcessResult,
type ProcessCaptureCleanupHost,
} from "./ProcessCaptureLifecycle.js";
import { DarwinProcessOwnershipInspectionError } from "../DarwinProcessRunTokenReader.js";
it("rejects legacy replay output instead of silently discarding it", () => {
expect(
@@ -16,6 +32,21 @@ it("rejects legacy replay output instead of silently discarding it", () => {
).toBe(false);
});
it("preserves the actionable Darwin ownership compiler prerequisite", () => {
const normalized = normalizeCaptureFailure(
new DarwinProcessOwnershipInspectionError(
"macOS process ownership inspection requires the Apple Swift compiler via xcrun",
),
undefined,
);
expect(normalized).toBeInstanceOf(ProcessCaptureError);
expect(normalized).toMatchObject({
message:
"macOS process ownership inspection requires the Apple Swift compiler via xcrun",
});
});
it("waits for terminal observations delivered after the exit callback", async () => {
const journal: Array<{
capture_order: number;
@@ -41,6 +72,265 @@ it("waits for terminal observations delivered after the exit callback", async ()
expect(journal[1]).toMatchObject({ collection: "frames", index: 1 });
});
it("keeps empty cleanup exception messages actionable in the report", async () => {
const host: ProcessCaptureCleanupHost = {
platform: process.platform,
cleanupProcessGroup: async () => ({
cleaned: false,
reason: "unused process cleanup",
}),
verifyTokenOwnedProcesses: async () => ({
cleaned: false,
reason: "unused process cleanup",
}),
removeTemporaryRoot: async () => {
throw new Error("");
},
};
const report = await releaseProcessResources({
timers: new Set(),
terminal: undefined,
renderer: undefined,
runId: "fixture-run",
temporaryRoot: "/fixture/root",
host,
});
expect(report.temporary_root).toEqual({ state: "failed", reason: "Error" });
});
it("waits for token-owned processes to exit after one cleanup signal", async () => {
vi.useFakeTimers();
try {
let verificationCalls = 0;
const exitsAt = Date.now() + 50;
let cleanupCalls = 0;
const host: ProcessCaptureCleanupHost = {
platform: "linux",
cleanupProcessGroup: async () => {
cleanupCalls += 1;
return { cleaned: true, signaled: true };
},
verifyTokenOwnedProcesses: async () => {
verificationCalls += 1;
return Date.now() < exitsAt
? { cleaned: false, reason: "owned process is exiting" }
: { cleaned: true, signaled: false };
},
removeTemporaryRoot: async () => undefined,
};
const result = releaseProcessResources({
timers: new Set(),
terminal: { pid: 321 },
renderer: undefined,
runId: "fixture-run",
temporaryRoot: "/fixture/root",
host,
});
await vi.runAllTimersAsync();
const report = await result;
expect(report.owned_process_group).toEqual({
state: "cleaned",
reason: null,
});
expect(cleanupCalls).toBe(1);
expect(verificationCalls).toBeGreaterThan(1);
} finally {
vi.useRealTimers();
}
});
it("retains the last ownership failure when the verification grace expires", async () => {
vi.useFakeTimers();
try {
let verificationCalls = 0;
let cleanupCalls = 0;
const host: ProcessCaptureCleanupHost = {
platform: "linux",
cleanupProcessGroup: async () => {
cleanupCalls += 1;
return { cleaned: true, signaled: true };
},
verifyTokenOwnedProcesses: async () => {
verificationCalls += 1;
return {
cleaned: false,
reason:
verificationCalls === 1
? "owned process has not exited yet"
: "owned process remained after identity recheck",
};
},
removeTemporaryRoot: async () => undefined,
};
const result = releaseProcessResources({
timers: new Set(),
terminal: { pid: 322 },
renderer: undefined,
runId: "fixture-run",
temporaryRoot: "/fixture/root",
host,
});
await vi.runAllTimersAsync();
const report = await result;
expect(report.owned_process_group).toEqual({
state: "unverified",
reason: "owned process remained after identity recheck",
});
expect(cleanupCalls).toBe(1);
expect(verificationCalls).toBeGreaterThan(1);
} finally {
vi.useRealTimers();
}
});
it("passes sampled detached groups into cleanup even when their token is unknown", async () => {
let sampledGroups: readonly number[] | undefined;
const host: ProcessCaptureCleanupHost = {
platform: "linux",
cleanupProcessGroup: async (ownership) => {
sampledGroups = ownership.sampledProcessGroupIds;
return {
cleaned: false,
reason: "sampled group has no verifiable run token",
};
},
verifyTokenOwnedProcesses: async () => ({
cleaned: true,
signaled: false,
}),
removeTemporaryRoot: async () => undefined,
};
const report = await releaseProcessResources({
timers: new Set(),
terminal: { pid: 321 },
renderer: undefined,
runId: "fixture-run",
temporaryRoot: "/fixture/root",
sampledProcessGroupIds: [654],
host,
});
expect(sampledGroups).toEqual([654]);
expect(report.owned_process_group).toEqual({
state: "unverified",
reason: "sampled group has no verifiable run token",
});
});
it("retains observations and both causes when process cleanup is unverifiable", () => {
const verified = emptyProcessCapture();
const capture = parseProcessCapture({
...verified,
frames: [{ sequence: 0, at_ms: 0, data: "observed output" }],
process_samples: [
{
at_ms: 1,
pid: 654,
parent_pid: 321,
command: "sanitized-detached-child",
process_group_id: 654,
session_id: 654,
},
],
event_journal: [],
});
const executionFailure = new Error("capture ended after a fixture error");
const cleanup = {
owned_process_group: {
state: "unverified" as const,
reason:
"process ownership token could not be read for 1 live process(es): environment_unavailable=1",
},
terminal_renderer: { state: "cleaned" as const, reason: null },
temporary_root: { state: "cleaned" as const, reason: null },
};
let error: ProcessCaptureError | undefined;
try {
resolveProcessResult(capture, executionFailure, cleanup);
} catch (cause: unknown) {
if (!(cause instanceof ProcessCaptureError)) throw cause;
error = cause;
}
expect(error).toBeDefined();
if (error === undefined) throw new Error("expected cleanup-incomplete error");
const projection = projectAnalysisError(error);
const parsedProjection = analysisErrorProjectionSchema.parse(projection);
expect(parsedProjection).toMatchObject({
code: "cleanup_incomplete",
details: {
cleanup_report: cleanup,
execution_failure: "capture ended after a fixture error",
partial_observation: {
capture: {
frames: [{ data: "observed output" }],
process_samples: [{ pid: 654, process_group_id: 654 }],
settlement: { cleanup_outcome: "failed" },
},
execution_failure: "capture ended after a fixture error",
},
},
});
expect(error.cause).toBe(executionFailure);
const partialObservation = error.partialObservation;
expect(partialObservation).toBeDefined();
if (partialObservation === undefined)
throw new Error("expected validated partial observation");
expect(
partialProcessCaptureObservationSchema.safeParse({
...partialObservation,
cleanup: {
owned_process_group: { state: "cleaned", reason: null },
terminal_renderer: { state: "cleaned", reason: null },
temporary_root: { state: "cleaned", reason: null },
},
}).success,
).toBe(false);
if (!("capture" in partialObservation))
throw new Error("expected completed partial capture observations");
const partialCapture = partialObservation.capture;
expect(() => parseProcessCapture(partialCapture)).toThrow();
});
it("projects execution and cleanup failures when capture never completed", () => {
const executionFailure = new Error("terminal startup failed");
const cleanup = {
owned_process_group: {
state: "unverified" as const,
reason: "process ownership token could not be read",
},
terminal_renderer: { state: "cleaned" as const, reason: null },
temporary_root: { state: "cleaned" as const, reason: null },
};
let error: ProcessCaptureError | undefined;
try {
resolveProcessResult(undefined, executionFailure, cleanup);
} catch (cause: unknown) {
if (!(cause instanceof ProcessCaptureError)) throw cause;
error = cause;
}
expect(error).toBeDefined();
if (error === undefined) throw new Error("expected cleanup-incomplete error");
expect(error.partialObservation).toBeUndefined();
expect(error.cause).toBe(executionFailure);
expect(projectAnalysisError(error)).toMatchObject({
code: "cleanup_incomplete",
details: {
cleanup_report: cleanup,
execution_failure: "terminal startup failed",
},
});
});
it("fails closed on Windows before resolving or launching scenario paths", async () => {
const scenario = processScenarioSchema.parse({
executable: "Z:/missing/should-never-be-resolved.exe",
+251 -41
View File
@@ -14,6 +14,7 @@ import { err, ok, type Result } from "../../domain/result.js";
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
import { type AnalysisError } from "../../domain/analysisErrorBase.js";
import {
describeProcessCaptureExecutionFailure,
ProcessCaptureError,
processCaptureCancelled,
} from "./ProcessCaptureError.js";
@@ -27,13 +28,19 @@ import { selectCapturedProcessGroupIds } from "../ProcessOwnershipProcessTree.js
import {
awaitTerminalExit,
buildCaptureResult,
cleanupReportFailure,
captureTerminalFrames,
createProcessCaptureObservationBuffer,
createRunManifest,
observeLaunchedExecutable,
observeSelectedExecutable,
observeSettlement,
prepareProcessCapture,
releaseProcessResources,
resolveProcessResult,
settleProcessCaptureJournal,
type ProcessCaptureCleanupHost,
type ProcessCaptureObservationBuffer,
type PendingProcessCapture,
} from "./ProcessCaptureLifecycle.js";
import {
@@ -47,6 +54,8 @@ import {
import { makeProcessCaptureEnvironment } from "./ProcessCaptureEnvironment.js";
import { classifyFilesystemEffects } from "./ProcessFilesystemEffects.js";
import { processCaptureOwnershipUnavailableReason } from "./ProcessCaptureCapability.js";
import type { ProcessOwnershipBaseline } from "../ProcessOwnership.js";
import { DarwinProcessOwnershipInspectionError } from "../DarwinProcessRunTokenReader.js";
export { probeProcessCaptureCapability } from "./ProcessCaptureCapability.js";
interface StartedCaptureRuntime {
@@ -54,6 +63,7 @@ interface StartedCaptureRuntime {
readonly terminal: IPty;
readonly started: number;
readonly startedAt: Date;
readonly executableIdentity: ReturnType<typeof observeLaunchedExecutable>;
readonly lastOutput: () => number;
readonly framesTruncated: () => boolean;
readonly stopSampler: () => Promise<{ readonly partial: boolean }>;
@@ -65,19 +75,58 @@ const cleanupFailedStartup = async (options: {
readonly terminal: IPty | undefined;
readonly renderer: TerminalRenderer | undefined;
readonly runId: string;
readonly captureBaseline: ProcessOwnershipBaseline;
readonly scenario: ProcessScenario;
readonly cleanupHost?: ProcessCaptureCleanupHost;
readonly observations?: ProcessCaptureObservationBuffer;
readonly temporaryRoot: string;
}): Promise<never> => {
const sampledProcessGroupIds =
options.terminal !== undefined &&
options.observations?.process_samples.state === "available"
? selectCapturedProcessGroupIds(
options.terminal.pid,
options.observations.process_samples.value,
).filter((groupId) => groupId !== options.terminal?.pid)
: undefined;
options.terminal?.kill("SIGKILL");
if (options.observations !== undefined && options.renderer !== undefined) {
try {
options.observations.rendered_frames = {
state: "available",
value: await options.renderer.frames(),
};
} catch (cause: unknown) {
options.observations.rendered_frames = {
state: "unavailable",
reason: `Rendered terminal frame collection failed: ${cause instanceof Error ? cause.message : "unknown failure"}`,
};
}
}
const cleanupFailure = await releaseProcessResources({
...options,
capturedProcessGroupIds:
options.terminal === undefined ? [] : [options.terminal.pid],
timers: options.timers,
terminal: options.terminal,
renderer: options.renderer,
runId: options.runId,
captureBaseline: options.captureBaseline,
...(sampledProcessGroupIds === undefined ? {} : { sampledProcessGroupIds }),
temporaryRoot: options.temporaryRoot,
...(options.cleanupHost === undefined ? {} : { host: options.cleanupHost }),
});
if (cleanupFailure !== undefined)
throw new ProcessCaptureError(cleanupFailure, {
cause: options.cause,
reason: "cleanup_incomplete",
});
if (cleanupReportFailure(cleanupFailure) !== undefined) {
resolveProcessResult(
undefined,
options.cause,
cleanupFailure,
options.observations,
{
scenario: options.scenario,
...(options.terminal === undefined
? {}
: { rootPid: options.terminal.pid }),
},
);
}
throw options.cause;
};
@@ -102,6 +151,10 @@ interface StartCaptureRuntimeOptions {
readonly hostEnvironment: Readonly<Record<string, string | undefined>>;
readonly temporaryRoot: string;
readonly runId: string;
readonly ownershipBaseline: ProcessOwnershipBaseline;
readonly cleanupHost?: ProcessCaptureCleanupHost;
readonly observationBuffer: ProcessCaptureObservationBuffer;
readonly onSpawn: (pid: number) => void;
readonly frames: TerminalFrame[];
readonly samples: ProcessSample[];
readonly interactions: InteractionEvent[];
@@ -119,6 +172,10 @@ const startCaptureRuntime = async (
let terminal: IPty | undefined;
try {
const { spawn } = await import("@lydell/node-pty");
const selectedExecutable = await observeSelectedExecutable(
scenario.executable,
options.signal,
);
const startedAt = new Date();
const started = Date.now();
let lastOutput = started;
@@ -135,6 +192,15 @@ const startCaptureRuntime = async (
rows: scenario.terminal.rows,
name: "xterm-256color",
});
options.onSpawn(terminal.pid);
options.observationBuffer.target_pid = {
state: "available",
value: scenario.normalization.pids ? 1 : terminal.pid,
};
const executableIdentity = observeLaunchedExecutable(
scenario.executable,
selectedExecutable,
);
const framesTruncated = captureTerminalFrames({
...options,
terminal,
@@ -161,6 +227,7 @@ const startCaptureRuntime = async (
terminal,
started,
startedAt,
executableIdentity,
lastOutput: () => lastOutput,
framesTruncated,
stopSampler,
@@ -172,6 +239,12 @@ const startCaptureRuntime = async (
terminal,
renderer,
runId: options.runId,
captureBaseline: options.ownershipBaseline,
scenario,
...(options.cleanupHost === undefined
? {}
: { cleanupHost: options.cleanupHost }),
observations: options.observationBuffer,
temporaryRoot: options.temporaryRoot,
});
}
@@ -181,27 +254,36 @@ const finishProcessRun = async (options: {
readonly runtime: StartedCaptureRuntime | undefined;
readonly timers: Set<ProcessTimer>;
readonly runId: string;
readonly captureBaseline: ProcessOwnershipBaseline;
readonly cleanupHost?: ProcessCaptureCleanupHost;
readonly temporaryRoot: string;
readonly scenario: ProcessScenario;
readonly samples: readonly ProcessSample[];
readonly stopSampler: () => Promise<{ readonly partial: boolean }>;
readonly capture: PendingProcessCapture | undefined;
readonly executionFailure: unknown;
readonly observations?: ProcessCaptureObservationBuffer;
readonly rootPid?: number;
}): Promise<ProcessCapture> => {
await options.stopSampler();
const cleanupFailure = await releaseProcessResources({
const sampledProcessGroupIds =
options.runtime === undefined
? undefined
: selectCapturedProcessGroupIds(
options.runtime.terminal.pid,
options.samples,
).filter((groupId) => groupId !== options.runtime?.terminal.pid);
const cleanup = await releaseProcessResources({
timers: options.timers,
terminal: options.runtime?.terminal,
renderer: options.runtime?.renderer,
runId: options.runId,
captureBaseline: options.captureBaseline,
...(sampledProcessGroupIds === undefined ? {} : { sampledProcessGroupIds }),
temporaryRoot: options.temporaryRoot,
capturedProcessGroupIds:
options.runtime === undefined
? []
: selectCapturedProcessGroupIds(
options.runtime.terminal.pid,
options.samples,
),
...(options.cleanupHost === undefined ? {} : { host: options.cleanupHost }),
});
const cleanupFailure = cleanupReportFailure(cleanup);
let { capture, executionFailure } = options;
let verifiedCapture: ProcessCapture | undefined;
if (capture !== undefined && cleanupFailure === undefined) {
@@ -226,18 +308,28 @@ const finishProcessRun = async (options: {
);
}
}
const rootPid = options.runtime?.terminal.pid ?? options.rootPid;
const partialContext =
rootPid === undefined
? { scenario: options.scenario }
: { scenario: options.scenario, rootPid };
return resolveProcessResult(
verifiedCapture,
cleanupFailure === undefined ? verifiedCapture : capture,
executionFailure,
cleanupFailure,
cleanup,
options.observations,
partialContext,
);
};
const normalizeCaptureFailure = (
/** Preserve typed process-inspection preflight failures at the capture boundary. */
export const normalizeCaptureFailure = (
cause: unknown,
signal: AbortSignal | undefined,
): unknown => {
if (cause instanceof ProcessCaptureError) return cause;
if (cause instanceof DarwinProcessOwnershipInspectionError)
return new ProcessCaptureError(cause.message, { cause });
if (
signal?.aborted === true &&
(cause === signal.reason ||
@@ -262,24 +354,72 @@ const completeCapture = async (options: {
readonly captureSnapshot: typeof snapshotRoots;
readonly initiallyTruncated: boolean;
readonly eventJournal: readonly ProcessCaptureEventJournalEntry[];
readonly observationBuffer: ProcessCaptureObservationBuffer;
readonly recordEvent: RecordProcessCaptureEvent;
}): Promise<PendingProcessCapture> => {
const unavailableReason = (stage: string, cause: unknown): string =>
`${stage} failed: ${cause instanceof Error ? cause.message : "unknown failure"}`;
const { runtime, scenario } = options;
const { reason } = options.exit;
if (reason === "cancelled") throw processCaptureCancelled();
const settlement = await observeSettlement(
options.runId,
selectCapturedProcessGroupIds(runtime.terminal.pid, options.samples),
scenario.settle_ms,
options.recordEvent,
options.hostPlatform,
);
let settlement: Awaited<ReturnType<typeof observeSettlement>>;
try {
settlement = await observeSettlement(
options.runId,
selectCapturedProcessGroupIds(runtime.terminal.pid, options.samples),
scenario.settle_ms,
options.recordEvent,
options.hostPlatform,
);
} catch (cause: unknown) {
options.observationBuffer.settlement = {
state: "unavailable",
reason: unavailableReason("Process settlement observation", cause),
};
throw cause;
}
options.observationBuffer.settlement = {
state: "available",
value: settlement,
};
const samplingPartial = (await runtime.stopSampler()).partial;
await settleProcessCaptureJournal(options.eventJournal);
assertNotCancelled(options.signal);
const after = await options.captureSnapshot(scenario, options.signal);
let after: Awaited<ReturnType<typeof snapshotRoots>>;
try {
after = await options.captureSnapshot(scenario, options.signal);
} catch (cause: unknown) {
options.observationBuffer.filesystem_snapshots = {
...options.observationBuffer.filesystem_snapshots,
after: {
state: "unavailable",
reason: unavailableReason("Final filesystem snapshot", cause),
},
};
throw cause;
}
options.observationBuffer.filesystem_snapshots = {
...options.observationBuffer.filesystem_snapshots,
after: {
state: "available",
value: { files: after.files, truncated: after.truncated },
},
};
options.recordEvent("filesystem_checkpoints", 1);
const renderedFrames = await runtime.renderer.frames();
let renderedFrames: Awaited<ReturnType<TerminalRenderer["frames"]>>;
try {
renderedFrames = await runtime.renderer.frames();
} catch (cause: unknown) {
options.observationBuffer.rendered_frames = {
state: "unavailable",
reason: unavailableReason("Rendered terminal frame collection", cause),
};
throw cause;
}
options.observationBuffer.rendered_frames = {
state: "available",
value: renderedFrames,
};
const checkpoints: UnverifiedProcessCapture["filesystem_checkpoints"] = [
{
name: "before",
@@ -296,12 +436,23 @@ const completeCapture = async (options: {
truncated: after.truncated,
},
];
const manifest = await createRunManifest(
scenario,
runtime.startedAt,
new Date(),
{ platform: options.hostPlatform, architecture: process.arch },
);
let manifest: Awaited<ReturnType<typeof createRunManifest>>;
try {
manifest = await createRunManifest(
scenario,
runtime.startedAt,
new Date(),
runtime.executableIdentity,
{ platform: options.hostPlatform, architecture: process.arch },
);
} catch (cause: unknown) {
options.observationBuffer.manifest = {
state: "unavailable",
reason: unavailableReason("Capture manifest creation", cause),
};
throw cause;
}
options.observationBuffer.manifest = { state: "available", value: manifest };
const truncated =
options.initiallyTruncated ||
after.truncated ||
@@ -335,23 +486,29 @@ const runProcessScenario = async (
hostEnvironment: Readonly<Record<string, string | undefined>> = process.env,
hostPlatform: NodeJS.Platform = process.platform,
captureSnapshot: typeof snapshotRoots = snapshotRoots,
cleanupHost?: ProcessCaptureCleanupHost,
): Promise<ProcessCapture> => {
const { temporaryRoot, runId, before } = await prepareProcessCapture(
scenario,
signal,
captureSnapshot,
);
const { temporaryRoot, runId, ownershipBaseline, before } =
await prepareProcessCapture(scenario, signal, captureSnapshot);
const frames: TerminalFrame[] = [];
const samples: ProcessSample[] = [];
const interactions: InteractionEvent[] = [];
const journal = createProcessCaptureJournal();
const { entries: eventJournal, recordEvent } = journal;
recordEvent("filesystem_checkpoints", 0);
const observations = createProcessCaptureObservationBuffer({
frames,
interactions,
samples,
eventJournal,
before,
});
let runtime: StartedCaptureRuntime | undefined;
let actualRootPid: number | undefined;
const timers = new Set<ProcessTimer>();
let capture: PendingProcessCapture | undefined;
let executionFailure: unknown;
let stopSampler = async () => ({ partial: false });
const interactions: InteractionEvent[] = [];
const dispatchedEventIndexes = new Set<number>();
try {
@@ -360,6 +517,12 @@ const runProcessScenario = async (
hostEnvironment,
temporaryRoot,
runId,
ownershipBaseline,
...(cleanupHost === undefined ? {} : { cleanupHost }),
observationBuffer: observations,
onSpawn: (pid) => {
actualRootPid = pid;
},
frames,
samples,
interactions,
@@ -380,6 +543,15 @@ const runProcessScenario = async (
dispatchedEventIndexes,
recordEvent,
});
observations.exit = {
state: "available",
value: {
code:
exit.reason === "exited" && exit.exitCode >= 0 ? exit.exitCode : null,
signal: exit.signal ?? null,
reason: exit.reason,
},
};
capture = await completeCapture({
scenario,
hostPlatform,
@@ -393,6 +565,7 @@ const runProcessScenario = async (
exit,
initiallyTruncated: before.truncated,
eventJournal,
observationBuffer: observations,
recordEvent,
captureSnapshot,
...(signal === undefined ? {} : { signal }),
@@ -400,16 +573,44 @@ const runProcessScenario = async (
} catch (cause: unknown) {
runtime?.terminal.kill("SIGKILL");
executionFailure = normalizeCaptureFailure(cause, signal);
if (
runtime !== undefined &&
observations.rendered_frames.state === "unavailable" &&
observations.rendered_frames.reason.startsWith(
"Rendered terminal frames were not collected",
)
) {
try {
observations.rendered_frames = {
state: "available",
value: await runtime.renderer.frames(),
};
} catch (renderCause: unknown) {
const renderFailure =
renderCause instanceof Error
? renderCause.message
: "unknown failure";
observations.rendered_frames = {
state: "unavailable",
reason: `Rendered terminal frame collection failed: ${renderFailure}`,
};
}
}
}
return finishProcessRun({
runtime,
timers,
runId,
captureBaseline: ownershipBaseline,
...(cleanupHost === undefined ? {} : { cleanupHost }),
temporaryRoot,
scenario,
samples,
stopSampler,
capture,
executionFailure,
observations,
...(actualRootPid === undefined ? {} : { rootPid: actualRootPid }),
});
};
@@ -420,6 +621,7 @@ export const captureProcessScenario = async (
platform: NodeJS.Platform = process.platform,
environment: Readonly<Record<string, string | undefined>> = process.env,
captureSnapshot: typeof snapshotRoots = snapshotRoots,
cleanupHost?: ProcessCaptureCleanupHost,
): Promise<Result<ProcessCapture, ProcessCaptureError | AnalysisError>> => {
const ownershipReason = processCaptureOwnershipUnavailableReason(platform);
if (ownershipReason !== undefined)
@@ -444,14 +646,22 @@ export const captureProcessScenario = async (
environment,
platform,
captureSnapshot,
cleanupHost,
),
);
} catch (cause: unknown) {
const failure = normalizeCaptureFailure(cause, signal);
const executionFailureReason =
describeProcessCaptureExecutionFailure(cause);
return err(
failure instanceof ProcessCaptureError
? failure
: new ProcessCaptureError("process capture failed", { cause }),
: new ProcessCaptureError("process capture failed", {
cause,
...(executionFailureReason === undefined
? {}
: { executionFailure: executionFailureReason }),
}),
);
}
};
+8 -3
View File
@@ -7,7 +7,7 @@ import type { BinarySessionPort } from "../application/binary/BinarySession.js";
import type { PromptCompletionKind } from "../contracts/promptContracts.js";
import { artifactInventoryResultSchema } from "../domain/artifactGraph.js";
import { artifactInspectionResultSchema } from "../domain/artifactInspection.js";
import { processCaptureSchema } from "../domain/process/processCapture.js";
import { parseProcessCapture } from "../domain/process/processCapture.js";
const documentListSchema = z.array(z.string().min(1));
const providerStatusSchema = z.object({
@@ -171,8 +171,13 @@ const evidenceValues = (
const isProcessCaptureEvidence = (evidence: LedgerEvidence): boolean => {
if (evidence.operation !== "capture_process_scenario") return false;
const capture = processCaptureSchema.safeParse(evidence.normalized_result);
return capture.success;
try {
parseProcessCapture(evidence.normalized_result);
return true;
} catch (cause: unknown) {
if (cause instanceof TypeError || cause instanceof z.ZodError) return false;
throw cause;
}
};
const unknownCandidates = (
@@ -8,6 +8,7 @@ import type { AnalysisClient } from "../../../src/application/AnalysisProvider.j
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { ARTIFACT_COMPARISON_EXAMPLE } from "../../../src/contracts/artifactComparisonExample.js";
import { PROCESS_CAPTURE_REFERENCE } from "../../../src/contracts/investigationExamples.js";
import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../../../src/domain/process/processCaptureExample.js";
import { createEvidence } from "../../../src/domain/evidence.js";
import { createArtifactInspection } from "../../../src/domain/artifactInspection.js";
import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js";
@@ -57,6 +58,36 @@ describe("guided prompt completion from live analysis", () => {
});
describe("guided prompt completion from investigation records", () => {
it("completes process captures imported in the original v3 format", async () => {
const session = createTestBinarySession(() => client([]));
const {
selected_executable_sha256: _selectedDigest,
executable_identity: _identity,
executable_sha256,
...legacyManifest
} = EMPTY_PROCESS_CAPTURE_EXAMPLE.manifest;
const legacyCapture = {
...EMPTY_PROCESS_CAPTURE_EXAMPLE,
manifest: { ...legacyManifest, executable_sha256 },
};
const evidence = createEvidence(undefined, fixtureProvider, {
operation: "capture_process_scenario",
parameters: {},
result: legacyCapture,
confidence: "observed",
authority: "controlled-replay",
environment: fixtureEnvironment,
});
expect(session.recordEvidence(evidence).ok).toBe(true);
const completion = createPromptCompletionSource(session, session);
expect(
await completion.complete("capture", evidence.evidence_id.slice(0, 8)),
).toEqual([evidence.evidence_id]);
await session.close();
});
it("projects only validated typed identifiers from the evidence ledger", async () => {
const session = createTestBinarySession(() => client([]));
const invalidCapture = createEvidence(undefined, fixtureProvider, {
+222 -3
View File
@@ -1,14 +1,24 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { McpServer } from "@modelcontextprotocol/server";
import type { ToolContract } from "../../../src/contracts/toolContracts.js";
import { ok } from "../../../src/domain/result.js";
import { err } from "../../../src/domain/result.js";
import { ProcessCaptureError } from "../../../src/process/capture/ProcessCaptureError.js";
import { resolveProcessResult } from "../../../src/process/capture/ProcessCaptureLifecycle.js";
import {
toolContract,
type ToolContract,
} from "../../../src/contracts/toolContracts.js";
import { err, ok } from "../../../src/domain/result.js";
import { HopperProcessError } from "../../../src/domain/hopperErrors.js";
import type { IncompleteProcessCaptureObservations } from "../../../src/domain/process/processCapture.js";
import { processScenarioSchema } from "../../../src/domain/process/processScenario.js";
import { toCallToolResult } from "../../../src/server/toolResult.js";
import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
import { emptyProcessCapture } from "../../../src/domain/process/processCapture.fixture.js";
import type { JsonValue } from "../../../src/domain/jsonValue.js";
import { evidenceResultOf } from "../../../src/contracts/toolOutputSchemas.js";
import { toolRegistrationOptions } from "../../../src/server/toolRegistrationOptions.js";
const contract: ToolContract = {
name: "provider_neutral_fixture",
@@ -36,6 +46,215 @@ const contract: ToolContract = {
examples: [{ title: "Example fixture request", input: {} }],
};
describe("completed partial process capture MCP projection", () => {
it("preserves partial capture details over the MCP SDK transport", async () => {
const executionFailure = new Error("capture ended after a fixture error");
const cleanup = {
owned_process_group: {
state: "unverified" as const,
reason: "process ownership token could not be read",
},
terminal_renderer: { state: "cleaned" as const, reason: null },
temporary_root: { state: "cleaned" as const, reason: null },
};
const capture = {
...emptyProcessCapture(),
frames: [{ sequence: 0, at_ms: 0, data: "observed output" }],
event_journal: [],
};
let failure: ProcessCaptureError | undefined;
try {
resolveProcessResult(capture, executionFailure, cleanup);
} catch (cause: unknown) {
if (!(cause instanceof ProcessCaptureError)) throw cause;
failure = cause;
}
expect(failure).toBeDefined();
if (failure === undefined)
throw new Error("expected cleanup-incomplete result");
const captureContract = toolContract("capture_process_scenario");
const server = new McpServer({
name: "process-partial-error",
version: "1",
});
server.registerTool(
captureContract.name,
toolRegistrationOptions(captureContract),
async () => toCallToolResult(err(failure), captureContract),
);
const client = new Client({
name: "process-partial-error-client",
version: "1",
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const result = await client.callTool({
name: captureContract.name,
arguments: captureContract.examples[0]?.input ?? {},
});
expect(result.isError).toBe(true);
expect(result.structuredContent).toMatchObject({
error: {
code: "cleanup_incomplete",
details: {
cleanup_report: cleanup,
execution_failure: "capture ended after a fixture error",
partial_observation: {
capture: {
frames: [{ data: "observed output" }],
settlement: { cleanup_outcome: "failed" },
},
execution_failure: "capture ended after a fixture error",
},
},
},
});
const textContent = result.content.find((block) => block.type === "text");
expect(textContent?.type === "text" ? textContent.text : undefined).toBe(
JSON.stringify(result.structuredContent),
);
} finally {
await Promise.all([client.close(), server.close()]);
}
});
});
describe("incomplete partial process observations MCP projection", () => {
it("preserves normalized raw observations over the MCP SDK transport", async () => {
const captureContract = toolContract("capture_process_scenario");
const example = captureContract.examples[0];
if (example === undefined)
throw new Error("missing process scenario example");
const scenario = processScenarioSchema.parse({
...example.input,
normalization: { pids: false },
});
const rootPid = 45678;
const cleanup = {
owned_process_group: {
state: "unverified" as const,
reason: "process ownership token could not be read",
},
terminal_renderer: { state: "cleaned" as const, reason: null },
temporary_root: { state: "cleaned" as const, reason: null },
};
const observations: IncompleteProcessCaptureObservations = {
target_pid: { state: "available", value: rootPid },
frames: {
state: "available",
value: [{ sequence: 0, at_ms: 0, data: "partial output" }],
},
rendered_frames: {
state: "unavailable",
reason: "fixture renderer observation unavailable",
},
interaction_events: { state: "available", value: [] },
exit: {
state: "unavailable",
reason: "fixture terminal exit unavailable",
},
settlement: {
state: "unavailable",
reason: "fixture process settlement unavailable",
},
process_samples: {
state: "available",
value: [
{
at_ms: 1,
pid: rootPid,
parent_pid: 1,
command: `fixture ${String(rootPid)}`,
process_group_id: rootPid,
session_id: rootPid,
},
],
},
filesystem_snapshots: {
before: { state: "available", value: { files: [], truncated: false } },
after: {
state: "unavailable",
reason: "fixture final snapshot unavailable",
},
},
event_journal: { state: "available", value: [] },
manifest: {
state: "unavailable",
reason: "fixture capture manifest unavailable",
},
};
let failure: ProcessCaptureError | undefined;
try {
resolveProcessResult(
undefined,
new Error("capture failed before completion"),
cleanup,
observations,
{ scenario, rootPid },
);
} catch (cause: unknown) {
if (!(cause instanceof ProcessCaptureError)) throw cause;
failure = cause;
}
if (failure === undefined)
throw new Error("expected cleanup-incomplete result");
const server = new McpServer({
name: "process-partial-observations",
version: "1",
});
server.registerTool(
captureContract.name,
toolRegistrationOptions(captureContract),
async () => toCallToolResult(err(failure), captureContract),
);
const client = new Client({
name: "process-partial-observations-client",
version: "1",
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const result = await client.callTool({
name: captureContract.name,
arguments: example.input,
});
expect(result.isError).toBe(true);
expect(result.structuredContent).toMatchObject({
error: {
details: {
partial_observation: {
observations: {
target_pid: { state: "available", value: rootPid },
frames: {
state: "available",
value: [{ data: "partial output" }],
},
process_samples: {
state: "available",
value: [{ pid: rootPid }],
},
},
},
},
},
});
} finally {
await Promise.all([client.close(), server.close()]);
}
});
});
describe("tool result projection", () => {
it("exposes an actionable adapter code to MCP callers", () => {
const result = toCallToolResult(err(new HopperProcessError(76)), contract);
+18 -2
View File
@@ -1,7 +1,11 @@
import { expect, it } from "vitest";
import { createProcessCaptureEvidence } from "../../../src/application/process/ProcessEvidence.js";
import { createRunManifest } from "../../../src/process/capture/ProcessCaptureLifecycle.js";
import {
createRunManifest,
observeLaunchedExecutable,
observeSelectedExecutable,
} from "../../../src/process/capture/ProcessCaptureLifecycle.js";
import { emptyUnverifiedProcessCapture } from "../../../src/domain/process/processCapture.fixture.js";
import {
compareProcessCaptures,
@@ -61,8 +65,20 @@ const captureEvidenceForEnvironmentSecret = async (value: string) => {
environment: { API_TOKEN: value },
events: [{ type: "input", at_ms: 0, data: value }],
});
const selectedExecutable = await observeSelectedExecutable(
scenario.executable,
);
const executableIdentity = observeLaunchedExecutable(
scenario.executable,
selectedExecutable,
);
const manifest = await createRunManifest(
scenario,
new Date(0),
new Date(1),
executableIdentity,
);
const base = emptyUnverifiedProcessCapture();
const manifest = await createRunManifest(scenario, new Date(0), new Date(1));
const scenarioManifest = {
...manifest,
normalization_sha256: digestProcessCommitment(scenario.normalization),
@@ -0,0 +1,724 @@
import { execFile, spawn, type ChildProcess } from "node:child_process";
import {
access,
chmod,
mkdtemp,
readFile,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { _electron as electron } from "playwright-core";
import { expect, it, vi } from "vitest";
import {
createSystemProcessOwnershipHost,
systemProcessOwnershipHost,
} from "../../../src/process/ProcessOwnershipObservation.js";
import { createDarwinProcessRunTokenReader } from "../../../src/process/DarwinProcessRunTokenReader.js";
import { execFileOutput } from "../../../src/process/ExecFileOutput.js";
import { normalizeCaptureFailure } from "../../../src/process/capture/ProcessHarness.js";
import { PlaywrightElectronActiveProvider } from "../../../src/browser/PlaywrightElectronActiveProvider.js";
import { electronActiveObservationInputSchema } from "../../../src/domain/javascript/electronActiveObservation.js";
const execFileAsync = promisify(execFile);
const onDarwin = process.platform === "darwin";
const writeBlockingCompiler = async (directory: string) => {
const executable = join(directory, "fake-xcrun");
const statePath = join(directory, "compile-state.json");
const releasePath = join(directory, "release-compiler");
const source = `#!/usr/bin/env node
const fs = require("node:fs");
const path = require("node:path");
const statePath = ${JSON.stringify(statePath)};
const releasePath = ${JSON.stringify(releasePath)};
const state = fs.existsSync(statePath)
? JSON.parse(fs.readFileSync(statePath, "utf8"))
: { calls: 0 };
state.calls += 1;
const moduleIndex = process.argv.indexOf("-module-cache-path");
state.root = path.dirname(process.argv[moduleIndex + 1]);
const outputIndex = process.argv.indexOf("-o");
state.output = process.argv[outputIndex + 1];
state.releasePath = releasePath;
fs.writeFileSync(statePath, JSON.stringify(state));
if (state.calls === 1) {
const timer = setInterval(() => {
if (!fs.existsSync(releasePath)) return;
clearInterval(timer);
fs.writeFileSync(state.output, "compiled");
}, 10);
} else {
fs.writeFileSync(state.output, "compiled");
}
`;
await writeFile(executable, source);
await chmod(executable, 0o755);
return { executable, statePath, releasePath };
};
const writeBlockingIdentityCompiler = async (directory: string) => {
const executable = join(directory, "fake-identity-xcrun");
const statePath = join(directory, "identity-helper-state.json");
const helperSource = `#!/usr/bin/env node
const fs = require("node:fs");
fs.writeFileSync(${JSON.stringify(statePath)}, JSON.stringify({
root: __dirname,
}));
setInterval(() => {}, 1000);
`;
const compilerSource = `#!/usr/bin/env node
const fs = require("node:fs");
const outputIndex = process.argv.indexOf("-o");
const output = process.argv[outputIndex + 1];
fs.writeFileSync(output, ${JSON.stringify(helperSource)});
fs.chmodSync(output, 0o755);
`;
await writeFile(executable, compilerSource);
await chmod(executable, 0o755);
return { executable, statePath };
};
const waitForCompileState = async (statePath: string) => {
const deadline = Date.now() + 5_000;
while (Date.now() < deadline) {
try {
const parsed: unknown = JSON.parse(await readFile(statePath, "utf8"));
if (
typeof parsed === "object" &&
parsed !== null &&
"calls" in parsed &&
typeof parsed.calls === "number" &&
"root" in parsed &&
typeof parsed.root === "string" &&
"output" in parsed &&
typeof parsed.output === "string" &&
"releasePath" in parsed &&
typeof parsed.releasePath === "string"
)
return {
calls: parsed.calls,
root: parsed.root,
output: parsed.output,
releasePath: parsed.releasePath,
};
} catch {
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
throw new Error("fake compiler did not start");
};
const waitForIdentityHelper = async (statePath: string) => {
const deadline = Date.now() + 5_000;
while (Date.now() < deadline) {
try {
const parsed: unknown = JSON.parse(await readFile(statePath, "utf8"));
if (
typeof parsed === "object" &&
parsed !== null &&
"root" in parsed &&
typeof parsed.root === "string"
)
return { root: parsed.root };
} catch {
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
throw new Error("fake identity helper did not start");
};
type ElectronCaptureResult = Awaited<
ReturnType<PlaywrightElectronActiveProvider["capture"]>
>;
type ElectronCaptureOutcome =
| { readonly state: "result"; readonly value: ElectronCaptureResult }
| { readonly state: "rejected"; readonly cause: unknown };
it("reports an actionable missing Swift compiler without installing it", async () => {
const reader = createDarwinProcessRunTokenReader({
xcrun: join(tmpdir(), "rea-no-such-xcrun"),
});
try {
await expect(
reader.read([
{
pid: 1,
parentPid: 0,
processGroupId: 1,
state: "S",
command: "fixture",
},
]),
).rejects.toThrow(/requires the Apple Swift compiler via xcrun/u);
} finally {
await reader.close();
}
});
it.skipIf(process.platform === "win32")(
"rethrows compile cancellation, removes its temporary root, and permits retry",
async () => {
const directory = await mkdtemp(
join(tmpdir(), "rea-process-token-abort-test-"),
);
const fakeCompiler = await writeBlockingCompiler(directory);
const reader = createDarwinProcessRunTokenReader({
xcrun: fakeCompiler.executable,
});
const controller = new AbortController();
const compilation = reader.prepare(controller.signal).then(
() => ({ state: "fulfilled" as const }),
(cause: unknown) => ({ state: "rejected" as const, cause }),
);
try {
const firstAttempt = await waitForCompileState(fakeCompiler.statePath);
controller.abort();
const outcome = await compilation;
expect(outcome.state).toBe("rejected");
if (outcome.state !== "rejected")
throw new Error(
"aborted native reader compilation unexpectedly succeeded",
);
expect(outcome.cause).toMatchObject({ name: "AbortError" });
expect(
normalizeCaptureFailure(outcome.cause, controller.signal),
).toMatchObject({ reason: "cancelled", userCategory: "cancelled" });
await expect(access(firstAttempt.root)).rejects.toThrow();
await reader.prepare();
const retry = await waitForCompileState(fakeCompiler.statePath);
expect(retry).toMatchObject({ calls: 2 });
await expect(access(retry.output)).resolves.toBeUndefined();
await reader.close();
await expect(access(retry.root)).rejects.toThrow();
} finally {
if (!controller.signal.aborted) controller.abort();
await compilation;
await reader.close();
await rm(directory, { recursive: true, force: true });
}
},
);
it.skipIf(process.platform === "win32").each(["first", "second"] as const)(
"keeps shared compilation alive when the %s waiter cancels",
async (cancelledWaiter) => {
const directory = await mkdtemp(
join(tmpdir(), "rea-process-token-shared-compile-test-"),
);
const fakeCompiler = await writeBlockingCompiler(directory);
const reader = createDarwinProcessRunTokenReader({
xcrun: fakeCompiler.executable,
});
const firstController = new AbortController();
const secondController = new AbortController();
const firstWaiter = reader.prepare(firstController.signal).then(
(value) => ({ state: "fulfilled" as const, value }),
(cause: unknown) => ({ state: "rejected" as const, cause }),
);
const secondWaiterPromise = (async () => {
const state = await waitForCompileState(fakeCompiler.statePath);
const secondWaiter = reader.prepare(secondController.signal).then(
(value) => ({ state: "fulfilled" as const, value }),
(cause: unknown) => ({ state: "rejected" as const, cause }),
);
const cancelled =
cancelledWaiter === "first" ? firstWaiter : secondWaiter;
const surviving =
cancelledWaiter === "first" ? secondWaiter : firstWaiter;
(cancelledWaiter === "first"
? firstController
: secondController
).abort();
const firstOutcome = await cancelled;
expect(firstOutcome.state).toBe("rejected");
if (firstOutcome.state !== "rejected")
throw new Error("cancelled compilation waiter unexpectedly succeeded");
expect(firstOutcome.cause).toMatchObject({ name: "AbortError" });
await expect(access(state.root)).resolves.toBeUndefined();
await writeFile(fakeCompiler.releasePath, "release");
return { state, outcome: await surviving };
})();
try {
const { state, outcome } = await secondWaiterPromise;
expect(state.calls).toBe(1);
expect(outcome.state).toBe("fulfilled");
if (outcome.state !== "fulfilled") throw outcome.cause;
expect(outcome.value).toBe(state.output);
await reader.close();
await expect(access(state.root)).rejects.toThrow();
} finally {
if (!firstController.signal.aborted) firstController.abort();
if (!secondController.signal.aborted) secondController.abort();
await Promise.allSettled([firstWaiter, secondWaiterPromise]);
await reader.close();
await rm(directory, { recursive: true, force: true });
}
},
);
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"retains a failed compilation cleanup for a later close retry",
async () => {
const directory = await mkdtemp(
join(tmpdir(), "rea-process-token-cleanup-retry-test-"),
);
const fakeCompiler = await writeBlockingCompiler(directory);
const reader = createDarwinProcessRunTokenReader({
xcrun: fakeCompiler.executable,
});
const controller = new AbortController();
const preparation = reader.prepare(controller.signal).then(
() => ({ state: "fulfilled" as const }),
(cause: unknown) => ({ state: "rejected" as const, cause }),
);
let compilationRoot: string | undefined;
try {
const state = await waitForCompileState(fakeCompiler.statePath);
compilationRoot = state.root;
await writeFile(
join(state.root, "retained.txt"),
"cleanup must remain tracked",
);
await chmod(state.root, 0o000);
controller.abort();
expect((await preparation).state).toBe("rejected");
await expect(reader.close()).rejects.toThrow();
await chmod(state.root, 0o700);
await reader.close();
await expect(access(state.root)).rejects.toThrow();
} finally {
if (!controller.signal.aborted) controller.abort();
await preparation;
if (compilationRoot !== undefined)
await chmod(compilationRoot, 0o700).catch(() => undefined);
await reader.close();
await rm(directory, { recursive: true, force: true });
}
},
);
it.skipIf(process.platform === "win32")(
"aborts and cleans an active compilation when the reader closes",
async () => {
const directory = await mkdtemp(
join(tmpdir(), "rea-process-token-close-compile-test-"),
);
const fakeCompiler = await writeBlockingCompiler(directory);
const reader = createDarwinProcessRunTokenReader({
xcrun: fakeCompiler.executable,
});
const preparation = reader.prepare().then(
() => ({ state: "fulfilled" as const }),
(cause: unknown) => ({ state: "rejected" as const, cause }),
);
try {
const state = await waitForCompileState(fakeCompiler.statePath);
await reader.close();
const outcome = await preparation;
expect(outcome.state).toBe("rejected");
if (outcome.state !== "rejected")
throw new Error("closed reader compilation unexpectedly succeeded");
expect(outcome.cause).toMatchObject({ name: "AbortError" });
await expect(access(state.root)).rejects.toThrow();
await expect(reader.prepare()).rejects.toThrow(/reader is closed/u);
await expect(reader.close()).resolves.toBeUndefined();
} finally {
await Promise.allSettled([preparation]);
await reader.close();
await rm(directory, { recursive: true, force: true });
}
},
);
it.skipIf(process.platform === "win32")(
"maps cancellation during native preparation before Electron path resolution",
async () => {
const directory = await mkdtemp(
join(tmpdir(), "rea-electron-token-abort-test-"),
);
const fakeCompiler = await writeBlockingCompiler(directory);
const reader = createDarwinProcessRunTokenReader({
xcrun: fakeCompiler.executable,
});
const prepareSpy = vi
.spyOn(systemProcessOwnershipHost, "prepare")
.mockImplementation(async (signal) => {
await reader.prepare(signal);
});
const launchSpy = vi.spyOn(electron, "launch");
const controller = new AbortController();
let captureOutcome: Promise<ElectronCaptureOutcome> | undefined;
try {
const input = electronActiveObservationInputSchema.parse({
executable_path: "/missing/electron",
application_path: "/missing/application.js",
});
captureOutcome = new PlaywrightElectronActiveProvider()
.capture(input, { signal: controller.signal })
.then(
(value) => ({ state: "result" as const, value }),
(cause: unknown) => ({ state: "rejected" as const, cause }),
);
const compileState = await waitForCompileState(fakeCompiler.statePath);
controller.abort();
const outcome = await captureOutcome;
expect(outcome.state).toBe("result");
if (outcome.state !== "result") throw outcome.cause;
expect(outcome.value).toMatchObject({
ok: false,
error: { reason: "cancelled", userCategory: "cancelled" },
});
expect(launchSpy).not.toHaveBeenCalled();
await expect(access(compileState.root)).rejects.toThrow();
} finally {
if (!controller.signal.aborted) controller.abort();
if (captureOutcome !== undefined) await captureOutcome;
prepareSpy.mockRestore();
launchSpy.mockRestore();
await reader.close();
await rm(directory, { recursive: true, force: true });
}
},
);
it.skipIf(process.platform === "win32")(
"preserves cancellation through system-host baseline identity inspection",
async () => {
const directory = await mkdtemp(
join(tmpdir(), "rea-process-baseline-abort-test-"),
);
const fakeCompiler = await writeBlockingIdentityCompiler(directory);
const host = createSystemProcessOwnershipHost("darwin", process.env, {
darwinXcrun: fakeCompiler.executable,
});
const controller = new AbortController();
let helperRoot: string | undefined;
const baseline =
host.captureBaseline?.(controller.signal).then(
() => ({ state: "fulfilled" as const }),
(cause: unknown) => ({ state: "rejected" as const, cause }),
) ?? Promise.resolve({ state: "missing" as const });
try {
if (host.captureBaseline === undefined)
throw new Error("Darwin host did not expose process baselines");
const helper = await waitForIdentityHelper(fakeCompiler.statePath);
helperRoot = helper.root;
controller.abort();
const outcome = await baseline;
expect(outcome.state).toBe("rejected");
if (outcome.state !== "rejected")
throw new Error("aborted identity inspection unexpectedly succeeded");
expect(outcome.cause).toMatchObject({ name: "AbortError" });
expect(
normalizeCaptureFailure(outcome.cause, controller.signal),
).toMatchObject({ reason: "cancelled", userCategory: "cancelled" });
await expect(access(helper.root)).resolves.toBeUndefined();
} finally {
if (!controller.signal.aborted) controller.abort();
await baseline;
await host.close?.();
if (helperRoot !== undefined)
await expect(access(helperRoot)).rejects.toThrow();
await rm(directory, { recursive: true, force: true });
}
},
);
it.skipIf(process.platform === "win32")(
"passes Electron cancellation into capture-local baseline inspection",
async () => {
const directory = await mkdtemp(
join(tmpdir(), "rea-electron-baseline-abort-test-"),
);
const fakeCompiler = await writeBlockingIdentityCompiler(directory);
const reader = createDarwinProcessRunTokenReader({
xcrun: fakeCompiler.executable,
});
const prepareSpy = vi
.spyOn(systemProcessOwnershipHost, "prepare")
.mockResolvedValue();
const baselineSpy = vi
.spyOn(systemProcessOwnershipHost, "captureBaseline")
.mockImplementation(async (signal) => {
await reader.identities(
[
{
pid: process.pid,
parentPid: process.ppid,
processGroupId: process.pid,
state: "S",
command: process.execPath,
},
],
signal,
);
return [];
});
const launchSpy = vi.spyOn(electron, "launch");
const controller = new AbortController();
let captureOutcome: Promise<ElectronCaptureOutcome> | undefined;
try {
const input = electronActiveObservationInputSchema.parse({
executable_path: process.execPath,
application_path: fileURLToPath(import.meta.url),
application_root: process.cwd(),
});
captureOutcome = new PlaywrightElectronActiveProvider()
.capture(input, { signal: controller.signal })
.then(
(value) => ({ state: "result" as const, value }),
(cause: unknown) => ({ state: "rejected" as const, cause }),
);
const helper = await waitForIdentityHelper(fakeCompiler.statePath);
controller.abort();
const outcome = await captureOutcome;
expect(outcome.state).toBe("result");
if (outcome.state !== "result") throw outcome.cause;
expect(outcome.value).toMatchObject({
ok: false,
error: { reason: "cancelled", userCategory: "cancelled" },
});
expect(launchSpy).not.toHaveBeenCalled();
await expect(access(helper.root)).resolves.toBeUndefined();
} finally {
if (!controller.signal.aborted) controller.abort();
if (captureOutcome !== undefined) await captureOutcome;
prepareSpy.mockRestore();
baselineSpy.mockRestore();
launchSpy.mockRestore();
await reader.close();
await rm(directory, { recursive: true, force: true });
}
},
);
it.skipIf(!onDarwin)(
"parses exact NUL-delimited token records and rejects truncated argv",
async () => {
const root = await mkdtemp(
join(tmpdir(), "rea-process-token-parser-test-"),
);
const executable = join(root, "parser-test");
const core = fileURLToPath(
new URL(
"../../../bridge/process/ProcessRunTokenReader.swift",
import.meta.url,
),
);
const fixture = fileURLToPath(
new URL(
"../../fixtures/processRunTokenParserProbe.swift",
import.meta.url,
),
);
try {
await execFileOutput(
"/usr/bin/xcrun",
[
"swiftc",
"-module-cache-path",
join(root, "modules"),
core,
fixture,
"-o",
executable,
],
{ timeout: 60_000 },
);
const { stdout } = await execFileAsync(executable, [], {
encoding: "utf8",
timeout: 10_000,
});
expect(JSON.parse(stdout)).toEqual({
argvDecoyIgnored: true,
alternatePaddingRead: true,
appleVectorTokenFailsClosed: true,
clearedAppleVectorTokenFailsClosed: true,
callerPfzBeforeTokenRead: true,
emptyLaterArgumentRead: true,
emptyEnvironmentFailsClosed: true,
emptyArgv0Read: true,
emptyEnvironmentRecordsBeforeTokenFailClosed: true,
duplicateTokenFailsClosed: true,
finalAssignmentArgumentRead: true,
missingAppleBoundaryFailsClosed: true,
nonAsciiPathRead: true,
realTokenRead: true,
reservedArgumentIgnored: true,
truncatedArgvFailsClosed: true,
zeroArgumentsRead: true,
});
} finally {
await rm(root, { recursive: true, force: true });
}
},
);
it.skipIf(!onDarwin)(
"retries a growing process table after ENOMEM and preserves other sysctl errors",
async () => {
const root = await mkdtemp(
join(tmpdir(), "rea-process-snapshot-retry-test-"),
);
const executable = join(root, "snapshot-probe");
const core = fileURLToPath(
new URL(
"../../../bridge/process/ProcessRunTokenReader.swift",
import.meta.url,
),
);
const fixture = fileURLToPath(
new URL(
"../../fixtures/processRunTokenSnapshotProbe.swift",
import.meta.url,
),
);
try {
await execFileOutput(
"/usr/bin/xcrun",
[
"swiftc",
"-module-cache-path",
join(root, "modules"),
core,
fixture,
"-o",
executable,
],
{ timeout: 60_000 },
);
const { stdout } = await execFileAsync(executable, [], {
encoding: "utf8",
timeout: 10_000,
});
expect(JSON.parse(stdout)).toEqual({
preservedOtherErrno: true,
retriedAfterFreshSize: true,
});
} finally {
await rm(root, { recursive: true, force: true });
}
},
);
it.skipIf(!onDarwin)(
"reads only the exact ownership key from live process environments",
async () => {
const host = createSystemProcessOwnershipHost("darwin");
const decoy = await startNodeChild({
DECOY: "words REA_PROCESS_RUN_ID=synthetic-decoy",
});
const owned = await startNodeChild({
REA_PROCESS_RUN_ID: "synthetic-owned",
});
try {
const processes = await host.listProcesses();
const entries = [decoy, owned].map(({ pid }) => {
const entry = processes.find((process) => process.pid === pid);
if (entry === undefined)
throw new Error("test child is absent from process table");
return entry;
});
const observations = await host.runTokens?.(entries);
const identities = await host.processIdentities?.(entries);
expect(observations?.get(decoy.pid)).toEqual({
state: "readable",
runId: undefined,
});
expect(observations?.get(owned.pid)).toEqual({
state: "readable",
runId: "synthetic-owned",
});
expect(identities?.get(decoy.pid)?.state).toBe("readable");
expect(identities?.get(owned.pid)?.state).toBe("readable");
} finally {
await stopNodeChild(decoy.child);
await stopNodeChild(owned.child);
await host.close?.();
}
},
);
it.skipIf(!onDarwin)(
"reads owned tokens with empty later arguments across executable path padding",
async () => {
const root = await mkdtemp(join(tmpdir(), "rea-process-token-padding-"));
const host = createSystemProcessOwnershipHost("darwin");
const children: Awaited<ReturnType<typeof startNodeChild>>[] = [];
try {
const executables = [join(root, "n"), join(root, "node")];
for (const executable of executables)
await symlink(process.execPath, executable);
for (let index = 0; index < executables.length; index += 1) {
const executable = executables[index];
if (executable === undefined)
throw new Error("test executable is missing");
children.push(
await startNodeChild(
{ REA_PROCESS_RUN_ID: `synthetic-padding-${String(index)}` },
executable,
["-e", "setInterval(() => {}, 1_000)", "", "FOO=bar"],
index === 0 ? "" : undefined,
),
);
}
const processes = await host.listProcesses();
const entries = children.map(({ pid }) => {
const entry = processes.find((process) => process.pid === pid);
if (entry === undefined)
throw new Error("owned test child is absent from process table");
return entry;
});
const observations = await host.runTokens?.(entries);
expect(children.map(({ pid }) => observations?.get(pid))).toEqual([
{ state: "readable", runId: "synthetic-padding-0" },
{ state: "readable", runId: "synthetic-padding-1" },
]);
} finally {
for (const { child } of children) await stopNodeChild(child);
await host.close?.();
await rm(root, { recursive: true, force: true });
}
},
);
const startNodeChild = async (
environment: NodeJS.ProcessEnv,
command = process.execPath,
arguments_: readonly string[] = ["-e", "setInterval(() => {}, 1_000)"],
argv0?: string,
) => {
const child = spawn(command, [...arguments_], {
env: environment,
stdio: "ignore",
...(argv0 === undefined ? {} : { argv0 }),
});
if (child.pid === undefined)
throw new Error("test child did not receive a PID");
await new Promise<void>((resolve, reject) => {
child.once("spawn", resolve);
child.once("error", reject);
});
return { child, pid: child.pid };
};
const stopNodeChild = async (child: ChildProcess) => {
if (child.exitCode !== null || child.signalCode !== null) return;
const exited = new Promise<void>((resolve) =>
child.once("exit", () => resolve()),
);
child.kill("SIGKILL");
await exited;
};
@@ -52,6 +52,31 @@ describe("process capture capability diagnostics", () => {
).toContain("spawn EACCES");
});
it("prepares Darwin ownership inspection before probing or admitting a PTY", async () => {
let loaded = false;
const capability = await probeProcessCaptureCapability({
platform: "darwin",
prepareOwnershipInspector: async () => {
throw new Error(
"macOS process ownership inspection requires the Apple Swift compiler via xcrun",
);
},
loadPty: async () => {
loaded = true;
return await import("@lydell/node-pty");
},
});
expect(loaded).toBe(false);
expect(capability).toEqual({
available: false,
backend: "node-pty",
reason: expect.stringContaining(
"requires the Apple Swift compiler via xcrun",
),
});
});
it("keeps the ownership limitation separate from native PTY support", () => {
expect(processCaptureOwnershipUnavailableReason("win32")).toContain(
"Windows PTY process capture",
@@ -1,5 +1,6 @@
import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import { rm, symlink, writeFile } from "node:fs/promises";
import { access, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
@@ -9,7 +10,16 @@ import { itWithCaptureCapability } from "./processCaptureCapability.js";
import { captureProcessScenario } from "../../../src/process/capture/ProcessHarness.js";
import { snapshotRoots } from "../../../src/process/capture/FilesystemSnapshot.js";
import { parseProcessScenario } from "../../../src/domain/process/processCapture.js";
import { ProcessCaptureError } from "../../../src/process/capture/ProcessCaptureError.js";
import {
type ProcessCaptureCleanupHost,
observeLaunchedExecutable,
observeSelectedExecutable,
} from "../../../src/process/capture/ProcessCaptureLifecycle.js";
import {
parseProcessScenario,
type ProcessCapture,
} from "../../../src/domain/process/processCapture.js";
const processFixture = fileURLToPath(
new URL("../../fixtures/processFidelity.mjs", import.meta.url),
@@ -19,6 +29,357 @@ const snapshotCancellationFixture = fileURLToPath(
);
const execFileAsync = promisify(execFile);
type CaptureRun = Awaited<ReturnType<typeof captureProcessScenario>>;
type PartialCapture = Extract<
NonNullable<ProcessCaptureError["partialObservation"]>,
{ readonly capture: unknown }
>["capture"];
const captureObservations = (
result: CaptureRun,
): {
readonly capture: ProcessCapture | PartialCapture;
readonly cleanupIncomplete: boolean;
} => {
if (result.ok) return { capture: result.value, cleanupIncomplete: false };
if (!(result.error instanceof ProcessCaptureError)) throw result.error;
const report = result.error.cleanupReport;
const partial = result.error.partialObservation;
expect(result.error.reason).toBe("cleanup_incomplete");
expect(report?.owned_process_group.state).toBe("unverified");
const [summary, breakdown] = (report?.owned_process_group.reason ?? "").split(
": ",
);
expect(summary).toMatch(
/^process ownership token could not be read for [1-9][0-9]* live process\(es\)$/u,
);
const categories = breakdown?.split(", ") ?? [];
expect(categories.length).toBeGreaterThan(0);
const expectedCategory =
process.platform === "linux"
? /^environment_errno_(?:EACCES|EPERM)=[1-9][0-9]*$/u
: /^environment_unavailable=[1-9][0-9]*$/u;
for (const category of categories) expect(category).toMatch(expectedCategory);
expect(report?.terminal_renderer.state).toBe("cleaned");
expect(report?.temporary_root.state).toBe("cleaned");
if (partial === undefined || !("capture" in partial))
throw new Error(
"cleanup-incomplete capture omitted completed observations",
);
const capture = partial.capture;
expect(capture.settlement.cleanup_outcome).toBe("failed");
return { capture, cleanupIncomplete: true };
};
const processState = async (pid: number): Promise<string | undefined> => {
try {
const { stdout } = await execFileAsync("ps", [
"-o",
"stat=",
"-p",
String(pid),
]);
return stdout.trim() || undefined;
} catch (cause: unknown) {
if (
cause instanceof Error &&
"code" in cause &&
cause.code === 1 &&
"stdout" in cause &&
cause.stdout === ""
)
return undefined;
throw cause;
}
};
const waitForProcessExit = async (pid: number): Promise<void> => {
const deadline = Date.now() + 2_000;
while (Date.now() < deadline) {
const state = await processState(pid);
if (state === undefined || state.startsWith("Z")) return;
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error(`Process ${String(pid)} did not exit within the test bound`);
};
const readDetachedFixturePids = async (marker: string): Promise<number[]> => {
let contents: string;
try {
contents = await readFile(marker, "utf8");
} catch (cause: unknown) {
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
return [];
throw cause;
}
const parsed: unknown = JSON.parse(contents);
if (typeof parsed !== "object" || parsed === null) return [];
const candidatePids = [
"ownedPid" in parsed ? parsed.ownedPid : undefined,
"unownedPid" in parsed ? parsed.unownedPid : undefined,
];
return candidatePids.flatMap((pid) =>
typeof pid === "number" && Number.isSafeInteger(pid) && pid > 1
? [pid]
: [],
);
};
const terminateFixtureProcess = async (pid: number): Promise<void> => {
try {
process.kill(pid, "SIGKILL");
} catch (cause: unknown) {
if (!(cause instanceof Error && "code" in cause && cause.code === "ESRCH"))
throw cause;
}
await waitForProcessExit(pid);
};
itWithCaptureCapability(
"cleans detached run-token children omitted by sampling and preserves an unowned neighbor",
async () => {
const root = await createTestTempDirectory("rea-detached-cleanup-");
const pidMarker = join(root, "children.json");
let ownedPid: number | undefined;
let unownedPid: number | undefined;
try {
const result = await captureProcessScenario(
parseProcessScenario({
executable: process.execPath,
arguments: [processFixture, "detached-cleanup", pidMarker],
working_directory: root,
settle_ms: 0,
timeout_ms: 5_000,
idle_timeout_ms: 5_000,
limits: { processes: 1 },
}),
);
const { capture, cleanupIncomplete } = captureObservations(result);
const parsed: unknown = JSON.parse(await readFile(pidMarker, "utf8"));
if (
typeof parsed !== "object" ||
parsed === null ||
!("ownedPid" in parsed) ||
typeof parsed.ownedPid !== "number" ||
!("unownedPid" in parsed) ||
typeof parsed.unownedPid !== "number"
)
throw new TypeError("detached-child fixture wrote invalid PIDs");
// Register fixture children before assertions so teardown can clean them
// even when a later expectation fails.
ownedPid = parsed.ownedPid;
unownedPid = parsed.unownedPid;
expect(capture.process_samples.some(({ pid }) => pid === ownedPid)).toBe(
false,
);
if (cleanupIncomplete)
expect(capture.settlement.cleanup_outcome).toBe("failed");
else
expect(result.ok && result.value.cleanup.owned_process_group).toBe(
"verified",
);
await waitForProcessExit(ownedPid);
const unownedState = await processState(unownedPid);
expect(unownedState).toBeDefined();
expect(unownedState?.startsWith("Z")).toBe(false);
} finally {
try {
let fixturePids: number[] = [];
let teardownFailure: unknown;
try {
fixturePids = await readDetachedFixturePids(pidMarker);
} catch (cause: unknown) {
teardownFailure = cause;
}
for (const pid of new Set([
...fixturePids,
...(ownedPid === undefined ? [] : [ownedPid]),
...(unownedPid === undefined ? [] : [unownedPid]),
])) {
try {
await terminateFixtureProcess(pid);
} catch (cause: unknown) {
teardownFailure ??= cause;
}
}
if (teardownFailure !== undefined) throw teardownFailure;
} finally {
await rm(root, { recursive: true, force: true });
}
}
},
10_000,
);
itWithCaptureCapability(
"retains collected observations when completion and cleanup both fail",
async () => {
let snapshotCalls = 0;
const captureSnapshot: typeof snapshotRoots = async () => {
snapshotCalls += 1;
if (snapshotCalls === 1) return { files: [], truncated: false };
throw new Error("fixture final snapshot failure");
};
const cleanupHost: ProcessCaptureCleanupHost = {
platform: process.platform,
cleanupProcessGroup: async () => ({
cleaned: false,
reason: "fixture cleanup could not be verified",
}),
verifyTokenOwnedProcesses: async () => ({
cleaned: false,
reason: "fixture cleanup could not be verified",
}),
removeTemporaryRoot: async (path) =>
rm(path, { recursive: true, force: true }),
};
const result = await captureProcessScenario(
parseProcessScenario({
executable: process.execPath,
arguments: [processFixture, "partial"],
working_directory: dirname(processFixture),
settle_ms: 0,
timeout_ms: 5_000,
idle_timeout_ms: 5_000,
}),
undefined,
process.platform,
process.env,
captureSnapshot,
cleanupHost,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected cleanup-incomplete failure");
if (!(result.error instanceof ProcessCaptureError)) throw result.error;
const partial = result.error.partialObservation;
expect(result.error.reason).toBe("cleanup_incomplete");
expect(result.error.executionFailure).toBe(
"fixture final snapshot failure",
);
expect(partial).toBeDefined();
if (partial === undefined || !("observations" in partial))
throw new Error("expected incomplete process observations");
const observations = partial.observations;
expect(observations.frames.state).toBe("available");
if (observations.frames.state !== "available")
throw new Error("expected terminal output observations");
expect(
observations.frames.value.map(({ data }) => data).join(""),
).toContain("partial-frame");
expect(observations.rendered_frames.state).toBe("available");
if (observations.rendered_frames.state !== "available")
throw new Error("expected rendered terminal observations");
expect(observations.rendered_frames.value.length).toBeGreaterThan(0);
expect(observations.interaction_events.state).toBe("available");
expect(observations.exit).toMatchObject({
state: "available",
value: { reason: "exited" },
});
expect(observations.settlement.state).toBe("available");
expect(observations.process_samples.state).toBe("available");
expect(observations.target_pid).toEqual({ state: "available", value: 1 });
expect(observations.filesystem_snapshots.before.state).toBe("available");
if (observations.filesystem_snapshots.before.state !== "available")
throw new Error("expected initial filesystem snapshot");
expect(observations.filesystem_snapshots.before.value.truncated).toBe(
false,
);
expect(observations.filesystem_snapshots.after).toEqual({
state: "unavailable",
reason:
"Final filesystem snapshot failed: fixture final snapshot failure",
});
expect(observations.event_journal.state).toBe("available");
if (observations.event_journal.state !== "available")
throw new Error("expected capture event journal");
expect(
observations.event_journal.value.some(
({ collection }) => collection === "lifecycle",
),
).toBe(true);
expect(observations.manifest.state).toBe("unavailable");
expect(snapshotCalls).toBe(2);
},
10_000,
);
itWithCaptureCapability(
"keeps selected executable evidence separate when the path identity changes before validation",
async () => {
const root = await createTestTempDirectory("rea-executable-replaced-");
const executable = join(root, "selected-executable");
await symlink(process.execPath, executable);
try {
const selected = await observeSelectedExecutable(executable);
expect(selected.sha256).toMatch(/^[a-f0-9]{64}$/u);
await rm(executable);
await symlink("/bin/sh", executable);
expect(observeLaunchedExecutable(executable, selected)).toEqual({
selectedSha256: selected.sha256,
sha256: null,
state: "unknown",
reason: "Executable path metadata changed between sampling and spawn.",
});
} finally {
await rm(root, { recursive: true, force: true });
}
},
);
itWithCaptureCapability(
"keeps the prelaunch executable sample when the selected path is replaced during capture",
async () => {
const root = await createTestTempDirectory("rea-executable-identity-");
const executable = join(root, "selected-executable");
const readyMarker = join(root, "child-ready");
await symlink(process.execPath, executable);
const launchedDigest = createHash("sha256")
.update(await readFile(process.execPath))
.digest("hex");
try {
const capturePromise = captureProcessScenario(
parseProcessScenario({
executable,
arguments: [processFixture, "ready-hang", readyMarker],
working_directory: root,
timeout_ms: 1_500,
idle_timeout_ms: 5_000,
}),
);
const deadline = Date.now() + 2_000;
let ready = false;
while (!ready && Date.now() < deadline) {
try {
await access(readyMarker);
ready = true;
} catch {
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
expect(ready).toBe(true);
await rm(executable);
await symlink("/bin/sh", executable);
const result = await capturePromise;
const { capture } = captureObservations(result);
expect(capture.manifest).toMatchObject({
selected_executable_sha256: launchedDigest,
executable_sha256: launchedDigest,
executable_identity: {
state: "path_metadata_unchanged",
reason: null,
},
});
expect(capture.exit.reason).toBe("timeout");
expect(capture.limitations).toContain(
"The executable digest is a prelaunch file sample; matching path metadata immediately after spawn does not prove an atomic operating-system image binding.",
);
} finally {
await rm(root, { recursive: true, force: true });
}
},
10_000,
);
itWithCaptureCapability(
"records external symlink metadata without following the target",
async () => {
@@ -32,16 +393,15 @@ itWithCaptureCapability(
filesystem_observation_paths: [root],
}),
);
expect(result.ok).toBe(true);
if (!result.ok) throw result.error;
const escaped = result.value.files_after.find((file) =>
const { capture } = captureObservations(result);
const escaped = capture.files_after.find((file) =>
file.path.endsWith(":escape"),
);
expect(escaped?.symlink_target).toBe("/etc/passwd");
expect(result.value.truncated).toBe(false);
expect(JSON.stringify(result.value.files_after)).not.toContain(root);
expect(capture.truncated).toBe(false);
expect(JSON.stringify(capture.files_after)).not.toContain(root);
expect(
result.value.files_after.some((file) => file.path.includes("passwd")),
capture.files_after.some((file) => file.path.includes("passwd")),
).toBe(false);
} finally {
await rm(root, { recursive: true, force: true });
@@ -61,13 +421,13 @@ itWithCaptureCapability(
idle_timeout_ms: 5_000,
}),
);
expect(timedOut.ok).toBe(true);
if (!timedOut.ok) throw timedOut.error;
expect(timedOut.value.exit.reason).toBe("timeout");
expect(timedOut.value.cleanup).toEqual({
owned_process_group: "verified",
temporary_root: "removed",
});
const timedOutCapture = captureObservations(timedOut);
expect(timedOutCapture.capture.exit.reason).toBe("timeout");
if (!timedOutCapture.cleanupIncomplete && timedOut.ok)
expect(timedOut.value.cleanup).toEqual({
owned_process_group: "verified",
temporary_root: "removed",
});
const controller = new AbortController();
setTimeout(() => controller.abort(), 50);
@@ -212,17 +572,16 @@ itWithCaptureCapability(
idle_timeout_ms: 10_000,
}),
);
if (!result.ok) throw result.error;
expect(result.ok).toBe(true);
const output = result.value.frames.map(({ data }) => data).join("");
const { capture } = captureObservations(result);
const output = capture.frames.map(({ data }) => data).join("");
expect(output).toContain("prompt>");
expect(output).toContain("input:answer unicode:雪");
expect(output).toContain("resize:100x40");
expect(result.value.interaction_events).toMatchObject([
expect(capture.interaction_events).toMatchObject([
{ type: "resize", outcome: "dispatched", scheduled_at_ms: 0 },
{ type: "input", outcome: "dispatched", scheduled_at_ms: 0 },
]);
const resized = result.value.rendered_frames.find(
const resized = capture.rendered_frames.find(
({ columns, rows, lines }) =>
columns === 100 &&
rows === 40 &&
@@ -230,7 +589,7 @@ itWithCaptureCapability(
);
expect(resized).toBeDefined();
expect(resized?.lines.join("\n")).toContain("input:answer unicode:雪");
expect(result.value.exit.code).toBe(0);
expect(capture.exit.code).toBe(0);
},
);
@@ -247,12 +606,11 @@ itWithCaptureCapability(
idle_timeout_ms: 2_000,
}),
);
expect(result.ok).toBe(true);
if (!result.ok) throw result.error;
expect(result.value.interaction_events).toMatchObject([
const { capture } = captureObservations(result);
expect(capture.interaction_events).toMatchObject([
{ type: "signal", data: "SIGTERM", outcome: "dispatched" },
]);
expect(result.value.exit).toMatchObject({ signal: 15, reason: "exited" });
expect(capture.exit).toMatchObject({ signal: 15, reason: "exited" });
},
);
@@ -272,24 +630,22 @@ itWithCaptureCapability(
idle_timeout_ms: 20_000,
}),
);
expect(result.ok).toBe(true);
if (!result.ok) throw result.error;
expect(result.value.frames.map(({ data }) => data).join("")).toContain(
const { capture } = captureObservations(result);
expect(capture.frames.map(({ data }) => data).join("")).toContain(
"input:answer",
);
expect(result.value.interaction_events).toMatchObject([
expect(capture.interaction_events).toMatchObject([
{ type: "resize", scheduled_at_ms: 25, outcome: "dispatched" },
{ type: "input", scheduled_at_ms: 50, outcome: "dispatched" },
]);
expect(
result.value.interaction_events.every(
capture.interaction_events.every(
({ scheduled_at_ms, dispatched_at_ms }) =>
dispatched_at_ms >= scheduled_at_ms,
),
).toBe(true);
expect(
result.value.frames.find(({ data }) => data.includes("input:answer"))
?.at_ms,
capture.frames.find(({ data }) => data.includes("input:answer"))?.at_ms,
).toBeGreaterThanOrEqual(50);
},
);
@@ -306,13 +662,12 @@ itWithCaptureCapability(
idle_timeout_ms: 20_000,
}),
);
expect(result.ok).toBe(true);
if (!result.ok) throw result.error;
expect(result.value.exit).toMatchObject({ code: 0, reason: "exited" });
expect(result.value.frames.map(({ data }) => data).join("")).toContain(
const { capture } = captureObservations(result);
expect(capture.exit).toMatchObject({ code: 0, reason: "exited" });
expect(capture.frames.map(({ data }) => data).join("")).toContain(
"tree-ready",
);
const commands = result.value.process_samples.map(({ command }) => command);
const commands = capture.process_samples.map(({ command }) => command);
expect(commands.some((command) => command.includes("tree-child"))).toBe(
true,
);
@@ -322,7 +677,7 @@ itWithCaptureCapability(
expect(
commands.some((command) => command.includes("tree-grandchild")),
).toBe(true);
expect(JSON.stringify(result.value.process_samples)).toContain(
expect(JSON.stringify(capture.process_samples)).toContain(
dirname(processFixture),
);
const { stdout } = await execFileAsync("ps", ["-axo", "command="]);
@@ -3,6 +3,8 @@ import { expect, it } from "vitest";
import { parseProcessScenario } from "../../../src/domain/process/processScenario.js";
import {
createRunManifest,
observeLaunchedExecutable,
observeSelectedExecutable,
observeSettlement,
} from "../../../src/process/capture/ProcessCaptureLifecycle.js";
@@ -16,10 +18,14 @@ it("uses one selected host identity for manifest and settlement", async () => {
platform: "win32" as const,
architecture: "arm64" as const,
};
const selectedExecutable = await observeSelectedExecutable(
scenario.executable,
);
const manifest = await createRunManifest(
scenario,
new Date(0),
new Date(1),
observeLaunchedExecutable(scenario.executable, selectedExecutable),
selectedHost,
);
const settlement = await observeSettlement(
@@ -0,0 +1,62 @@
import { expect, it, vi } from "vitest";
import { snapshotRoots } from "../../../src/process/capture/FilesystemSnapshot.js";
import { prepareProcessCapture } from "../../../src/process/capture/ProcessCaptureLifecycle.js";
import { parseProcessScenario } from "../../../src/domain/process/processCapture.js";
it("prepares ownership inspection before snapshotting or allocating a target run", async () => {
const failure = new Error("native process identity inspection unavailable");
const createTemporaryRoot = vi.fn(async () => "/unused");
const cleanup = vi.fn(async () => undefined);
const prepareOwnershipInspector = vi.fn(async () => {
throw failure;
});
const captureSnapshot: typeof snapshotRoots = async () => {
throw new Error("snapshot must not run after failed ownership preflight");
};
await expect(
prepareProcessCapture(
parseProcessScenario({
executable: "/bin/true",
working_directory: "/tmp",
}),
undefined,
captureSnapshot,
{ prepareOwnershipInspector, createTemporaryRoot, cleanup },
),
).rejects.toBe(failure);
expect(prepareOwnershipInspector).toHaveBeenCalledOnce();
expect(createTemporaryRoot).not.toHaveBeenCalled();
expect(cleanup).not.toHaveBeenCalled();
});
it("cleans the temporary root when cancellation arrives during identity capture", async () => {
const controller = new AbortController();
const temporaryRoot = "/temporary-capture-root";
const cleanup = vi.fn(async () => undefined);
const host = {
createTemporaryRoot: vi.fn(async () => temporaryRoot),
captureOwnershipBaseline: vi.fn(async (signal?: AbortSignal) => {
expect(signal).toBe(controller.signal);
controller.abort();
return [];
}),
cleanup,
};
await expect(
prepareProcessCapture(
parseProcessScenario({
executable: "/bin/true",
working_directory: "/tmp",
}),
controller.signal,
async () => ({ files: [], truncated: false }),
host,
),
).rejects.toThrow(/cancelled/u);
expect(host.captureOwnershipBaseline).toHaveBeenCalledWith(controller.signal);
expect(cleanup).toHaveBeenCalledWith(temporaryRoot);
});
+50 -1
View File
@@ -8,7 +8,10 @@ import { PendingOperations } from "../../../src/process/PendingOperations.js";
import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js";
import { ProviderStartupDeadline } from "../../../src/process/ProviderDeadline.js";
import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js";
import { observeOwnedProcessLineage } from "../../../src/process/ProcessOwnershipObservation.js";
import {
observeOwnedProcessLineage,
systemProcessOwnershipHost,
} from "../../../src/process/ProcessOwnershipObservation.js";
import {
ProviderProcessSupervisor,
spawnOwnedProviderProcess,
@@ -371,6 +374,52 @@ describe("provider process host injection", () => {
});
});
describe("provider startup cancellation", () => {
it("rejects an already cancelled launch before preparing or spawning", async () => {
const controller = new AbortController();
const cancellation = new Error("provider launch already cancelled");
controller.abort(cancellation);
await expect(
spawnOwnedProviderProcess({
command: `${tmpdir()}/rea-provider-must-not-spawn-after-cancel`,
arguments: [],
runId: "provider-process-pre-aborted-run",
signal: controller.signal,
}),
).rejects.toBe(cancellation);
});
it.skipIf(process.platform !== "darwin")(
"forwards cancellation through native ownership preparation before spawning",
async () => {
const controller = new AbortController();
const cancellation = new Error("cancelled during native preparation");
const prepare = vi.spyOn(systemProcessOwnershipHost, "prepare");
prepare.mockImplementation(async (signal) => {
expect(signal).toBe(controller.signal);
await new Promise<void>((resolve) => {
signal?.addEventListener("abort", () => resolve(), { once: true });
});
});
try {
const starting = spawnOwnedProviderProcess({
command: `${tmpdir()}/rea-provider-must-not-spawn-after-cancel`,
arguments: [],
runId: "provider-process-cancelled-preparation-run",
signal: controller.signal,
});
controller.abort(cancellation);
await expect(starting).rejects.toBe(cancellation);
expect(prepare).toHaveBeenCalledOnce();
} finally {
prepare.mockRestore();
}
},
);
});
describe("provider process spawning primitives", () => {
it("spawns an owned process group with exact identity coordinates", async () => {
const runId = "provider-process-run";
+27
View File
@@ -1,4 +1,5 @@
import { spawn } from "node:child_process";
import { writeFile } from "node:fs/promises";
const mode = process.argv[2];
@@ -63,6 +64,32 @@ if (mode === "interactive") {
process.exit(23);
} else if (mode === "hang") {
setInterval(() => undefined, 1_000);
} else if (mode === "ready-hang") {
await writeFile(process.argv[3], "ready");
process.stdout.write("ready\n");
setInterval(() => undefined, 1_000);
} else if (mode === "detached-cleanup") {
const owned = spawn(process.execPath, [process.argv[1], "detached-child"], {
detached: true,
stdio: "ignore",
});
const unowned = spawn(process.execPath, [process.argv[1], "detached-child"], {
detached: true,
env: { ...process.env, REA_PROCESS_RUN_ID: "unowned-neighbor" },
stdio: "ignore",
});
if (owned.pid === undefined || unowned.pid === undefined)
throw new Error("detached fixture children did not receive PIDs");
owned.unref();
unowned.unref();
await writeFile(
process.argv[3],
JSON.stringify({ ownedPid: owned.pid, unownedPid: unowned.pid }),
);
await new Promise((resolve) => setTimeout(resolve, 100));
process.stdout.write("detached-ready\n");
} else if (mode === "detached-child") {
setInterval(() => undefined, 1_000);
} else {
process.stderr.write("unknown fixture mode\n");
process.exit(2);
+176
View File
@@ -0,0 +1,176 @@
import Foundation
func fixture(
arguments: [String],
environment: [String],
pointerSize: Int = 8,
executablePath: String = "/fixture/executable",
appleRecords: [String] = ["pfz=synthetic"],
zeroedPfz: Bool = false
) -> [UInt8] {
var bytes = withUnsafeBytes(of: Int32(arguments.count).littleEndian, Array.init)
let pathBytes = Array(executablePath.utf8)
bytes.append(contentsOf: pathBytes + [0])
let prefixLength = Array("executable_path=".utf8).count
let padding = (pointerSize - ((prefixLength + pathBytes.count + 1) % pointerSize)) % pointerSize
bytes.append(contentsOf: repeatElement(UInt8(0), count: padding))
for argument in arguments { bytes.append(contentsOf: Array(argument.utf8) + [0]) }
for entry in environment { bytes.append(contentsOf: Array(entry.utf8) + [0]) }
if zeroedPfz {
bytes.append(contentsOf: repeatElement(UInt8(0), count: Array("pfz=synthetic".utf8).count + 1))
}
for record in appleRecords { bytes.append(contentsOf: Array(record.utf8) + [0]) }
return bytes
}
@main
struct ProcessRunTokenParserProbe {
static func main() throws {
let argvDecoy = try token(in: fixture(
arguments: ["/fixture/executable", "REA_PROCESS_RUN_ID=argv-decoy", "trailing"],
environment: ["DECOY=words REA_PROCESS_RUN_ID=synthetic-decoy"]
))
let realToken = try token(in: fixture(
arguments: ["/fixture/executable"],
environment: ["DECOY=words REA_PROCESS_RUN_ID=synthetic-decoy", "REA_PROCESS_RUN_ID=owned-token"]
))
let emptyLaterArgument = try token(in: fixture(
arguments: ["/fixture/executable", "", "after-empty"],
environment: ["REA_PROCESS_RUN_ID=empty-argument-token"],
pointerSize: 8
))
let alternatePadding = try token(in: fixture(
arguments: ["/fixture/executable", "argument"],
environment: ["REA_PROCESS_RUN_ID=alternate-padding-token"],
pointerSize: 4
), pointerSize: 4)
let emptyArgv0Read = try token(in: fixture(
arguments: ["", "", "after-empty"],
environment: ["REA_PROCESS_RUN_ID=empty-argv0-token"],
pointerSize: 8
))
let finalAssignmentArgumentRead = try token(in: fixture(
arguments: ["/fixture/executable", "FOO=bar"],
environment: ["REA_PROCESS_RUN_ID=final-assignment-token"]
))
let reservedArgumentIgnored = try token(in: fixture(
arguments: ["/fixture/executable", "REA_PROCESS_RUN_ID=argv-decoy"],
environment: ["DECOY=words REA_PROCESS_RUN_ID=synthetic-decoy"]
))
let nonAsciiPathRead = try token(in: fixture(
arguments: ["/fixture/é", "FOO=bar"],
environment: ["REA_PROCESS_RUN_ID=utf8-path-token"],
pointerSize: 8,
executablePath: "/fixture/é"
))
let appleVectorTokenFailsClosed: Bool
do {
_ = try token(in: fixture(
arguments: ["/fixture/executable"],
environment: ["pfz=user-value", "PATH=/usr/bin"],
appleRecords: ["pfz=synthetic", "REA_PROCESS_RUN_ID=apple-decoy"]
))
appleVectorTokenFailsClosed = false
} catch RunTokenReadError.ambiguousEnvironmentBoundary {
appleVectorTokenFailsClosed = true
}
let clearedAppleVectorTokenFailsClosed: Bool
do {
_ = try token(in: fixture(
arguments: ["/fixture/executable"],
environment: ["REA_PROCESS_RUN_ID=cleared-pfz-owned-token"],
appleRecords: ["REA_PROCESS_RUN_ID=apple-decoy"],
zeroedPfz: true
))
clearedAppleVectorTokenFailsClosed = false
} catch RunTokenReadError.ambiguousEnvironmentBoundary {
clearedAppleVectorTokenFailsClosed = true
}
let callerPfzBeforeTokenRead = try token(in: fixture(
arguments: ["/fixture/executable"],
environment: ["pfz=caller-value", "REA_PROCESS_RUN_ID=cleared-pfz-owned-token"],
zeroedPfz: true
))
let emptyEnvironmentRecordsBeforeTokenFailClosed: Bool
do {
_ = try token(in: fixture(
arguments: ["/fixture/executable"],
environment: ["DECOY=value", "", "", "REA_PROCESS_RUN_ID=synthetic-decoy"],
appleRecords: [],
zeroedPfz: true
))
emptyEnvironmentRecordsBeforeTokenFailClosed = false
} catch RunTokenReadError.ambiguousEnvironmentBoundary {
emptyEnvironmentRecordsBeforeTokenFailClosed = true
}
let zeroArgumentsRead = try token(in: fixture(
arguments: [],
environment: ["REA_PROCESS_RUN_ID=zero-argc-token"],
pointerSize: 4
), pointerSize: 4)
var truncatedArgv = withUnsafeBytes(of: Int32(2).littleEndian, Array.init)
let truncatedPath = Array("/fixture/executable".utf8)
truncatedArgv.append(contentsOf: truncatedPath + [0])
let truncatedPadding = (8 - ((Array("executable_path=".utf8).count + truncatedPath.count + 1) % 8)) % 8
truncatedArgv.append(contentsOf: repeatElement(UInt8(0), count: truncatedPadding))
truncatedArgv.append(contentsOf: Array("/fixture/executable".utf8) + [0])
truncatedArgv.append(contentsOf: Array("REA_PROCESS_RUN_ID=synthetic-decoy".utf8))
let truncatedArgvFailsClosed: Bool
do {
_ = try token(in: truncatedArgv)
truncatedArgvFailsClosed = false
} catch {
truncatedArgvFailsClosed = true
}
let duplicateTokenFailsClosed: Bool
do {
_ = try token(in: fixture(
arguments: ["/fixture/executable"],
environment: ["REA_PROCESS_RUN_ID=first", "REA_PROCESS_RUN_ID=second"]
))
duplicateTokenFailsClosed = false
} catch RunTokenReadError.duplicateToken {
duplicateTokenFailsClosed = true
}
let emptyEnvironmentFailsClosed: Bool
do {
_ = try token(in: fixture(arguments: ["/fixture/executable"], environment: []))
emptyEnvironmentFailsClosed = false
} catch RunTokenReadError.environmentUnavailable {
emptyEnvironmentFailsClosed = true
}
let missingAppleBoundaryFailsClosed: Bool
do {
_ = try token(in: fixture(
arguments: ["/fixture/executable"],
environment: ["REA_PROCESS_RUN_ID=synthetic-decoy"],
appleRecords: []
))
missingAppleBoundaryFailsClosed = false
} catch RunTokenReadError.appleVectorUnavailable {
missingAppleBoundaryFailsClosed = true
}
let result: [String: Any] = [
"argvDecoyIgnored": argvDecoy == nil,
"realTokenRead": realToken == "owned-token",
"emptyLaterArgumentRead": emptyLaterArgument == "empty-argument-token",
"alternatePaddingRead": alternatePadding == "alternate-padding-token",
"truncatedArgvFailsClosed": truncatedArgvFailsClosed,
"duplicateTokenFailsClosed": duplicateTokenFailsClosed,
"emptyEnvironmentFailsClosed": emptyEnvironmentFailsClosed,
"missingAppleBoundaryFailsClosed": missingAppleBoundaryFailsClosed,
"emptyArgv0Read": emptyArgv0Read == "empty-argv0-token",
"finalAssignmentArgumentRead": finalAssignmentArgumentRead == "final-assignment-token",
"reservedArgumentIgnored": reservedArgumentIgnored == nil,
"nonAsciiPathRead": nonAsciiPathRead == "utf8-path-token",
"appleVectorTokenFailsClosed": appleVectorTokenFailsClosed,
"clearedAppleVectorTokenFailsClosed": clearedAppleVectorTokenFailsClosed,
"callerPfzBeforeTokenRead": callerPfzBeforeTokenRead == "cleared-pfz-owned-token",
"emptyEnvironmentRecordsBeforeTokenFailClosed": emptyEnvironmentRecordsBeforeTokenFailClosed,
"zeroArgumentsRead": zeroArgumentsRead == "zero-argc-token",
]
let output = try JSONSerialization.data(withJSONObject: result, options: [.sortedKeys])
FileHandle.standardOutput.write(output)
}
}
+40
View File
@@ -0,0 +1,40 @@
import Darwin
import Foundation
@main
struct ProcessRunTokenSnapshotProbe {
static func main() throws {
var sizeQueries = 0
var readAttempts = 0
let retried = readProcessSnapshot { _, output, size in
guard output != nil else {
sizeQueries += 1
size = MemoryLayout<kinfo_proc>.stride
return 0
}
readAttempts += 1
if readAttempts == 1 {
errno = ENOMEM
return -1
}
size = 0
return 0
}
let otherError = readProcessSnapshot { _, output, size in
if output == nil {
size = MemoryLayout<kinfo_proc>.stride
return 0
}
errno = EACCES
return -1
}
let output: [String: Any] = [
"retriedAfterFreshSize": retried.failure == nil && sizeQueries == 2 && readAttempts == 2,
"preservedOtherErrno": otherError.failure == "process_table_failed_\(EACCES)"
]
let data = try JSONSerialization.data(withJSONObject: output, options: [.sortedKeys])
FileHandle.standardOutput.write(data)
}
}