Keep a run-scoped evidence owner from acquisition through settlement and cleanup retry. Bind token observations to native generations and retain related generations and unresolved identity lookups across table omissions, reparenting and process-group changes without treating PID hints as authority. Use native effective UID facts for withholding decisions, corroborate Linux procfs absence, and share the Linux stat parser with sampling and debugger identity checks. Signal only after current token and group revalidation. Complete swept cleanup across all groups with one bounded final family check; retain unresolved owners and successful-peer completion across retries. Validation: npm run check, docs:check and test discovery passed. Focused ownership/consumer coverage totals 513 passed and 1 skipped across 35 files, including the corrected Electron cancellation test rerun independently. Darwin helper compilation, real owned Node child cleanup, CLI/MCP capture and retained cleanup retries ran. Real Electron applications, native Linux races/PID reuse and Windows cleanup were not qualified. The original Linux reports remain open for that work. Fixes #5238 Refs #4322, #5206
236 lines
7.9 KiB
TypeScript
236 lines
7.9 KiB
TypeScript
import { fileURLToPath } from "node:url";
|
|
import { access } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
|
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
import { afterEach, expect, it } from "vitest";
|
|
import { z } from "zod";
|
|
|
|
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
|
import {
|
|
createCacheProvider,
|
|
createTestBinarySession,
|
|
} from "../../fixtures/binarySession.js";
|
|
import { createServer } from "../../../src/server/createServer.js";
|
|
import { silentLogger } from "../../../src/logger.js";
|
|
import { itWithCaptureCapability } from "./processCaptureCapability.js";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
|
|
const processFixture = fileURLToPath(
|
|
new URL("../../fixtures/processFidelity.mjs", import.meta.url),
|
|
);
|
|
const resources: Array<{ close(): Promise<unknown> }> = [];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(resources.splice(0).map((resource) => resource.close()));
|
|
});
|
|
|
|
it("rejects oversized initial and resize terminal buffers through MCP before launching", async () => {
|
|
const root = await createTestTempDirectory("rea-terminal-admission-");
|
|
const marker = join(root, "launched");
|
|
const session = createTestBinarySession(() => {
|
|
throw new Error("Terminal admission must not launch a binary provider");
|
|
});
|
|
const server = createServer(
|
|
{ kind: "session", session },
|
|
{ logger: silentLogger },
|
|
);
|
|
const client = new Client({ name: "terminal-admission", version: "1" });
|
|
resources.push(client, server, session);
|
|
const [clientTransport, serverTransport] =
|
|
InMemoryTransport.createLinkedPair();
|
|
await server.connect(serverTransport);
|
|
await client.connect(clientTransport);
|
|
for (const input of [
|
|
{ terminal: { columns: 1_000, rows: 1, scrollback: 1_000 } },
|
|
{ events: [{ type: "resize", at_ms: 10, columns: 1_000, rows: 1 }] },
|
|
] as const) {
|
|
const result = await client.callTool({
|
|
name: "capture_process_scenario",
|
|
arguments: {
|
|
executable: process.execPath,
|
|
arguments: [
|
|
"-e",
|
|
"require('node:fs').writeFileSync(process.argv[1], 'launched')",
|
|
marker,
|
|
],
|
|
...input,
|
|
},
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
expect(result.content).toContainEqual(
|
|
expect.objectContaining({
|
|
type: "text",
|
|
text: expect.stringContaining("1000000"),
|
|
}),
|
|
);
|
|
await expect(access(marker)).rejects.toMatchObject({ code: "ENOENT" });
|
|
}
|
|
await client.ping();
|
|
});
|
|
|
|
itWithCaptureCapability(
|
|
"bounds normalized PTY text and preserves equal-size output through MCP",
|
|
async () => {
|
|
const root = await createTestTempDirectory(
|
|
"rea-process-normalized-budget-",
|
|
);
|
|
const session = createTestBinarySession(createCacheProvider([]));
|
|
const server = createServer(
|
|
{ kind: "session", session },
|
|
{ logger: silentLogger },
|
|
);
|
|
const client = new Client({
|
|
name: "process-normalized-budget",
|
|
version: "1",
|
|
});
|
|
resources.push(client, server, session);
|
|
const [clientTransport, serverTransport] =
|
|
InMemoryTransport.createLinkedPair();
|
|
await server.connect(serverTransport);
|
|
await client.connect(clientTransport);
|
|
|
|
const capture = async (replacement: string) => {
|
|
const result = await client.callTool({
|
|
name: "capture_process_scenario",
|
|
arguments: {
|
|
executable: process.execPath,
|
|
working_directory: root,
|
|
arguments: ["-e", 'process.stdout.write("a".repeat(100) + "\\n")'],
|
|
limits: { output_bytes: 1_000 },
|
|
normalization: {
|
|
paths: false,
|
|
pids: false,
|
|
ports: false,
|
|
patterns: [{ pattern: "a", replacement }],
|
|
},
|
|
settle_ms: 50,
|
|
},
|
|
});
|
|
expect(result.isError, JSON.stringify(result.content)).not.toBe(true);
|
|
return toolContract("capture_process_scenario").outputSchema.parse(
|
|
result.structuredContent,
|
|
).normalized_result;
|
|
};
|
|
|
|
const expanded = await capture("x".repeat(500));
|
|
expect(expanded.frames).toHaveLength(0);
|
|
expect(expanded.truncated).toBe(true);
|
|
expect(expanded.truncation_details.normalized_terminal).toMatchObject({
|
|
budget_bytes: 1_000,
|
|
retained_bytes: 0,
|
|
omitted_frames: expect.any(Number),
|
|
});
|
|
expect(
|
|
expanded.truncation_details.normalized_terminal?.omitted_frames,
|
|
).toBeGreaterThan(0);
|
|
expect(expanded.truncation_details.raw_terminal).toMatchObject({
|
|
budget_bytes: 1_000,
|
|
observed_bytes: expect.any(Number),
|
|
retained_bytes: 0,
|
|
observed_frames: expect.any(Number),
|
|
retained_frames: 0,
|
|
});
|
|
expect(
|
|
expanded.limitations.some((limitation) =>
|
|
limitation.includes(
|
|
"final normalized sizes for the latter omissions are unknown",
|
|
),
|
|
),
|
|
).toBe(true);
|
|
expect(expanded.cleanup).toMatchObject({
|
|
owned_process_group: "verified",
|
|
temporary_root: "removed",
|
|
});
|
|
|
|
const equalSize = await capture("b");
|
|
// This short-lived target can exit before process sampling finishes.
|
|
// Assert the terminal coverage that this normalization control exercises.
|
|
expect(equalSize.truncation_details.raw_terminal).toMatchObject({
|
|
observed_bytes: Buffer.byteLength(`${"a".repeat(100)}\r\n`),
|
|
retained_bytes: Buffer.byteLength(`${"a".repeat(100)}\r\n`),
|
|
});
|
|
expect(equalSize.frames.map(({ data }) => data).join("")).toBe(
|
|
`${"b".repeat(100)}\r\n`,
|
|
);
|
|
expect(equalSize.truncation_details.normalized_terminal).toMatchObject({
|
|
budget_bytes: 1_000,
|
|
retained_bytes: Buffer.byteLength(`${"b".repeat(100)}\r\n`),
|
|
omitted_frames: 0,
|
|
});
|
|
expect(equalSize.cleanup).toMatchObject({
|
|
owned_process_group: "verified",
|
|
temporary_root: "removed",
|
|
});
|
|
},
|
|
30_000,
|
|
);
|
|
|
|
itWithCaptureCapability(
|
|
"records process residuals linked to capture Evidence",
|
|
async () => {
|
|
const session = createTestBinarySession(createCacheProvider([]));
|
|
const server = createServer(
|
|
{ kind: "session", session },
|
|
{
|
|
logger: silentLogger,
|
|
},
|
|
);
|
|
const mcp = new Client({ name: "process-unknown", version: "1.0.0" });
|
|
const [clientTransport, serverTransport] =
|
|
InMemoryTransport.createLinkedPair();
|
|
resources.push(mcp, server);
|
|
await server.connect(serverTransport);
|
|
await mcp.connect(clientTransport);
|
|
|
|
const captured = await mcp.callTool({
|
|
name: "capture_process_scenario",
|
|
arguments: {
|
|
executable: process.execPath,
|
|
arguments: [processFixture, "partial"],
|
|
},
|
|
});
|
|
expect(captured.isError, JSON.stringify(captured.content)).not.toBe(true);
|
|
const contract = toolContract("capture_process_scenario");
|
|
const wire = (await mcp.listTools()).tools.find(
|
|
({ name }) => name === contract.name,
|
|
);
|
|
if (wire?.outputSchema === undefined)
|
|
throw new Error("Missing process capture output schema");
|
|
expect(
|
|
new Ajv2020({ strict: false, validateFormats: false }).validate(
|
|
z.record(z.string(), z.unknown()).parse(wire.outputSchema),
|
|
captured.structuredContent,
|
|
),
|
|
).toBe(true);
|
|
const evidence = contract.outputSchema.parse(captured.structuredContent);
|
|
const listedUnknowns = await mcp.callTool({
|
|
name: "list_unknowns",
|
|
arguments: {},
|
|
});
|
|
expect(
|
|
listedUnknowns.isError,
|
|
JSON.stringify(listedUnknowns.content),
|
|
).not.toBe(true);
|
|
const listed = z
|
|
.object({
|
|
result: z.object({
|
|
items: z.array(
|
|
z.object({
|
|
question: z.string(),
|
|
domain: z.string(),
|
|
}),
|
|
),
|
|
}),
|
|
})
|
|
.parse(listedUnknowns.structuredContent).result.items;
|
|
expect(listed).toContainEqual(
|
|
expect.objectContaining({
|
|
question: `Was network behavior fully observed during capture? (Evidence ${evidence.evidence_id})`,
|
|
domain: "process-network",
|
|
}),
|
|
);
|
|
},
|
|
);
|