228 lines
7.7 KiB
TypeScript
228 lines
7.7 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import { rm, symlink } from "node:fs/promises";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { promisify } from "node:util";
|
|
import { expect, it } from "vitest";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
|
|
import {
|
|
captureProcessScenario,
|
|
probeProcessCaptureCapability,
|
|
ProcessCaptureError,
|
|
} from "../../../src/application/ProcessHarness.js";
|
|
import {
|
|
parseProcessScenario,
|
|
type ProcessExecutionPolicy,
|
|
} from "../../../src/domain/processCapture.js";
|
|
|
|
const processFixture = fileURLToPath(
|
|
new URL("../../fixtures/processFidelity.mjs", import.meta.url),
|
|
);
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
it("does not follow or disclose symlink targets outside declared roots", async () => {
|
|
const root = await createTestTempDirectory("rea-symlink-test-");
|
|
await symlink("/etc/passwd", join(root, "escape"));
|
|
try {
|
|
const capability = await probeProcessCaptureCapability();
|
|
if (!capability.available) return;
|
|
const result = await captureProcessScenario(
|
|
parseProcessScenario({
|
|
executable: "/usr/bin/true",
|
|
working_directory: root,
|
|
filesystem_roots: [root],
|
|
}),
|
|
{
|
|
status: "enabled",
|
|
executableRoots: ["/usr/bin"],
|
|
workingRoots: [root],
|
|
allowedEnvironment: [],
|
|
networkAccess: "external",
|
|
},
|
|
);
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) throw result.error;
|
|
const escaped = result.value.files_after.find((file) =>
|
|
file.path.endsWith(":escape"),
|
|
);
|
|
expect(escaped?.symlink_target).toBe("<outside-declared-root>");
|
|
expect(result.value.truncated).toBe(true);
|
|
expect(JSON.stringify(result.value.files_after)).not.toContain(root);
|
|
expect(JSON.stringify(result.value.files_after)).not.toContain(
|
|
"/etc/passwd",
|
|
);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("does not launch when policy denies capture", async () => {
|
|
const scenario = parseProcessScenario({
|
|
executable: "/bin/sh",
|
|
working_directory: "/tmp",
|
|
});
|
|
const result = await captureProcessScenario(scenario, {
|
|
status: "disabled",
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) throw new Error("expected policy refusal");
|
|
expect(result.error).toBeInstanceOf(ProcessCaptureError);
|
|
});
|
|
|
|
it("distinguishes timeout from cancellation and cleans both runs", async () => {
|
|
const capability = await probeProcessCaptureCapability();
|
|
if (!capability.available) return;
|
|
const policy: ProcessExecutionPolicy = {
|
|
status: "enabled",
|
|
executableRoots: [dirname(process.execPath)],
|
|
workingRoots: [dirname(processFixture)],
|
|
allowedEnvironment: [],
|
|
networkAccess: "external",
|
|
};
|
|
const timedOut = await captureProcessScenario(
|
|
parseProcessScenario({
|
|
executable: process.execPath,
|
|
arguments: [processFixture, "hang"],
|
|
working_directory: dirname(processFixture),
|
|
timeout_ms: 50,
|
|
idle_timeout_ms: 5_000,
|
|
}),
|
|
policy,
|
|
);
|
|
expect(timedOut.ok).toBe(true);
|
|
if (!timedOut.ok) throw timedOut.error;
|
|
expect(timedOut.value.exit.reason).toBe("timeout");
|
|
expect(timedOut.value.cleanup).toEqual({
|
|
owned_process_group: "verified",
|
|
temporary_root: "removed",
|
|
});
|
|
|
|
const controller = new AbortController();
|
|
setTimeout(() => controller.abort(), 50);
|
|
const cancelled = await captureProcessScenario(
|
|
parseProcessScenario({
|
|
executable: process.execPath,
|
|
arguments: [processFixture, "hang"],
|
|
working_directory: dirname(processFixture),
|
|
timeout_ms: 5_000,
|
|
idle_timeout_ms: 5_000,
|
|
}),
|
|
policy,
|
|
controller.signal,
|
|
);
|
|
expect(cancelled.ok).toBe(false);
|
|
if (cancelled.ok) throw new Error("expected cancellation");
|
|
expect(cancelled.error.message).toContain("cancelled");
|
|
});
|
|
|
|
it("captures source-owned interactive, resize, Unicode, and signal behavior", async () => {
|
|
const capability = await probeProcessCaptureCapability();
|
|
if (!capability.available) return;
|
|
const result = await captureProcessScenario(
|
|
parseProcessScenario({
|
|
executable: process.execPath,
|
|
arguments: [processFixture, "interactive"],
|
|
working_directory: dirname(processFixture),
|
|
events: [
|
|
{ type: "input", at_ms: 100, data: "answer" },
|
|
{ type: "resize", at_ms: 300, columns: 100, rows: 40 },
|
|
{ type: "signal", at_ms: 700, signal: "SIGINT" },
|
|
],
|
|
normalization: { time_bucket_ms: 60_000 },
|
|
timeout_ms: 2_000,
|
|
idle_timeout_ms: 2_000,
|
|
}),
|
|
{
|
|
status: "enabled",
|
|
executableRoots: [dirname(process.execPath)],
|
|
workingRoots: [dirname(processFixture)],
|
|
allowedEnvironment: [],
|
|
networkAccess: "external",
|
|
},
|
|
);
|
|
if (!result.ok) throw result.error;
|
|
expect(result.ok).toBe(true);
|
|
const output = result.value.frames.map(({ data }) => data).join("");
|
|
expect(output).toContain("prompt>");
|
|
expect(output).toContain("input:answer unicode:雪");
|
|
expect(output).toContain("resize:100x40");
|
|
expect(output).toContain("signal:SIGINT");
|
|
expect(result.value.exit.code).toBe(0);
|
|
});
|
|
|
|
it("dispatches scheduled events before a silent PTY produces output", async () => {
|
|
const capability = await probeProcessCaptureCapability();
|
|
if (!capability.available) return;
|
|
const result = await captureProcessScenario(
|
|
parseProcessScenario({
|
|
executable: process.execPath,
|
|
arguments: [processFixture, "silent-interactive"],
|
|
working_directory: dirname(processFixture),
|
|
events: [
|
|
{ type: "resize", at_ms: 25, columns: 100, rows: 40 },
|
|
{ type: "input", at_ms: 50, data: "answer" },
|
|
],
|
|
timeout_ms: 2_000,
|
|
idle_timeout_ms: 2_000,
|
|
}),
|
|
{
|
|
status: "enabled",
|
|
executableRoots: [
|
|
join(dirname(process.execPath), "missing"),
|
|
dirname(process.execPath),
|
|
],
|
|
workingRoots: [
|
|
join(dirname(processFixture), "missing"),
|
|
dirname(processFixture),
|
|
],
|
|
allowedEnvironment: [],
|
|
networkAccess: "external",
|
|
},
|
|
);
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.frames.map(({ data }) => data).join("")).toContain(
|
|
"input:answer",
|
|
);
|
|
expect(result.value.interaction_events).toMatchObject([
|
|
{ type: "resize", outcome: "dispatched" },
|
|
{ type: "input", outcome: "dispatched" },
|
|
]);
|
|
});
|
|
|
|
it("samples and cleans a source-owned child and grandchild process tree", async () => {
|
|
const capability = await probeProcessCaptureCapability();
|
|
if (!capability.available) return;
|
|
const result = await captureProcessScenario(
|
|
parseProcessScenario({
|
|
executable: process.execPath,
|
|
arguments: [processFixture, "tree"],
|
|
working_directory: dirname(processFixture),
|
|
timeout_ms: 2_000,
|
|
idle_timeout_ms: 2_000,
|
|
}),
|
|
{
|
|
status: "enabled",
|
|
executableRoots: [dirname(process.execPath)],
|
|
workingRoots: [dirname(processFixture)],
|
|
allowedEnvironment: [],
|
|
networkAccess: "external",
|
|
},
|
|
);
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) throw result.error;
|
|
const commands = result.value.process_samples.map(({ command }) => command);
|
|
expect(commands.some((command) => command.includes("tree-child"))).toBe(true);
|
|
expect(commands.some((command) => command.includes("forks.js"))).toBe(false);
|
|
expect(commands.some((command) => command.includes("tree-grandchild"))).toBe(
|
|
true,
|
|
);
|
|
expect(JSON.stringify(result.value.process_samples)).not.toContain(
|
|
dirname(processFixture),
|
|
);
|
|
const { stdout } = await execFileAsync("ps", ["-axo", "command="]);
|
|
expect(stdout).not.toContain(`${processFixture} tree-child`);
|
|
expect(stdout).not.toContain(`${processFixture} tree-grandchild`);
|
|
}, 20_000);
|