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:
morluto
2026-10-23 22:13:04 +08:00
parent 5f702dd6cc
commit a68cfd7db0
6 changed files with 112 additions and 21 deletions
@@ -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");
+25 -4
View File
@@ -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", () => {
+5 -2
View File
@@ -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 = (
+14 -14
View File
@@ -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,
)) {
+9 -1
View File
@@ -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();
},
);