mirror of
https://github.com/morluto/rea.git
synced 2026-11-13 18:22:42 +00:00
* fix(process): preserve producer-owned inspection cancellation Prototype the explicit inspection origin and retained file/batch owner protocol. Downstream adapter migration and lease binding remain required before integration. * fix(process): type inspection ownership handoffs precisely * fix(process): retain plain inspection preparation cancellation * fix(process): retain explicit inspection cleanup and cancellation facts * fix(process): require explicit inspection facts and cleanup scope * refactor(process): remove redundant cleanup forwarding wrapper * fix(firmware): retain inspection cancellation and cleanup ownership * fix(wakaru): retain typed inspection outcomes and caller scope * fix(ghidra): preserve inspection facts and launch cleanup scope * fix(providers): preserve typed process inspection outcomes * refactor(process): remove obsolete inspection cancellation re-export * fix(native): preserve inspection preparation cleanup and cancellation * fix(registration): retain typed inspection cleanup during Git discovery * fix(process): preserve inspection deadline origin in adapter handoff * fix(process): consume the canonical inspection selector in binding * fix(native): preserve inspection deadlines and cleanup retry completion * test(process): narrow the retained Darwin preparation owner * fix(providers): preserve preparation ownership and timeout policy Narrow preparation facts through their canonical owner, preserve actual cleanup capabilities and recorded cancellation, and keep enclosing command deadlines authoritative. Retain raw inspection records through Java refusal and configuration projections without changing availability fallback policy. * refactor(process): use one inspection failure type owner * fix(providers): retain recorded secondary failures when rebinding * fix(ghidra): preserve normalized inspection facts through public projection * fix(process): normalize native process listing cancellation * docs(development): clarify failure provenance and cleanup verification * fix(process): preserve direct inspection cancellation facts * fix(process): retain inspection owners through termination * fix(process): preserve command inspection cleanup provenance * fix(desktop): preserve inspection facts after cleanup retry * refactor(process)!: migrate desktop capture with inspection producers * fix(desktop)!: preserve capture failure origins and cleanup owners (#10115) * fix(desktop): preserve capture failure origins and cleanup * fix(desktop): type keyring cleanup cancellation * fix(kuna)!: retain copy cleanup before retiring workspaces (#10114) * fix(kuna): own copy cancellation and descriptor cleanup * fix(kuna): close copy descriptors before workspace removal * test(kuna): type copy lifecycle gates for project library * docs(kuna): explain retained copy-file cleanup and staging ownership * docs(kuna): separate cache publication, integrity and cleanup guidance * fix(desktop): retain sampling inspection owners before release * fix(process): preserve capture file read cancellation origin * fix(process): retain capture inspection cancellation and cleanup owners * fix(process): retain sampling inspection failures in capture scope * fix(process): retain inspection failures through mandatory cleanup * fix(process): distinguish sampler shutdown from capture cancellation * fix(browser): retain startup EOF before later cancellation * fix(process): bind capture inspection cleanup to the owning run * test(process): migrate cancellation fixtures to typed owners Record explicit preparation and command cancellation facts, retain exact opaque reasons and physical causes, and guard the mocked version probe from native preparation. Keep the live Wakaru command control while adding a recording equivalent for bounded source verification. * fix(process): own sampling reads and settle inspection peers * fix(errors): retain authored recovery through cleanup composition * fix(process): retain all sampling inspection leaves for final cleanup * test(browser): report recording cleanup signal outcome * fix(process): keep inspection error context out of implicit JSON * fix(desktop): retain normalized sampling cleanup ownership * test(process): assert tagged startup cancellation
1923 lines
71 KiB
TypeScript
1923 lines
71 KiB
TypeScript
import { readProcessInspectionFile } from "./ProcessInspectionFile.js";
|
|
import {
|
|
assertProcessOwnershipInspectionActive,
|
|
ProcessOwnershipIdentityBatchFailure,
|
|
rethrowProcessOwnershipInspectionFailure,
|
|
waitForProcessOwnershipInspection,
|
|
} from "./ProcessInspectionCancellation.js";
|
|
import { ProcessAdmissionError } from "./ProcessAdmissionError.js";
|
|
import { readlink, stat as statPath } from "node:fs/promises";
|
|
import { performance } from "node:perf_hooks";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import {
|
|
processIdentityHasExited,
|
|
processIdentityIsExiting,
|
|
PROCESS_CLEANUP_VERIFICATION_GRACE_MS,
|
|
PROCESS_CLEANUP_VERIFICATION_INTERVAL_MS,
|
|
type ProcessExitSettlementBudget,
|
|
} from "./ProcessLifecycle.js";
|
|
import { parseLinuxProcessStat } from "./LinuxProcessStat.js";
|
|
import {
|
|
linuxEnvironmentRestriction,
|
|
linuxUidMapsDiffer,
|
|
parseLinuxProcessIds,
|
|
type LinuxEnvironmentRestriction,
|
|
} from "./LinuxProcessStatus.js";
|
|
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
|
|
|
|
import { launcherIdentityFailure } from "./ProcessOwnershipIdentity.js";
|
|
import { descendantsOf, liveProcesses } from "./ProcessOwnershipProcessTree.js";
|
|
import {
|
|
execFileOutput,
|
|
execFileOutputFailure,
|
|
HOST_SPAWN_CAPACITY_CODES,
|
|
spawnCapacityExhausted,
|
|
} from "./ExecFileOutput.js";
|
|
import {
|
|
createDarwinProcessRunTokenReader,
|
|
DarwinProcessOwnershipInspectionError,
|
|
type DarwinProcessReaderCompiler,
|
|
} from "./DarwinProcessRunTokenReader.js";
|
|
import { darwinHelperCacheDirectory } from "./DarwinHelperCache.js";
|
|
import {
|
|
ProcessRunTokenReadError,
|
|
readProcessRunToken,
|
|
readProcessRunTokens,
|
|
} from "./ProcessRunTokenObservations.js";
|
|
import type {
|
|
OwnedProcessGroup,
|
|
ProcessGroupObservation,
|
|
ProcessLineageObservation,
|
|
ProcessOwnershipHost,
|
|
ProcessIdentityObservation,
|
|
ProcessOwnershipFact,
|
|
ProcessOwnershipBaseline,
|
|
ProcessRunTokenObservation,
|
|
ProcessTableEntry,
|
|
} from "./ProcessOwnership.js";
|
|
import type { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js";
|
|
|
|
/** Native preparation transfers its typed helper owner instead of an unavailable row. */
|
|
const rethrowInspectionFailure = (cause: unknown): void => {
|
|
if (
|
|
cause instanceof DarwinProcessOwnershipInspectionError &&
|
|
(cause.cleanupOwner !== undefined || cause.cleanupFailure !== undefined)
|
|
)
|
|
throw cause;
|
|
rethrowProcessOwnershipInspectionFailure(cause);
|
|
};
|
|
|
|
/** Observe multiple groups from one process snapshot and one token batch. */
|
|
export const observeOwnedProcessGroups = async (
|
|
runId: string,
|
|
processGroupIds: readonly number[],
|
|
host: ProcessOwnershipHost = systemProcessOwnershipHost,
|
|
signal?: AbortSignal,
|
|
evidenceOwner?: ProcessOwnershipEvidenceOwner,
|
|
): Promise<ReadonlyMap<number, ProcessGroupObservation>> => {
|
|
assertProcessOwnershipInspectionActive(signal);
|
|
const groupIds = [...new Set(processGroupIds)];
|
|
if (groupIds.length === 0) return new Map();
|
|
let processes: readonly ProcessTableEntry[];
|
|
try {
|
|
processes = liveProcesses(await host.listProcesses(signal));
|
|
} catch (cause: unknown) {
|
|
rethrowInspectionFailure(cause);
|
|
const failure = {
|
|
state: "unverifiable" as const,
|
|
reason: `process group could not be inspected: ${errorMessage(cause)}`,
|
|
};
|
|
return new Map(groupIds.map((groupId) => [groupId, failure]));
|
|
}
|
|
assertProcessOwnershipInspectionActive(signal);
|
|
if (host.processIdentities !== undefined) {
|
|
const observationRows =
|
|
evidenceOwner?.rowsForObservation(processes) ?? processes;
|
|
const retainedPids = new Set(
|
|
evidenceOwner?.candidates.map(({ pid }) => pid) ?? [],
|
|
);
|
|
const firstIdentities = await readNativeProcessIdentityMap(
|
|
observationRows,
|
|
host,
|
|
signal,
|
|
);
|
|
const candidateProcesses = observationRows.filter((process) => {
|
|
if (retainedPids.has(process.pid)) return true;
|
|
const identity = firstIdentities.get(process.pid);
|
|
if (identity?.state === "readable") {
|
|
if (processIdentityHasExited(identity)) return false;
|
|
if (
|
|
identity.relation?.state === "observed" &&
|
|
groupIds.includes(identity.relation.processGroupId)
|
|
)
|
|
return true;
|
|
return groupIds.includes(process.processGroupId);
|
|
}
|
|
return groupIds.includes(process.processGroupId);
|
|
});
|
|
const facts = await readProcessOwnershipFacts(
|
|
candidateProcesses,
|
|
host,
|
|
signal,
|
|
{ identityBefore: firstIdentities },
|
|
);
|
|
evidenceOwner?.reconcile(facts, {
|
|
runId,
|
|
listedRows: processes.length,
|
|
listedPids: new Set(processes.map(({ pid }) => pid)),
|
|
leaderPid: -1,
|
|
processGroupIds: new Set(groupIds),
|
|
scopeFacts: facts,
|
|
});
|
|
const observations = new Map<number, ProcessGroupObservation>(
|
|
groupIds.map((groupId) => [groupId, { state: "empty" }]),
|
|
);
|
|
for (const fact of facts) {
|
|
if (processIdentityHasExited(fact.identityAfter)) continue;
|
|
// An initial unreadable membership remains uncertain only while the
|
|
// final native fact does not establish that this candidate has exited.
|
|
if (
|
|
groupIds.includes(fact.process.processGroupId) &&
|
|
(fact.identityBefore.state !== "readable" ||
|
|
fact.identityBefore.relation?.state !== "observed")
|
|
)
|
|
observations.set(fact.process.processGroupId, {
|
|
state: "unverifiable",
|
|
reason: "native process-group membership could not be verified",
|
|
});
|
|
const nativeGroupId =
|
|
fact.identityAfter.state === "readable" &&
|
|
fact.identityAfter.relation?.state === "observed"
|
|
? fact.identityAfter.relation.processGroupId
|
|
: undefined;
|
|
const candidateGroups = groupIds.filter(
|
|
(groupId) =>
|
|
nativeGroupId === groupId ||
|
|
(nativeGroupId === undefined &&
|
|
fact.process.processGroupId === groupId),
|
|
);
|
|
for (const groupId of candidateGroups) {
|
|
if (observations.get(groupId)?.state === "unverifiable") continue;
|
|
if (
|
|
fact.state !== "same-generation" ||
|
|
fact.identityAfter.state !== "readable" ||
|
|
nativeGroupId !== groupId
|
|
) {
|
|
observations.set(groupId, {
|
|
state: "unverifiable",
|
|
reason: `native process-group membership could not be revalidated for PID ${String(fact.process.pid)}`,
|
|
});
|
|
continue;
|
|
}
|
|
if (fact.token.state !== "readable" || fact.token.runId !== runId) {
|
|
observations.set(groupId, {
|
|
state: "unverifiable",
|
|
reason:
|
|
fact.token.state === "unavailable"
|
|
? `process ownership could not be revalidated for PID ${String(fact.process.pid)}: ${fact.token.reason}`
|
|
: "process ownership did not match",
|
|
});
|
|
continue;
|
|
}
|
|
observations.set(groupId, { state: "alive" });
|
|
}
|
|
}
|
|
assertProcessOwnershipInspectionActive(signal);
|
|
return observations;
|
|
}
|
|
const membersByGroup = new Map(
|
|
groupIds.map((groupId) => [groupId, [] as ProcessTableEntry[]]),
|
|
);
|
|
for (const process of processes)
|
|
membersByGroup.get(process.processGroupId)?.push(process);
|
|
const observations = new Map<number, ProcessGroupObservation>(
|
|
groupIds.map((groupId) => [
|
|
groupId,
|
|
(membersByGroup.get(groupId)?.length ?? 0) > 0
|
|
? { state: "alive" }
|
|
: { state: "empty" },
|
|
]),
|
|
);
|
|
const liveMembers = groupIds.flatMap(
|
|
(groupId) => membersByGroup.get(groupId) ?? [],
|
|
);
|
|
const pendingGroups = new Set(
|
|
groupIds.filter(
|
|
(groupId) => (membersByGroup.get(groupId)?.length ?? 0) > 0,
|
|
),
|
|
);
|
|
for await (const { process: member, observation } of readProcessRunTokens(
|
|
host,
|
|
liveMembers,
|
|
signal,
|
|
)) {
|
|
if (!pendingGroups.has(member.processGroupId)) continue;
|
|
if (observation.state === "readable") {
|
|
if (observation.runId !== runId) {
|
|
observations.set(member.processGroupId, {
|
|
state: "unverifiable",
|
|
reason: "process ownership did not match",
|
|
});
|
|
pendingGroups.delete(member.processGroupId);
|
|
}
|
|
} else {
|
|
const gone = await processIsGone(host, member.pid, signal);
|
|
assertProcessOwnershipInspectionActive(signal);
|
|
if (!gone) {
|
|
observations.set(member.processGroupId, {
|
|
state: "unverifiable",
|
|
reason: `process ownership could not be revalidated for PID ${member.pid}: ${observation.reason}`,
|
|
});
|
|
pendingGroups.delete(member.processGroupId);
|
|
}
|
|
}
|
|
if (pendingGroups.size === 0) break;
|
|
}
|
|
assertProcessOwnershipInspectionActive(signal);
|
|
return observations;
|
|
};
|
|
|
|
/**
|
|
* Record the live launcher and descendant lineage after run-token validation.
|
|
*
|
|
* The observation is intentionally point-in-time. A verified empty descendant
|
|
* list means no descendants were live during this observation, not that the
|
|
* run never created a short-lived child.
|
|
*/
|
|
export const observeOwnedProcessLineage = async (
|
|
ownership: OwnedProcessGroup,
|
|
host: ProcessOwnershipHost = systemProcessOwnershipHost,
|
|
): Promise<ProcessLineageObservation> => {
|
|
let processes: readonly ProcessTableEntry[];
|
|
try {
|
|
processes = liveProcesses(await host.listProcesses());
|
|
} catch (cause: unknown) {
|
|
rethrowInspectionFailure(cause);
|
|
return unavailableLineage(
|
|
ownership,
|
|
`process table could not be inspected: ${errorMessage(cause)}`,
|
|
);
|
|
}
|
|
if (host.processIdentities !== undefined)
|
|
return observeNativeOwnedProcessLineage(ownership, processes, host);
|
|
const launcher = processes.find(({ pid }) => pid === ownership.leaderPid);
|
|
if (launcher === undefined)
|
|
return unavailableLineage(ownership, "owned launcher is not live");
|
|
const identityFailure = launcherIdentityFailure(launcher, ownership);
|
|
if (identityFailure !== null)
|
|
return unavailableLineage(ownership, identityFailure);
|
|
const descendants = descendantsOf([launcher.pid], processes);
|
|
const verifiedDescendants: ProcessTableEntry[] = [];
|
|
// Seed every initial row. A later refresh reads a row again when its parent,
|
|
// group, or launcher-tree membership changes. A row whose token was read is
|
|
// read again on every later refresh: parent and group are not a generation.
|
|
const initialTree = new Set(descendants.map(({ pid }) => pid));
|
|
const initialRelations = new Map<number, ExaminedLineageRefresh>();
|
|
const examinedRefresh = new Map<number, ExaminedLineageRefresh>();
|
|
for (const process of processes) {
|
|
if (process.pid === launcher.pid) continue;
|
|
const relation: ExaminedLineageRefresh = {
|
|
parentPid: process.parentPid,
|
|
processGroupId: process.processGroupId,
|
|
inTree: initialTree.has(process.pid),
|
|
tokenChecked: false,
|
|
descendant: undefined,
|
|
};
|
|
initialRelations.set(process.pid, relation);
|
|
examinedRefresh.set(process.pid, relation);
|
|
}
|
|
let refreshDescendants: readonly ProcessTableEntry[] = [];
|
|
let suppressedRefreshPids = new Set<number>();
|
|
let latestRefreshPids: ReadonlySet<number> | undefined;
|
|
for await (const { process: member, observation } of readProcessRunTokens(
|
|
host,
|
|
[launcher, ...descendants],
|
|
)) {
|
|
if (observation.state === "readable") {
|
|
if (observation.runId !== ownership.runId)
|
|
return unavailableLineage(
|
|
ownership,
|
|
lineageTokenMismatchReason(member.pid, observation.runId),
|
|
);
|
|
if (member.pid !== launcher.pid) verifiedDescendants.push(member);
|
|
const seeded = examinedRefresh.get(member.pid);
|
|
if (seeded !== undefined)
|
|
examinedRefresh.set(member.pid, { ...seeded, tokenChecked: true });
|
|
} else {
|
|
let refreshed: readonly ProcessTableEntry[];
|
|
try {
|
|
refreshed = liveProcesses(await host.listProcesses());
|
|
} catch (cause: unknown) {
|
|
rethrowInspectionFailure(cause);
|
|
void cause;
|
|
return unavailableLineage(
|
|
ownership,
|
|
`process ownership could not be revalidated for PID ${member.pid}: ${observation.reason}`,
|
|
);
|
|
}
|
|
if (refreshed.some((process) => process.pid === member.pid))
|
|
return unavailableLineage(
|
|
ownership,
|
|
`process ownership could not be revalidated for PID ${member.pid}: ${observation.reason}`,
|
|
);
|
|
if (member.pid === launcher.pid)
|
|
return unavailableLineage(
|
|
ownership,
|
|
"owned launcher exited during lineage validation",
|
|
);
|
|
const classified = await classifyIdentitylessLineageRefresh(
|
|
ownership,
|
|
launcher.pid,
|
|
refreshed,
|
|
host,
|
|
examinedRefresh,
|
|
initialRelations,
|
|
);
|
|
if (classified.status === "unavailable")
|
|
return unavailableLineage(ownership, classified.reason);
|
|
refreshDescendants = classified.descendants;
|
|
suppressedRefreshPids = new Set(classified.suppressedPids);
|
|
latestRefreshPids = new Set(refreshed.map(({ pid }) => pid));
|
|
}
|
|
}
|
|
return {
|
|
status: "verified",
|
|
observedAt: new Date().toISOString(),
|
|
lineage: {
|
|
runId: ownership.runId,
|
|
launcherPid: launcher.pid,
|
|
launcherParentPid: launcher.parentPid,
|
|
processGroupId: launcher.processGroupId,
|
|
descendants: [
|
|
...verifiedDescendants.filter(
|
|
({ pid }) =>
|
|
(latestRefreshPids === undefined || latestRefreshPids.has(pid)) &&
|
|
!suppressedRefreshPids.has(pid) &&
|
|
!refreshDescendants.some((descendant) => descendant.pid === pid),
|
|
),
|
|
...refreshDescendants,
|
|
]
|
|
.sort((left, right) => left.pid - right.pid)
|
|
.map(({ pid, parentPid, processGroupId }) => ({
|
|
pid,
|
|
parentPid,
|
|
processGroupId,
|
|
})),
|
|
},
|
|
};
|
|
};
|
|
|
|
const observeNativeOwnedProcessLineage = async (
|
|
ownership: OwnedProcessGroup,
|
|
processes: readonly ProcessTableEntry[],
|
|
host: ProcessOwnershipHost,
|
|
): Promise<ProcessLineageObservation> => {
|
|
const initialIdentities = await readNativeProcessIdentityMap(processes, host);
|
|
const launcher = processes.find(({ pid }) => pid === ownership.leaderPid);
|
|
const launcherIdentity =
|
|
launcher === undefined ? undefined : initialIdentities.get(launcher.pid);
|
|
const nativeLauncher =
|
|
launcher !== undefined &&
|
|
launcherIdentity?.state === "readable" &&
|
|
!processIdentityHasExited(launcherIdentity)
|
|
? processWithNativeRelation(launcher, launcherIdentity)
|
|
: undefined;
|
|
const identityFailure =
|
|
nativeLauncher === undefined
|
|
? null
|
|
: launcherIdentityFailure(nativeLauncher, ownership);
|
|
// Missing native facts leave table ancestry as candidate scope only. Keep
|
|
// unresolved and exited intermediates as discovery bridges to descendants;
|
|
// only validated live native facts may authorize the published lineage.
|
|
const candidateProcesses = processes.map((process) => {
|
|
const identity = initialIdentities.get(process.pid);
|
|
const nativeProcess =
|
|
identity?.state === "readable"
|
|
? processWithNativeRelation(process, identity)
|
|
: undefined;
|
|
return nativeProcess ?? process;
|
|
});
|
|
const descendants = descendantsOf([ownership.leaderPid], candidateProcesses);
|
|
const lineageRows = [
|
|
...(launcher === undefined ? [] : [launcher]),
|
|
...descendants,
|
|
];
|
|
// A retained candidate may have moved outside the current launcher lineage.
|
|
const nativeExitedPids = new Set(
|
|
processes
|
|
.filter(({ pid }) => processIdentityHasExited(initialIdentities.get(pid)))
|
|
.map(({ pid }) => pid),
|
|
);
|
|
// A native terminal fact settles the old candidate. Keep its ancestry link,
|
|
// but do not enroll a replacement that appears during later token reads.
|
|
const candidates = lineageRows.filter(
|
|
({ pid }) => !nativeExitedPids.has(pid),
|
|
);
|
|
const facts = await readProcessOwnershipFacts(candidates, host, undefined, {
|
|
identityBefore: initialIdentities,
|
|
});
|
|
// Preserve each generation's ownership evidence before projecting a lineage.
|
|
// A later candidate failure or a smaller future lineage cannot erase a
|
|
// token-verified helper that has since detached from this launcher.
|
|
ownership.evidenceOwner?.reconcile(facts, {
|
|
runId: ownership.runId,
|
|
listedRows: processes.length,
|
|
listedPids: new Set(processes.map(({ pid }) => pid)),
|
|
leaderPid: ownership.leaderPid,
|
|
processGroupIds: new Set([
|
|
ownership.processGroupId,
|
|
...(ownership.sampledProcessGroupIds ?? []),
|
|
]),
|
|
// A readable mismatching launcher disproves the captured anchors. Keep
|
|
// independent token evidence, existing candidates, and unknown/exit bridges.
|
|
// Later same-generation movement cannot erase valid initial scope.
|
|
allowCapturedScope: identityFailure === null,
|
|
nativeExitedPids,
|
|
unverifiedLineageRelations: new Map(
|
|
lineageRows.map(({ pid, parentPid, processGroupId }) => [
|
|
pid,
|
|
{ parentPid, processGroupId },
|
|
]),
|
|
),
|
|
});
|
|
// A launcher failure prevents publication, but must not discard partial
|
|
// ownership evidence already observed for this run's descendants.
|
|
if (launcher === undefined)
|
|
return unavailableLineage(ownership, "owned launcher is not live");
|
|
if (launcherIdentity?.state === "absent")
|
|
return unavailableLineage(ownership, "owned launcher is not live");
|
|
if (launcherIdentity?.state !== "readable")
|
|
return unavailableLineage(
|
|
ownership,
|
|
`owned launcher identity could not be read: ${launcherIdentity?.reason ?? "identity observation was omitted"}`,
|
|
);
|
|
if (processIdentityHasExited(launcherIdentity))
|
|
return unavailableLineage(ownership, "owned launcher is not live");
|
|
if (nativeLauncher === undefined)
|
|
return unavailableLineage(
|
|
ownership,
|
|
"owned launcher native parent/group relation was unavailable",
|
|
);
|
|
if (identityFailure !== null)
|
|
return unavailableLineage(ownership, identityFailure);
|
|
const factsByPid = new Map(facts.map((fact) => [fact.process.pid, fact]));
|
|
const verifiedDescendants: ProcessTableEntry[] = [];
|
|
let currentLauncher = nativeLauncher;
|
|
for (const member of candidates) {
|
|
const fact = factsByPid.get(member.pid);
|
|
if (fact === undefined)
|
|
return unavailableLineage(
|
|
ownership,
|
|
`native process facts were unavailable for PID ${String(member.pid)}`,
|
|
);
|
|
if (fact.state === "absent") {
|
|
if (member.pid === ownership.leaderPid)
|
|
return unavailableLineage(
|
|
ownership,
|
|
"owned launcher exited during lineage validation",
|
|
);
|
|
continue;
|
|
}
|
|
if (fact.state !== "same-generation")
|
|
return unavailableLineage(
|
|
ownership,
|
|
`process identity changed or became unavailable during lineage validation for PID ${String(member.pid)}`,
|
|
);
|
|
if (processIdentityHasExited(fact.identityAfter)) {
|
|
if (member.pid === ownership.leaderPid)
|
|
return unavailableLineage(
|
|
ownership,
|
|
"owned launcher exited during lineage validation",
|
|
);
|
|
continue;
|
|
}
|
|
if (fact.token.state !== "readable")
|
|
return unavailableLineage(
|
|
ownership,
|
|
`process ownership could not be revalidated for PID ${String(member.pid)}: ${fact.token.reason}`,
|
|
);
|
|
if (fact.token.runId !== ownership.runId)
|
|
return unavailableLineage(
|
|
ownership,
|
|
lineageTokenMismatchReason(member.pid, fact.token.runId),
|
|
);
|
|
const nativeMember = processWithNativeRelation(
|
|
fact.process,
|
|
fact.identityAfter,
|
|
);
|
|
if (nativeMember === undefined)
|
|
return unavailableLineage(
|
|
ownership,
|
|
`native process relation was unavailable for PID ${String(member.pid)}`,
|
|
);
|
|
if (member.pid === ownership.leaderPid) {
|
|
const failure = launcherIdentityFailure(nativeMember, ownership);
|
|
if (failure !== null) return unavailableLineage(ownership, failure);
|
|
currentLauncher = nativeMember;
|
|
continue;
|
|
}
|
|
verifiedDescendants.push(nativeMember);
|
|
}
|
|
const currentDescendantPids = new Set(
|
|
descendantsOf(
|
|
[ownership.leaderPid],
|
|
[currentLauncher, ...verifiedDescendants],
|
|
).map(({ pid }) => pid),
|
|
);
|
|
const moved = verifiedDescendants.find(
|
|
({ pid }) => !currentDescendantPids.has(pid),
|
|
);
|
|
if (moved !== undefined)
|
|
return unavailableLineage(
|
|
ownership,
|
|
`process lineage relation changed during validation for PID ${String(moved.pid)}`,
|
|
);
|
|
return {
|
|
status: "verified",
|
|
observedAt: new Date().toISOString(),
|
|
lineage: {
|
|
runId: ownership.runId,
|
|
launcherPid: ownership.leaderPid,
|
|
launcherParentPid: currentLauncher.parentPid,
|
|
processGroupId: currentLauncher.processGroupId,
|
|
descendants: verifiedDescendants
|
|
.sort((left, right) => left.pid - right.pid)
|
|
.map(({ pid, parentPid, processGroupId }) => ({
|
|
pid,
|
|
parentPid,
|
|
processGroupId,
|
|
})),
|
|
},
|
|
};
|
|
};
|
|
|
|
/** Relation already classified for one PID seen by an identityless refresh. */
|
|
interface ExaminedLineageRefresh {
|
|
readonly parentPid: number;
|
|
readonly processGroupId: number;
|
|
/** True when this row was a launcher descendant at this classification. */
|
|
readonly inTree: boolean;
|
|
/** True after this refresh has read the row's run token. */
|
|
readonly tokenChecked: boolean;
|
|
readonly descendant: ProcessTableEntry | undefined;
|
|
}
|
|
|
|
/**
|
|
* A liveness refresh is a process table, not a boolean. New rows still under
|
|
* the launcher join the published lineage after a matching token. A matching
|
|
* token outside that tree, or an unreadable live row, blocks a verified result.
|
|
* A readable foreign token outside the tree is not part of this run. A row is
|
|
* read again when its parent, process group, or launcher-tree membership
|
|
* changes. A row whose token was already read is read again on every later
|
|
* refresh, because those fields do not identify a process generation.
|
|
* Unchanged rows from the initial snapshot are not read again. The launcher
|
|
* is read on every refresh; a missing, unowned, or relation-mismatched
|
|
* launcher PID is unavailable.
|
|
*/
|
|
const classifyIdentitylessLineageRefresh = async (
|
|
ownership: OwnedProcessGroup,
|
|
launcherPid: number,
|
|
refreshed: readonly ProcessTableEntry[],
|
|
host: ProcessOwnershipHost,
|
|
examined: Map<number, ExaminedLineageRefresh>,
|
|
initialRelations: ReadonlyMap<number, ExaminedLineageRefresh>,
|
|
): Promise<
|
|
| {
|
|
readonly status: "classified";
|
|
readonly descendants: readonly ProcessTableEntry[];
|
|
readonly suppressedPids: readonly number[];
|
|
}
|
|
| { readonly status: "unavailable"; readonly reason: string }
|
|
> => {
|
|
const refreshedLauncher = refreshed.find(
|
|
(process) => process.pid === launcherPid,
|
|
);
|
|
if (refreshedLauncher === undefined)
|
|
return {
|
|
status: "unavailable",
|
|
reason: "owned launcher exited during lineage validation",
|
|
};
|
|
for await (const { observation } of readProcessRunTokens(host, [
|
|
refreshedLauncher,
|
|
])) {
|
|
if (observation.state !== "readable")
|
|
return {
|
|
status: "unavailable",
|
|
reason: `process ownership could not be revalidated for PID ${String(launcherPid)}: ${observation.reason}`,
|
|
};
|
|
if (observation.runId !== ownership.runId)
|
|
return {
|
|
status: "unavailable",
|
|
reason: lineageTokenMismatchReason(launcherPid, observation.runId),
|
|
};
|
|
}
|
|
const identityFailure = launcherIdentityFailure(refreshedLauncher, ownership);
|
|
if (identityFailure !== null)
|
|
return { status: "unavailable", reason: identityFailure };
|
|
const inTree = new Set(
|
|
descendantsOf([launcherPid], refreshed).map(({ pid }) => pid),
|
|
);
|
|
const due = refreshed.filter((process) => {
|
|
if (process.pid === launcherPid) return false;
|
|
const previous = examined.get(process.pid);
|
|
return (
|
|
previous === undefined ||
|
|
previous.tokenChecked ||
|
|
previous.parentPid !== process.parentPid ||
|
|
previous.processGroupId !== process.processGroupId ||
|
|
previous.inTree !== inTree.has(process.pid)
|
|
);
|
|
});
|
|
if (due.length > 0) {
|
|
const included = new Map<number, ProcessTableEntry>();
|
|
const ownedOutside: number[] = [];
|
|
for await (const { process, observation } of readProcessRunTokens(
|
|
host,
|
|
due,
|
|
)) {
|
|
if (observation.state !== "readable")
|
|
return {
|
|
status: "unavailable",
|
|
reason: `process ownership could not be revalidated for PID ${String(process.pid)}: ${observation.reason}`,
|
|
};
|
|
if (observation.runId !== ownership.runId) {
|
|
if (inTree.has(process.pid))
|
|
return {
|
|
status: "unavailable",
|
|
reason: lineageTokenMismatchReason(process.pid, observation.runId),
|
|
};
|
|
continue;
|
|
}
|
|
if (inTree.has(process.pid)) included.set(process.pid, process);
|
|
else ownedOutside.push(process.pid);
|
|
}
|
|
if (ownedOutside.length > 0)
|
|
return {
|
|
status: "unavailable",
|
|
reason: `process lineage refresh contained owned PID(s) outside the observed descendants: ${ownedOutside
|
|
.sort((left, right) => left - right)
|
|
.join(", ")}`,
|
|
};
|
|
for (const process of due)
|
|
examined.set(process.pid, {
|
|
parentPid: process.parentPid,
|
|
processGroupId: process.processGroupId,
|
|
inTree: inTree.has(process.pid),
|
|
tokenChecked: true,
|
|
descendant: included.get(process.pid),
|
|
});
|
|
}
|
|
const livePids = new Set(refreshed.map(({ pid }) => pid));
|
|
for (const pid of examined.keys())
|
|
if (!livePids.has(pid)) examined.delete(pid);
|
|
const suppressedPids = [...examined.keys()].filter((pid) => {
|
|
const seed = initialRelations.get(pid);
|
|
const entry = examined.get(pid);
|
|
return (
|
|
seed !== undefined &&
|
|
entry !== undefined &&
|
|
(seed.parentPid !== entry.parentPid ||
|
|
seed.processGroupId !== entry.processGroupId ||
|
|
seed.inTree !== entry.inTree)
|
|
);
|
|
});
|
|
return {
|
|
status: "classified",
|
|
descendants: [...examined.values()].flatMap((entry) =>
|
|
entry.descendant === undefined ? [] : [entry.descendant],
|
|
),
|
|
suppressedPids,
|
|
};
|
|
};
|
|
|
|
const processWithNativeRelation = (
|
|
process: ProcessTableEntry,
|
|
identity: ProcessIdentityObservation,
|
|
): ProcessTableEntry | undefined =>
|
|
identity.state === "readable" && identity.relation?.state === "observed"
|
|
? {
|
|
...process,
|
|
parentPid: identity.relation.parentPid,
|
|
processGroupId: identity.relation.processGroupId,
|
|
}
|
|
: undefined;
|
|
|
|
const lineageTokenMismatchReason = (
|
|
pid: number,
|
|
observedRunId: string | undefined,
|
|
): string =>
|
|
observedRunId === undefined
|
|
? `REA_PROCESS_RUN_ID was absent from the readable environment for PID ${String(pid)}; process lineage ownership remains unverified`
|
|
: "process lineage contains an unowned or PID-reused process";
|
|
|
|
const unavailableLineage = (
|
|
ownership: OwnedProcessGroup,
|
|
reason: string,
|
|
): Extract<ProcessLineageObservation, { readonly status: "unavailable" }> => ({
|
|
status: "unavailable",
|
|
observedAt: new Date().toISOString(),
|
|
runId: ownership.runId,
|
|
launcherPid: ownership.leaderPid,
|
|
processGroupId: ownership.processGroupId,
|
|
reason,
|
|
});
|
|
|
|
/** Parse the NUL-delimited Linux process environment without nameless keys. */
|
|
export const parseProcessEnvironment = (
|
|
value: string,
|
|
): Readonly<Record<string, string>> => {
|
|
const environment: Record<string, string> = Object.create(null);
|
|
for (const entry of value.split("\0")) {
|
|
const separator = entry.indexOf("=");
|
|
if (separator > 0)
|
|
environment[entry.slice(0, separator)] = entry.slice(separator + 1);
|
|
}
|
|
return environment;
|
|
};
|
|
|
|
const identityFact = (
|
|
observations: ReadonlyMap<number, ProcessIdentityObservation> | undefined,
|
|
pid: number,
|
|
phase: "before" | "after",
|
|
failure?: string,
|
|
): ProcessIdentityObservation =>
|
|
observations?.get(pid) ?? {
|
|
state: "unavailable",
|
|
reason:
|
|
failure ??
|
|
`process identity was not returned during ${phase} observation`,
|
|
};
|
|
|
|
const readIdentityFacts = async (
|
|
processes: readonly ProcessTableEntry[],
|
|
host: ProcessOwnershipHost,
|
|
phase: "before" | "after",
|
|
signal?: AbortSignal,
|
|
credentialPids?: ReadonlySet<number>,
|
|
): Promise<ReadonlyMap<number, ProcessIdentityObservation>> => {
|
|
assertProcessOwnershipInspectionActive(signal);
|
|
if (host.processIdentities === undefined)
|
|
return new Map<number, ProcessIdentityObservation>(
|
|
processes.map(({ pid }) => [
|
|
pid,
|
|
{
|
|
state: "unavailable",
|
|
reason: "process identity inspection is unsupported",
|
|
} as const,
|
|
]),
|
|
);
|
|
try {
|
|
const observations = await host.processIdentities(
|
|
processes,
|
|
signal,
|
|
credentialPids,
|
|
);
|
|
assertProcessOwnershipInspectionActive(signal);
|
|
return new Map<number, ProcessIdentityObservation>(
|
|
processes.map(({ pid }) => [pid, identityFact(observations, pid, phase)]),
|
|
);
|
|
} catch (cause: unknown) {
|
|
rethrowInspectionFailure(cause);
|
|
const reason = errorMessage(cause);
|
|
return new Map<number, ProcessIdentityObservation>(
|
|
processes.map(({ pid }) => [
|
|
pid,
|
|
identityFact(undefined, pid, phase, reason),
|
|
]),
|
|
);
|
|
}
|
|
};
|
|
|
|
/** Read native identities/relations for caller-supplied rows without listing processes. */
|
|
export const readNativeProcessIdentityMap = async (
|
|
processes: readonly ProcessTableEntry[],
|
|
host: ProcessOwnershipHost,
|
|
signal?: AbortSignal,
|
|
): Promise<ReadonlyMap<number, ProcessIdentityObservation>> =>
|
|
readIdentityFacts(processes, host, "before", signal);
|
|
|
|
/**
|
|
* Wait only for positively observed native exit-in-progress. Never infer exit
|
|
* from EACCES or ps text, enumerate the process table, or follow a reused PID.
|
|
* A deadline, unavailable read or changed generation remains explicit evidence.
|
|
*/
|
|
export const settleExitingProcessIdentities = async (
|
|
processes: readonly ProcessTableEntry[],
|
|
host: ProcessOwnershipHost,
|
|
initial: ReadonlyMap<number, ProcessIdentityObservation>,
|
|
signal?: AbortSignal,
|
|
budget: ProcessExitSettlementBudget = { deadline: undefined },
|
|
/** Keep the initial read's selective Linux credential facts on refresh. */
|
|
credentialPids?: ReadonlySet<number>,
|
|
): Promise<ReadonlyMap<number, ProcessIdentityObservation>> => {
|
|
assertProcessOwnershipInspectionActive(signal);
|
|
let pending = processes.filter(({ pid }) =>
|
|
processIdentityIsExiting(initial.get(pid)),
|
|
);
|
|
if (pending.length === 0) return initial;
|
|
const identities = new Map(initial);
|
|
const deadline = (budget.deadline ??=
|
|
performance.now() + PROCESS_CLEANUP_VERIFICATION_GRACE_MS);
|
|
while (pending.length > 0) {
|
|
const remaining = deadline - performance.now();
|
|
if (remaining <= 0) break;
|
|
await waitForProcessOwnershipInspection(
|
|
Math.min(PROCESS_CLEANUP_VERIFICATION_INTERVAL_MS, remaining),
|
|
signal,
|
|
);
|
|
const refreshed = await readIdentityFacts(
|
|
pending,
|
|
host,
|
|
"before",
|
|
signal,
|
|
credentialPids,
|
|
);
|
|
for (const { pid } of pending)
|
|
identities.set(pid, identityFact(refreshed, pid, "after"));
|
|
pending = pending.filter(({ pid }) => {
|
|
const first = initial.get(pid);
|
|
const current = identities.get(pid);
|
|
return (
|
|
first?.state === "readable" &&
|
|
current?.state === "readable" &&
|
|
processIdentityIsExiting(current) &&
|
|
current.identity === first.identity
|
|
);
|
|
});
|
|
}
|
|
assertProcessOwnershipInspectionActive(signal);
|
|
return identities;
|
|
};
|
|
|
|
/** Corroborate Linux token-open exit races without granting signal authority. */
|
|
export const settleVanishedTokenFacts = async (
|
|
facts: readonly ProcessOwnershipFact[],
|
|
host: ProcessOwnershipHost,
|
|
budget: ProcessExitSettlementBudget,
|
|
signal?: AbortSignal,
|
|
): Promise<readonly ProcessOwnershipFact[]> => {
|
|
if ((host.platform ?? process.platform) !== "linux") return facts;
|
|
let pending = facts
|
|
.filter(
|
|
({ token, identityAfter }) =>
|
|
token.state === "unavailable" &&
|
|
(token.code === "ENOENT" || token.code === "ESRCH") &&
|
|
!processIdentityHasExited(identityAfter),
|
|
)
|
|
.map(({ process }) => process);
|
|
if (pending.length === 0) return facts;
|
|
let current = facts;
|
|
const deadline = (budget.deadline ??=
|
|
performance.now() + PROCESS_CLEANUP_VERIFICATION_GRACE_MS);
|
|
for (let attempt = 0; attempt < 4 && pending.length > 0; attempt += 1) {
|
|
assertProcessOwnershipInspectionActive(signal);
|
|
if (attempt > 0) {
|
|
const remaining = deadline - performance.now();
|
|
if (remaining <= 0) break;
|
|
await waitForProcessOwnershipInspection(Math.min(40, remaining), signal);
|
|
}
|
|
const confirmed = await readNativeProcessIdentityMap(pending, host, signal);
|
|
const pendingPids = new Set(pending.map(({ pid }) => pid));
|
|
const nextPending = new Set<number>();
|
|
current = current.map((fact) => {
|
|
if (!pendingPids.has(fact.process.pid)) return fact;
|
|
const after = confirmed.get(fact.process.pid);
|
|
if (after?.state === "absent")
|
|
return {
|
|
...fact,
|
|
state: "absent" as const,
|
|
identityAfter: after,
|
|
beforeExitSettlement: fact.beforeExitSettlement ?? fact,
|
|
};
|
|
const previous = fact.identityAfter;
|
|
if (previous.state !== "readable" || after?.state !== "readable")
|
|
return fact;
|
|
if (previous.identity !== after.identity)
|
|
return {
|
|
...fact,
|
|
state: "unavailable" as const,
|
|
identityAfter: {
|
|
state: "unavailable" as const,
|
|
reason: "process_identity_changed_during_final_token_exit_check",
|
|
},
|
|
};
|
|
if (processIdentityHasExited(after))
|
|
return {
|
|
...fact,
|
|
identityAfter: after,
|
|
beforeExitSettlement: fact.beforeExitSettlement ?? fact,
|
|
};
|
|
nextPending.add(fact.process.pid);
|
|
return fact;
|
|
});
|
|
pending = pending.filter(({ pid }) => nextPending.has(pid));
|
|
}
|
|
assertProcessOwnershipInspectionActive(signal);
|
|
return current;
|
|
};
|
|
|
|
/**
|
|
* Read identity, token, then identity for caller-provided rows. This owner
|
|
* never enumerates the process table; a token is tied to a generation only
|
|
* when both native identity observations match.
|
|
*/
|
|
export const readProcessOwnershipFacts = async (
|
|
processes: readonly ProcessTableEntry[],
|
|
host: ProcessOwnershipHost,
|
|
signal?: AbortSignal,
|
|
options: {
|
|
readonly identityBefore?: ReadonlyMap<number, ProcessIdentityObservation>;
|
|
readonly exitSettlement?: ProcessExitSettlementBudget;
|
|
} = {},
|
|
): Promise<readonly ProcessOwnershipFact[]> => {
|
|
assertProcessOwnershipInspectionActive(signal);
|
|
if (processes.length === 0) return [];
|
|
const identityBefore =
|
|
options.identityBefore === undefined
|
|
? await readIdentityFacts(processes, host, "before", signal)
|
|
: new Map(
|
|
processes.map(({ pid }) => [
|
|
pid,
|
|
identityFact(options.identityBefore, pid, "before"),
|
|
]),
|
|
);
|
|
assertProcessOwnershipInspectionActive(signal);
|
|
|
|
const tokens = new Map<number, ProcessRunTokenObservation>();
|
|
try {
|
|
for await (const { process, observation } of readProcessRunTokens(
|
|
host,
|
|
processes,
|
|
signal,
|
|
)) {
|
|
assertProcessOwnershipInspectionActive(signal);
|
|
tokens.set(process.pid, observation);
|
|
}
|
|
} catch (cause: unknown) {
|
|
rethrowInspectionFailure(cause);
|
|
const reason = errorMessage(cause);
|
|
for (const { pid } of processes)
|
|
if (!tokens.has(pid)) tokens.set(pid, { state: "unavailable", reason });
|
|
}
|
|
assertProcessOwnershipInspectionActive(signal);
|
|
|
|
const credentialPids = new Set(
|
|
processes
|
|
.filter(({ pid }) => tokens.get(pid)?.state !== "readable")
|
|
.map(({ pid }) => pid),
|
|
);
|
|
const observedAfter = await readIdentityFacts(
|
|
processes,
|
|
host,
|
|
"after",
|
|
signal,
|
|
credentialPids,
|
|
);
|
|
// An ENOENT/ESRCH environment failure can race with process exit while
|
|
// /proc/PID/stat is still readable. Do not treat the token error itself as
|
|
// proof of exit: ask the native identity owner again, which corroborates a
|
|
// missing procfs entry with signal-zero ESRCH. Keep the previous live
|
|
// observation if the refresh is unreadable; PID reuse remains a mismatch.
|
|
let finalObservedAfter = observedAfter;
|
|
if ((host.platform ?? process.platform) === "linux") {
|
|
const disappearing = processes.filter(({ pid }) => {
|
|
const token = tokens.get(pid);
|
|
return (
|
|
token?.state === "unavailable" &&
|
|
(token.code === "ENOENT" || token.code === "ESRCH") &&
|
|
observedAfter.get(pid)?.state === "readable"
|
|
);
|
|
});
|
|
if (disappearing.length > 0) {
|
|
const refreshed = await readIdentityFacts(
|
|
disappearing,
|
|
host,
|
|
"after",
|
|
signal,
|
|
credentialPids,
|
|
);
|
|
const confirmed = new Map(observedAfter);
|
|
for (const { pid } of disappearing) {
|
|
const before = observedAfter.get(pid);
|
|
const after = refreshed.get(pid);
|
|
if (after?.state === "absent") {
|
|
confirmed.set(pid, after);
|
|
} else if (
|
|
before?.state === "readable" &&
|
|
after?.state === "readable"
|
|
) {
|
|
// A recycled PID (including a zombie replacement) is not proof of
|
|
// the original process exit. Keep its ownership unresolved.
|
|
if (before.identity !== after.identity)
|
|
confirmed.set(pid, {
|
|
state: "unavailable",
|
|
reason: "process_identity_changed_during_environment_exit_check",
|
|
});
|
|
else if (processIdentityHasExited(after)) confirmed.set(pid, after);
|
|
}
|
|
}
|
|
finalObservedAfter = confirmed;
|
|
}
|
|
}
|
|
const identityAfter = await settleExitingProcessIdentities(
|
|
processes,
|
|
host,
|
|
finalObservedAfter,
|
|
signal,
|
|
options.exitSettlement,
|
|
credentialPids,
|
|
);
|
|
assertProcessOwnershipInspectionActive(signal);
|
|
const deriveFact = (
|
|
process: ProcessTableEntry,
|
|
observation: ProcessIdentityObservation | undefined,
|
|
): ProcessOwnershipFact => {
|
|
const before = identityBefore.get(process.pid) ?? {
|
|
state: "unavailable" as const,
|
|
reason: "process identity was not returned during before observation",
|
|
};
|
|
const after = observation ?? {
|
|
state: "unavailable" as const,
|
|
reason: "process identity was not returned during after observation",
|
|
};
|
|
const token = tokens.get(process.pid) ?? {
|
|
state: "unavailable" as const,
|
|
reason: "process token was not returned",
|
|
};
|
|
const state =
|
|
after.state === "absent"
|
|
? "absent"
|
|
: before.state === "readable" && after.state === "readable"
|
|
? before.identity === after.identity
|
|
? "same-generation"
|
|
: "replaced"
|
|
: before.state === "absent" && after.state === "readable"
|
|
? "replaced"
|
|
: "unavailable";
|
|
return {
|
|
process,
|
|
state,
|
|
identityBefore: before,
|
|
token,
|
|
identityAfter: after,
|
|
};
|
|
};
|
|
const facts = processes.map((process): ProcessOwnershipFact => {
|
|
const after = identityAfter.get(process.pid);
|
|
const original = observedAfter.get(process.pid);
|
|
const fact = deriveFact(process, after);
|
|
return after === original
|
|
? fact
|
|
: { ...fact, beforeExitSettlement: deriveFact(process, original) };
|
|
});
|
|
assertProcessOwnershipInspectionActive(signal);
|
|
return facts;
|
|
};
|
|
|
|
/** Re-reads after a live fact's run token was unavailable. */
|
|
export const TOKEN_READ_RETRY_ATTEMPTS = 3;
|
|
/** Delay before each re-read. */
|
|
export const TOKEN_READ_RETRY_DELAY_MS = 40;
|
|
|
|
/** Which process a token re-read describes; the caller owns the run token. */
|
|
export type TokenReobservationScope =
|
|
/** A sweep classifies whichever process now holds each PID. */
|
|
| { readonly generation: "current" }
|
|
/** A pre-signal check stays on the generation it first read. */
|
|
| { readonly generation: "first-observed"; readonly runId: string };
|
|
|
|
/**
|
|
* Re-read facts whose token was unavailable while their process was neither
|
|
* exited nor exiting. Darwin KERN_PROCARGS2 samples the argument length and
|
|
* stack top separately from its address-space read, and exec publishes the
|
|
* new length before it copies the new strings out (xnu sysctl_procargsx,
|
|
* exec_copyout_strings). A read during exec can therefore see a zero-filled or
|
|
* stale strings area, and exec keeps the PID and start time that bracket the
|
|
* read. Each attempt is a complete identity/token/identity read, and a fact
|
|
* that stays unreadable keeps its observation so its caller fails closed.
|
|
*
|
|
* A pre-signal check re-reads only same-generation facts. Every attempt also
|
|
* re-reads its owned members, so a member that moves groups or turns
|
|
* unreadable while another waits is seen within the same bound. A re-read that
|
|
* sees another generation marks that fact changed, because a reused PID is
|
|
* not proof that the first generation exited. Changed, exited, exiting and
|
|
* foreign-token facts are final and never re-read.
|
|
*/
|
|
export const reobserveUnavailableTokenFacts = async (
|
|
facts: readonly ProcessOwnershipFact[],
|
|
host: ProcessOwnershipHost,
|
|
exitSettlement: ProcessExitSettlementBudget,
|
|
scope: TokenReobservationScope,
|
|
): Promise<readonly ProcessOwnershipFact[]> => {
|
|
const settled = ({ identityAfter }: ProcessOwnershipFact) =>
|
|
processIdentityHasExited(identityAfter) ||
|
|
processIdentityIsExiting(identityAfter);
|
|
const transient = (fact: ProcessOwnershipFact) =>
|
|
fact.token.state === "unavailable" &&
|
|
!settled(fact) &&
|
|
(scope.generation === "current" || fact.state === "same-generation");
|
|
const reread = (fact: ProcessOwnershipFact) =>
|
|
transient(fact) ||
|
|
(scope.generation === "first-observed" &&
|
|
fact.state === "same-generation" &&
|
|
!settled(fact) &&
|
|
fact.token.state === "readable" &&
|
|
fact.token.runId === scope.runId);
|
|
const bind = (
|
|
fact: ProcessOwnershipFact,
|
|
next: ProcessOwnershipFact | undefined,
|
|
): ProcessOwnershipFact => {
|
|
if (next === undefined || scope.generation === "current")
|
|
return next ?? fact;
|
|
if (fact.identityAfter.state !== "readable") return fact;
|
|
const first = fact.identityAfter.identity;
|
|
if (next.identityAfter.state === "absent") return next;
|
|
if (
|
|
[next.identityBefore, next.identityAfter].some(
|
|
(identity) =>
|
|
identity.state === "readable" && identity.identity !== first,
|
|
)
|
|
)
|
|
return {
|
|
...fact,
|
|
state: "unavailable",
|
|
identityAfter: {
|
|
state: "unavailable",
|
|
reason: "process_identity_changed_during_token_reread",
|
|
},
|
|
};
|
|
// An inconclusive read keeps an unreadable fact for the next attempt but
|
|
// replaces an owned one, so stale membership cannot authorize a signal.
|
|
return next.state === "same-generation" || fact.token.state === "readable"
|
|
? next
|
|
: fact;
|
|
};
|
|
|
|
let current = facts;
|
|
for (let attempt = 0; attempt < TOKEN_READ_RETRY_ATTEMPTS; attempt += 1) {
|
|
if (!current.some(transient)) break;
|
|
await delay(TOKEN_READ_RETRY_DELAY_MS);
|
|
const next = new Map(
|
|
(
|
|
await readProcessOwnershipFacts(
|
|
current.filter(reread).map(({ process }) => process),
|
|
host,
|
|
undefined,
|
|
{ exitSettlement },
|
|
)
|
|
).map((fact) => [fact.process.pid, fact]),
|
|
);
|
|
current = current.map((fact) => bind(fact, next.get(fact.process.pid)));
|
|
}
|
|
return current;
|
|
};
|
|
|
|
/** Create the operating-system process inspector for an explicit host context. */
|
|
export const createSystemProcessOwnershipHost = (
|
|
platform: NodeJS.Platform = process.platform,
|
|
hostEnvironment: NodeJS.ProcessEnv = process.env,
|
|
options: {
|
|
/** Override the compiler executable used by the Darwin native reader. */
|
|
readonly darwinCompiler?: DarwinProcessReaderCompiler;
|
|
/** Override the caller-owned module cache used compiling that helper. */
|
|
readonly darwinModuleCachePath?: string;
|
|
/** Deadline for Darwin ownership-helper compilation. */
|
|
readonly compileTimeoutMs?: number;
|
|
/** Subprocess output execution seam for testing process listing failures. */
|
|
readonly execFileOutput?: typeof execFileOutput;
|
|
} = {},
|
|
): ProcessOwnershipHost => {
|
|
const runExecFileOutput = options.execFileOutput ?? execFileOutput;
|
|
const darwinModuleCachePath =
|
|
options.darwinModuleCachePath ??
|
|
hostEnvironment.REA_DARWIN_MODULE_CACHE_PATH;
|
|
const swiftcPath = hostEnvironment.REA_DARWIN_SWIFTC_PATH;
|
|
const darwinCompiler =
|
|
options.darwinCompiler ??
|
|
(swiftcPath === undefined || swiftcPath === ""
|
|
? undefined
|
|
: {
|
|
kind: "swiftc" as const,
|
|
executable: swiftcPath,
|
|
sdkRoot: hostEnvironment.SDKROOT ?? "",
|
|
});
|
|
const helperCacheDirectory = darwinHelperCacheDirectory({
|
|
home: hostEnvironment.HOME,
|
|
compilerSelected: darwinCompiler !== undefined,
|
|
moduleCachePath: darwinModuleCachePath,
|
|
});
|
|
const darwinTokens =
|
|
platform === "darwin"
|
|
? createDarwinProcessRunTokenReader({
|
|
environment: hostEnvironment,
|
|
...(darwinCompiler === undefined ? {} : { compiler: darwinCompiler }),
|
|
...(darwinModuleCachePath === undefined
|
|
? {}
|
|
: { moduleCachePath: darwinModuleCachePath }),
|
|
...(helperCacheDirectory === undefined
|
|
? {}
|
|
: { helperCacheDirectory }),
|
|
...(options.compileTimeoutMs === undefined
|
|
? {}
|
|
: { compileTimeoutMs: options.compileTimeoutMs }),
|
|
})
|
|
: undefined;
|
|
const listProcesses = async (signal?: AbortSignal) => {
|
|
if (platform === "win32") return [];
|
|
let stdout: string;
|
|
try {
|
|
({ stdout } = await runExecFileOutput(
|
|
"ps",
|
|
["-axo", "pid=,ppid=,pgid=,uid=,stat=,command="],
|
|
{
|
|
env: hostEnvironment,
|
|
stopSignal: "SIGTERM",
|
|
...(signal === undefined ? {} : { signal }),
|
|
},
|
|
));
|
|
} catch (cause: unknown) {
|
|
rethrowInspectionFailure(cause);
|
|
throw cause;
|
|
}
|
|
return stdout
|
|
.split("\n")
|
|
.map((line) =>
|
|
/\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)/u.exec(line),
|
|
)
|
|
.filter((match): match is RegExpExecArray => match !== null)
|
|
.map((match) => ({
|
|
pid: Number(match[1]),
|
|
parentPid: Number(match[2]),
|
|
processGroupId: Number(match[3]),
|
|
uid: Number(match[4]),
|
|
state: match[5] ?? "",
|
|
command: match[6] ?? "",
|
|
}));
|
|
};
|
|
const processIdentities: NonNullable<
|
|
ProcessOwnershipHost["processIdentities"]
|
|
> = async (processes, signal, credentialPids) => {
|
|
if (processes.length === 0) return new Map();
|
|
if (platform === "linux") {
|
|
const callerUserNamespace =
|
|
credentialPids !== undefined && credentialPids.size > 0
|
|
? readlink("/proc/self/ns/user").catch(() => undefined)
|
|
: undefined;
|
|
let callerUidMap: Promise<string | undefined> | undefined;
|
|
const readUidMap = async (path: string): Promise<string | undefined> => {
|
|
try {
|
|
return await readProcessInspectionFile(path, signal);
|
|
} catch (cause: unknown) {
|
|
rethrowInspectionFailure(cause);
|
|
return undefined;
|
|
}
|
|
};
|
|
const physicalReadFailures: {
|
|
pid: number;
|
|
requestIndex: number;
|
|
cause: unknown;
|
|
}[] = [];
|
|
const results = await Promise.allSettled(
|
|
processes.map(async ({ pid }, requestIndex) => {
|
|
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
return [
|
|
pid,
|
|
{ state: "unavailable", reason: "invalid_process_id" } as const,
|
|
] as const;
|
|
try {
|
|
const stat = await readProcessInspectionFile(
|
|
`/proc/${String(pid)}/stat`,
|
|
signal,
|
|
);
|
|
const parsed = parseLinuxProcessStat(stat);
|
|
if (parsed === undefined || parsed.pid !== pid)
|
|
return [
|
|
pid,
|
|
{
|
|
state: "unavailable",
|
|
reason: "malformed_proc_stat",
|
|
} as const,
|
|
] as const;
|
|
|
|
const readableIdentity = (
|
|
facts: typeof parsed,
|
|
effectiveUid?: number,
|
|
environmentRestriction?: LinuxEnvironmentRestriction,
|
|
) => ({
|
|
state: "readable" as const,
|
|
identity: `linux-starttime:${facts.startTime}`,
|
|
...(facts.name === "" ? {} : { name: facts.name }),
|
|
...(effectiveUid === undefined ? {} : { effectiveUid }),
|
|
...(environmentRestriction === undefined
|
|
? {}
|
|
: { environmentRestriction }),
|
|
lifecycle:
|
|
facts.lifecycle === undefined
|
|
? {
|
|
state: "unavailable" as const,
|
|
reason: "malformed_proc_flags",
|
|
}
|
|
: {
|
|
state: "observed" as const,
|
|
lifecycle: facts.lifecycle,
|
|
},
|
|
relation:
|
|
facts.processGroupId < 0
|
|
? {
|
|
state: "unavailable" as const,
|
|
reason: "proc_stat_relation_unavailable",
|
|
}
|
|
: {
|
|
state: "observed" as const,
|
|
parentPid: facts.parentPid,
|
|
processGroupId: facts.processGroupId,
|
|
},
|
|
});
|
|
|
|
if (!credentialPids?.has(pid))
|
|
return [pid, readableIdentity(parsed)] as const;
|
|
|
|
let status: string;
|
|
try {
|
|
status = await readProcessInspectionFile(
|
|
`/proc/${String(pid)}/status`,
|
|
signal,
|
|
);
|
|
} catch (cause: unknown) {
|
|
rethrowInspectionFailure(cause);
|
|
return [pid, readableIdentity(parsed)] as const;
|
|
}
|
|
assertProcessOwnershipInspectionActive(signal);
|
|
const uids = parseLinuxProcessIds(status, "Uid");
|
|
if (uids === undefined)
|
|
return [pid, readableIdentity(parsed)] as const;
|
|
// procfs reports root ownership of environ while a process is non-dumpable.
|
|
let procfsOwnerUid: number | undefined;
|
|
try {
|
|
procfsOwnerUid = (await statPath(`/proc/${String(pid)}/environ`))
|
|
.uid;
|
|
} catch (cause: unknown) {
|
|
rethrowInspectionFailure(cause);
|
|
void cause;
|
|
}
|
|
assertProcessOwnershipInspectionActive(signal);
|
|
const restrictionFacts = {
|
|
uids,
|
|
gids: parseLinuxProcessIds(status, "Gid"),
|
|
procfsOwnerUid,
|
|
callerUid: process.geteuid?.(),
|
|
callerGid: process.getegid?.(),
|
|
};
|
|
let environmentRestriction =
|
|
linuxEnvironmentRestriction(restrictionFacts);
|
|
if (environmentRestriction === undefined) {
|
|
let userNamespaceDiffers: boolean | undefined;
|
|
try {
|
|
const targetUserNamespace = await readlink(
|
|
`/proc/${String(pid)}/ns/user`,
|
|
);
|
|
const callerNamespace = await callerUserNamespace;
|
|
if (callerNamespace !== undefined)
|
|
userNamespaceDiffers =
|
|
callerNamespace !== targetUserNamespace;
|
|
} catch (cause: unknown) {
|
|
rethrowInspectionFailure(cause);
|
|
void cause;
|
|
}
|
|
assertProcessOwnershipInspectionActive(signal);
|
|
if (userNamespaceDiffers === undefined) {
|
|
// ns/user readlink shares environ's ptrace check. A differing
|
|
// readable UID map can still establish distinct namespaces.
|
|
const targetUidMap = await readUidMap(
|
|
`/proc/${String(pid)}/uid_map`,
|
|
);
|
|
if (targetUidMap !== undefined) {
|
|
callerUidMap ??= readUidMap("/proc/self/uid_map");
|
|
userNamespaceDiffers = linuxUidMapsDiffer(
|
|
targetUidMap,
|
|
await callerUidMap,
|
|
);
|
|
}
|
|
}
|
|
environmentRestriction = linuxEnvironmentRestriction({
|
|
...restrictionFacts,
|
|
userNamespaceDiffers,
|
|
});
|
|
}
|
|
assertProcessOwnershipInspectionActive(signal);
|
|
|
|
let afterStatus: string;
|
|
try {
|
|
afterStatus = await readProcessInspectionFile(
|
|
`/proc/${String(pid)}/stat`,
|
|
signal,
|
|
);
|
|
} catch (cause: unknown) {
|
|
rethrowInspectionFailure(cause);
|
|
return [pid, readableIdentity(parsed)] as const;
|
|
}
|
|
assertProcessOwnershipInspectionActive(signal);
|
|
const verified = parseLinuxProcessStat(afterStatus);
|
|
if (
|
|
verified !== undefined &&
|
|
verified.pid === pid &&
|
|
verified.startTime !== parsed.startTime
|
|
)
|
|
return [
|
|
pid,
|
|
{
|
|
state: "unavailable",
|
|
reason: "process_identity_changed_during_uid_observation",
|
|
} as const,
|
|
] as const;
|
|
if (verified === undefined || verified.pid !== pid)
|
|
return [pid, readableIdentity(parsed)] as const;
|
|
return [
|
|
pid,
|
|
readableIdentity(verified, uids[1], environmentRestriction),
|
|
] as const;
|
|
} catch (cause: unknown) {
|
|
rethrowInspectionFailure(cause);
|
|
if (
|
|
cause instanceof Error &&
|
|
"code" in cause &&
|
|
(cause.code === "ENOENT" || cause.code === "ESRCH")
|
|
) {
|
|
assertProcessOwnershipInspectionActive(signal);
|
|
let presenceProbeFailure: unknown;
|
|
let presenceProbeSucceeded = false;
|
|
try {
|
|
presenceProbeSucceeded = process.kill(pid, 0);
|
|
} catch (probeCause: unknown) {
|
|
presenceProbeFailure = probeCause;
|
|
}
|
|
assertProcessOwnershipInspectionActive(signal);
|
|
if (
|
|
presenceProbeFailure instanceof Error &&
|
|
"code" in presenceProbeFailure &&
|
|
presenceProbeFailure.code === "ESRCH"
|
|
)
|
|
return [
|
|
pid,
|
|
{
|
|
state: "absent",
|
|
reason: "not-found-in-caller-pid-namespace",
|
|
} as const,
|
|
] as const;
|
|
physicalReadFailures.push({ pid, requestIndex, cause });
|
|
return [
|
|
pid,
|
|
{
|
|
state: "unavailable",
|
|
reason:
|
|
presenceProbeFailure === undefined && presenceProbeSucceeded
|
|
? "proc_stat_missing_but_process_signal_probe_succeeded"
|
|
: presenceProbeFailure === undefined
|
|
? "proc_stat_missing_and_process_signal_probe_returned_false"
|
|
: `proc_stat_missing_and_process_signal_probe_failed: ${errorMessage(presenceProbeFailure)}`,
|
|
} as const,
|
|
] as const;
|
|
}
|
|
physicalReadFailures.push({ pid, requestIndex, cause });
|
|
return [
|
|
pid,
|
|
{ state: "unavailable", reason: errorMessage(cause) } as const,
|
|
] as const;
|
|
}
|
|
}),
|
|
);
|
|
const observations = new Map<number, ProcessIdentityObservation>();
|
|
const failures: { pid: number; requestIndex: number; cause: unknown }[] =
|
|
[];
|
|
results.forEach((result, requestIndex) => {
|
|
if (result.status === "fulfilled")
|
|
observations.set(result.value[0], result.value[1]);
|
|
else
|
|
failures.push({
|
|
pid: processes[requestIndex]!.pid,
|
|
requestIndex,
|
|
cause: result.reason,
|
|
});
|
|
});
|
|
if (failures.length > 0)
|
|
throw new ProcessOwnershipIdentityBatchFailure(
|
|
failures,
|
|
observations,
|
|
physicalReadFailures,
|
|
);
|
|
return observations;
|
|
}
|
|
if (darwinTokens === undefined) return new Map();
|
|
try {
|
|
return await darwinTokens.identities(processes, signal);
|
|
} catch (cause: unknown) {
|
|
rethrowInspectionFailure(cause);
|
|
const reason = errorMessage(cause);
|
|
return new Map(
|
|
processes.map(({ pid }) => [
|
|
pid,
|
|
{ state: "unavailable", reason } as const,
|
|
]),
|
|
);
|
|
}
|
|
};
|
|
const safeListProcesses = async (signal?: AbortSignal) => {
|
|
try {
|
|
return await listProcesses(signal);
|
|
} catch (cause: unknown) {
|
|
rethrowInspectionFailure(cause);
|
|
const failure = execFileOutputFailure(cause);
|
|
if (
|
|
spawnCapacityExhausted(failure) ||
|
|
(typeof cause === "object" &&
|
|
cause !== null &&
|
|
typeof Reflect.get(cause, "code") === "string" &&
|
|
HOST_SPAWN_CAPACITY_CODES.has(Reflect.get(cause, "code") as string))
|
|
)
|
|
throw cause;
|
|
const rawCode =
|
|
failure?.code ??
|
|
(typeof cause === "object" && cause !== null
|
|
? Reflect.get(cause, "code")
|
|
: undefined);
|
|
const systemCode = typeof rawCode === "string" ? rawCode : undefined;
|
|
if (platform === "linux")
|
|
throw new LinuxOwnedProcessPrerequisiteError(cause, {
|
|
missing: systemCode === "ENOENT",
|
|
systemCode,
|
|
});
|
|
throw new ProcessAdmissionError(
|
|
"baseline_unavailable",
|
|
systemCode === undefined ? undefined : { system_code: systemCode },
|
|
{ cause },
|
|
);
|
|
}
|
|
};
|
|
const captureBaseline = async (
|
|
signal?: AbortSignal,
|
|
): Promise<ProcessOwnershipBaseline> => {
|
|
if (platform !== "linux" && darwinTokens === undefined) return [];
|
|
const processes = liveProcesses(await safeListProcesses(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 safeListProcesses(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 safeListProcesses(signal));
|
|
const stillUnresolved = unresolved.filter(({ pid }) =>
|
|
liveAfterRetry.some((entry) => entry.pid === pid),
|
|
);
|
|
if (stillUnresolved.length > 0)
|
|
throw new ProcessAdmissionError("identity_snapshot_unavailable", {
|
|
live_processes: stillUnresolved.length,
|
|
});
|
|
}
|
|
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);
|
|
if (platform === "linux") {
|
|
const processes = await safeListProcesses(signal);
|
|
if (!processes.some(({ pid }) => pid === process.pid))
|
|
throw new LinuxOwnedProcessPrerequisiteError(
|
|
new ProcessAdmissionError("current_process_unreported"),
|
|
);
|
|
}
|
|
},
|
|
listProcesses,
|
|
async environment(pid, signal) {
|
|
assertProcessOwnershipInspectionActive(signal);
|
|
if (platform === "linux")
|
|
return parseProcessEnvironment(
|
|
await readProcessInspectionFile(`/proc/${pid}/environ`, signal),
|
|
);
|
|
if (platform === "darwin") {
|
|
const process = (await listProcesses(signal)).find(
|
|
(entry) => entry.pid === pid,
|
|
);
|
|
if (process === undefined)
|
|
throw new ProcessRunTokenReadError(
|
|
pid,
|
|
"absent",
|
|
"process_not_in_snapshot",
|
|
);
|
|
const observation = (await darwinTokens?.read([process], signal))?.get(
|
|
pid,
|
|
);
|
|
if (observation === undefined || observation.state === "unavailable")
|
|
throw new ProcessRunTokenReadError(
|
|
pid,
|
|
observation?.reason === "process_not_in_snapshot"
|
|
? "absent"
|
|
: "unavailable",
|
|
observation?.reason ?? "process run token could not be read",
|
|
);
|
|
const environment: Record<string, string> = Object.create(null);
|
|
if (observation.runId !== undefined)
|
|
environment.REA_PROCESS_RUN_ID = observation.runId;
|
|
return environment;
|
|
}
|
|
const { stdout } = await runExecFileOutput(
|
|
"ps",
|
|
["eww", "-p", String(pid)],
|
|
{
|
|
env: hostEnvironment,
|
|
stopSignal: "SIGTERM",
|
|
...(signal === undefined ? {} : { signal }),
|
|
},
|
|
);
|
|
const observedEnvironment: Record<string, string> = Object.create(null);
|
|
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, signal) {
|
|
assertProcessOwnershipInspectionActive(signal);
|
|
if (platform !== "darwin" || processes.length === 0) return new Map();
|
|
try {
|
|
return (await darwinTokens?.read(processes, signal)) ?? new Map();
|
|
} catch (cause: unknown) {
|
|
rethrowInspectionFailure(cause);
|
|
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);
|
|
},
|
|
};
|
|
};
|
|
|
|
export const systemProcessOwnershipHost = createSystemProcessOwnershipHost();
|
|
|
|
/**
|
|
* Observe a live PID's start identity, optionally binding it to an owned run.
|
|
* Token validation is bracketed by identity reads so PID reuse cannot supply
|
|
* the first identity of a different process for an owned launch.
|
|
*/
|
|
export const observeProcessStartIdentity = async (
|
|
pid: number,
|
|
host: ProcessOwnershipHost = systemProcessOwnershipHost,
|
|
expectedRunId?: string,
|
|
): Promise<ProcessIdentityObservation | undefined> => {
|
|
if (host.processIdentities === undefined)
|
|
return { state: "unavailable", reason: "process identity is unsupported" };
|
|
const identity: ProcessIdentityObservation = (
|
|
await host.processIdentities([{ pid }])
|
|
).get(pid) ?? {
|
|
state: "unavailable",
|
|
reason: "process identity was not returned",
|
|
};
|
|
if (identity.state === "absent" || processIdentityHasExited(identity))
|
|
return undefined;
|
|
if (expectedRunId === undefined || identity.state !== "readable")
|
|
return identity;
|
|
const process = (await host.listProcesses()).find(
|
|
(entry) => entry.pid === pid,
|
|
);
|
|
let token: ProcessRunTokenObservation | undefined;
|
|
// Darwin's environment() admits a token only after `ps` lists the PID.
|
|
// The native reader already observed this live identity by PID, so an
|
|
// omitted row uses that reader directly instead of another process table.
|
|
if (process === undefined && host.runTokens !== undefined)
|
|
token = (await host.runTokens([{ pid }])).get(pid);
|
|
if (token === undefined) {
|
|
if (process === undefined) token = await readProcessRunToken(host, pid);
|
|
else
|
|
for await (const { observation } of readProcessRunTokens(host, [process]))
|
|
token = observation;
|
|
}
|
|
if (token === undefined || token.state === "unavailable")
|
|
return {
|
|
state: "unavailable",
|
|
reason: `process ownership token could not be read for PID ${String(pid)}: ${token?.reason ?? "token was not returned"}`,
|
|
};
|
|
if (token.runId !== expectedRunId)
|
|
return {
|
|
state: "unavailable",
|
|
reason: `process ownership token did not match the captured run for PID ${String(pid)}`,
|
|
};
|
|
const confirmed = await observeProcessStartIdentity(pid, host);
|
|
if (confirmed === undefined || confirmed.state !== "readable")
|
|
return confirmed;
|
|
return confirmed.identity === identity.identity
|
|
? identity
|
|
: {
|
|
state: "unavailable",
|
|
reason: `process identity changed while validating the captured run token for PID ${String(pid)}`,
|
|
};
|
|
};
|
|
|
|
/** Confirm whether the process that held an authenticated bridge has exited. */
|
|
export const observeProcessStartIdentityExit = async (
|
|
pid: number,
|
|
expectedIdentity: string,
|
|
host: ProcessOwnershipHost = systemProcessOwnershipHost,
|
|
): Promise<"exited" | "present" | "unverified"> => {
|
|
try {
|
|
const observed = await observeProcessStartIdentity(pid, host);
|
|
if (observed === undefined) return "exited";
|
|
if (observed.state !== "readable") return "unverified";
|
|
return observed.identity === expectedIdentity ? "present" : "exited";
|
|
} catch (cause: unknown) {
|
|
rethrowInspectionFailure(cause);
|
|
return "unverified";
|
|
}
|
|
};
|
|
|
|
/** Revalidate a launch-time start identity immediately before signaling a PID. */
|
|
export const signalProcessWithStartIdentity = async (
|
|
pid: number,
|
|
expectedIdentity: string,
|
|
signal: NodeJS.Signals,
|
|
options: {
|
|
readonly host?: ProcessOwnershipHost;
|
|
readonly sendSignal?: (pid: number, signal: NodeJS.Signals) => void;
|
|
} = {},
|
|
): Promise<"signaled" | "gone" | "identity-changed" | "unverified"> => {
|
|
const host = options.host ?? systemProcessOwnershipHost;
|
|
try {
|
|
const observed = await observeProcessStartIdentity(pid, host);
|
|
if (observed === undefined) return "gone";
|
|
if (observed.state !== "readable") return "unverified";
|
|
if (observed.identity !== expectedIdentity) return "identity-changed";
|
|
(options.sendSignal ?? process.kill)(pid, signal);
|
|
return "signaled";
|
|
} catch (cause: unknown) {
|
|
rethrowInspectionFailure(cause);
|
|
return "unverified";
|
|
}
|
|
};
|
|
|
|
/** Actionable remediation when Linux `ps` is missing or incompatible. */
|
|
export const LINUX_PROCESS_OWNERSHIP_REMEDIATION =
|
|
"Install your distribution's procps or procps-ng package, or put a procps-compatible ps on REA's PATH, then retry.";
|
|
|
|
/** Options describing why Linux process ownership inspection could not run `ps`. */
|
|
export interface LinuxOwnedProcessPrerequisiteOptions {
|
|
readonly missing?: boolean;
|
|
readonly systemCode?: string | undefined;
|
|
}
|
|
|
|
/** Refusal before an owned launch when Linux `ps` is missing or incompatible. */
|
|
export class LinuxOwnedProcessPrerequisiteError extends AnalysisCapabilityUnavailableError {
|
|
readonly systemCode: string | undefined;
|
|
readonly admissionCode: string | undefined;
|
|
|
|
constructor(
|
|
cause: unknown,
|
|
optionsOrSystemCode: LinuxOwnedProcessPrerequisiteOptions | string = {},
|
|
) {
|
|
const options: LinuxOwnedProcessPrerequisiteOptions =
|
|
typeof optionsOrSystemCode === "string"
|
|
? {
|
|
missing: optionsOrSystemCode === "ENOENT",
|
|
systemCode: optionsOrSystemCode,
|
|
}
|
|
: optionsOrSystemCode;
|
|
const admissionCode =
|
|
cause instanceof ProcessAdmissionError ? cause.code : undefined;
|
|
const rawCode =
|
|
options.systemCode ??
|
|
execFileOutputFailure(cause)?.code ??
|
|
(typeof cause === "object" && cause !== null
|
|
? Reflect.get(cause, "code")
|
|
: undefined);
|
|
const resolvedCode =
|
|
admissionCode === undefined && typeof rawCode === "string"
|
|
? rawCode
|
|
: undefined;
|
|
const isMissing =
|
|
admissionCode === undefined &&
|
|
(options.missing ??
|
|
(resolvedCode === "ENOENT" || resolvedCode === undefined));
|
|
const reason =
|
|
admissionCode !== undefined || isMissing
|
|
? `Linux process ownership inspection requires a procps-compatible ps on REA's PATH before launching a child: ${errorMessage(cause)}`
|
|
: resolvedCode !== undefined
|
|
? `Linux process ownership inspection failed to execute ps (${resolvedCode}): ${errorMessage(cause)}`
|
|
: `Linux process ownership inspection could not obtain the process table using ps: ${errorMessage(cause)}`;
|
|
const remediation =
|
|
admissionCode !== undefined
|
|
? processOwnershipAdmissionRecovery(admissionCode, "linux")
|
|
: isMissing
|
|
? LINUX_PROCESS_OWNERSHIP_REMEDIATION
|
|
: processOwnershipAdmissionRecovery(
|
|
"baseline_unavailable",
|
|
"linux",
|
|
resolvedCode,
|
|
);
|
|
super("process-ownership", "prepare_owned_process", reason, {
|
|
cause,
|
|
userMessage: reason,
|
|
remediationAction: remediation,
|
|
diagnostics: {
|
|
boundary: "process-ownership",
|
|
...(admissionCode === undefined
|
|
? {}
|
|
: { admission_code: admissionCode }),
|
|
...(resolvedCode === undefined ? {} : { system_code: resolvedCode }),
|
|
},
|
|
});
|
|
this.systemCode = resolvedCode;
|
|
this.admissionCode = admissionCode;
|
|
}
|
|
}
|
|
|
|
/** Actionable remediation for Darwin process ownership inspection preparation failure. */
|
|
export const DARWIN_PROCESS_OWNERSHIP_REMEDIATION =
|
|
"Check the selected Xcode or Command Line Tools and Swift compiler configuration, then restart REA and retry.";
|
|
|
|
/** Actionable recovery guidance for process ownership admission failures. */
|
|
export const processOwnershipAdmissionRecovery = (
|
|
code: string,
|
|
platform: NodeJS.Platform = systemProcessOwnershipHost.platform ??
|
|
process.platform,
|
|
systemCode?: string,
|
|
): string => {
|
|
if (code === "identity_snapshot_unavailable")
|
|
return platform === "darwin"
|
|
? "Check host process churn and ensure REA has permission to inspect process identities, then retry once process state settles."
|
|
: "Check host process churn and ensure REA has permission to inspect /proc entries for running processes, then retry once process state settles.";
|
|
if (code === "current_process_unreported")
|
|
return platform === "darwin"
|
|
? "Ensure the host process table reports the running REA process under ps, then retry."
|
|
: "Ensure the host process table reports the running REA process under /proc or ps, then retry.";
|
|
if (code === "baseline_unavailable") {
|
|
if (systemCode === "ENOENT")
|
|
return platform === "darwin"
|
|
? "Ensure ps is available on PATH and process ownership inspection is supported on macOS before launching captured children."
|
|
: LINUX_PROCESS_OWNERSHIP_REMEDIATION;
|
|
if (systemCode !== undefined)
|
|
return `Verify host process table inspection can execute ps (${systemCode}) before launching captured children, then retry.`;
|
|
return platform === "darwin"
|
|
? "Ensure ps is available on PATH and process ownership inspection is supported on macOS before launching captured children."
|
|
: "Ensure process ownership inspection is supported on the host platform before launching captured children.";
|
|
}
|
|
return "Verify host process ownership inspection prerequisites before retrying.";
|
|
};
|
|
|
|
/** Prepare native token inspection before REA launches a captured child. */
|
|
export const prepareProcessOwnershipInspection = async (
|
|
signal?: AbortSignal,
|
|
): Promise<void> => {
|
|
assertProcessOwnershipInspectionActive(signal);
|
|
await systemProcessOwnershipHost.prepare?.(signal);
|
|
assertProcessOwnershipInspectionActive(signal);
|
|
};
|
|
|
|
const processIsGone = async (
|
|
host: ProcessOwnershipHost,
|
|
pid: number,
|
|
signal?: AbortSignal,
|
|
): Promise<boolean> => {
|
|
try {
|
|
const processes =
|
|
signal === undefined
|
|
? await host.listProcesses()
|
|
: await host.listProcesses(signal);
|
|
return !liveProcesses(processes).some((process) => process.pid === pid);
|
|
} catch (cause: unknown) {
|
|
rethrowInspectionFailure(cause);
|
|
// A failed optional probe cannot establish that the process exited.
|
|
void cause;
|
|
return false;
|
|
}
|
|
};
|
|
|
|
export const errorMessage = (cause: unknown): string =>
|
|
cause instanceof Error ? cause.message : String(cause);
|