mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
288 lines
9.7 KiB
TypeScript
288 lines
9.7 KiB
TypeScript
import { fileURLToPath } from "node:url";
|
|
import { access, chmod, mkdir, writeFile } 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",
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
|
|
itWithCaptureCapability(
|
|
"resolves and inherits the server's selected environment through MCP",
|
|
async () => {
|
|
const root = await createTestTempDirectory("rea-process-mcp-env-");
|
|
const bin = join(root, "bin");
|
|
const executable = join(bin, "rea-injected-process-probe");
|
|
await mkdir(bin);
|
|
await writeFile(
|
|
executable,
|
|
[
|
|
"#!/bin/sh",
|
|
'printf "%s|%s\\n" "$REA_CAPTURE_INJECTED_MARKER" "$REA_CAPTURE_SCENARIO_OVERRIDE"',
|
|
].join("\n"),
|
|
);
|
|
await chmod(executable, 0o755);
|
|
const session = createTestBinarySession(createCacheProvider([]));
|
|
const server = createServer(
|
|
{ kind: "session", session },
|
|
{
|
|
logger: silentLogger,
|
|
environment: {
|
|
PATH: bin,
|
|
REA_CAPTURE_INJECTED_MARKER: "injected-value",
|
|
REA_CAPTURE_SCENARIO_OVERRIDE: "host-value",
|
|
},
|
|
},
|
|
);
|
|
const client = new Client({ name: "process-mcp-env", version: "1" });
|
|
resources.push(client, server, session);
|
|
const [clientTransport, serverTransport] =
|
|
InMemoryTransport.createLinkedPair();
|
|
await server.connect(serverTransport);
|
|
await client.connect(clientTransport);
|
|
|
|
const result = await client.callTool({
|
|
name: "capture_process_scenario",
|
|
arguments: {
|
|
executable: "rea-injected-process-probe",
|
|
working_directory: root,
|
|
environment: { REA_CAPTURE_SCENARIO_OVERRIDE: "scenario-value" },
|
|
},
|
|
});
|
|
expect(result.isError, JSON.stringify(result.content)).not.toBe(true);
|
|
const capture = toolContract("capture_process_scenario").outputSchema.parse(
|
|
result.structuredContent,
|
|
).normalized_result;
|
|
expect(capture.frames.map(({ data }) => data).join("")).toContain(
|
|
"injected-value|scenario-value",
|
|
);
|
|
},
|
|
);
|