mirror of
https://github.com/morluto/rea.git
synced 2026-10-12 02:19:24 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -147,6 +147,7 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
||||
platform,
|
||||
command.command,
|
||||
),
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
});
|
||||
await writeGhidraRuntimeFile(
|
||||
paths.ownershipPath,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
Vendored
+27
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user