Keep one ProcessOwnershipEvidenceOwner for each graceful/SIGKILL session and share it across active and terminal scoped scans. Require live token-verified evidence before stopping the owner, and retain structured cleanup failure diagnostics. The real Ubuntu 24.04 Hopper clean-setup/CLI/MCP job passes, including owner exit, target reacquisition, idempotence and zero remaining descendants. Local verification passes 87 CI guards, 51 ownership controls, discovery of all 1,714 test files and full static checks. Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage)
166 lines
5.5 KiB
JavaScript
166 lines
5.5 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { execFile } from "node:child_process";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import { promisify } from "node:util";
|
|
|
|
import { Client } from "@modelcontextprotocol/client";
|
|
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
|
|
|
import { verifyNoTokenOwnedProcesses } from "../../dist/process/ProcessOwnership.js";
|
|
import { ProcessOwnershipEvidenceOwner } from "../../dist/process/ProcessOwnershipEvidence.js";
|
|
import {
|
|
observeProcessStartIdentity,
|
|
signalProcessWithStartIdentity,
|
|
systemProcessOwnershipHost,
|
|
} from "../../dist/process/ProcessOwnershipObservation.js";
|
|
import { startUnrelatedHopperSentinel } from "./unrelated-hopper-sentinel.mjs";
|
|
import {
|
|
requireMcpEvidenceResult,
|
|
requireMcpLifecycleResult,
|
|
} from "./mcp-verifier-results.mjs";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
/** Prove actual MCP owner exit releases the demo singleton for subsequent CLI/MCP sessions. */
|
|
export async function verifyLinuxHopperOwnerExit({
|
|
primary,
|
|
secondary,
|
|
serverCommand,
|
|
serverArgs,
|
|
environment,
|
|
}) {
|
|
const sentinel = await startUnrelatedHopperSentinel();
|
|
const outcomes = [];
|
|
try {
|
|
for (const mode of ["graceful", "SIGKILL"]) {
|
|
const baseline = await systemProcessOwnershipHost.captureBaseline?.();
|
|
assert.ok(
|
|
baseline,
|
|
"Could not capture pre-launch process ownership baseline",
|
|
);
|
|
const transport = new StdioClientTransport({
|
|
command: serverCommand,
|
|
args: serverArgs,
|
|
env: environment,
|
|
stderr: "pipe",
|
|
});
|
|
// Drain launcher diagnostics even on the deliberately interrupted owner.
|
|
transport.stderr?.resume();
|
|
const client = new Client({ name: "hopper-owner-exit", version: "1" });
|
|
const options = { timeout: 180_000 };
|
|
try {
|
|
await client.connect(transport);
|
|
const ownerPid = transport.pid;
|
|
assert.ok(ownerPid !== null);
|
|
const owner = await observeProcessStartIdentity(ownerPid);
|
|
assert.equal(owner?.state, "readable");
|
|
const ownerProcess = (
|
|
await systemProcessOwnershipHost.listProcesses()
|
|
).find(({ pid }) => pid === ownerPid);
|
|
assert.ok(
|
|
ownerProcess,
|
|
"Could not observe the launched MCP process group",
|
|
);
|
|
assert.equal(typeof ownerProcess.processGroupId, "number");
|
|
const relation = {
|
|
leaderPid: ownerPid,
|
|
processGroupId: ownerProcess.processGroupId,
|
|
evidenceOwner: new ProcessOwnershipEvidenceOwner(),
|
|
};
|
|
requireMcpLifecycleResult(
|
|
await client.callTool(
|
|
{
|
|
name: "open_binary",
|
|
arguments: { path: primary },
|
|
},
|
|
options,
|
|
),
|
|
"open_binary",
|
|
);
|
|
const procedures = requireMcpEvidenceResult(
|
|
await client.callTool(
|
|
{ name: "list_procedures", arguments: {} },
|
|
options,
|
|
),
|
|
"list_procedures",
|
|
);
|
|
assert.ok(procedures.length > 0);
|
|
const status = requireMcpLifecycleResult(
|
|
await client.callTool(
|
|
{ name: "binary_session", arguments: {} },
|
|
options,
|
|
),
|
|
"binary_session",
|
|
);
|
|
const runId = status.analysis_run?.run_id;
|
|
assert.equal(typeof runId, "string");
|
|
assert.equal(status.analysis_provider_binding?.provider.id, "hopper");
|
|
const active = await verifyNoTokenOwnedProcesses(
|
|
runId,
|
|
systemProcessOwnershipHost,
|
|
baseline,
|
|
relation,
|
|
);
|
|
assert.equal(active.cleaned, false, JSON.stringify(active));
|
|
assert.ok(
|
|
relation.evidenceOwner.coverage.retainedOwned > 0,
|
|
`The active Hopper session has no token-verified process ownership evidence: ${JSON.stringify(active)}`,
|
|
);
|
|
if (mode === "graceful") await client.close();
|
|
else {
|
|
assert.equal(
|
|
await signalProcessWithStartIdentity(
|
|
ownerPid,
|
|
owner.identity,
|
|
"SIGKILL",
|
|
),
|
|
"signaled",
|
|
);
|
|
}
|
|
const deadline = Date.now() + 10_000;
|
|
let cleanup;
|
|
do {
|
|
cleanup = await verifyNoTokenOwnedProcesses(
|
|
runId,
|
|
systemProcessOwnershipHost,
|
|
baseline,
|
|
relation,
|
|
);
|
|
if (cleanup.cleaned) break;
|
|
await delay(100);
|
|
} while (Date.now() < deadline);
|
|
assert.equal(cleanup.cleaned, true, JSON.stringify(cleanup));
|
|
// The SIGKILL case must recover the stale lease for both target identities.
|
|
for (const target of [primary, secondary]) {
|
|
const result = await execFileAsync(
|
|
process.execPath,
|
|
[
|
|
"scripts/rea.mjs",
|
|
"inspect",
|
|
target,
|
|
"--provider",
|
|
"hopper",
|
|
"--format",
|
|
"json",
|
|
],
|
|
{ env: environment, timeout: 180_000, maxBuffer: 16 * 1024 * 1024 },
|
|
);
|
|
assert.ok(JSON.parse(result.stdout).normalized_result);
|
|
}
|
|
outcomes.push({
|
|
mode,
|
|
ownedProcessesStopped: true,
|
|
sameTargetReopened: true,
|
|
differentTargetReopened: true,
|
|
});
|
|
} finally {
|
|
await client.close();
|
|
await transport.close();
|
|
}
|
|
}
|
|
} finally {
|
|
assert.equal(await sentinel.verifyAndClose(), true);
|
|
}
|
|
return { outcomes, unrelatedHopperSurvived: true };
|
|
}
|