mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(process): require observed environment ownership tokens
Centralize run-token extraction at the environment observation boundary and require an own field. Build decoded OS environment records without host prototypes while retaining source names, empty values and last-assignment precedence. Reject inherited matching tokens before cleanup signaling, preserve normal owned-group behavior, and verify live Linux process environments alongside native CLI/MCP capture workflows.
This commit is contained in:
@@ -3,6 +3,22 @@ import { expect, it } from "vitest";
|
||||
import type { ProcessOwnershipHost } from "./ProcessOwnership.js";
|
||||
import { readProcessRunId } from "./ProcessOwnership.js";
|
||||
|
||||
it.each([undefined, "selected-token", ""])(
|
||||
"reads only the process environment's own run token %j",
|
||||
async (selected) => {
|
||||
const environment: Record<string, string> = Object.create({
|
||||
REA_PROCESS_RUN_ID: "inherited-token",
|
||||
});
|
||||
if (selected !== undefined) environment.REA_PROCESS_RUN_ID = selected;
|
||||
const host: ProcessOwnershipHost = {
|
||||
listProcesses: async () => [],
|
||||
environment: async () => environment,
|
||||
signalGroup: () => undefined,
|
||||
};
|
||||
expect(await readProcessRunId(42, host)).toBe(selected);
|
||||
},
|
||||
);
|
||||
|
||||
it("passes cancellation through run-token reads", async () => {
|
||||
const controller = new AbortController();
|
||||
const cancellation = new Error("cancelled");
|
||||
|
||||
@@ -44,10 +44,18 @@ describe("owned process-group cleanup discovery", () => {
|
||||
]),
|
||||
).toEqual([100, 102]);
|
||||
});
|
||||
it("drops nameless Linux environment entries", () => {
|
||||
expect(
|
||||
parseProcessEnvironment("=ignored\0REA_PROCESS_RUN_ID=owned\0EMPTY=\0"),
|
||||
).toEqual({ REA_PROCESS_RUN_ID: "owned", EMPTY: "" });
|
||||
it("preserves only named source entries in the Linux environment", () => {
|
||||
const environment = parseProcessEnvironment(
|
||||
"=ignored\0REA_PROCESS_RUN_ID=earlier\0EMPTY=\0REA_PROCESS_RUN_ID=owned\0constructor=source\0__proto__=retained\0VALUE=a=b\0",
|
||||
);
|
||||
expect(environment).toEqual({
|
||||
REA_PROCESS_RUN_ID: "owned",
|
||||
EMPTY: "",
|
||||
constructor: "source",
|
||||
["__proto__"]: "retained",
|
||||
VALUE: "a=b",
|
||||
});
|
||||
expect(environment["toString"]).toBeUndefined();
|
||||
});
|
||||
it("matches macOS executable names after process-table normalization", () => {
|
||||
expect(
|
||||
@@ -89,6 +97,19 @@ describe("owned process-group cleanup discovery", () => {
|
||||
});
|
||||
expect(signalGroup).toHaveBeenCalledWith(100, "SIGKILL");
|
||||
});
|
||||
it("does not signal a group when a member only inherits its matching token", async () => {
|
||||
const inherited: Record<string, string> = Object.create({
|
||||
REA_PROCESS_RUN_ID: ownership.runId,
|
||||
});
|
||||
const { adapter, signalGroup } = host({
|
||||
100: { REA_PROCESS_RUN_ID: ownership.runId },
|
||||
101: inherited,
|
||||
});
|
||||
expect(await cleanupOwnedProcessGroup(ownership, adapter)).toMatchObject({
|
||||
cleaned: false,
|
||||
});
|
||||
expect(signalGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("opaque process neighbors during cleanup", () => {
|
||||
|
||||
@@ -12,7 +12,10 @@ import {
|
||||
} from "./ProcessOwnershipObservation.js";
|
||||
import { launcherIdentityFailure } from "./ProcessOwnershipIdentity.js";
|
||||
import { descendantsOf, liveProcesses } from "./ProcessOwnershipProcessTree.js";
|
||||
import { readProcessRunTokens } from "./ProcessRunTokenObservations.js";
|
||||
import {
|
||||
processRunIdFromEnvironment,
|
||||
readProcessRunTokens,
|
||||
} from "./ProcessRunTokenObservations.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
import type { LinuxEnvironmentRestriction } from "./LinuxProcessStatus.js";
|
||||
import { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js";
|
||||
@@ -310,7 +313,7 @@ export const readProcessRunId = async (
|
||||
host: ProcessOwnershipHost = systemHost,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string | undefined> =>
|
||||
(await host.environment(pid, signal)).REA_PROCESS_RUN_ID;
|
||||
processRunIdFromEnvironment(await host.environment(pid, signal));
|
||||
|
||||
/** Merge duplicate uncertainty rows from discovery and final verification. */
|
||||
const mergeUnverifiedObservations = (
|
||||
|
||||
@@ -691,16 +691,15 @@ const unavailableLineage = (
|
||||
/** Parse the NUL-delimited Linux process environment without nameless keys. */
|
||||
export const parseProcessEnvironment = (
|
||||
value: string,
|
||||
): Readonly<Record<string, string>> =>
|
||||
Object.fromEntries(
|
||||
value
|
||||
.split("\0")
|
||||
.filter((entry) => entry.indexOf("=") > 0)
|
||||
.map((entry) => {
|
||||
const separator = entry.indexOf("=");
|
||||
return [entry.slice(0, separator), entry.slice(separator + 1)];
|
||||
}),
|
||||
);
|
||||
): 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,
|
||||
@@ -1274,9 +1273,10 @@ export const createSystemProcessOwnershipHost = (
|
||||
throw new Error(
|
||||
observation?.reason ?? "process run token could not be read",
|
||||
);
|
||||
return observation.runId === undefined
|
||||
? {}
|
||||
: { REA_PROCESS_RUN_ID: observation.runId };
|
||||
const environment: Record<string, string> = Object.create(null);
|
||||
if (observation.runId !== undefined)
|
||||
environment.REA_PROCESS_RUN_ID = observation.runId;
|
||||
return environment;
|
||||
}
|
||||
const { stdout } = await execFileOutput(
|
||||
"ps",
|
||||
@@ -1287,7 +1287,7 @@ export const createSystemProcessOwnershipHost = (
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
},
|
||||
);
|
||||
const observedEnvironment: Record<string, string> = {};
|
||||
const observedEnvironment: Record<string, string> = Object.create(null);
|
||||
for (const match of stdout.matchAll(
|
||||
/(?:^|\s)([A-Za-z_][A-Za-z0-9_]*)=([^\s]*)/gu,
|
||||
)) {
|
||||
|
||||
@@ -7,6 +7,14 @@ import type {
|
||||
const errorMessage = (cause: unknown): string =>
|
||||
cause instanceof Error ? cause.message : String(cause);
|
||||
|
||||
/** Only a variable observed on this process supplies its run token. */
|
||||
export const processRunIdFromEnvironment = (
|
||||
environment: Readonly<Record<string, string>>,
|
||||
): string | undefined =>
|
||||
Object.hasOwn(environment, "REA_PROCESS_RUN_ID")
|
||||
? environment.REA_PROCESS_RUN_ID
|
||||
: undefined;
|
||||
|
||||
/** Read only ownership tokens, using per-process fallback for missing batch rows. */
|
||||
export async function* readProcessRunTokens(
|
||||
host: ProcessOwnershipHost,
|
||||
@@ -42,7 +50,7 @@ export async function* readProcessRunTokens(
|
||||
: await host.environment(process.pid, signal);
|
||||
observation = {
|
||||
state: "readable",
|
||||
runId: environment.REA_PROCESS_RUN_ID,
|
||||
runId: processRunIdFromEnvironment(environment),
|
||||
};
|
||||
} catch (cause: unknown) {
|
||||
signal?.throwIfAborted();
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { once } from "node:events";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { readProcessRunId } from "../../../src/process/ProcessOwnership.js";
|
||||
import { createSystemProcessOwnershipHost } from "../../../src/process/ProcessOwnershipObservation.js";
|
||||
import { readProcessRunTokens } from "../../../src/process/ProcessRunTokenObservations.js";
|
||||
import { processTest } from "../../support/process/processFixture.js";
|
||||
|
||||
processTest.skipIf(process.platform !== "linux").for([
|
||||
{ name: "absent", runId: undefined },
|
||||
{ name: "selected", runId: "source-process-token" },
|
||||
])(
|
||||
"reads $name run tokens from the live process environment",
|
||||
async ({ runId }, { processes }) => {
|
||||
const environment = {
|
||||
EMPTY: "",
|
||||
VALUE: "a=b",
|
||||
["__proto__"]: "source-value",
|
||||
...(runId === undefined ? {} : { REA_PROCESS_RUN_ID: runId }),
|
||||
};
|
||||
const child = processes.spawn(
|
||||
process.execPath,
|
||||
["-e", "process.stdin.resume()"],
|
||||
{ env: environment },
|
||||
);
|
||||
await once(child, "spawn");
|
||||
const host = createSystemProcessOwnershipHost("linux", {});
|
||||
const row = (await host.listProcesses()).find(
|
||||
({ pid }) => pid === child.pid,
|
||||
);
|
||||
if (row === undefined) throw new Error("Selected child was not observed");
|
||||
|
||||
expect(await host.environment(row.pid)).toEqual(environment);
|
||||
expect(await readProcessRunId(row.pid, host)).toBe(runId);
|
||||
const observations = [];
|
||||
for await (const observation of readProcessRunTokens(host, [row]))
|
||||
observations.push(observation);
|
||||
expect(observations).toEqual([
|
||||
{ process: row, observation: { state: "readable", runId } },
|
||||
]);
|
||||
child.stdin.end();
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user