333 lines
11 KiB
TypeScript
333 lines
11 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 { itWithCaptureCapability } from "./processCaptureCapability.js";
|
|
|
|
import {
|
|
captureProcessScenario,
|
|
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);
|
|
|
|
itWithCaptureCapability(
|
|
"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 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 });
|
|
}
|
|
},
|
|
);
|
|
|
|
// Policy denial is decided before any process is launched, so this assertion
|
|
// stays ungated and still runs on hosts without native PTY authority.
|
|
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);
|
|
});
|
|
|
|
itWithCaptureCapability(
|
|
"distinguishes timeout from cancellation and cleans both runs",
|
|
async () => {
|
|
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");
|
|
},
|
|
);
|
|
|
|
// Each action is triggered by observed terminal output rather than a wall-clock
|
|
// offset. A fixed `at_ms` can fire before the child has written its prompt under
|
|
// host contention, which drops the input and the resize echo and made this test
|
|
// load-flaky.
|
|
//
|
|
// This also covers the reactive `resize` path against time bucketing: rendered
|
|
// frames must share the normalized clock used by captured frames. They did not,
|
|
// so the capture failed validation with "rendered_frames: timestamps must be
|
|
// ordered" until the renderer call in ProcessReactiveEffects was normalized.
|
|
itWithCaptureCapability(
|
|
"captures source-owned interactive, resize, Unicode, and signal behavior",
|
|
async () => {
|
|
const result = await captureProcessScenario(
|
|
parseProcessScenario({
|
|
executable: process.execPath,
|
|
arguments: [processFixture, "interactive"],
|
|
working_directory: dirname(processFixture),
|
|
normalization: { time_bucket_ms: 10 },
|
|
timeout_ms: 20_000,
|
|
idle_timeout_ms: 10_000,
|
|
reactive: {
|
|
initial_state: "awaiting_prompt",
|
|
deadline_ms: 15_000,
|
|
states: [
|
|
{
|
|
id: "awaiting_prompt",
|
|
max_visits: 1,
|
|
deadline_ms: 10_000,
|
|
on: [
|
|
{
|
|
id: "answer",
|
|
priority: 0,
|
|
max_uses: 1,
|
|
when: {
|
|
kind: "terminal_text",
|
|
view: "decoded",
|
|
encoding: "utf8",
|
|
literal: "prompt>",
|
|
case_sensitive: true,
|
|
control_sequences: "include",
|
|
occurrence: 1,
|
|
since: { kind: "scenario_start" },
|
|
consume: true,
|
|
},
|
|
actions: [
|
|
{ type: "send_input", data: "answer", sensitive: false },
|
|
],
|
|
target: { kind: "goto", state: "awaiting_resize" },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: "awaiting_resize",
|
|
max_visits: 1,
|
|
deadline_ms: 10_000,
|
|
on: [
|
|
{
|
|
id: "resize",
|
|
priority: 0,
|
|
max_uses: 1,
|
|
when: {
|
|
kind: "terminal_text",
|
|
view: "decoded",
|
|
encoding: "utf8",
|
|
literal: "input:answer",
|
|
case_sensitive: true,
|
|
control_sequences: "include",
|
|
occurrence: 1,
|
|
since: { kind: "scenario_start" },
|
|
consume: true,
|
|
},
|
|
actions: [{ type: "resize", columns: 100, rows: 40 }],
|
|
target: { kind: "goto", state: "awaiting_signal" },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: "awaiting_signal",
|
|
max_visits: 1,
|
|
deadline_ms: 10_000,
|
|
on: [
|
|
{
|
|
id: "signal",
|
|
priority: 0,
|
|
max_uses: 1,
|
|
when: {
|
|
kind: "terminal_text",
|
|
view: "decoded",
|
|
encoding: "utf8",
|
|
literal: "resize:100x40",
|
|
case_sensitive: true,
|
|
control_sequences: "include",
|
|
occurrence: 1,
|
|
since: { kind: "scenario_start" },
|
|
consume: true,
|
|
},
|
|
actions: [
|
|
{
|
|
type: "send_signal",
|
|
target: { kind: "root" },
|
|
signal: "SIGINT",
|
|
},
|
|
],
|
|
target: { kind: "finish", outcome: "passed" },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
{
|
|
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);
|
|
},
|
|
);
|
|
|
|
itWithCaptureCapability(
|
|
"dispatches scheduled events before a silent PTY produces output",
|
|
async () => {
|
|
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" },
|
|
]);
|
|
},
|
|
);
|
|
|
|
itWithCaptureCapability(
|
|
"samples and cleans a source-owned child and grandchild process tree",
|
|
async () => {
|
|
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,
|
|
);
|