paperclip/packages/adapter-utils/src/execution-target-stdin-race...

2143 lines
91 KiB
TypeScript

import { execFile as execFileCallback, spawn } from "node:child_process";
import { symlinkSync } from "node:fs";
import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, readlink, rename, rm, writeFile } from "node:fs/promises";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { afterAll, afterEach, describe, expect, it } from "vitest";
import {
getProcessSessionRemoteSource,
startAdapterExecutionTargetProcessSessionBridge,
type AdapterSandboxExecutionTarget,
} from "./execution-target.js";
import { createCommandManagedSandboxCallbackBridgeQueueClient } from "./sandbox-callback-bridge.js";
import { runChildProcess, type RunProcessResult } from "./server-utils.js";
const execFile = promisify(execFileCallback);
// Regression coverage for the stdin file race (parent PAP-4037): the host sends
// each ACP message as a file in the sandbox stdin directory, and a poller in
// the sandbox reads the file and writes the data to the child. Two defects lost
// a message. The host write was not atomic, so the poller could read an empty
// or partial `.json` file. The poller deleted the file before it validated the
// content, so an empty read was lost and a partial read stopped the loop.
describe("stdin file race (parent PAP-4037)", () => {
const cleanupDirs: string[] = [];
afterEach(async () => {
while (cleanupDirs.length > 0) {
const dir = cleanupDirs.pop();
if (dir) await rm(dir, { recursive: true, force: true }).catch(() => undefined);
}
});
// ---- Poller wrapper harness -------------------------------------------
type DeliveredFrame = { seq: number; type: string; stream?: string; data?: string; message?: string };
// Run the real emitted poller wrapper as a node process. The streamed variant
// writes one JSON frame per line to its stdout, so the test reads the frames
// directly. The child command is `cat`, so every byte the poller writes to
// the child stdin comes back as a `data` frame.
async function startPollerWrapper(options?: { maxRetries?: number }) {
const sessionDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-stdin-poll-"));
cleanupDirs.push(sessionDir);
const stdinDir = path.join(sessionDir, "stdin");
await mkdir(stdinDir, { recursive: true });
const wrapperPath = path.join(sessionDir, "wrapper.mjs");
await writeFile(wrapperPath, getProcessSessionRemoteSource({ outputToStdout: true }), "utf8");
const config = { command: "cat", args: [] as string[], cwd: sessionDir, env: {} };
const commandPayload = Buffer.from(JSON.stringify(config), "utf8").toString("base64");
const env: Record<string, string> = {
...process.env,
PAPERCLIP_PROCESS_SESSION_DIR: sessionDir,
PAPERCLIP_PROCESS_SESSION_COMMAND_B64: commandPayload,
};
if (options?.maxRetries != null) {
env.PAPERCLIP_PROCESS_SESSION_STDIN_MAX_RETRIES = String(options.maxRetries);
}
const child = spawn(process.execPath, [wrapperPath], {
cwd: sessionDir,
env,
stdio: ["ignore", "pipe", "pipe"],
});
const frames: DeliveredFrame[] = [];
let stdoutBuffer = "";
child.stdout.on("data", (chunk: Buffer) => {
stdoutBuffer += chunk.toString("utf8");
const lines = stdoutBuffer.split("\n");
stdoutBuffer = lines.pop() || "";
for (const line of lines) {
if (!line.trim()) continue;
frames.push(JSON.parse(line) as DeliveredFrame);
}
});
const exited = new Promise<void>((resolve) => child.on("close", () => resolve()));
return {
sessionDir,
stdinDir,
frames,
// Write a complete stdin file with an atomic rename, so the test never
// creates its own partial-write race.
writeFileAtomic: async (name: string, content: string) => {
const finalPath = path.join(stdinDir, name);
const tempPath = `${finalPath}.writing`;
await writeFile(tempPath, content, "utf8");
await rename(tempPath, finalPath);
},
// Write a `.json` file directly, so a reader can observe it before the
// content lands. This simulates the non-atomic-write window.
writeFileRaw: async (name: string, content: string) => {
await writeFile(path.join(stdinDir, name), content, "utf8");
},
exited,
kill: () => child.kill("SIGKILL"),
};
}
function stdinMessage(text: string): string {
return `${JSON.stringify({ type: "stdin", data: Buffer.from(text, "utf8").toString("base64") })}\n`;
}
const stdinEndMessage = `${JSON.stringify({ type: "stdinEnd" })}\n`;
// Concatenate every stdout `data` frame and decode it back to text.
function collectDelivered(frames: DeliveredFrame[]): string {
return frames
.filter((frame) => frame.type === "data" && frame.stream === "stdout" && typeof frame.data === "string")
.map((frame) => Buffer.from(frame.data as string, "base64").toString("utf8"))
.join("");
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitFor(check: () => boolean, timeoutMs = 4_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (check()) return;
await delay(20);
}
throw new Error("Timed out waiting for condition.");
}
// ---- Poller tests -----------------------------------------------------
it("delivers a stdin file that appears empty first and then gets content", async () => {
const poller = await startPollerWrapper();
// The file appears empty first (the non-atomic-write window). The poller
// must keep it and retry, not delete it and lose the message.
await poller.writeFileRaw("000000000001.json", "");
await delay(200);
// The poller keeps the empty file for a later retry. A poller that deletes
// before it validates would drop the file here and lose the message.
const afterEmpty = await readdir(poller.stdinDir);
expect(afterEmpty).toContain("000000000001.json");
// The content lands in the same file. The poller must deliver it on a later
// cycle, because it kept the file across the empty read.
await poller.writeFileAtomic("000000000001.json", stdinMessage("late-payload"));
await waitFor(() => collectDelivered(poller.frames).includes("late-payload"));
await poller.writeFileAtomic("000000000002.json", stdinEndMessage);
await poller.exited;
expect(collectDelivered(poller.frames)).toBe("late-payload");
expect(poller.frames.some((frame) => frame.type === "exit")).toBe(true);
});
it("keeps polling after a malformed file and still delivers a later valid file", async () => {
const poller = await startPollerWrapper({ maxRetries: 3 });
// A malformed file sorts before the valid file. The poller keeps the send
// order: it holds the later file until it drops the malformed file after the
// retry limit, then it delivers the later valid file. So one bad file blocks
// the loop only until the retry limit, not forever.
await poller.writeFileRaw("000000000001.json", "{ this is not valid json");
await poller.writeFileAtomic("000000000002.json", stdinMessage("valid-after-bad"));
await waitFor(() => collectDelivered(poller.frames).includes("valid-after-bad"));
await poller.writeFileAtomic("000000000003.json", stdinEndMessage);
await poller.exited;
expect(collectDelivered(poller.frames)).toBe("valid-after-bad");
expect(poller.frames.some((frame) => frame.type === "exit")).toBe(true);
});
it("does not close the stream on a later stdinEnd while an earlier file awaits retry", async () => {
const poller = await startPollerWrapper();
// An earlier stdin file is momentarily unreadable (the non-atomic-write
// window). A later stdinEnd file is already complete. The poller must keep
// the send order: it must not read the stdinEnd ahead of the earlier file
// and close the stream. It must hold the stream open until the earlier file
// is readable.
await poller.writeFileRaw("000000000001.json", "");
await poller.writeFileAtomic("000000000002.json", stdinEndMessage);
// Give the poller time to scan. The stream stays open, so the child does not
// exit and no exit frame appears yet.
await delay(300);
expect(poller.frames.some((frame) => frame.type === "exit")).toBe(false);
// The earlier file's content lands. The poller delivers it, then reads the
// stdinEnd and closes the stream.
await poller.writeFileAtomic("000000000001.json", stdinMessage("early-payload"));
await waitFor(() => collectDelivered(poller.frames).includes("early-payload"));
await poller.exited;
expect(collectDelivered(poller.frames)).toBe("early-payload");
expect(poller.frames.some((frame) => frame.type === "exit")).toBe(true);
});
it("drops a permanently malformed file after the retry limit and writes an error event", async () => {
const poller = await startPollerWrapper({ maxRetries: 3 });
// This file never becomes valid. After the retry limit the poller drops it
// and writes an error event, so the lost message fails loudly.
await poller.writeFileRaw("000000000001.json", "{ permanently broken");
await waitFor(() =>
poller.frames.some(
(frame) => frame.type === "error" && typeof frame.message === "string" && frame.message.includes("Dropped unreadable stdin file"),
),
);
// The loop still works after the drop: a later valid file is delivered.
await poller.writeFileAtomic("000000000002.json", stdinMessage("still-alive"));
await waitFor(() => collectDelivered(poller.frames).includes("still-alive"));
await poller.writeFileAtomic("000000000003.json", stdinEndMessage);
await poller.exited;
expect(collectDelivered(poller.frames)).toBe("still-alive");
expect(poller.frames.some((frame) => frame.type === "exit")).toBe(true);
});
it("holds a later stdin file until the missing earlier file appears", async () => {
const poller = await startPollerWrapper();
// File 2 is complete, but file 1 has not appeared yet (a host reordering).
// The poller must not deliver file 2 ahead of the missing file 1. It holds
// the send order and waits for the earlier file.
await poller.writeFileAtomic("000000000002.json", stdinMessage("second-payload"));
await delay(300);
// File 2 is still on disk and nothing was delivered: the poller holds it.
const afterHold = await readdir(poller.stdinDir);
expect(afterHold).toContain("000000000002.json");
expect(collectDelivered(poller.frames)).toBe("");
// File 1 arrives. The poller now delivers file 1 then file 2, in send order.
await poller.writeFileAtomic("000000000001.json", stdinMessage("first-payload"));
await waitFor(() => collectDelivered(poller.frames).includes("second-payload"));
expect(collectDelivered(poller.frames)).toBe("first-payloadsecond-payload");
await poller.writeFileAtomic("000000000003.json", stdinEndMessage);
await poller.exited;
expect(poller.frames.some((frame) => frame.type === "exit")).toBe(true);
});
it("fails loud and advances past a missing stdin file after the retry limit", async () => {
const poller = await startPollerWrapper({ maxRetries: 3 });
// File 1 never appears. File 2 is complete. After the retry limit the poller
// writes a loud error event and advances past the gap, then delivers file 2.
// So a permanent reordering fails loud, never silently.
await poller.writeFileAtomic("000000000002.json", stdinMessage("after-gap"));
await waitFor(() =>
poller.frames.some(
(frame) =>
frame.type === "error" &&
typeof frame.message === "string" &&
frame.message.includes("Advanced past missing stdin files"),
),
);
await waitFor(() => collectDelivered(poller.frames).includes("after-gap"));
await poller.writeFileAtomic("000000000003.json", stdinEndMessage);
await poller.exited;
expect(collectDelivered(poller.frames)).toBe("after-gap");
expect(poller.frames.some((frame) => frame.type === "exit")).toBe(true);
});
// ---- Host serialization test (drives the real bridge) -----------------
// A runner that runs each bridge shell script as a real child process, so the
// test drives the whole legacy-poll bridge: the socket handler, the command-
// managed `writeTextFile` script, the nohup wrapper, and the output poll.
function createLocalSandboxRunner(
onExecute?: (script: string) => Promise<void>,
) {
let counter = 0;
return {
execute: async (input: {
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
stdin?: string;
timeoutMs?: number;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
}): Promise<RunProcessResult> => {
counter += 1;
const script = input.args?.[1] ?? "";
if (onExecute) await onExecute(script);
const command =
input.command === "bash" ? "/bin/bash" : input.command === "sh" ? "/bin/sh" : input.command;
return runChildProcess(`stdin-order-run-${counter}`, command, input.args ?? [], {
cwd: input.cwd ?? process.cwd(),
env: input.env ?? {},
stdin: input.stdin,
timeoutSec: Math.max(1, Math.ceil((input.timeoutMs ?? 30_000) / 1000)),
graceSec: 5,
onLog: input.onLog ?? (async () => {}),
});
},
};
}
it("serializes host stdin writes so a slow earlier write still lands first", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-stdin-host-order-"));
cleanupDirs.push(rootDir);
// The child echoes every stdin byte to stdout, so the wrapper reports the
// exact bytes and order the child received on its stdin.
const childPath = path.join(rootDir, "echo-child.mjs");
await writeFile(childPath, "process.stdin.on('data', (c) => process.stdout.write(c));\n", "utf8");
// Record the send-order-relevant event: the completion of each stdin file's
// finalize (atomic rename). Delay the finalize of the FIRST file, so its
// write resolves slower than the second. Without serialization the second
// rename would land first; the per-session chain must keep the send order.
const finalizeOrder: string[] = [];
const runner = createLocalSandboxRunner(async (script) => {
const finalizeMatch = /base64 -d[\s\S]*mv '[^']*\.decoded' '([^']+\.json)'/.exec(script);
if (finalizeMatch) {
const remotePath = finalizeMatch[1];
if (remotePath.endsWith("000000000001.json")) await delay(300);
finalizeOrder.push(path.posix.basename(remotePath));
}
});
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "local-test",
remoteCwd: rootDir,
timeoutMs: 30_000,
runner,
};
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
runId: "run-stdin-host-order",
target,
runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
adapterKey: "acpx",
command: process.execPath,
args: [childPath],
cwd: rootDir,
env: {},
timeoutSec: 5,
onLog: async () => {},
});
expect(bridge).not.toBeNull();
let peer: net.Socket | null = null;
try {
const proxySource = await readFile(bridge!.agentCommand, "utf8");
const port = Number(/port: (\d+)/.exec(proxySource)?.[1] ?? Number.NaN);
const tokenLiteral = /const token = (".*?");/.exec(proxySource)?.[1];
expect(Number.isFinite(port)).toBe(true);
const token = JSON.parse(tokenLiteral as string) as string;
const peerSocket = net.createConnection({ host: "127.0.0.1", port });
peer = peerSocket;
peerSocket.setEncoding("utf8");
peerSocket.on("error", () => undefined);
const delivered: string[] = [];
let peerBuffer = "";
peerSocket.on("data", (chunk: string) => {
peerBuffer += chunk;
const lines = peerBuffer.split("\n");
peerBuffer = lines.pop() || "";
for (const line of lines) {
if (!line.trim()) continue;
const frame = JSON.parse(line) as { type?: string; stream?: string; data?: string };
if (frame.type === "data" && frame.stream === "stdout" && typeof frame.data === "string") {
delivered.push(Buffer.from(frame.data, "base64").toString("utf8"));
}
}
});
await new Promise<void>((resolve, reject) => {
peerSocket.once("connect", () => resolve());
peerSocket.once("error", reject);
});
// Send two stdin messages back to back. The first authenticates and writes
// file 1; the second writes file 2. Both are scheduled before file 1's
// delayed finalize resolves, so an un-chained handler would race them.
const head = `${JSON.stringify({ token, type: "stdin", data: Buffer.from("HEAD_ONE_", "utf8").toString("base64") })}\n`;
const tail = `${JSON.stringify({ token, type: "stdin", data: Buffer.from("TAIL_TWO", "utf8").toString("base64") })}\n`;
peerSocket.write(head);
peerSocket.write(tail);
// The two finalize renames complete in send order, not in the order the
// delayed and fast writes would otherwise finish.
await waitFor(() => finalizeOrder.length >= 2, 8_000);
expect(finalizeOrder.slice(0, 2)).toEqual(["000000000001.json", "000000000002.json"]);
// End to end: the child receives the two payloads intact and in send
// order, so the prompt is byte-identical on the child stdin.
await waitFor(() => delivered.join("").includes("TAIL_TWO"), 8_000);
expect(delivered.join("")).toBe("HEAD_ONE_TAIL_TWO");
} finally {
peer?.destroy();
await bridge?.stop();
}
});
it("holds stdinEnd on stop until an earlier pending stdin write lands first", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-stdin-stop-order-"));
cleanupDirs.push(rootDir);
const childPath = path.join(rootDir, "echo-child.mjs");
await writeFile(childPath, "process.stdin.on('data', (c) => process.stdout.write(c));\n", "utf8");
// Record each stdin file finalize (atomic rename). `finalizeStarted` marks
// the start; `finalizeOrder` marks the completion. Delay the FIRST chunk's
// finalize, so its write is still pending when `stop()` runs. `stop()` must
// chain the `stdinEnd` write after the pending chunk, so file 2 (stdinEnd)
// never finishes its rename before file 1.
const finalizeStarted: string[] = [];
const finalizeOrder: string[] = [];
const runner = createLocalSandboxRunner(async (script) => {
const finalizeMatch = /base64 -d[\s\S]*mv '[^']*\.decoded' '([^']+\.json)'/.exec(script);
if (finalizeMatch) {
const name = path.posix.basename(finalizeMatch[1]);
finalizeStarted.push(name);
if (name === "000000000001.json") await delay(300);
finalizeOrder.push(name);
}
});
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "local-test",
remoteCwd: rootDir,
timeoutMs: 30_000,
runner,
};
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
runId: "run-stdin-stop-order",
target,
runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
adapterKey: "acpx",
command: process.execPath,
args: [childPath],
cwd: rootDir,
env: {},
timeoutSec: 5,
onLog: async () => {},
});
expect(bridge).not.toBeNull();
let peer: net.Socket | null = null;
let stopped = false;
try {
const proxySource = await readFile(bridge!.agentCommand, "utf8");
const port = Number(/port: (\d+)/.exec(proxySource)?.[1] ?? Number.NaN);
const tokenLiteral = /const token = (".*?");/.exec(proxySource)?.[1];
expect(Number.isFinite(port)).toBe(true);
const token = JSON.parse(tokenLiteral as string) as string;
const peerSocket = net.createConnection({ host: "127.0.0.1", port });
peer = peerSocket;
peerSocket.setEncoding("utf8");
peerSocket.on("error", () => undefined);
peerSocket.on("data", () => undefined);
await new Promise<void>((resolve, reject) => {
peerSocket.once("connect", () => resolve());
peerSocket.once("error", reject);
});
// Send one stdin message. It authenticates and writes file 1, whose
// finalize the runner delays. Wait until that finalize has started, so the
// write is in flight when `stop()` runs.
const head = `${JSON.stringify({ token, type: "stdin", data: Buffer.from("HEAD_ONE_", "utf8").toString("base64") })}\n`;
peerSocket.write(head);
await waitFor(() => finalizeStarted.includes("000000000001.json"), 8_000);
// Stop the bridge while file 1's write is still pending. `stop()` awaits
// the chained `stdinEnd` write, then chains a `shutdown` write after it,
// so all three finalizes are complete when it returns, in send order.
await bridge!.stop();
stopped = true;
expect(finalizeOrder).toEqual(["000000000001.json", "000000000002.json", "000000000003.json"]);
} finally {
peer?.destroy();
if (!stopped) await bridge?.stop();
}
});
// ---- Host atomic-write tests ------------------------------------------
// A runner that executes each bridge shell script on the local filesystem,
// so the test exercises the real command-managed `writeTextFile` script.
function createLocalShellRunner(scripts: string[]) {
return {
execute: async (input: {
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
stdin?: string;
timeoutMs?: number;
}): Promise<RunProcessResult> => {
const args = input.args ?? [];
if ((input.command === "sh" || input.command === "bash") && args[0] === "-c" && typeof args[1] === "string") {
scripts.push(args[1]);
}
const command = input.command === "sh" ? "/bin/sh" : input.command === "bash" ? "/bin/bash" : input.command;
try {
const result = await execFile(command, args, {
cwd: input.cwd,
env: { ...process.env, ...input.env },
maxBuffer: 32 * 1024 * 1024,
});
return {
exitCode: 0,
signal: null,
timedOut: false,
stdout: result.stdout,
stderr: result.stderr,
pid: null,
startedAt: null,
};
} catch (error) {
const err = error as NodeJS.ErrnoException & { stdout?: string; stderr?: string; code?: string | number | null };
return {
exitCode: typeof err.code === "number" ? err.code : null,
signal: null,
timedOut: false,
stdout: err.stdout ?? "",
stderr: err.stderr ?? "",
pid: null,
startedAt: null,
};
}
},
};
}
it("finalizes the command-managed host write with an atomic rename onto the .json path", async () => {
const remoteRoot = await mkdtemp(path.join(os.tmpdir(), "paperclip-stdin-host-cmd-"));
cleanupDirs.push(remoteRoot);
const stdinDir = path.join(remoteRoot, "stdin");
await mkdir(stdinDir, { recursive: true });
const scripts: string[] = [];
const client = createCommandManagedSandboxCallbackBridgeQueueClient({
runner: createLocalShellRunner(scripts),
remoteCwd: remoteRoot,
timeoutMs: 30_000,
});
const jsonPath = path.join(stdinDir, "000000000001.json");
const body = `${JSON.stringify({ type: "stdin", data: Buffer.from("host-payload", "utf8").toString("base64") })}\n`;
await client.writeTextFile(jsonPath, body);
// The final file holds the complete body.
expect(await readFile(jsonPath, "utf8")).toBe(body);
// No temporary upload file remains next to the final file.
const entries = await readdir(stdinDir);
expect(entries).toEqual(["000000000001.json"]);
// The finalize script renames a non-`.json` temporary file onto the final
// path. It never redirects the decode output straight into the `.json`
// file, so a reader never sees an empty or partial `.json` file.
const finalizeScript = scripts.find((script) => script.includes("base64 -d"));
expect(finalizeScript).toBeDefined();
expect(finalizeScript).toContain(`mv `);
expect(finalizeScript).not.toContain(`> '${jsonPath}'`);
expect(finalizeScript).toContain(`> '${jsonPath}.paperclip-upload.decoded'`);
});
it("never exposes a partial .json file under a concurrent reader (command-managed host write)", async () => {
const remoteRoot = await mkdtemp(path.join(os.tmpdir(), "paperclip-stdin-host-race-"));
cleanupDirs.push(remoteRoot);
const stdinDir = path.join(remoteRoot, "stdin");
await mkdir(stdinDir, { recursive: true });
const client = createCommandManagedSandboxCallbackBridgeQueueClient({
runner: createLocalShellRunner([]),
remoteCwd: remoteRoot,
timeoutMs: 30_000,
});
const jsonPath = path.join(stdinDir, "000000000001.json");
// A large body needs many decode bytes, so the write window is wide.
const bigText = "x".repeat(64 * 1024);
const body = `${JSON.stringify({ type: "stdin", data: Buffer.from(bigText, "utf8").toString("base64") })}\n`;
let stop = false;
const readerErrors: string[] = [];
let observedComplete = 0;
const reader = (async () => {
while (!stop) {
const raw = await readFile(jsonPath, "utf8").catch(() => null);
if (raw) {
try {
JSON.parse(raw);
observedComplete += 1;
} catch (error) {
readerErrors.push(error instanceof Error ? error.message : String(error));
}
}
}
})();
for (let round = 0; round < 6; round += 1) {
await client.remove(jsonPath);
await client.writeTextFile(jsonPath, body);
}
stop = true;
await reader;
// Every read of the final file parsed as complete JSON. The reader never
// saw an empty or partial `.json` file.
expect(readerErrors).toEqual([]);
expect(observedComplete).toBeGreaterThan(0);
});
});
// Coverage for deterministic wrapper shutdown (parent PAP-5307): a bridge
// stop must leave no remote wrapper process and no direct child process
// alive, the host must send no operating-system signal, and the host must
// store no process identifier. These tests drive the real emitted wrapper as
// a node process and, where noted, the real bridge through a local runner.
describe("deterministic remote process-session wrapper shutdown (PAP-5316)", () => {
const cleanupDirs: string[] = [];
afterEach(async () => {
while (cleanupDirs.length > 0) {
const dir = cleanupDirs.pop();
if (dir) await rm(dir, { recursive: true, force: true }).catch(() => undefined);
}
});
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitFor(check: () => boolean | Promise<boolean>, timeoutMs = 8_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await check()) return;
await delay(20);
}
throw new Error("Timed out waiting for condition.");
}
function isPidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
// A read-only, test-only liveness probe over the real OS process table. It
// never signals anything; it only greps `ps` output to tell the test
// whether a specific test-authored script (identified by its own temp file
// path) is still running. Production host code never does this — it never
// matches or signals a process by name or command line.
async function findLivePidsByArgvSubstring(substring: string): Promise<number[]> {
try {
const { stdout } = await execFile("ps", ["-eo", "pid=,args="]);
const pids: number[] = [];
for (const line of stdout.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const match = /^(\d+)\s+(.*)$/.exec(trimmed);
if (match && match[2].includes(substring)) {
const pid = Number.parseInt(match[1], 10);
if (Number.isFinite(pid)) pids.push(pid);
}
}
return pids;
} catch {
return [];
}
}
type WrapperFrame = {
seq?: number;
type?: string;
stream?: string;
data?: string;
code?: number | null;
signal?: string | null;
message?: string;
};
// A test-only preload module for the wrapper's node process (PAP-5338).
// This sandbox's filesystems all report a real, working birthtime, so a
// test cannot reach the two known "no usable creation time" fallbacks by
// using a real filesystem alone. This preload patches fs.promises.lstat
// inside the wrapper's own process, for one directory the test names
// through an env var, so the wrapper observes the exact Stats shape each
// fallback produces. It never runs unless a test opts in, and it never
// touches this test file's own process.
// Kept outside cleanupDirs (which afterEach drains after every single test):
// this preload file is created once and reused by every test in this
// describe block, so an early test's cleanup must not delete it out from
// under a later test.
let fakeBirthtimePreloadDir: string | null = null;
afterAll(async () => {
if (fakeBirthtimePreloadDir) await rm(fakeBirthtimePreloadDir, { recursive: true, force: true }).catch(() => undefined);
});
let fakeBirthtimePreloadPath: Promise<string> | null = null;
async function getFakeBirthtimePreloadPath(): Promise<string> {
if (!fakeBirthtimePreloadPath) {
fakeBirthtimePreloadPath = (async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "paperclip-birthtime-preload-"));
fakeBirthtimePreloadDir = dir;
const preloadPath = path.join(dir, "fake-birthtime-preload.cjs");
await writeFile(
preloadPath,
[
`const fs = require("fs");`,
`const path = require("path");`,
`const target = process.env.PAPERCLIP_TEST_FAKE_BIRTHTIME_TARGET;`,
`const mode = process.env.PAPERCLIP_TEST_FAKE_BIRTHTIME_MODE;`,
`const sessionDir = process.env.PAPERCLIP_PROCESS_SESSION_DIR;`,
`if (target && mode && sessionDir) {`,
` const resolvedTarget = path.resolve(target === "stdinDir" ? path.join(sessionDir, "stdin") : sessionDir);`,
` const originalLstat = fs.promises.lstat.bind(fs.promises);`,
` fs.promises.lstat = async (candidatePath, opts) => {`,
` const stats = await originalLstat(candidatePath, opts);`,
` if (path.resolve(String(candidatePath)) === resolvedTarget) {`,
` const fakeValue = mode === "zero" ? 0 : stats.ctimeMs;`,
` Object.defineProperty(stats, "birthtimeMs", { value: fakeValue, configurable: true });`,
` }`,
` return stats;`,
` };`,
`}`,
].join("\n"),
"utf8",
);
return preloadPath;
})();
}
return fakeBirthtimePreloadPath;
}
// A test-only preload for PAP-5355: it deterministically simulates a
// same-sandbox peer that wins the gap between the wrapper's final identity
// check and its removal call. nextProbeFileName() is deterministic (pid +
// call sequence), so this preload can compute the exact probe path the
// wrapper itself will check next. It patches fs.promises.lstat inside the
// wrapper's own process: the first time that call targets the expected
// probe path, it replaces the path with a peer-owned entry before the real
// lstat runs, so the wrapper observes the swapped entry's identity, not its
// own. This is the worst case for the wrapper (the swap always lands
// before the wrapper's very last look at the path), so a wrapper that
// still leaves the peer's entry untouched under this preload proves the
// fix for every less-adversarial timing too. It never runs unless a test
// opts in, and it never touches this test file's own process.
let probeSwapPreloadDir: string | null = null;
afterAll(async () => {
if (probeSwapPreloadDir) await rm(probeSwapPreloadDir, { recursive: true, force: true }).catch(() => undefined);
});
let probeSwapPreloadPath: Promise<string> | null = null;
async function getProbeSwapPreloadPath(): Promise<string> {
if (!probeSwapPreloadPath) {
probeSwapPreloadPath = (async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "paperclip-probe-swap-preload-"));
probeSwapPreloadDir = dir;
const preloadPath = path.join(dir, "probe-swap-preload.cjs");
await writeFile(
preloadPath,
[
`const fs = require("fs");`,
`const path = require("path");`,
`const mode = process.env.PAPERCLIP_TEST_PROBE_SWAP_MODE;`,
`const seq = process.env.PAPERCLIP_TEST_PROBE_SWAP_SEQ;`,
`const symlinkTarget = process.env.PAPERCLIP_TEST_PROBE_SWAP_SYMLINK_TARGET;`,
`if (mode && seq) {`,
` const expectedName = ".paperclip-birthtime-probe-" + process.pid + "-" + seq;`,
` let swapped = false;`,
` const originalLstat = fs.promises.lstat.bind(fs.promises);`,
` fs.promises.lstat = async (candidatePath, opts) => {`,
` if (!swapped && path.basename(String(candidatePath)) === expectedName) {`,
` swapped = true;`,
` try { fs.unlinkSync(candidatePath); } catch {}`,
` if (mode === "file") fs.writeFileSync(candidatePath, "peer-owned-content");`,
` else if (mode === "dir") fs.mkdirSync(candidatePath);`,
` else if (mode === "symlink") fs.symlinkSync(symlinkTarget, candidatePath);`,
` }`,
` return originalLstat(candidatePath, opts);`,
` };`,
`}`,
].join("\n"),
"utf8",
);
return preloadPath;
})();
}
return probeSwapPreloadPath;
}
// A test-only preload for PAP-5374: it simulates fstat() failing on the
// wrapper's own just-opened probe file descriptor, the one signal the real
// filesystem in this sandbox never produces on demand. nextProbeFileName()
// is deterministic (pid + call sequence), so this preload knows which
// fs.promises.open() call is the wrapper's probe write and patches only the
// FileHandle that call returns, leaving every other open() untouched. It
// never runs unless a test opts in, and it never touches this test file's
// own process.
let fstatFailurePreloadDir: string | null = null;
afterAll(async () => {
if (fstatFailurePreloadDir) await rm(fstatFailurePreloadDir, { recursive: true, force: true }).catch(() => undefined);
});
let fstatFailurePreloadPath: Promise<string> | null = null;
async function getFstatFailurePreloadPath(): Promise<string> {
if (!fstatFailurePreloadPath) {
fstatFailurePreloadPath = (async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "paperclip-fstat-failure-preload-"));
fstatFailurePreloadDir = dir;
const preloadPath = path.join(dir, "fstat-failure-preload.cjs");
await writeFile(
preloadPath,
[
`const fs = require("fs");`,
`const path = require("path");`,
`const seq = process.env.PAPERCLIP_TEST_FSTAT_FAILURE_SEQ;`,
`if (seq) {`,
` const expectedName = ".paperclip-birthtime-probe-" + process.pid + "-" + seq;`,
` const originalOpen = fs.promises.open.bind(fs.promises);`,
` fs.promises.open = async (targetPath, flags, mode) => {`,
` const handle = await originalOpen(targetPath, flags, mode);`,
` if (path.basename(String(targetPath)) === expectedName) {`,
` handle.stat = async () => {`,
` throw new Error("EIO: simulated fstat failure for test");`,
` };`,
` }`,
` return handle;`,
` };`,
`}`,
].join("\n"),
"utf8",
);
return preloadPath;
})();
}
return fstatFailurePreloadPath;
}
// Run the real emitted wrapper (either variant) as a node process, with no
// sandbox and no bridge in front of it. The test owns the wrapper's node
// ChildProcess handle directly, so it can observe the wrapper's own exit
// without storing or signaling any process identifier itself.
async function startWrapperProcess(options?: {
outputToStdout?: boolean;
command?: string;
args?: string[];
maxRetries?: number;
terminateGraceMs?: number;
// A dedicated parent for this wrapper's session directory, instead of the
// shared OS temp directory. A test that must chmod sessionDir's own
// parent (to force EACCES on sessionDir itself) needs a parent it owns,
// never the shared OS temp directory every other process on the host
// also uses.
parentDir?: string;
// Makes the wrapper's own process observe an unusable birthtimeMs on one
// control directory, through the preload above. See PAP-5338 AC-1: a
// real "no usable creation time" filesystem is not reachable in this
// sandbox, so the test simulates the exact Stats shape instead.
fakeBirthtime?: { target: "sessionDir" | "stdinDir"; mode: "zero" | "followCtime" };
// Makes the wrapper's own process observe a same-sandbox peer replacing
// its birth-time probe file, through the preload above (PAP-5355). seq 1
// is sessionDir's probe (the first one captureSessionIdentity() runs).
probeSwap?: { seq: 1 | 2; mode: "file" | "dir" | "symlink"; symlinkTarget?: string };
// Makes the wrapper's own process observe an fstat() failure on the open
// descriptor for its own birth-time probe file, through the preload above
// (PAP-5374). seq 1 is sessionDir's probe (the first one
// captureSessionIdentity() runs).
fstatFailure?: { seq: 1 | 2 };
}) {
const sessionDir = await mkdtemp(path.join(options?.parentDir ?? os.tmpdir(), "paperclip-wrapper-lifecycle-"));
cleanupDirs.push(sessionDir);
const stdinDir = path.join(sessionDir, "stdin");
const eventsDir = path.join(sessionDir, "events");
await mkdir(stdinDir, { recursive: true });
if (options?.outputToStdout !== true) await mkdir(eventsDir, { recursive: true });
const wrapperPath = path.join(sessionDir, "wrapper.mjs");
await writeFile(wrapperPath, getProcessSessionRemoteSource({ outputToStdout: options?.outputToStdout === true }), "utf8");
const config = { command: options?.command ?? "cat", args: options?.args ?? [], cwd: sessionDir, env: {} };
const commandPayload = Buffer.from(JSON.stringify(config), "utf8").toString("base64");
const env: Record<string, string> = {
...process.env,
PAPERCLIP_PROCESS_SESSION_DIR: sessionDir,
PAPERCLIP_PROCESS_SESSION_COMMAND_B64: commandPayload,
};
if (options?.maxRetries != null) env.PAPERCLIP_PROCESS_SESSION_STDIN_MAX_RETRIES = String(options.maxRetries);
if (options?.terminateGraceMs != null) env.PAPERCLIP_PROCESS_SESSION_TERMINATE_GRACE_MS = String(options.terminateGraceMs);
const execArgv: string[] = [];
if (options?.fakeBirthtime) {
env.PAPERCLIP_TEST_FAKE_BIRTHTIME_TARGET = options.fakeBirthtime.target;
env.PAPERCLIP_TEST_FAKE_BIRTHTIME_MODE = options.fakeBirthtime.mode;
execArgv.push("--require", await getFakeBirthtimePreloadPath());
}
if (options?.probeSwap) {
env.PAPERCLIP_TEST_PROBE_SWAP_SEQ = String(options.probeSwap.seq);
env.PAPERCLIP_TEST_PROBE_SWAP_MODE = options.probeSwap.mode;
if (options.probeSwap.symlinkTarget) env.PAPERCLIP_TEST_PROBE_SWAP_SYMLINK_TARGET = options.probeSwap.symlinkTarget;
execArgv.push("--require", await getProbeSwapPreloadPath());
}
if (options?.fstatFailure) {
env.PAPERCLIP_TEST_FSTAT_FAILURE_SEQ = String(options.fstatFailure.seq);
execArgv.push("--require", await getFstatFailurePreloadPath());
}
const child = spawn(process.execPath, [...execArgv, wrapperPath], {
cwd: sessionDir,
env,
stdio: ["ignore", "pipe", "pipe"],
});
const frames: WrapperFrame[] = [];
let stdoutBuffer = "";
child.stdout.on("data", (chunk: Buffer) => {
stdoutBuffer += chunk.toString("utf8");
const lines = stdoutBuffer.split("\n");
stdoutBuffer = lines.pop() || "";
for (const line of lines) {
if (!line.trim()) continue;
try {
frames.push(JSON.parse(line) as WrapperFrame);
} catch {
// A partial line at a chunk boundary; ignore.
}
}
});
let stderrText = "";
child.stderr.on("data", (chunk: Buffer) => {
stderrText += chunk.toString("utf8");
});
let exitCode: number | null = null;
let exitSignal: NodeJS.Signals | null = null;
const exited = new Promise<void>((resolve) => {
child.on("close", (code, signal) => {
exitCode = code;
exitSignal = signal;
resolve();
});
});
async function readEventFiles(): Promise<WrapperFrame[]> {
const names = (await readdir(eventsDir).catch(() => [])).filter((name) => name.endsWith(".json")).sort();
const out: WrapperFrame[] = [];
for (const name of names) {
const body = await readFile(path.join(eventsDir, name), "utf8").catch(() => "");
if (!body.trim()) continue;
try {
out.push(JSON.parse(body) as WrapperFrame);
} catch {
// Not fully written yet; the test polls again.
}
}
return out;
}
return {
pid: child.pid,
sessionDir,
stdinDir,
eventsDir,
wrapperPath,
frames,
stderrText: () => stderrText,
exited,
exitInfo: () => ({ code: exitCode, signal: exitSignal }),
readEventFiles,
};
}
// A runner that runs each bridge shell script as a real child process
// (matches the harness in the stdin-race describe block above), so a test
// drives the whole legacy-poll bridge for real: the socket handler, the
// command-managed `writeTextFile`/`remove` scripts, the nohup wrapper
// launch, and the output poll.
function createLocalSandboxRunner(onExecute?: (script: string) => Promise<void>) {
let counter = 0;
return {
execute: async (input: {
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
stdin?: string;
timeoutMs?: number;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
}): Promise<RunProcessResult> => {
counter += 1;
const script = input.args?.[1] ?? "";
if (onExecute) await onExecute(script);
const command =
input.command === "bash" ? "/bin/bash" : input.command === "sh" ? "/bin/sh" : input.command;
return runChildProcess(`wrapper-lifecycle-run-${counter}`, command, input.args ?? [], {
cwd: input.cwd ?? process.cwd(),
env: input.env ?? {},
stdin: input.stdin,
timeoutSec: Math.max(1, Math.ceil((input.timeoutMs ?? 30_000) / 1000)),
graceSec: 5,
onLog: input.onLog ?? (async () => {}),
});
},
};
}
function trackedChildSource(pidFile: string): string {
return [
`import { writeFileSync } from "node:fs";`,
`writeFileSync(${JSON.stringify(pidFile)}, String(process.pid));`,
`process.stdin.resume();`,
].join("\n");
}
// T2's child ignores SIGTERM, so `terminate()` must escalate to SIGKILL
// after its grace period. Ignoring end-of-file on stdin alone would not
// prove that: a plain `cat`-like child already dies from the default
// SIGTERM disposition.
function stubbornChildSource(pidFile: string): string {
return [
`import { writeFileSync } from "node:fs";`,
`writeFileSync(${JSON.stringify(pidFile)}, String(process.pid));`,
`process.stdin.resume();`,
`process.on("SIGTERM", () => {});`,
`setInterval(() => {}, 1000);`,
].join("\n");
}
async function startTrackedBridgeSession(input: {
rootDir: string;
runId: string;
childSource: string;
runner: ReturnType<typeof createLocalSandboxRunner>;
}) {
const pidFile = path.join(input.rootDir, `${input.runId}.pid`);
const childPath = path.join(input.rootDir, `${input.runId}-child.mjs`);
await writeFile(childPath, input.childSource, "utf8");
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "local-test",
remoteCwd: input.rootDir,
timeoutMs: 30_000,
runner: input.runner,
};
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
runId: input.runId,
target,
runtimeRootDir: path.posix.join(input.rootDir, ".paperclip-runtime", "acpx"),
adapterKey: "acpx",
command: process.execPath,
args: [childPath],
cwd: input.rootDir,
env: {},
timeoutSec: 5,
onLog: async () => {},
});
expect(bridge).not.toBeNull();
await waitFor(async () => (await readFile(pidFile, "utf8").catch(() => "")).trim().length > 0, 8_000);
const pid = Number.parseInt((await readFile(pidFile, "utf8")).trim(), 10);
return { bridge: bridge!, pid };
}
it("T1 exits within a bounded time when its session directory disappears with no message ever sent", async () => {
const wrapper = await startWrapperProcess({ outputToStdout: false, terminateGraceMs: 200 });
// Let the poll loop run a few cycles before the directory disappears.
await delay(150);
await rm(wrapper.sessionDir, { recursive: true, force: true });
await Promise.race([
wrapper.exited,
delay(4_000).then(() => {
throw new Error("The wrapper did not exit after its session directory disappeared.");
}),
]);
});
it("T2 leaves neither the wrapper nor a stubborn child alive after stop()", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-stubborn-child-"));
cleanupDirs.push(rootDir);
const runner = createLocalSandboxRunner();
const session = await startTrackedBridgeSession({
rootDir,
runId: "stubborn",
childSource: stubbornChildSource(path.join(rootDir, "stubborn.pid")),
runner,
});
// The emitted wrapper script's own path is unique to this test (it lives
// under this test's fresh temp root), so a `ps` grep on it identifies
// only this test's wrapper process, not a sibling test's.
const wrapperScriptSubstring = path.posix.join(rootDir, ".paperclip-runtime", "acpx", "process-sessions");
try {
expect(isPidAlive(session.pid)).toBe(true);
await waitFor(async () => (await findLivePidsByArgvSubstring(wrapperScriptSubstring)).length > 0, 4_000);
await session.bridge.stop();
// The child ignores SIGTERM, so `terminate()` needs its own grace
// period (default 3s) before it escalates to SIGKILL.
await waitFor(() => !isPidAlive(session.pid), 8_000);
expect(isPidAlive(session.pid)).toBe(false);
await waitFor(async () => (await findLivePidsByArgvSubstring(wrapperScriptSubstring)).length === 0, 4_000);
} finally {
await session.bridge.stop().catch(() => undefined);
}
}, 15_000);
it("T3 exits on its own when the child exits first and no stdinEnd is ever sent", async () => {
const wrapper = await startWrapperProcess({
outputToStdout: false,
command: process.execPath,
args: ["-e", "process.exit(0)"],
});
await Promise.race([
wrapper.exited,
delay(4_000).then(() => {
throw new Error("The wrapper did not exit on its own after its child exited.");
}),
]);
const events = await wrapper.readEventFiles();
expect(events.some((event) => event.type === "exit")).toBe(true);
});
it("T4 stopping one session leaves a sibling session's wrapper and child alive", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-session-isolation-"));
cleanupDirs.push(rootDir);
const runner = createLocalSandboxRunner();
const sessionA = await startTrackedBridgeSession({
rootDir,
runId: "session-a",
childSource: trackedChildSource(path.join(rootDir, "session-a.pid")),
runner,
});
const sessionB = await startTrackedBridgeSession({
rootDir,
runId: "session-b",
childSource: trackedChildSource(path.join(rootDir, "session-b.pid")),
runner,
});
try {
expect(isPidAlive(sessionA.pid)).toBe(true);
expect(isPidAlive(sessionB.pid)).toBe(true);
await sessionA.bridge.stop();
await waitFor(() => !isPidAlive(sessionA.pid), 8_000);
expect(isPidAlive(sessionA.pid)).toBe(false);
// Session B never received a stdinEnd or a shutdown message.
expect(isPidAlive(sessionB.pid)).toBe(true);
} finally {
await sessionA.bridge.stop().catch(() => undefined);
await sessionB.bridge.stop().catch(() => undefined);
}
}, 15_000);
it("T5 still writes both control messages, finishes fast, and warns never after a forged exit event the live poll reads early", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-forged-exit-"));
cleanupDirs.push(rootDir);
const childPath = path.join(rootDir, "quiet-child.mjs");
await writeFile(childPath, "process.stdin.resume();\n", "utf8");
const scripts: string[] = [];
const runner = createLocalSandboxRunner(async (script) => {
scripts.push(script);
});
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "local-test",
remoteCwd: rootDir,
timeoutMs: 30_000,
runner,
};
let warnedCount = 0;
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
runId: "run-forged-exit",
target,
runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
adapterKey: "acpx",
command: process.execPath,
args: [childPath],
cwd: rootDir,
env: {},
timeoutSec: 5,
onLog: async (stream, chunk) => {
if (stream === "stderr" && chunk.includes("did not acknowledge shutdown")) warnedCount += 1;
},
});
expect(bridge).not.toBeNull();
const mkdirScript = scripts.find((script) => script.startsWith("mkdir -p"));
const dirsMatch = /mkdir -p '([^']+)' '([^']+)'/.exec(mkdirScript ?? "");
expect(dirsMatch).not.toBeNull();
const eventsDir = dirsMatch![2];
// Forge an exit event from outside the wrapper, before any real shutdown.
await mkdir(eventsDir, { recursive: true });
await writeFile(path.join(eventsDir, "999999999999.json"), `${JSON.stringify({ type: "exit", code: 0 })}\n`, "utf8");
// Give the live 100 ms host poll time to read the forged file well
// before stop() runs. This closes the gap T5 used to leave open: a
// forged event `stop()` observes only through its own bounded reader
// (not through the live poll, which sets `stopping` on its own and
// stops re-arming) must not shorten the wait either.
await delay(600);
scripts.length = 0;
const start = Date.now();
await bridge!.stop();
const elapsedMs = Date.now() - start;
const finalizeWrites = scripts.filter((script) => script.includes("base64 -d") && script.includes(".paperclip-upload.decoded"));
// stdinEnd, then shutdown: both control messages still land.
expect(finalizeWrites.length).toBeGreaterThanOrEqual(2);
const removeScript = scripts.find((script) => script.trim().startsWith("rm -rf"));
expect(removeScript).toBeDefined();
// The child never exits on its own, so the wrapper's own genuine
// shutdownAck -- not the forged exit event -- is the only thing that can
// finish this fast with no warning. T14 (below) proves the forged event
// alone gives no such shortcut when no genuine acknowledgement ever
// follows it.
expect(elapsedMs).toBeLessThan(1_000);
expect(warnedCount).toBe(0);
}, 10_000);
it("T14 a forged exit event alone does not shorten the wait when the wrapper never truly runs", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-forged-only-"));
cleanupDirs.push(rootDir);
const childPath = path.join(rootDir, "quiet-child.mjs");
await writeFile(childPath, "process.stdin.resume();\n", "utf8");
const scripts: string[] = [];
let counter = 0;
// Run every script for real except the wrapper launch itself, so the
// wrapper never starts. The forged file below is then the only event
// that will ever exist under the session's events directory.
const runner = {
execute: async (input: {
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
stdin?: string;
timeoutMs?: number;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
}): Promise<RunProcessResult> => {
const script = input.args?.[1] ?? "";
scripts.push(script);
if (script.includes("nohup node")) {
return { exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "", pid: null, startedAt: null };
}
counter += 1;
const command =
input.command === "bash" ? "/bin/bash" : input.command === "sh" ? "/bin/sh" : input.command;
return runChildProcess(`forged-only-run-${counter}`, command, input.args ?? [], {
cwd: input.cwd ?? process.cwd(),
env: input.env ?? {},
stdin: input.stdin,
timeoutSec: Math.max(1, Math.ceil((input.timeoutMs ?? 30_000) / 1000)),
graceSec: 5,
onLog: input.onLog ?? (async () => {}),
});
},
};
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "local-test",
remoteCwd: rootDir,
timeoutMs: 30_000,
runner,
};
let warnedCount = 0;
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
runId: "run-forged-only",
target,
runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
adapterKey: "acpx",
command: process.execPath,
args: [childPath],
cwd: rootDir,
env: {},
timeoutSec: 5,
onLog: async (stream, chunk) => {
if (stream === "stderr" && chunk.includes("did not acknowledge shutdown")) warnedCount += 1;
},
});
expect(bridge).not.toBeNull();
const mkdirScript = scripts.find((script) => script.startsWith("mkdir -p"));
const dirsMatch = /mkdir -p '([^']+)' '([^']+)'/.exec(mkdirScript ?? "");
expect(dirsMatch).not.toBeNull();
const eventsDir = dirsMatch![2];
// Forge a terminal event from outside the wrapper. The wrapper never
// started, so this is the only event that will ever exist on disk.
await mkdir(eventsDir, { recursive: true });
await writeFile(path.join(eventsDir, "999999999999.json"), `${JSON.stringify({ type: "exit", code: 0 })}\n`, "utf8");
// Give the live 100 ms host poll time to read the forged file well
// before stop() runs, so this test cannot pass by accident: `stop()`
// never itself observes this event through its own first-line
// `stopping` flag.
await delay(600);
const start = Date.now();
await bridge!.stop();
const elapsedMs = Date.now() - start;
// An exit event under `sessionDir` is untrusted telemetry. With no
// genuine wrapper ever running to write a real shutdownAck, the wait
// still runs its full budget and still warns, exactly as it would with
// no forged event at all (compare T13).
expect(elapsedMs).toBeGreaterThanOrEqual(2_900);
expect(warnedCount).toBe(1);
const removeScript = scripts.find((script) => script.trim().startsWith("rm -rf"));
expect(removeScript).toBeDefined();
}, 10_000);
it("T6 issues no operating-system signal from the host during stop()", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-no-signal-"));
cleanupDirs.push(rootDir);
const childPath = path.join(rootDir, "quiet-child.mjs");
await writeFile(childPath, "process.stdin.resume();\n", "utf8");
const scripts: string[] = [];
const runner = createLocalSandboxRunner(async (script) => {
scripts.push(script);
});
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "local-test",
remoteCwd: rootDir,
timeoutMs: 30_000,
runner,
};
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
runId: "run-no-signal",
target,
runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
adapterKey: "acpx",
command: process.execPath,
args: [childPath],
cwd: rootDir,
env: {},
timeoutSec: 5,
onLog: async () => {},
});
expect(bridge).not.toBeNull();
scripts.length = 0;
await bridge!.stop();
// stop() only ever writes files and removes a directory. None of the
// scripts it runs names a signal or a kill command.
const signalLike = scripts.filter((script) => /\bkill\b|SIGTERM|SIGKILL/i.test(script));
expect(signalLike).toEqual([]);
});
it("T8 running terminate() a second time, after the child already exited, is a safe no-op", async () => {
const wrapper = await startWrapperProcess({
outputToStdout: false,
command: process.execPath,
args: ["-e", "process.exit(0)"],
});
await Promise.race([
wrapper.exited,
delay(4_000).then(() => {
throw new Error("The wrapper did not exit after its child exited on its own.");
}),
]);
// The wrapper's own child-close handler already ran terminate() once (the
// child was already gone, so its child.kill() call no-opped). The
// wrapper did not throw and did not hang.
expect(wrapper.stderrText()).toBe("");
expect(wrapper.exitInfo().code).toBe(0);
const events = await wrapper.readEventFiles();
expect(events.filter((event) => event.type === "exit").length).toBe(1);
// No stray signal-triggered event (e.g. a second exit from a SIGKILL)
// ever landed.
expect(events.filter((event) => event.type === "error").length).toBe(0);
});
it("T9 exits with an error event when sessionDir is a symbolic link", async () => {
const targetDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-symlink-target-"));
cleanupDirs.push(targetDir);
const linkDir = `${targetDir}-link`;
const { symlink } = await import("node:fs/promises");
await symlink(targetDir, linkDir, "dir");
cleanupDirs.push(linkDir);
const wrapperPath = path.join(targetDir, "wrapper.mjs");
await writeFile(wrapperPath, getProcessSessionRemoteSource({ outputToStdout: true }), "utf8");
const config = { command: "cat", args: [] as string[], cwd: targetDir, env: {} };
const commandPayload = Buffer.from(JSON.stringify(config), "utf8").toString("base64");
const child = spawn(process.execPath, [wrapperPath], {
cwd: targetDir,
env: {
...process.env,
PAPERCLIP_PROCESS_SESSION_DIR: linkDir,
PAPERCLIP_PROCESS_SESSION_COMMAND_B64: commandPayload,
},
stdio: ["ignore", "pipe", "pipe"],
});
const frames: WrapperFrame[] = [];
let stdoutBuffer = "";
child.stdout.on("data", (chunk: Buffer) => {
stdoutBuffer += chunk.toString("utf8");
const lines = stdoutBuffer.split("\n");
stdoutBuffer = lines.pop() || "";
for (const line of lines) {
if (!line.trim()) continue;
frames.push(JSON.parse(line) as WrapperFrame);
}
});
const exited = new Promise<void>((resolve) => child.on("close", () => resolve()));
await Promise.race([
exited,
delay(4_000).then(() => {
throw new Error("The wrapper did not exit after sessionDir was a symbolic link.");
}),
]);
expect(frames.some((frame) => frame.type === "error" && typeof frame.message === "string" && frame.message.includes("symbolic link"))).toBe(
true,
);
});
it("T10 the emitted wrapper strips its own session env vars from the child", async () => {
const wrapper = await startWrapperProcess({
outputToStdout: true,
command: process.execPath,
args: [
"-e",
"process.stdout.write(JSON.stringify(Object.keys(process.env).filter((k) => k.startsWith('PAPERCLIP_PROCESS_SESSION'))));process.exit(0)",
],
});
await waitFor(() => wrapper.frames.some((frame) => frame.type === "exit"), 4_000);
const text = wrapper.frames
.filter((frame) => frame.type === "data" && frame.stream === "stdout" && typeof frame.data === "string")
.map((frame) => Buffer.from(frame.data as string, "base64").toString("utf8"))
.join("");
const leakedKeys = JSON.parse(text || "[]") as string[];
expect(leakedKeys).toEqual([]);
});
it("T11 each wrapper source has exactly one spawn call site and every kill call is child.kill()", () => {
for (const outputToStdout of [true, false]) {
const src = getProcessSessionRemoteSource({ outputToStdout });
// Strip `//` line comments first, so prose that happens to mention
// "spawn(" or "kill(" (e.g. explaining `ChildProcess#kill()`) is never
// mistaken for a call site. This checks the code, not the comments.
const code = src
.split("\n")
.map((line) => line.replace(/\/\/.*$/, ""))
.join("\n");
const spawnCallSites = code.match(/\bspawn\(/g) ?? [];
expect(spawnCallSites.length).toBe(1);
const killCallSites = [...code.matchAll(/[A-Za-z0-9_.$]*kill\(/g)].map((match) => match[0]);
expect(killCallSites.length).toBeGreaterThan(0);
for (const site of killCallSites) {
expect(site).toBe("child.kill(");
}
// Regression coverage for PAP-5336: the shared tail must carry the
// session-identity latch, not the old counter it replaced. A counter
// that a successful `readdir` reset to zero let an attacker who
// recreated a deleted control directory keep the wrapper alive
// forever. Both wrapper variants append the same shared tail, so this
// check runs once per variant and fails if a future edit lands the
// latch in only one of them.
expect(src).not.toContain("missingSessionDirStreak");
const identityLatchDeclarations = code.match(/\blet identityLost = false;/g) ?? [];
expect(identityLatchDeclarations.length).toBe(1);
const identityCaptureCallSites = code.match(/\bcaptureSessionIdentity\(\)/g) ?? [];
expect(identityCaptureCallSites.length).toBeGreaterThan(0);
const identityVerifyCallSites = code.match(/\bverifySessionIdentity\(\)/g) ?? [];
expect(identityVerifyCallSites.length).toBeGreaterThan(0);
// The capture must run before the first poll cycle: its call site must
// precede the `pollStdin()` call site in the emitted source.
expect(code.indexOf("await captureSessionIdentity();")).toBeGreaterThan(0);
expect(code.indexOf("await captureSessionIdentity();")).toBeLessThan(code.indexOf("void pollStdin()"));
}
});
// Regression coverage for PAP-5323: the host used to burn the full
// shutdown budget and log a false warning on every normal run, because
// the file-poll loop stopped re-arming right after it delivered the
// `exit` event and so never read the `shutdownAck` file that followed it.
it("T12 stop() finishes well inside the shutdown budget and logs no warning after a normal child exit", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-normal-exit-"));
cleanupDirs.push(rootDir);
const childPath = path.join(rootDir, "quick-exit-child.mjs");
await writeFile(childPath, "process.exit(0);\n", "utf8");
const runner = createLocalSandboxRunner();
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "local-test",
remoteCwd: rootDir,
timeoutMs: 30_000,
runner,
};
let warnedCount = 0;
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
runId: "run-normal-exit",
target,
runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
adapterKey: "acpx",
command: process.execPath,
args: [childPath],
cwd: rootDir,
env: {},
timeoutSec: 5,
onLog: async (stream, chunk) => {
if (stream === "stderr" && chunk.includes("did not acknowledge shutdown")) warnedCount += 1;
},
});
expect(bridge).not.toBeNull();
// Let the child exit and the wrapper write its own `exit` event before
// stop() runs, so this matches a normal run-completion teardown.
await delay(500);
const start = Date.now();
await bridge!.stop();
const elapsedMs = Date.now() - start;
expect(elapsedMs).toBeLessThan(1_000);
expect(warnedCount).toBe(0);
}, 10_000);
// Regression coverage for PAP-5323: a genuinely stuck wrapper, one that
// never writes any event, must still warn after the budget and still
// remove `sessionDir`. The fix must not turn the bounded wait into an
// unconditional skip.
it("T13 warns and still removes sessionDir when the wrapper never acknowledges and never exits", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-never-acks-"));
cleanupDirs.push(rootDir);
const childPath = path.join(rootDir, "quiet-child.mjs");
await writeFile(childPath, "process.stdin.resume();\n", "utf8");
const scripts: string[] = [];
let counter = 0;
// Run every script for real except the wrapper launch itself, so the
// wrapper never starts and the events directory stays empty on every
// poll. `stop()` can then never observe a real `shutdownAck` or a real
// terminal `exit`/`error` event.
const runner = {
execute: async (input: {
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
stdin?: string;
timeoutMs?: number;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
}): Promise<RunProcessResult> => {
const script = input.args?.[1] ?? "";
scripts.push(script);
if (script.includes("nohup node")) {
return { exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "", pid: null, startedAt: null };
}
counter += 1;
const command =
input.command === "bash" ? "/bin/bash" : input.command === "sh" ? "/bin/sh" : input.command;
return runChildProcess(`never-acks-run-${counter}`, command, input.args ?? [], {
cwd: input.cwd ?? process.cwd(),
env: input.env ?? {},
stdin: input.stdin,
timeoutSec: Math.max(1, Math.ceil((input.timeoutMs ?? 30_000) / 1000)),
graceSec: 5,
onLog: input.onLog ?? (async () => {}),
});
},
};
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "local-test",
remoteCwd: rootDir,
timeoutMs: 30_000,
runner,
};
let warnedCount = 0;
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
runId: "run-never-acks",
target,
runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
adapterKey: "acpx",
command: process.execPath,
args: [childPath],
cwd: rootDir,
env: {},
timeoutSec: 5,
onLog: async (stream, chunk) => {
if (stream === "stderr" && chunk.includes("did not acknowledge shutdown")) warnedCount += 1;
},
});
expect(bridge).not.toBeNull();
const start = Date.now();
await bridge!.stop();
const elapsedMs = Date.now() - start;
// The full shutdown budget elapsed, because nothing ever proved the
// wrapper stopped.
expect(elapsedMs).toBeGreaterThanOrEqual(2_900);
expect(warnedCount).toBe(1);
const removeScript = scripts.find((script) => script.trim().startsWith("rm -rf"));
expect(removeScript).toBeDefined();
}, 10_000);
// Regression coverage for PAP-5336: the finding this test reproduces is a
// sandbox control-plane integrity failure, not a timing quirk. During
// `stop()`, a sandbox peer with access to the session directory can (1)
// delete the real `shutdown` control file before the wrapper ever reads
// it, (2) forge a `shutdownAck` event so the host's wait ends early, and
// (3) recreate `sessionDir/stdin` right after the host removes
// `sessionDir`. On the parent commit, a successful `readdir` on the
// recreated directory reset the wrapper's only terminal counter to zero,
// so the wrapper (and its child) polled forever.
//
// The fix replaces the counter with an identity captured at startup: the
// device number, the inode number, and the inode's own creation time. All
// three matter for this test to be a real regression check, not a check
// that passes by luck. Recreating a directory at the same path right after
// removal, with nothing else on the filesystem in between, can reissue the
// exact same device and inode numbers on common filesystems (this test's
// recreate step does exactly that): a device/inode-only identity would
// then wrongly read as unchanged. The creation time does not have this
// gap, because it is set fresh on every inode allocation even when the
// allocator reissues an old inode number.
it("T15 latches on a lost session identity: a recreated control directory cannot keep the wrapper or its child alive", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-lost-identity-"));
cleanupDirs.push(rootDir);
const pidFile = path.join(rootDir, "t15-child.pid");
const childPath = path.join(rootDir, "t15-child.mjs");
await writeFile(childPath, trackedChildSource(pidFile), "utf8");
let sessionDir = "";
let stdinDir = "";
let eventsDir = "";
let shutdownFileDeleted = false;
let shutdownAckForged = false;
let stdinDirRecreated = false;
const scripts: string[] = [];
let counter = 0;
const syntheticSuccess: RunProcessResult = {
exitCode: 0,
signal: null,
timedOut: false,
stdout: "",
stderr: "",
pid: null,
startedAt: null,
};
// Run every script for real on the local filesystem (matching T2/T4/T5's
// harness), so a real wrapper process and a real child process come up,
// with two exceptions that make the attack deterministic instead of a
// race against the wrapper's own 50 ms poll:
//
// 1. With no stdin data ever sent, the host's shutdown control message
// always targets stdin file 000000000002.json (file 1 is stdinEnd).
// Never let that write's script pipeline actually run: this is
// equivalent to an attacker who deletes the file before the wrapper
// ever reads it, but with no window in which the wrapper could win a
// race and read it first.
// 2. Perform the sessionDir removal and the sessionDir/stdin,
// sessionDir/events recreation as direct filesystem calls in this
// same async step, instead of spawning a real `rm -rf` subprocess.
// That removes an external process's scheduling latency from the
// window the wrapper's next poll cycle has to observe the recreated
// directory.
const runner = {
execute: async (input: {
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
stdin?: string;
timeoutMs?: number;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
}): Promise<RunProcessResult> => {
counter += 1;
const script = input.args?.[1] ?? "";
scripts.push(script);
const shutdownFilePath = stdinDir ? path.posix.join(stdinDir, "000000000002.json") : null;
if (shutdownFilePath && script.includes(shutdownFilePath)) {
if (!shutdownFileDeleted) {
shutdownFileDeleted = true;
await writeFile(
path.join(eventsDir, "999999999999.json"),
`${JSON.stringify({ type: "shutdownAck" })}\n`,
"utf8",
).catch(() => undefined);
shutdownAckForged = true;
}
return syntheticSuccess;
}
// Match the removal of sessionDir itself, not the host's own
// per-file event cleanup (which also runs `rm -rf` on a path that
// has sessionDir as a substring).
if (!stdinDirRecreated && sessionDir && script.trim() === `rm -rf '${sessionDir}'`) {
stdinDirRecreated = true;
await rm(sessionDir, { recursive: true, force: true }).catch(() => undefined);
await mkdir(stdinDir, { recursive: true }).catch(() => undefined);
await mkdir(eventsDir, { recursive: true }).catch(() => undefined);
return syntheticSuccess;
}
const command =
input.command === "bash" ? "/bin/bash" : input.command === "sh" ? "/bin/sh" : input.command;
return runChildProcess(`lost-identity-run-${counter}`, command, input.args ?? [], {
cwd: input.cwd ?? process.cwd(),
env: input.env ?? {},
stdin: input.stdin,
timeoutSec: Math.max(1, Math.ceil((input.timeoutMs ?? 30_000) / 1000)),
graceSec: 5,
onLog: input.onLog ?? (async () => {}),
});
},
};
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "local-test",
remoteCwd: rootDir,
timeoutMs: 30_000,
runner,
};
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
runId: "run-lost-identity",
target,
runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
adapterKey: "acpx",
command: process.execPath,
args: [childPath],
cwd: rootDir,
env: {},
timeoutSec: 5,
onLog: async () => {},
});
expect(bridge).not.toBeNull();
const mkdirScript = scripts.find((script) => script.startsWith("mkdir -p"));
const dirsMatch = /mkdir -p '([^']+)' '([^']+)'/.exec(mkdirScript ?? "");
expect(dirsMatch).not.toBeNull();
stdinDir = dirsMatch![1];
eventsDir = dirsMatch![2];
sessionDir = path.posix.dirname(stdinDir);
await waitFor(async () => (await readFile(pidFile, "utf8").catch(() => "")).trim().length > 0, 8_000);
const pid = Number.parseInt((await readFile(pidFile, "utf8")).trim(), 10);
expect(isPidAlive(pid)).toBe(true);
const wrapperScriptSubstring = path.posix.join(rootDir, ".paperclip-runtime", "acpx", "process-sessions");
await waitFor(async () => (await findLivePidsByArgvSubstring(wrapperScriptSubstring)).length > 0, 4_000);
await bridge!.stop();
// Both the attacker's forged shutdownAck and its directory recreation
// ran; this is the full chain the finding describes, not a partial one.
expect(shutdownFileDeleted).toBe(true);
expect(shutdownAckForged).toBe(true);
expect(stdinDirRecreated).toBe(true);
// The wrapper process and its child process both exit within a bounded
// time, even though the wrapper never read a real shutdown message and
// the host's wait ended early on a forged hint. Only the wrapper's own
// identity latch can explain this: it observes the recreated directory
// carries a different identity than the one captured at startup.
await waitFor(() => !isPidAlive(pid), 8_000);
expect(isPidAlive(pid)).toBe(false);
await waitFor(async () => (await findLivePidsByArgvSubstring(wrapperScriptSubstring)).length === 0, 8_000);
}, 15_000);
// ---- PAP-5338: reject a change-time creation-time substitute, and every
// lstat error during verification -------------------------------------
async function waitForTrackedChildPid(pidFile: string): Promise<number> {
await waitFor(async () => (await readFile(pidFile, "utf8").catch(() => "")).trim().length > 0, 8_000);
return Number.parseInt((await readFile(pidFile, "utf8")).trim(), 10);
}
async function expectWrapperAndTrackedChildToDie(
wrapper: { exited: Promise<void> },
pid: number,
): Promise<void> {
await waitFor(() => !isPidAlive(pid), 8_000);
expect(isPidAlive(pid)).toBe(false);
await Promise.race([
wrapper.exited,
delay(8_000).then(() => {
throw new Error("The wrapper process did not exit.");
}),
]);
}
// A capture failure latches and calls terminate() before the poll loop
// ever starts, often within a few milliseconds of the child's own spawn()
// call returning. A freshly spawned Node.js child needs real wall-clock
// time just to boot before it can run its own code, so it can lose the
// race to write a pid file before terminate()'s SIGTERM reaches it. The
// change-time fallback test below therefore proves death through the OS
// process table by the child's own script path (the same technique T15
// above uses for the wrapper itself), which needs no cooperation from code
// inside the child.
async function expectNoLiveProcessByArgvSubstring(substring: string): Promise<void> {
await waitFor(async () => (await findLivePidsByArgvSubstring(substring)).length === 0, 8_000);
expect(await findLivePidsByArgvSubstring(substring)).toEqual([]);
}
it("T16 accepts a zero creation time when the filesystem does not report birth time", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-birthtime-zero-"));
cleanupDirs.push(rootDir);
const pidFile = path.join(rootDir, "t16-child.pid");
const childPath = path.join(rootDir, "t16-child.mjs");
await writeFile(childPath, trackedChildSource(pidFile), "utf8");
const wrapper = await startWrapperProcess({
outputToStdout: false,
command: process.execPath,
args: [childPath],
fakeBirthtime: { target: "sessionDir", mode: "zero" },
});
const pid = await waitForTrackedChildPid(pidFile);
expect(isPidAlive(pid)).toBe(true);
await writeFile(
path.join(wrapper.stdinDir, "000000000001.json"),
`${JSON.stringify({ type: "stdinEnd" })}\n`,
"utf8",
);
await Promise.race([
wrapper.exited,
delay(8_000).then(() => {
throw new Error("The wrapper process did not exit.");
}),
]);
expect(wrapper.exitInfo().code).toBe(0);
expect(wrapper.stderrText()).not.toMatch(/not usable/);
}, 15_000);
it("T17 fails closed at capture when the reported creation time follows the change time, so a change-time copy never passes as a real creation time", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-birthtime-followctime-"));
cleanupDirs.push(rootDir);
const pidFile = path.join(rootDir, "t17-child.pid");
const childPath = path.join(rootDir, "t17-child.mjs");
await writeFile(childPath, trackedChildSource(pidFile), "utf8");
const wrapper = await startWrapperProcess({
outputToStdout: false,
command: process.execPath,
args: [childPath],
fakeBirthtime: { target: "stdinDir", mode: "followCtime" },
});
await Promise.race([
wrapper.exited,
delay(8_000).then(() => {
throw new Error("The wrapper process did not exit.");
}),
]);
await expectNoLiveProcessByArgvSubstring(childPath);
expect(wrapper.stderrText()).toMatch(/changed after a probe write/);
}, 15_000);
it("T18 latches on an EACCES lstat failure on sessionDir during verification, not only on a removed directory", async () => {
// sessionDir lives inside a parent this test owns, never the shared OS
// temp directory: the test denies traversal on that parent, and doing
// that to the shared OS temp directory would break every other process
// on the host that also uses it.
const parentDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-eacces-sessiondir-"));
cleanupDirs.push(parentDir);
const pidFile = path.join(parentDir, "t18-child.pid");
const childPath = path.join(parentDir, "t18-child.mjs");
await writeFile(childPath, trackedChildSource(pidFile), "utf8");
const wrapper = await startWrapperProcess({
outputToStdout: false,
command: process.execPath,
args: [childPath],
parentDir,
terminateGraceMs: 200,
});
const pid = await waitForTrackedChildPid(pidFile);
// Let capture succeed and the poll loop run a clean cycle first, so the
// termination below proves the verify-time latch, not the capture-time
// one.
await delay(150);
await chmod(parentDir, 0o000);
try {
await expectWrapperAndTrackedChildToDie(wrapper, pid);
} finally {
// Restore permission so the shared cleanup can remove this directory.
await chmod(parentDir, 0o700).catch(() => undefined);
}
expect(wrapper.stderrText()).toMatch(/Latching on a lost process session identity/);
}, 15_000);
it("T19 latches on an EACCES lstat failure on stdinDir during verification, even though sessionDir itself still stats cleanly", async () => {
const wrapperOptions = { outputToStdout: false as const, terminateGraceMs: 200 };
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-eacces-stdindir-"));
cleanupDirs.push(rootDir);
const pidFile = path.join(rootDir, "t19-child.pid");
const childPath = path.join(rootDir, "t19-child.mjs");
await writeFile(childPath, trackedChildSource(pidFile), "utf8");
const wrapper = await startWrapperProcess({ ...wrapperOptions, command: process.execPath, args: [childPath] });
const pid = await waitForTrackedChildPid(pidFile);
await delay(150);
// Deny traversal into sessionDir itself: lstat(stdinDir) fails EACCES
// while lstat(sessionDir) still succeeds, since a directory's own mode
// never gates lstat of the directory itself, only lookups inside it.
await chmod(wrapper.sessionDir, 0o000);
try {
await expectWrapperAndTrackedChildToDie(wrapper, pid);
} finally {
await chmod(wrapper.sessionDir, 0o700).catch(() => undefined);
}
expect(wrapper.stderrText()).toMatch(/Latching on a lost process session identity/);
}, 15_000);
it("T20 refuses to write through a probe path a sandbox peer pre-created as a symbolic link, and leaves that link and its target untouched", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-probe-symlink-race-"));
cleanupDirs.push(rootDir);
const pidFile = path.join(rootDir, "t20-child.pid");
const childPath = path.join(rootDir, "t20-child.mjs");
await writeFile(childPath, trackedChildSource(pidFile), "utf8");
const sessionDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-probe-symlink-session-"));
cleanupDirs.push(sessionDir);
const stdinDir = path.join(sessionDir, "stdin");
await mkdir(stdinDir, { recursive: true });
const wrapperPath = path.join(sessionDir, "wrapper.mjs");
await writeFile(wrapperPath, getProcessSessionRemoteSource({ outputToStdout: true }), "utf8");
const config = { command: process.execPath, args: [childPath], cwd: sessionDir, env: {} };
const commandPayload = Buffer.from(JSON.stringify(config), "utf8").toString("base64");
// A file this test owns, standing in for a file a sandbox peer already
// controls. The wrapper's probe write must never reach it.
const probeLinkTarget = path.join(rootDir, "t20-probe-target.txt");
const knownContent = "t20-untouched-content";
await writeFile(probeLinkTarget, knownContent, "utf8");
const child = spawn(process.execPath, [wrapperPath], {
cwd: sessionDir,
env: {
...process.env,
PAPERCLIP_PROCESS_SESSION_DIR: sessionDir,
PAPERCLIP_PROCESS_SESSION_COMMAND_B64: commandPayload,
},
stdio: ["ignore", "pipe", "pipe"],
});
// Wins the race to the probe path against the wrapper's own probe write.
// nextProbeFileName() is deterministic: it names
// ".paperclip-birthtime-probe-<pid>-1" on the wrapper's first probe call,
// which always targets sessionDir. child.pid is available synchronously
// right after spawn() returns, well before the freshly spawned process
// has loaded Node or parsed its own script, so this synchronous
// symlinkSync call lands first. This is the same advantage a real
// sandbox peer racing to pre-create the path would have, so it gives the
// strongest proof: the real wrapper process, under the real race, must
// still refuse to follow the link.
const probePath = path.join(sessionDir, `.paperclip-birthtime-probe-${child.pid}-1`);
symlinkSync(probeLinkTarget, probePath);
let stderrText = "";
child.stderr.on("data", (chunk: Buffer) => {
stderrText += chunk.toString("utf8");
});
const exited = new Promise<void>((resolve) => child.on("close", () => resolve()));
await Promise.race([
exited,
delay(8_000).then(() => {
throw new Error("The wrapper process did not exit.");
}),
]);
await expectNoLiveProcessByArgvSubstring(childPath);
expect(stderrText).toMatch(/could not be created exclusively/);
expect((await lstat(probePath)).isSymbolicLink()).toBe(true);
expect(await readFile(probeLinkTarget, "utf8")).toBe(knownContent);
}, 15_000);
// ---- PAP-5355: identity-aware cleanup after a same-sandbox peer replaces
// the probe file this wrapper just created, in the gap between this
// wrapper's last identity check and its removal call. The probeSwap
// preload (see getProbeSwapPreloadPath above) simulates the worst-case
// timing for that gap deterministically, instead of racing real wall-clock
// time: it swaps the path the instant the wrapper itself looks at it for
// the last time before deciding whether to remove it.
it("T21 still removes its own probe file normally when no peer ever replaces it", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-probe-no-swap-"));
cleanupDirs.push(rootDir);
const pidFile = path.join(rootDir, "t21-child.pid");
const childPath = path.join(rootDir, "t21-child.mjs");
await writeFile(childPath, trackedChildSource(pidFile), "utf8");
const wrapper = await startWrapperProcess({
outputToStdout: false,
command: process.execPath,
args: [childPath],
});
await waitForTrackedChildPid(pidFile);
const probePath = path.join(wrapper.sessionDir, `.paperclip-birthtime-probe-${wrapper.pid}-1`);
await waitFor(async () => !(await lstat(probePath).then(() => true).catch(() => false)), 4_000);
await expect(lstat(probePath)).rejects.toThrow();
}, 15_000);
it("T22 leaves a peer's replacement file untouched instead of deleting it", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-probe-swap-file-"));
cleanupDirs.push(rootDir);
const pidFile = path.join(rootDir, "t22-child.pid");
const childPath = path.join(rootDir, "t22-child.mjs");
await writeFile(childPath, trackedChildSource(pidFile), "utf8");
const wrapper = await startWrapperProcess({
outputToStdout: false,
command: process.execPath,
args: [childPath],
probeSwap: { seq: 1, mode: "file" },
});
await waitForTrackedChildPid(pidFile);
const probePath = path.join(wrapper.sessionDir, `.paperclip-birthtime-probe-${wrapper.pid}-1`);
await waitFor(async () => (await readFile(probePath, "utf8").catch(() => null)) === "peer-owned-content", 4_000);
// The wrapper's own cleanup call already ran (the preload only swaps the
// path the moment the wrapper itself checks it). This delay proves that
// run settled and nothing removes the peer's file afterward.
await delay(200);
expect(await readFile(probePath, "utf8")).toBe("peer-owned-content");
}, 15_000);
it("T23 leaves a peer's replacement directory untouched instead of deleting it", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-probe-swap-dir-"));
cleanupDirs.push(rootDir);
const pidFile = path.join(rootDir, "t23-child.pid");
const childPath = path.join(rootDir, "t23-child.mjs");
await writeFile(childPath, trackedChildSource(pidFile), "utf8");
const wrapper = await startWrapperProcess({
outputToStdout: false,
command: process.execPath,
args: [childPath],
probeSwap: { seq: 1, mode: "dir" },
});
await waitForTrackedChildPid(pidFile);
const probePath = path.join(wrapper.sessionDir, `.paperclip-birthtime-probe-${wrapper.pid}-1`);
await waitFor(async () => await lstat(probePath).then((stats) => stats.isDirectory()).catch(() => false), 4_000);
await delay(200);
expect((await lstat(probePath)).isDirectory()).toBe(true);
}, 15_000);
it("T24 leaves a peer's replacement symbolic link and its target untouched instead of deleting or following it", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-probe-swap-symlink-"));
cleanupDirs.push(rootDir);
const pidFile = path.join(rootDir, "t24-child.pid");
const childPath = path.join(rootDir, "t24-child.mjs");
await writeFile(childPath, trackedChildSource(pidFile), "utf8");
const linkTarget = path.join(rootDir, "t24-probe-target.txt");
const knownContent = "t24-untouched-content";
await writeFile(linkTarget, knownContent, "utf8");
const wrapper = await startWrapperProcess({
outputToStdout: false,
command: process.execPath,
args: [childPath],
probeSwap: { seq: 1, mode: "symlink", symlinkTarget: linkTarget },
});
await waitForTrackedChildPid(pidFile);
const probePath = path.join(wrapper.sessionDir, `.paperclip-birthtime-probe-${wrapper.pid}-1`);
await waitFor(async () => await lstat(probePath).then((stats) => stats.isSymbolicLink()).catch(() => false), 4_000);
await delay(200);
expect((await lstat(probePath)).isSymbolicLink()).toBe(true);
expect(await readlink(probePath)).toBe(linkTarget);
expect(await readFile(linkTarget, "utf8")).toBe(knownContent);
}, 15_000);
it("T25 fails closed at capture when its own probe file's identity cannot be read, so no orphan wrapper or child ever starts polling", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-probe-fstat-failure-"));
cleanupDirs.push(rootDir);
const pidFile = path.join(rootDir, "t25-child.pid");
const childPath = path.join(rootDir, "t25-child.mjs");
await writeFile(childPath, trackedChildSource(pidFile), "utf8");
const wrapper = await startWrapperProcess({
outputToStdout: false,
command: process.execPath,
args: [childPath],
fstatFailure: { seq: 1 },
});
await Promise.race([
wrapper.exited,
delay(8_000).then(() => {
throw new Error("The wrapper process did not exit.");
}),
]);
expect(wrapper.stderrText()).toMatch(/its own probe file's identity could not be read/);
await expectNoLiveProcessByArgvSubstring(childPath);
// With no verified identity for the probe file, the wrapper must not
// remove it by path alone: it leaves the file exactly as it created it,
// rather than risking removal of a different entry a peer may have put
// at the same path.
const probePath = path.join(wrapper.sessionDir, `.paperclip-birthtime-probe-${wrapper.pid}-1`);
expect((await lstat(probePath)).isFile()).toBe(true);
}, 15_000);
});