diff --git a/bridge/process/ProcessRunTokenReader.swift b/bridge/process/ProcessRunTokenReader.swift new file mode 100644 index 000000000..f5c8da490 --- /dev/null +++ b/bridge/process/ProcessRunTokenReader.swift @@ -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.size, + size % MemoryLayout.stride == 0 else { + return ProcessSnapshot(metadata: [:], failure: "process_table_failed_\(errno)") + } + var processes = [kinfo_proc]( + repeating: kinfo_proc(), + count: size / MemoryLayout.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.stride == 0 else { + return ProcessSnapshot(metadata: [:], failure: "process_table_failed_\(errno)") + } + let processCount = size / MemoryLayout.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.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.size) throws -> String? { + guard bytes.count >= MemoryLayout.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.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..] = [] + 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[.. { @@ -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.`, + ); + }); }); diff --git a/src/application/ReconstructionObligationCandidates.ts b/src/application/ReconstructionObligationCandidates.ts index c79b451b4..014037692 100644 --- a/src/application/ReconstructionObligationCandidates.ts +++ b/src/application/ReconstructionObligationCandidates.ts @@ -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}`, diff --git a/src/browser/PlaywrightElectronActiveProvider.ts b/src/browser/PlaywrightElectronActiveProvider.ts index 903136908..2ca18b421 100644 --- a/src/browser/PlaywrightElectronActiveProvider.ts +++ b/src/browser/PlaywrightElectronActiveProvider.ts @@ -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 }; }; diff --git a/src/domain/analysisErrorBase.ts b/src/domain/analysisErrorBase.ts index 2c423fc3d..8c5c40cd6 100644 --- a/src/domain/analysisErrorBase.ts +++ b/src/domain/analysisErrorBase.ts @@ -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"; diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index f4e8b6197..85ff1901f 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -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, diff --git a/src/domain/process/processCapture.ts b/src/domain/process/processCapture.ts index 0079ef5c4..7c41ccb16 100644 --- a/src/domain/process/processCapture.ts +++ b/src/domain/process/processCapture.ts @@ -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>; 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; @@ -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 = + | { 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; + readonly frames: PartialProcessObservationField; + 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; + readonly after: PartialProcessObservationField; + }; + 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 = - 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 = ( + 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", diff --git a/src/domain/process/processCapture.validation.test.ts b/src/domain/process/processCapture.validation.test.ts index 0c7e899a8..912b7c16d 100644 --- a/src/domain/process/processCapture.validation.test.ts +++ b/src/domain/process/processCapture.validation.test.ts @@ -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(() => diff --git a/src/domain/process/processCaptureExample.ts b/src/domain/process/processCaptureExample.ts index dc63cf54a..d920dabdc 100644 --- a/src/domain/process/processCaptureExample.ts +++ b/src/domain/process/processCaptureExample.ts @@ -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, diff --git a/src/domain/process/processCaptureParsing.ts b/src/domain/process/processCaptureParsing.ts index dda070e59..018abf966 100644 --- a/src/domain/process/processCaptureParsing.ts +++ b/src/domain/process/processCaptureParsing.ts @@ -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" diff --git a/src/domain/process/processCaptureValidation.ts b/src/domain/process/processCaptureValidation.ts index 29be8c6e8..041c4c122 100644 --- a/src/domain/process/processCaptureValidation.ts +++ b/src/domain/process/processCaptureValidation.ts @@ -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], diff --git a/src/domain/reconstructionVerification.test.ts b/src/domain/reconstructionVerification.test.ts index b09a977d5..2de3e7717 100644 --- a/src/domain/reconstructionVerification.test.ts +++ b/src/domain/reconstructionVerification.test.ts @@ -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"); diff --git a/src/domain/reconstructionVerification.ts b/src/domain/reconstructionVerification.ts index a4258832a..3de1d8509 100644 --- a/src/domain/reconstructionVerification.ts +++ b/src/domain/reconstructionVerification.ts @@ -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); diff --git a/src/firmware/FirmwareCommand.ts b/src/firmware/FirmwareCommand.ts index 85abf068f..025596528 100644 --- a/src/firmware/FirmwareCommand.ts +++ b/src/firmware/FirmwareCommand.ts @@ -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", diff --git a/src/ghidra/GhidraLauncher.ts b/src/ghidra/GhidraLauncher.ts index 82af60f41..444c4b523 100644 --- a/src/ghidra/GhidraLauncher.ts +++ b/src/ghidra/GhidraLauncher.ts @@ -147,6 +147,7 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { platform, command.command, ), + ...(options.signal === undefined ? {} : { signal: options.signal }), }); await writeGhidraRuntimeFile( paths.ownershipPath, diff --git a/src/hopper/BridgeLauncher.ts b/src/hopper/BridgeLauncher.ts index efa5563f5..af60844e2 100644 --- a/src/hopper/BridgeLauncher.ts +++ b/src/hopper/BridgeLauncher.ts @@ -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); }; diff --git a/src/hopper/LinuxPrivateDisplayProbe.ts b/src/hopper/LinuxPrivateDisplayProbe.ts index 1ec6190d0..27f136185 100644 --- a/src/hopper/LinuxPrivateDisplayProbe.ts +++ b/src/hopper/LinuxPrivateDisplayProbe.ts @@ -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>; 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, diff --git a/src/javascript/recovery/WakaruCommand.ts b/src/javascript/recovery/WakaruCommand.ts index ad90338ef..42062feee 100644 --- a/src/javascript/recovery/WakaruCommand.ts +++ b/src/javascript/recovery/WakaruCommand.ts @@ -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: { diff --git a/src/javascript/sourceMaps/SourceMapCommand.ts b/src/javascript/sourceMaps/SourceMapCommand.ts index 62df4142c..eec1f8844 100644 --- a/src/javascript/sourceMaps/SourceMapCommand.ts +++ b/src/javascript/sourceMaps/SourceMapCommand.ts @@ -120,6 +120,7 @@ class OwnedCodec { runId: this.#runId, cwd: this.#runtime.path, hostEnvironment: environment, + signal: this.deadline.signal, }); this.#supervisor = new ProviderProcessSupervisor( { diff --git a/src/process/DarwinProcessRunTokenReader.ts b/src/process/DarwinProcessRunTokenReader.ts new file mode 100644 index 000000000..176688063 --- /dev/null +++ b/src/process/DarwinProcessRunTokenReader.ts @@ -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; + 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, + signal: AbortSignal | undefined, +): Promise => { + 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 | undefined; + let closed = false; + let exitCleanupInstalled = false; + + const cleanupAtExit = () => { + if (root !== undefined) rmSync(root, { recursive: true, force: true }); + }; + const cleanupRoot = async (target = root): Promise => { + 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 => { + 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 => { + 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> => { + 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(); + 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> => { + 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(); + 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 => { + 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 }; +}; diff --git a/src/process/ProcessOwnership.identity.test.ts b/src/process/ProcessOwnership.identity.test.ts new file mode 100644 index 000000000..406a35e33 --- /dev/null +++ b/src/process/ProcessOwnership.identity.test.ts @@ -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); + }); +}); diff --git a/src/process/ProcessOwnership.part2.test.ts b/src/process/ProcessOwnership.part2.test.ts index 6ee89b19e..ed6552bda 100644 --- a/src/process/ProcessOwnership.part2.test.ts +++ b/src/process/ProcessOwnership.part2.test.ts @@ -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(); + }, + ); +}); diff --git a/src/process/ProcessOwnership.test.ts b/src/process/ProcessOwnership.test.ts index ccbbd8c24..815d9188c 100644 --- a/src/process/ProcessOwnership.test.ts +++ b/src/process/ProcessOwnership.test.ts @@ -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 }, diff --git a/src/process/ProcessOwnership.ts b/src/process/ProcessOwnership.ts index d74961401..814009fe3 100644 --- a/src/process/ProcessOwnership.ts +++ b/src/process/ProcessOwnership.ts @@ -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; + /** Prepare required native identity inspection before launching a child. */ + prepare?(signal?: AbortSignal): Promise; + /** Release owned native inspection helpers and their temporary files. */ + close?(): Promise; + /** List current processes; startup inspection may be cancelled by the caller. */ + listProcesses(signal?: AbortSignal): Promise; environment(pid: number): Promise>>; + /** Read only run-token values in one host operation when the OS supports it. */ + runTokens?( + processes: readonly ProcessTableEntry[], + ): Promise>; + /** Read stable identities; omit the signal during cleanup so it can finish. */ + processIdentities?( + processes: readonly ProcessTableEntry[], + signal?: AbortSignal, + ): Promise>; + /** Snapshot identities before launch, honoring startup cancellation. */ + captureBaseline?(signal?: AbortSignal): Promise; 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 => { 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; + readonly unresolved: readonly ProcessOwnershipValidationFailure[]; } const createOwnedCleanupPlan = async ( @@ -250,12 +300,21 @@ const createOwnedCleanupPlan = async ( processes: readonly ProcessTableEntry[], host: ProcessOwnershipHost, ): Promise => { + 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(); + const unresolved = [...tokenOwned.failures]; + const unverifiableSampledGroups = new Set(); 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, ): 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(); + 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 = new Map(), ): Promise => { 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; } const scanTokenOwnedProcesses = async ( processes: readonly ProcessTableEntry[], runId: string, host: ProcessOwnershipHost, + captureBaseline?: ProcessOwnershipBaseline, ): Promise => { - const owned: ProcessTableEntry[] = []; + let candidates = processes; + let candidateIdentities: ReadonlyMap = + 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 | undefined; + if (host.runTokens !== undefined) + bulkTokens = await host.runTokens(candidates); + const owned: ProcessTableEntry[] = []; + const ownedIdentities = new Map(); + 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 => { + try { + return !liveProcesses(await host.listProcesses()).some( + (process) => process.pid === pid, + ); + } catch (cause: unknown) { + void cause; + return false; + } }; diff --git a/src/process/ProcessOwnershipObservation.ts b/src/process/ProcessOwnershipObservation.ts index ee835dcb9..c33663f02 100644 --- a/src/process/ProcessOwnershipObservation.ts +++ b/src/process/ProcessOwnershipObservation.ts @@ -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( + 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 = {}; - 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 => { + 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 = {}; + 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 => { + 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 => observeOwnedProcessGroupWithHost(ownership, host); @@ -209,7 +387,7 @@ export const observeOwnedProcessGroup = async ( */ export const observeOwnedProcessLineage = async ( ownership: OwnedProcessGroup, - host: ProcessOwnershipHost = systemHost, + host: ProcessOwnershipHost = systemProcessOwnershipHost, ): Promise => observeOwnedProcessLineageWithHost(ownership, host); diff --git a/src/process/ProviderProcess.ts b/src/process/ProviderProcess.ts index d41432d1b..07b0ba5be 100644 --- a/src/process/ProviderProcess.ts +++ b/src/process/ProviderProcess.ts @@ -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 => { + options.signal?.throwIfAborted(); + await prepareProcessOwnershipInspection(options.signal); + options.signal?.throwIfAborted(); const platform = options.platform ?? process.platform; const hostEnvironment = options.hostEnvironment ?? process.env; const environment = { diff --git a/src/process/capture/ProcessCaptureCapability.ts b/src/process/capture/ProcessCaptureCapability.ts index e7ec0ef3a..7d0de7625 100644 --- a/src/process/capture/ProcessCaptureCapability.ts +++ b/src/process/capture/ProcessCaptureCapability.ts @@ -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; + readonly prepareOwnershipInspector?: () => Promise; } /** 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")) diff --git a/src/process/capture/ProcessCaptureError.ts b/src/process/capture/ProcessCaptureError.ts index 97d02ff6b..4f2422559 100644 --- a/src/process/capture/ProcessCaptureError.ts +++ b/src/process/capture/ProcessCaptureError.ts @@ -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; 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", { diff --git a/src/process/capture/ProcessCaptureLifecycle.ts b/src/process/capture/ProcessCaptureLifecycle.ts index 982bab430..1dcec19d8 100644 --- a/src/process/capture/ProcessCaptureLifecycle.ts +++ b/src/process/capture/ProcessCaptureLifecycle.ts @@ -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; + frames: PartialProcessObservationField; + rendered_frames: PartialProcessObservationField< + UnverifiedProcessCapture["rendered_frames"] + >; + interaction_events: PartialProcessObservationField< + UnverifiedProcessCapture["interaction_events"] + >; + exit: IncompleteProcessCaptureObservations["exit"]; + settlement: IncompleteProcessCaptureObservations["settlement"]; + process_samples: PartialProcessObservationField; + filesystem_snapshots: { + before: PartialProcessObservationField; + after: PartialProcessObservationField; + }; + 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 => { - 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 => { + assertNotCancelled(signal); + let handle: Awaited> | 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 => { - 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; } 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, +): Promise => { + 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((resolve) => + setTimeout( + resolve, + Math.min(PROCESS_CLEANUP_VERIFICATION_INTERVAL_MS, remaining), + ), + ); + result = await verify(); + } + return result; +}; + export const releaseProcessResources = async (options: { readonly timers: ReadonlySet; - readonly terminal: IPty | undefined; + readonly terminal: Pick | 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 => { +}): Promise => { 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; + /** Capture the process baseline with the caller's startup cancellation. */ + captureOwnershipBaseline?( + signal?: AbortSignal, + ): Promise; createTemporaryRoot(): Promise; cleanup(path: string): Promise; } 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 }), }; diff --git a/src/process/capture/ProcessHarness.test.ts b/src/process/capture/ProcessHarness.test.ts index 80df39911..4108f6162 100644 --- a/src/process/capture/ProcessHarness.test.ts +++ b/src/process/capture/ProcessHarness.test.ts @@ -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", diff --git a/src/process/capture/ProcessHarness.ts b/src/process/capture/ProcessHarness.ts index 813779a67..3d7af9c80 100644 --- a/src/process/capture/ProcessHarness.ts +++ b/src/process/capture/ProcessHarness.ts @@ -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; 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 => { + 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>; 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; 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 => { 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 => { + 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>; + 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>; + 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>; + 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>; + 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> = process.env, hostPlatform: NodeJS.Platform = process.platform, captureSnapshot: typeof snapshotRoots = snapshotRoots, + cleanupHost?: ProcessCaptureCleanupHost, ): Promise => { - 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(); let capture: PendingProcessCapture | undefined; let executionFailure: unknown; let stopSampler = async () => ({ partial: false }); - const interactions: InteractionEvent[] = []; const dispatchedEventIndexes = new Set(); 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> = process.env, captureSnapshot: typeof snapshotRoots = snapshotRoots, + cleanupHost?: ProcessCaptureCleanupHost, ): Promise> => { 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 }), + }), ); } }; diff --git a/src/server/promptCompletion.ts b/src/server/promptCompletion.ts index e82e1f006..bb5193009 100644 --- a/src/server/promptCompletion.ts +++ b/src/server/promptCompletion.ts @@ -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 = ( diff --git a/tests/boundary/mcp/promptCompletion.test.ts b/tests/boundary/mcp/promptCompletion.test.ts index f1807bc99..8b8b31dbb 100644 --- a/tests/boundary/mcp/promptCompletion.test.ts +++ b/tests/boundary/mcp/promptCompletion.test.ts @@ -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, { diff --git a/tests/boundary/mcp/toolResult.test.ts b/tests/boundary/mcp/toolResult.test.ts index 224f47380..eb00c7874 100644 --- a/tests/boundary/mcp/toolResult.test.ts +++ b/tests/boundary/mcp/toolResult.test.ts @@ -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); diff --git a/tests/boundary/process/ProcessEvidence.test.ts b/tests/boundary/process/ProcessEvidence.test.ts index 65d8b764c..1c35de761 100644 --- a/tests/boundary/process/ProcessEvidence.test.ts +++ b/tests/boundary/process/ProcessEvidence.test.ts @@ -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), diff --git a/tests/boundary/process/darwinProcessRunTokenReader.test.ts b/tests/boundary/process/darwinProcessRunTokenReader.test.ts new file mode 100644 index 000000000..0c41c0ffd --- /dev/null +++ b/tests/boundary/process/darwinProcessRunTokenReader.test.ts @@ -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 +>; +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 | 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 | 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>[] = []; + 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((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((resolve) => + child.once("exit", () => resolve()), + ); + child.kill("SIGKILL"); + await exited; +}; diff --git a/tests/boundary/process/processCaptureCapability.test.ts b/tests/boundary/process/processCaptureCapability.test.ts index 375ca4706..23d0219c9 100644 --- a/tests/boundary/process/processCaptureCapability.test.ts +++ b/tests/boundary/process/processCaptureCapability.test.ts @@ -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", diff --git a/tests/boundary/process/processCaptureLifecycle.test.ts b/tests/boundary/process/processCaptureLifecycle.test.ts index 489866e96..53e42aaa6 100644 --- a/tests/boundary/process/processCaptureLifecycle.test.ts +++ b/tests/boundary/process/processCaptureLifecycle.test.ts @@ -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>; +type PartialCapture = Extract< + NonNullable, + { 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 => { + 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 => { + 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 => { + 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 => { + 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="]); diff --git a/tests/boundary/process/processHostIdentity.test.ts b/tests/boundary/process/processHostIdentity.test.ts index 6d8950736..995648ab5 100644 --- a/tests/boundary/process/processHostIdentity.test.ts +++ b/tests/boundary/process/processHostIdentity.test.ts @@ -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( diff --git a/tests/boundary/process/processPreparation.test.ts b/tests/boundary/process/processPreparation.test.ts new file mode 100644 index 000000000..262a0cb26 --- /dev/null +++ b/tests/boundary/process/processPreparation.test.ts @@ -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); +}); diff --git a/tests/boundary/process/providerProcess.test.ts b/tests/boundary/process/providerProcess.test.ts index 24f817962..8e1440eff 100644 --- a/tests/boundary/process/providerProcess.test.ts +++ b/tests/boundary/process/providerProcess.test.ts @@ -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((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"; diff --git a/tests/fixtures/processFidelity.mjs b/tests/fixtures/processFidelity.mjs index 83f130359..bac307179 100644 --- a/tests/fixtures/processFidelity.mjs +++ b/tests/fixtures/processFidelity.mjs @@ -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); diff --git a/tests/fixtures/processRunTokenParserProbe.swift b/tests/fixtures/processRunTokenParserProbe.swift new file mode 100644 index 000000000..b7185d433 --- /dev/null +++ b/tests/fixtures/processRunTokenParserProbe.swift @@ -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) + } +} diff --git a/tests/fixtures/processRunTokenSnapshotProbe.swift b/tests/fixtures/processRunTokenSnapshotProbe.swift new file mode 100644 index 000000000..bfb2aae7c --- /dev/null +++ b/tests/fixtures/processRunTokenSnapshotProbe.swift @@ -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.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.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) + } +}