mirror of
https://github.com/morluto/rea.git
synced 2026-11-14 10:43:30 +00:00
* fix(process): revalidate ownership before signaling * fix(process): retain cleanup ownership until completion * fix(process): keep omitted live descendants unresolved A non-empty process table that omits a retained live PID cannot authorize a signal or certify cleanup. An empty table can still describe a token-verified owner. * fix(process): read omitted Darwin run tokens by PID A live identity that ps omitted was still validated through Darwin environment(), which rejects any PID absent from a second process table. Ask the native reader for that PID directly so the launch token can bind. --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
382 lines
12 KiB
TypeScript
382 lines
12 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import type {
|
|
OwnedProcessGroup,
|
|
ProcessLineageObservation,
|
|
ProcessOwnershipHost,
|
|
ProcessTableEntry,
|
|
} from "../process/ProcessOwnership.js";
|
|
import { ProcessOwnershipEvidenceOwner } from "../process/ProcessOwnershipEvidence.js";
|
|
import { cleanupElectronProcesses } from "./ElectronProcessCleanup.js";
|
|
import { observeOwnedProcessLineage } from "../process/ProcessOwnershipObservation.js";
|
|
|
|
describe.skipIf(process.platform === "win32")(
|
|
"Electron cleanup ownership evidence",
|
|
() => {
|
|
it("keeps a previously token-owned helper unresolved after it reparents and changes groups", async () => {
|
|
let rows: readonly ProcessTableEntry[] = [
|
|
{
|
|
pid: 100,
|
|
parentPid: 1,
|
|
processGroupId: 100,
|
|
state: "S",
|
|
command: "electron",
|
|
},
|
|
{
|
|
pid: 101,
|
|
parentPid: 100,
|
|
processGroupId: 100,
|
|
state: "S",
|
|
command: "electron-helper",
|
|
},
|
|
];
|
|
let withholdToken = false;
|
|
const evidenceOwner = new ProcessOwnershipEvidenceOwner();
|
|
const ownership: OwnedProcessGroup = {
|
|
runId: "electron-run",
|
|
leaderPid: 100,
|
|
processGroupId: 100,
|
|
expectedParentPid: 1,
|
|
expectedCommand: "electron",
|
|
sweepTokenOwnedProcesses: true,
|
|
evidenceOwner,
|
|
};
|
|
const host: ProcessOwnershipHost = {
|
|
platform: process.platform,
|
|
listProcesses: () => Promise.resolve(rows),
|
|
environment: () => Promise.resolve({}),
|
|
runTokens: (members) =>
|
|
Promise.resolve(
|
|
new Map(
|
|
members.map(({ pid }) => [
|
|
pid,
|
|
withholdToken
|
|
? {
|
|
state: "unavailable" as const,
|
|
reason: "platform_binary_environment_withheld",
|
|
}
|
|
: { state: "readable" as const, runId: "electron-run" },
|
|
]),
|
|
),
|
|
),
|
|
processIdentities: (members) =>
|
|
Promise.resolve(
|
|
new Map(
|
|
members.map(({ pid }) => {
|
|
const current = rows.find((entry) => entry.pid === pid);
|
|
return [
|
|
pid,
|
|
current === undefined
|
|
? {
|
|
state: "absent" as const,
|
|
reason:
|
|
process.platform === "darwin"
|
|
? ("not-in-snapshot" as const)
|
|
: ("not-found-in-caller-pid-namespace" as const),
|
|
}
|
|
: {
|
|
state: "readable" as const,
|
|
identity: `start-${String(pid)}`,
|
|
lifecycle: {
|
|
state: "observed" as const,
|
|
lifecycle: "non-zombie" as const,
|
|
},
|
|
relation: {
|
|
state: "observed" as const,
|
|
parentPid: current.parentPid,
|
|
processGroupId: current.processGroupId,
|
|
},
|
|
},
|
|
];
|
|
}),
|
|
),
|
|
),
|
|
signalGroup: vi.fn(() => {
|
|
rows = [
|
|
{
|
|
pid: 101,
|
|
parentPid: 1,
|
|
processGroupId: 200,
|
|
state: "S",
|
|
command: "electron-helper",
|
|
},
|
|
];
|
|
withholdToken = true;
|
|
}),
|
|
};
|
|
const lineage: ProcessLineageObservation = {
|
|
status: "verified",
|
|
observedAt: "2026-10-19T00:00:00.000Z",
|
|
lineage: {
|
|
runId: ownership.runId,
|
|
launcherPid: ownership.leaderPid,
|
|
launcherParentPid: 1,
|
|
processGroupId: ownership.processGroupId,
|
|
descendants: [{ pid: 101, parentPid: 100, processGroupId: 100 }],
|
|
},
|
|
};
|
|
|
|
const result = await cleanupElectronProcesses(ownership, lineage, {
|
|
platform: process.platform,
|
|
terminateTree: vi.fn(),
|
|
processOwnershipHost: host,
|
|
});
|
|
|
|
expect(result.cleaned).toBe(false);
|
|
if (result.cleaned)
|
|
throw new Error("expected cleanup to remain unresolved");
|
|
expect(result.reason).toContain(
|
|
"process ownership token could not be read",
|
|
);
|
|
expect(evidenceOwner.candidates).toMatchObject([
|
|
{
|
|
pid: 101,
|
|
identity: "start-101",
|
|
applicability: "token-verified",
|
|
scopeEvidence: "run-token",
|
|
relation: { parentPid: 1, processGroupId: 200 },
|
|
},
|
|
]);
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{ peerFails: false, withholdToken: true },
|
|
{ peerFails: true, withholdToken: true },
|
|
{ peerFails: false, withholdToken: false },
|
|
])(
|
|
"retains detached lineage evidence (initial peer failure: $peerFails, withheld token: $withholdToken)",
|
|
async ({ peerFails, withholdToken }) => {
|
|
let detached = false;
|
|
const launcher: ProcessTableEntry = {
|
|
pid: 100,
|
|
parentPid: 1,
|
|
processGroupId: 100,
|
|
state: "S",
|
|
command: "electron",
|
|
};
|
|
const child: ProcessTableEntry = {
|
|
pid: 101,
|
|
parentPid: 100,
|
|
processGroupId: 100,
|
|
state: "S",
|
|
command: "helper",
|
|
};
|
|
const evidenceOwner = new ProcessOwnershipEvidenceOwner();
|
|
const ownership = {
|
|
runId: "lineage-run",
|
|
leaderPid: 100,
|
|
processGroupId: 100,
|
|
sweepTokenOwnedProcesses: true as const,
|
|
evidenceOwner,
|
|
};
|
|
let launcherExited = false;
|
|
let childExited = false;
|
|
const signalGroup = vi.fn((groupId: number) => {
|
|
if (groupId === 100) launcherExited = true;
|
|
if (groupId === 200) childExited = true;
|
|
});
|
|
|
|
const host: ProcessOwnershipHost = {
|
|
listProcesses: async () => [
|
|
...(launcherExited ? [] : [launcher]),
|
|
...(childExited
|
|
? []
|
|
: [
|
|
detached
|
|
? { ...child, parentPid: 1, processGroupId: 200 }
|
|
: child,
|
|
]),
|
|
...(!detached && peerFails ? [{ ...child, pid: 102 }] : []),
|
|
],
|
|
environment: async () => ({}),
|
|
runTokens: async (members) =>
|
|
new Map(
|
|
members.map(({ pid }) => [
|
|
pid,
|
|
(detached && withholdToken && pid === 101) ||
|
|
(peerFails && pid === 102)
|
|
? {
|
|
state: "unavailable" as const,
|
|
reason: "platform_binary_environment_withheld",
|
|
}
|
|
: { state: "readable" as const, runId: ownership.runId },
|
|
]),
|
|
),
|
|
processIdentities: async (members) =>
|
|
new Map(
|
|
members.map(({ pid }) => [
|
|
pid,
|
|
(launcherExited && pid === 100) ||
|
|
(childExited && pid === 101) ||
|
|
(detached && pid === 102)
|
|
? { state: "absent" as const, reason: "not-in-snapshot" as const }
|
|
: {
|
|
state: "readable" as const,
|
|
identity: `generation-${pid}`,
|
|
lifecycle: {
|
|
state: "observed" as const,
|
|
lifecycle: "non-zombie" as const,
|
|
},
|
|
relation: {
|
|
state: "observed" as const,
|
|
parentPid: pid === 100 || detached ? 1 : 100,
|
|
processGroupId: detached && pid === 101 ? 200 : 100,
|
|
},
|
|
},
|
|
]),
|
|
),
|
|
signalGroup,
|
|
};
|
|
const first = await observeOwnedProcessLineage(ownership, host);
|
|
expect(first.status).toBe(peerFails ? "unavailable" : "verified");
|
|
expect(evidenceOwner.candidates).toContainEqual(
|
|
expect.objectContaining({
|
|
pid: 101,
|
|
identity: "generation-101",
|
|
applicability: "token-verified",
|
|
}),
|
|
);
|
|
detached = true;
|
|
const current = await observeOwnedProcessLineage(ownership, host);
|
|
expect(current).toMatchObject({
|
|
status: "verified",
|
|
lineage: { descendants: [] },
|
|
});
|
|
const result = await cleanupElectronProcesses(ownership, current, {
|
|
platform: "darwin",
|
|
terminateTree: vi.fn(),
|
|
processOwnershipHost: host,
|
|
});
|
|
if (withholdToken) {
|
|
expect(result).toMatchObject({ cleaned: false });
|
|
expect(signalGroup).not.toHaveBeenCalledWith(200, expect.anything());
|
|
expect(evidenceOwner.candidates).toContainEqual(
|
|
expect.objectContaining({
|
|
pid: 101,
|
|
identity: "generation-101",
|
|
applicability: "token-verified",
|
|
}),
|
|
);
|
|
} else {
|
|
expect(result).toMatchObject({ cleaned: true, signaled: true });
|
|
expect(signalGroup.mock.calls).toEqual([
|
|
[100, "SIGKILL"],
|
|
[200, "SIGKILL"],
|
|
]);
|
|
expect(evidenceOwner.candidates).toEqual([]);
|
|
}
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"signals every verified captured group before the shared final census",
|
|
async () => {
|
|
let rows: readonly ProcessTableEntry[] = [
|
|
{
|
|
pid: 100,
|
|
parentPid: 1,
|
|
processGroupId: 100,
|
|
state: "S",
|
|
command: "electron",
|
|
},
|
|
{
|
|
pid: 101,
|
|
parentPid: 100,
|
|
processGroupId: 100,
|
|
state: "S",
|
|
command: "renderer",
|
|
},
|
|
{
|
|
pid: 200,
|
|
parentPid: 101,
|
|
processGroupId: 200,
|
|
state: "S",
|
|
command: "helper-group-leader",
|
|
},
|
|
{
|
|
pid: 201,
|
|
parentPid: 200,
|
|
processGroupId: 200,
|
|
state: "S",
|
|
command: "helper",
|
|
},
|
|
];
|
|
const signalGroups: number[] = [];
|
|
const ownership = {
|
|
runId: "multi-group-run",
|
|
leaderPid: 100,
|
|
processGroupId: 100,
|
|
expectedParentPid: 1,
|
|
sweepTokenOwnedProcesses: true as const,
|
|
evidenceOwner: new ProcessOwnershipEvidenceOwner(),
|
|
};
|
|
const host: ProcessOwnershipHost = {
|
|
platform: process.platform,
|
|
listProcesses: async () => rows,
|
|
environment: async () => ({}),
|
|
runTokens: async (members) =>
|
|
new Map(
|
|
members.map(({ pid }) => [
|
|
pid,
|
|
{ state: "readable" as const, runId: ownership.runId },
|
|
]),
|
|
),
|
|
processIdentities: async (members) => {
|
|
const liveByPid = new Map(rows.map((row) => [row.pid, row]));
|
|
return new Map(
|
|
members.map((member) => {
|
|
const current = liveByPid.get(member.pid);
|
|
return [
|
|
member.pid,
|
|
current === undefined
|
|
? {
|
|
state: "absent" as const,
|
|
reason: "not-in-snapshot" as const,
|
|
}
|
|
: {
|
|
state: "readable" as const,
|
|
identity: `generation-${String(member.pid)}`,
|
|
relation: {
|
|
state: "observed" as const,
|
|
parentPid: current.parentPid,
|
|
processGroupId: current.processGroupId,
|
|
},
|
|
},
|
|
] as const;
|
|
}),
|
|
);
|
|
},
|
|
signalGroup: (groupId) => {
|
|
signalGroups.push(groupId);
|
|
rows = rows.filter(({ processGroupId }) => processGroupId !== groupId);
|
|
},
|
|
};
|
|
const lineage: ProcessLineageObservation = {
|
|
status: "verified",
|
|
observedAt: "2026-10-19T00:00:00.000Z",
|
|
lineage: {
|
|
runId: ownership.runId,
|
|
launcherPid: ownership.leaderPid,
|
|
launcherParentPid: 1,
|
|
processGroupId: ownership.processGroupId,
|
|
descendants: [
|
|
{ pid: 101, parentPid: 100, processGroupId: 100 },
|
|
{ pid: 200, parentPid: 101, processGroupId: 200 },
|
|
{ pid: 201, parentPid: 200, processGroupId: 200 },
|
|
],
|
|
},
|
|
};
|
|
|
|
const result = await cleanupElectronProcesses(ownership, lineage, {
|
|
platform: process.platform,
|
|
terminateTree: vi.fn(),
|
|
processOwnershipHost: host,
|
|
});
|
|
|
|
expect(result).toMatchObject({ cleaned: true, signaled: true });
|
|
expect(signalGroups).toEqual([100, 200]);
|
|
expect(rows).toEqual([]);
|
|
},
|
|
);
|