Files
reverse-engineering-anything/src/process/ProcessOwnership.observation.test.ts
T
morluto 60149282fc test: strengthen integration oracles and verification (#8295)
* test: strengthen integration oracles and verification

* test: bound lifecycle gates and strengthen workflow checks

* test: verify complete paged and extracted results

* test: preserve bytecode and cancellation evidence

* test: strengthen retained evidence and fixture cleanup

* test: require cross-projection bridge identities

* test: retain fixture owners through cleanup failures

* test: verify graph examples and project selectors

* test: tighten provider and lifecycle regressions

* test: consolidate hex coverage and bound cutter cleanup

* test: bound subprocess output waits and strengthen graph checks

* test: check mcp recovery and preserve cleanup owners

* test: verify structured mcp argument failures

* test: preserve artifact cleanup and gdb limit evidence

* test: preserve evidence identities and bound provider gates

* test: retain browser and comparison cleanup owners

* test: bound startup and strengthen fixture oracles

* test: bound lifecycle gates and verify recovered evidence

* test: verify public outcomes and lifecycle ownership

* test: assert precise evidence and release lifecycle gates

* test: verify selected facts and bound cleanup waits

* test: check retained evidence and decoded records

* test: verify final reports and retain cleanup ownership

* test: bound reviewed startup gates and retain cleanup

* test: verify PE errors and preserve MCP cleanup

* test: preserve MCP fixture cleanup ownership

* test: release provider gates before late startup
2026-11-01 00:15:28 +08:00

651 lines
20 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { withVerifierCleanup } from "../../scripts/lib/verifier-cleanup.mjs";
import { awaitAcknowledgement } from "../../tests/fixtures/lifecycleAcknowledgement.js";
import {
cleanupOwnedProcessGroup,
type ProcessOwnershipHost,
} from "./ProcessOwnership.js";
import {
observeOwnedProcessGroups,
observeOwnedProcessLineage,
} from "./ProcessOwnershipObservation.js";
import { createOwnership, host } from "./ProcessOwnership.fixture.js";
import { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js";
let ownership = createOwnership();
beforeEach(() => {
ownership = createOwnership();
});
const processes = [100, 101].map((pid) => ({
pid,
parentPid: pid === 100 ? 1 : 100,
processGroupId: 100,
state: "S",
command: "fixture",
}));
it("uses native same-generation group relations instead of stale table groups", async () => {
const rows = [
{ ...processes[0]!, processGroupId: 200 },
{ ...processes[1]!, pid: 200, parentPid: 1, processGroupId: 100 },
];
const host: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve(rows),
environment: () => Promise.resolve({}),
runTokens: (members) =>
Promise.resolve(
new Map(
members.map(({ pid }) => [
pid,
{ state: "readable" as const, runId: ownership.runId },
]),
),
),
processIdentities: (members) =>
Promise.resolve(
new Map(
members.map((member) => [
member.pid,
{
state: "readable" as const,
identity: `start-${String(member.pid)}`,
relation: {
state: "observed" as const,
parentPid: member.pid === 100 ? 1 : 100,
processGroupId: member.pid === 100 ? 100 : 200,
},
},
]),
),
),
signalGroup: vi.fn(),
};
await expect(
observeOwnedProcessGroups(ownership.runId, [100, 200], host),
).resolves.toEqual(
new Map([
[100, { state: "alive" }],
[200, { state: "alive" }],
]),
);
});
it("does not report a native zombie as a live owned group member", async () => {
const row = processes[0]!;
const host: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve([row]),
environment: () => Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }),
runTokens: (members) =>
Promise.resolve(
new Map(
members.map(({ pid }) => [
pid,
{ state: "readable" as const, runId: ownership.runId },
]),
),
),
processIdentities: (members) =>
Promise.resolve(
new Map(
members.map(({ pid }) => [
pid,
{
state: "readable" as const,
identity: `start-${String(pid)}`,
lifecycle: {
state: "observed" as const,
lifecycle: "zombie" as const,
},
relation: {
state: "observed" as const,
parentPid: 1,
processGroupId: 100,
},
},
]),
),
),
signalGroup: vi.fn(),
};
await expect(
observeOwnedProcessGroups(ownership.runId, [100], host),
).resolves.toEqual(new Map([[100, { state: "empty" }]]));
});
it("observes multiple groups from one process snapshot and token batch", async () => {
const rows = [
...processes,
{
pid: 200,
parentPid: 1,
processGroupId: 200,
state: "S",
command: "other-owned-group",
},
{
pid: 300,
parentPid: 1,
processGroupId: 300,
state: "Z",
command: "[exited]",
},
];
const listProcesses = vi.fn(() => Promise.resolve(rows));
const environment = vi.fn(() => Promise.resolve({}));
const runTokens = vi.fn<NonNullable<ProcessOwnershipHost["runTokens"]>>(
(members) =>
Promise.resolve(
new Map(
members.map(({ pid }) => [
pid,
{
state: "readable" as const,
runId: pid === 200 ? "other-run" : ownership.runId,
},
]),
),
),
);
await expect(
observeOwnedProcessGroups(ownership.runId, [100, 200, 300], {
listProcesses,
environment,
runTokens,
signalGroup: vi.fn(),
}),
).resolves.toEqual(
new Map([
[100, { state: "alive" }],
[
200,
{ state: "unverifiable", reason: "process ownership did not match" },
],
[300, { state: "empty" }],
]),
);
expect(listProcesses).toHaveBeenCalledTimes(1);
expect(runTokens).toHaveBeenCalledTimes(1);
expect(runTokens).toHaveBeenCalledWith(
rows.filter(({ state }) => state !== "Z"),
);
expect(environment).not.toHaveBeenCalled();
});
it("rechecks unavailable members individually before classifying their groups", async () => {
const rows = [
...processes,
{
pid: 200,
parentPid: 1,
processGroupId: 200,
state: "S",
command: "unreadable-group",
},
];
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce(rows)
.mockResolvedValueOnce(rows.filter(({ pid }) => pid !== 101))
.mockResolvedValueOnce(rows);
const runTokens = vi.fn<NonNullable<ProcessOwnershipHost["runTokens"]>>(
(members) =>
Promise.resolve(
new Map(
members.map(({ pid }) => [
pid,
pid === 101 || pid === 200
? {
state: "unavailable" as const,
reason: `unreadable-${String(pid)}`,
}
: { state: "readable" as const, runId: ownership.runId },
]),
),
),
);
await expect(
observeOwnedProcessGroups(ownership.runId, [100, 200], {
listProcesses,
environment: vi.fn(() => Promise.resolve({})),
runTokens,
signalGroup: vi.fn(),
}),
).resolves.toEqual(
new Map([
[100, { state: "alive" }],
[
200,
{
state: "unverifiable",
reason:
"process ownership could not be revalidated for PID 200: unreadable-200",
},
],
]),
);
expect(listProcesses).toHaveBeenCalledTimes(3);
expect(runTokens).toHaveBeenCalledTimes(1);
});
describe("multi-group ownership observation cancellation", () => {
it("cancels an unavailable-token liveness recheck before the host releases it", async () => {
const controller = new AbortController();
const reason = new Error("cancel process liveness recheck");
const rows = [processes[0]!];
let listCalls = 0;
let releaseSecondList: (() => void) | undefined;
let signalAtSecondList: AbortSignal | undefined;
let announceSecondList: (() => void) | undefined;
const secondListStarted = new Promise<void>((resolve) => {
announceSecondList = resolve;
});
const listProcesses = vi.fn<ProcessOwnershipHost["listProcesses"]>(
(signal) => {
listCalls += 1;
if (listCalls === 1) return Promise.resolve(rows);
signalAtSecondList = signal;
announceSecondList?.();
return new Promise((resolve, reject) => {
releaseSecondList = () => resolve(rows);
if (signal === undefined) return;
const rejectOnAbort = () => reject(signal.reason);
signal.addEventListener("abort", rejectOnAbort, { once: true });
if (signal.aborted) rejectOnAbort();
});
},
);
const signalGroup = vi.fn();
const observed = observeOwnedProcessGroups(
ownership.runId,
[ownership.processGroupId],
{
listProcesses,
environment: vi.fn(() => Promise.resolve({})),
runTokens: vi.fn(() =>
Promise.resolve(
new Map([
[
ownership.leaderPid,
{ state: "unavailable" as const, reason: "token unavailable" },
],
]),
),
),
signalGroup,
},
controller.signal,
).then(
(value) => ({ kind: "resolved" as const, value }),
(cause: unknown) => ({ kind: "rejected" as const, cause }),
);
let settled = false;
const observedOutcome = observed.then((outcome) => {
settled = true;
return outcome;
});
let settledBeforeRelease = false;
await withVerifierCleanup(async () => {
await awaitAcknowledgement(
secondListStarted,
observedOutcome,
"liveness recheck",
);
controller.abort(reason);
await new Promise<void>((resolve) => setImmediate(resolve));
settledBeforeRelease = settled;
}, [
() => controller.abort(reason),
() => releaseSecondList?.(),
async () => {
await observedOutcome;
},
]);
const outcome = await observedOutcome;
expect(settledBeforeRelease).toBe(true);
expect(signalAtSecondList).toBe(controller.signal);
expect(outcome.kind).toBe("rejected");
if (outcome.kind !== "rejected")
throw new Error(
"The cancelled process observation unexpectedly resolved",
);
expect(outcome.cause).toBe(reason);
expect(signalGroup).not.toHaveBeenCalled();
expect(listProcesses).toHaveBeenCalledTimes(2);
});
it("preserves cancellation when a liveness recheck returns successfully after abort", async () => {
const controller = new AbortController();
const reason = new Error("cancel at liveness completion");
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce(processes)
.mockImplementation(async (signal) => {
expect(signal).toBe(controller.signal);
controller.abort(reason);
return [];
});
const signalGroup = vi.fn();
await expect(
observeOwnedProcessGroups(
ownership.runId,
[ownership.processGroupId],
{
listProcesses,
environment: async () => {
throw new Error("token unavailable");
},
signalGroup,
},
controller.signal,
),
).rejects.toBe(reason);
expect(listProcesses).toHaveBeenCalledTimes(2);
expect(signalGroup).not.toHaveBeenCalled();
});
});
describe("multi-group observation short-circuit", () => {
it("stops missing-row fallbacks once every live group is unverifiable", async () => {
const listProcesses = vi.fn(() => Promise.resolve(processes));
const environment = vi.fn((pid: number) =>
Promise.resolve({
REA_PROCESS_RUN_ID: pid === 100 ? "other-run" : ownership.runId,
}),
);
const runTokens = vi.fn<NonNullable<ProcessOwnershipHost["runTokens"]>>(
() => Promise.resolve(new Map()),
);
await expect(
observeOwnedProcessGroups(ownership.runId, [100], {
listProcesses,
environment,
runTokens,
signalGroup: vi.fn(),
}),
).resolves.toEqual(
new Map([
[
100,
{ state: "unverifiable", reason: "process ownership did not match" },
],
]),
);
expect(runTokens).toHaveBeenCalledTimes(1);
expect(environment.mock.calls).toEqual([[100]]);
expect(listProcesses).toHaveBeenCalledTimes(1);
});
});
it("builds lineage from same-generation native parent/group facts", async () => {
const rows = [
{ ...processes[0]!, pid: 100, parentPid: 1, processGroupId: 100 },
{ ...processes[1]!, pid: 101, parentPid: 1, processGroupId: 200 },
];
const host: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve(rows),
environment: () => Promise.resolve({}),
runTokens: (members) =>
Promise.resolve(
new Map(
members.map(({ pid }) => [
pid,
{ state: "readable" as const, runId: ownership.runId },
]),
),
),
processIdentities: (members) =>
Promise.resolve(
new Map(
members.map((member) => [
member.pid,
{
state: "readable" as const,
identity: `start-${String(member.pid)}`,
relation: {
state: "observed" as const,
parentPid: member.pid === 100 ? 1 : 100,
processGroupId: member.pid === 100 ? 100 : 200,
},
},
]),
),
),
signalGroup: vi.fn(),
};
await expect(
observeOwnedProcessLineage(ownership, host),
).resolves.toMatchObject({
status: "verified",
lineage: {
launcherParentPid: 1,
processGroupId: 100,
descendants: [{ pid: 101, parentPid: 100, processGroupId: 200 }],
},
});
});
it("observes group and lineage with one batch each and falls back only for missing rows", async () => {
const { adapter } = host({
100: { REA_PROCESS_RUN_ID: ownership.runId },
101: { REA_PROCESS_RUN_ID: ownership.runId },
});
const environment = vi.fn(adapter.environment);
const runTokens = vi.fn<NonNullable<ProcessOwnershipHost["runTokens"]>>(() =>
Promise.resolve(
new Map([[100, { state: "readable", runId: ownership.runId }]]),
),
);
const observedHost = { ...adapter, environment, runTokens };
await expect(
observeOwnedProcessGroups(
ownership.runId,
[ownership.processGroupId],
observedHost,
),
).resolves.toEqual(new Map([[ownership.processGroupId, { state: "alive" }]]));
await expect(
observeOwnedProcessLineage(ownership, observedHost),
).resolves.toMatchObject({
status: "verified",
lineage: {
descendants: [{ pid: 101, parentPid: 100, processGroupId: 100 }],
},
});
expect(runTokens.mock.calls).toEqual([[processes], [processes]]);
expect(environment.mock.calls).toEqual([[101], [101]]);
});
it.each(["batch", "environment"] as const)(
"preserves cancellation during %s reads, including successful completion races",
async (boundary) => {
const controller = new AbortController();
const reason = new Error("cancelled ownership read");
const listProcesses = vi.fn(() => Promise.resolve(processes));
const environment = vi.fn<ProcessOwnershipHost["environment"]>(
async (_pid, signal) => {
expect(signal).toBe(controller.signal);
controller.abort(reason);
return { REA_PROCESS_RUN_ID: ownership.runId };
},
);
const runTokens = vi.fn<NonNullable<ProcessOwnershipHost["runTokens"]>>(
async (_processes, signal) => {
expect(signal).toBe(controller.signal);
if (boundary === "batch") controller.abort(reason);
return new Map();
},
);
await expect(
observeOwnedProcessGroups(
ownership.runId,
[ownership.processGroupId],
{ listProcesses, environment, runTokens, signalGroup: vi.fn() },
controller.signal,
),
).rejects.toBe(reason);
expect(listProcesses).toHaveBeenCalledTimes(1);
expect(environment).toHaveBeenCalledTimes(boundary === "batch" ? 0 : 1);
},
);
it.each([100, 101])(
"rechecks unavailable batch rows for exited PID %i without overriding them through fallback",
async (exitedPid) => {
const listProcesses = vi
.fn<ProcessOwnershipHost["listProcesses"]>()
.mockResolvedValueOnce(processes)
.mockResolvedValue(processes.filter(({ pid }) => pid !== exitedPid));
const environment = vi.fn(() =>
Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId }),
);
const observedHost: ProcessOwnershipHost = {
listProcesses,
environment,
signalGroup: vi.fn(),
runTokens: () =>
Promise.resolve(
new Map(
processes.map(({ pid }) => [
pid,
pid === exitedPid
? { state: "unavailable" as const, reason: "process exited" }
: { state: "readable" as const, runId: ownership.runId },
]),
),
),
};
const result = await observeOwnedProcessLineage(ownership, observedHost);
if (exitedPid === 100)
expect(result).toMatchObject({
status: "unavailable",
reason: "owned launcher exited during lineage validation",
});
else
expect(result).toMatchObject({
status: "verified",
lineage: { descendants: [] },
});
expect(environment).not.toHaveBeenCalled();
},
);
describe("token sweep batch failures", () => {
it("cleans token-owned groups when a failed batch has readable fallback observations", async () => {
const { adapter, signalGroup } = host({
100: { REA_PROCESS_RUN_ID: ownership.runId },
101: { REA_PROCESS_RUN_ID: ownership.runId },
});
await expect(
cleanupOwnedProcessGroup(
{ ...ownership, sweepTokenOwnedProcesses: true, captureBaseline: [] },
{
...adapter,
runTokens: () =>
Promise.reject(new Error("batch reader unavailable")),
},
),
).resolves.toEqual({ cleaned: true, signaled: true });
expect(signalGroup.mock.calls).toEqual([[100, "SIGKILL"]]);
});
it("uses fallback ownership when the initial batch fails and preserves both failures through retries", async () => {
const signalGroup = vi.fn();
const adapter: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve(processes),
environment: (pid) =>
pid === 100
? Promise.resolve({ REA_PROCESS_RUN_ID: ownership.runId })
: Promise.reject(new Error("individual unreadable")),
runTokens: () => Promise.reject(new Error("batch reader unavailable")),
signalGroup,
};
const result = await cleanupOwnedProcessGroup(
{ ...ownership, sweepTokenOwnedProcesses: true, captureBaseline: [] },
adapter,
);
expect(result).toMatchObject({
cleaned: false,
failures: expect.arrayContaining([
expect.objectContaining({
pid: 101,
diagnostic:
"individual unreadable; run-token batch failed: batch reader unavailable",
}),
]),
});
expect(signalGroup).not.toHaveBeenCalled();
});
});
it.each(["parent", "group"] as const)(
"keeps a changed native launcher %s relation unavailable instead of publishing the earlier relation",
async (changedRelation) => {
const evidenceOwner = new ProcessOwnershipEvidenceOwner();
const launcher = {
pid: 100,
parentPid: 20,
processGroupId: 100,
state: "S",
command: "fixture",
};
let tokenRead = false;
const currentRelation = () => ({
state: "observed" as const,
parentPid: tokenRead && changedRelation === "parent" ? 1 : 20,
processGroupId: tokenRead && changedRelation === "group" ? 200 : 100,
});
const host: ProcessOwnershipHost = {
listProcesses: async () => [launcher],
environment: async () => ({}),
runTokens: async (members) => {
tokenRead = true;
return new Map(
members.map(({ pid }) => [
pid,
{ state: "readable" as const, runId: "run" },
]),
);
},
processIdentities: async (members) =>
new Map(
members.map(({ pid }) => [
pid,
{
state: "readable" as const,
identity: "same-launcher-generation",
relation: currentRelation(),
},
]),
),
signalGroup: vi.fn(),
};
const result = await observeOwnedProcessLineage(
{
runId: "run",
leaderPid: 100,
processGroupId: 100,
expectedParentPid: 20,
evidenceOwner,
},
host,
);
expect(result).toMatchObject({ status: "unavailable" });
expect(evidenceOwner.candidates).toMatchObject([
{
pid: 100,
applicability: "token-verified",
relation: currentRelation(),
},
]);
expect(host.signalGroup).not.toHaveBeenCalled();
},
);