mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
The wrapper deleted only two of its four own session variables from the spawned child's environment, so the child still inherited the lease refresh and TTL overrides. T10 asserted an empty leaked-key list but never set those two variables, so it passed without proving the scrub. Delete all four variables in both wrapper variants and extend T10 to set all four through the harness on both variants. The lease refresh script ran `mv` even when the exclusive create failed, because the two commands were separate statements with no `&&`. Join them so the rename runs only after the create succeeds, and extend T29 to assert that chained shape. Co-authored-by: Paperclip <noreply@paperclip.ing>
2323 lines
96 KiB
TypeScript
2323 lines
96 KiB
TypeScript
import { execFile as execFileCallback, spawn } from "node:child_process";
|
|
import { chmod, mkdir, mkdtemp, readFile, readdir, 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;
|
|
}
|
|
|
|
// 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" };
|
|
// Extra environment variables for this wrapper process, merged in last.
|
|
// Used to drive the lease timing overrides directly, with no host bridge
|
|
// in front of the wrapper.
|
|
env?: Record<string, string>;
|
|
}) {
|
|
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);
|
|
if (options?.env) Object.assign(env, options.env);
|
|
|
|
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());
|
|
}
|
|
|
|
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 {
|
|
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 () => {
|
|
for (const outputToStdout of [true, false]) {
|
|
const wrapper = await startWrapperProcess({
|
|
outputToStdout,
|
|
command: process.execPath,
|
|
args: [
|
|
"-e",
|
|
"process.stdout.write(JSON.stringify(Object.keys(process.env).filter((k) => k.startsWith('PAPERCLIP_PROCESS_SESSION'))));process.exit(0)",
|
|
],
|
|
// Drive all four of the wrapper's own session variables through the
|
|
// harness, so the assertion below proves the child sees none of
|
|
// them -- not just the two the harness happens to set by default.
|
|
env: {
|
|
PAPERCLIP_PROCESS_SESSION_LEASE_REFRESH_MS: "5000",
|
|
PAPERCLIP_PROCESS_SESSION_LEASE_TTL_MS: "15000",
|
|
},
|
|
});
|
|
// The stdout variant reports its frames on `wrapper.frames`; the
|
|
// event-file variant writes them under `eventsDir` instead.
|
|
const getFrames = async (): Promise<WrapperFrame[]> => (outputToStdout ? wrapper.frames : wrapper.readEventFiles());
|
|
await waitFor(async () => (await getFrames()).some((frame) => frame.type === "exit"), 4_000);
|
|
const text = (await getFrames())
|
|
.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: fail closed on an unusable creation time, and on 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. This
|
|
// is the correct, intended shape of a fail-fast capture: the child never
|
|
// gets a chance to become a live orphan. So these two tests prove 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 fails closed at capture when the reported creation time is zero, so no orphan wrapper or child ever starts polling", 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" },
|
|
});
|
|
|
|
await Promise.race([
|
|
wrapper.exited,
|
|
delay(8_000).then(() => {
|
|
throw new Error("The wrapper process did not exit.");
|
|
}),
|
|
]);
|
|
expect(wrapper.stderrText()).toMatch(/not usable/);
|
|
await expectNoLiveProcessByArgvSubstring(childPath);
|
|
}, 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);
|
|
|
|
// ---- PAP-5361: a host-refreshed lease bounds the wrapper's own lifetime,
|
|
// so a host crash cannot orphan it. The host writes and refreshes
|
|
// ".paperclip-session-lease" under sessionDir; the wrapper reads only its
|
|
// filesystem metadata (device, inode, modification time) on a monotonic
|
|
// clock, and never its content. -----------------------------------------
|
|
|
|
const LEASE_FILE_NAME = ".paperclip-session-lease";
|
|
|
|
it("T21 no refresh: the wrapper terminates itself and its child after the lease duration elapses", async () => {
|
|
for (const outputToStdout of [false, true]) {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-lease-no-refresh-"));
|
|
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,
|
|
command: process.execPath,
|
|
args: [childPath],
|
|
terminateGraceMs: 200,
|
|
env: {
|
|
PAPERCLIP_PROCESS_SESSION_LEASE_REFRESH_MS: "100",
|
|
PAPERCLIP_PROCESS_SESSION_LEASE_TTL_MS: "400",
|
|
},
|
|
});
|
|
|
|
const pid = await waitForTrackedChildPid(pidFile);
|
|
// No lease file is ever written under sessionDir.
|
|
await expectWrapperAndTrackedChildToDie(wrapper, pid);
|
|
expect(wrapper.stderrText()).toMatch(/[Ll]ease expired/);
|
|
}
|
|
}, 20_000);
|
|
|
|
it("T22 a valid rename-based refresh resets the deadline, and the wrapper stays alive past the lease duration", async () => {
|
|
for (const outputToStdout of [false, true]) {
|
|
const wrapper = await startWrapperProcess({
|
|
outputToStdout,
|
|
terminateGraceMs: 200,
|
|
env: {
|
|
PAPERCLIP_PROCESS_SESSION_LEASE_REFRESH_MS: "100",
|
|
PAPERCLIP_PROCESS_SESSION_LEASE_TTL_MS: "400",
|
|
},
|
|
});
|
|
const leasePath = path.join(wrapper.sessionDir, LEASE_FILE_NAME);
|
|
|
|
let refreshing = true;
|
|
let refreshCount = 0;
|
|
const refresh = (async () => {
|
|
while (refreshing) {
|
|
// Mirrors the production shape: a fresh temp name, then a rename
|
|
// onto the fixed lease path.
|
|
const tempPath = `${leasePath}.${refreshCount}.tmp`;
|
|
await writeFile(tempPath, "", "utf8");
|
|
await rename(tempPath, leasePath);
|
|
refreshCount += 1;
|
|
await delay(120);
|
|
}
|
|
})();
|
|
|
|
// Refresh faster than the 400ms lease duration, for more than twice
|
|
// the duration in total. A wrapper that ignored the refresh would
|
|
// already have expired well before this returns.
|
|
await delay(950);
|
|
refreshing = false;
|
|
await refresh;
|
|
|
|
expect(refreshCount).toBeGreaterThan(2);
|
|
expect(wrapper.exitInfo().code).toBeNull();
|
|
|
|
// Let the wrapper expire on its own now that the refresh stopped, so
|
|
// the test leaves no lingering process.
|
|
await Promise.race([
|
|
wrapper.exited,
|
|
delay(4_000).then(() => {
|
|
throw new Error("The wrapper did not expire once the refresh stopped.");
|
|
}),
|
|
]);
|
|
}
|
|
}, 20_000);
|
|
|
|
it("T23 a missing lease never resets the deadline, and ordinary stdin traffic does not substitute for it", async () => {
|
|
for (const outputToStdout of [false, true]) {
|
|
const wrapper = await startWrapperProcess({
|
|
outputToStdout,
|
|
terminateGraceMs: 200,
|
|
env: {
|
|
PAPERCLIP_PROCESS_SESSION_LEASE_REFRESH_MS: "100",
|
|
PAPERCLIP_PROCESS_SESSION_LEASE_TTL_MS: "400",
|
|
},
|
|
});
|
|
|
|
// Ordinary stdin traffic while the lease stays absent. Writing and
|
|
// deleting a stdin file changes stdinDir's own metadata, not the
|
|
// lease's, so this must not reset the deadline.
|
|
const finalPath = path.join(wrapper.stdinDir, "000000000001.json");
|
|
const tempPath = `${finalPath}.writing`;
|
|
await writeFile(
|
|
tempPath,
|
|
`${JSON.stringify({ type: "stdin", data: Buffer.from("hi", "utf8").toString("base64") })}\n`,
|
|
"utf8",
|
|
);
|
|
await rename(tempPath, finalPath);
|
|
|
|
await Promise.race([
|
|
wrapper.exited,
|
|
delay(4_000).then(() => {
|
|
throw new Error("The wrapper did not expire with a missing lease.");
|
|
}),
|
|
]);
|
|
expect(wrapper.stderrText()).toMatch(/[Ll]ease expired/);
|
|
}
|
|
}, 20_000);
|
|
|
|
it("T24 a symbolic link at the lease path is never followed, its target stays unchanged, and the wrapper expires", async () => {
|
|
const { symlink } = await import("node:fs/promises");
|
|
for (const outputToStdout of [false, true]) {
|
|
const wrapper = await startWrapperProcess({
|
|
outputToStdout,
|
|
terminateGraceMs: 200,
|
|
env: {
|
|
PAPERCLIP_PROCESS_SESSION_LEASE_REFRESH_MS: "100",
|
|
PAPERCLIP_PROCESS_SESSION_LEASE_TTL_MS: "400",
|
|
},
|
|
});
|
|
const targetPath = path.join(wrapper.sessionDir, "lease-link-target");
|
|
await writeFile(targetPath, "untouched", "utf8");
|
|
const leasePath = path.join(wrapper.sessionDir, LEASE_FILE_NAME);
|
|
await symlink(targetPath, leasePath);
|
|
|
|
await Promise.race([
|
|
wrapper.exited,
|
|
delay(4_000).then(() => {
|
|
throw new Error("The wrapper did not expire with a symbolic link at the lease path.");
|
|
}),
|
|
]);
|
|
expect(wrapper.stderrText()).toMatch(/[Ll]ease expired/);
|
|
expect(await readFile(targetPath, "utf8")).toBe("untouched");
|
|
}
|
|
}, 20_000);
|
|
|
|
it("T25 a directory at the lease path is never treated as a valid lease, and the wrapper expires", async () => {
|
|
for (const outputToStdout of [false, true]) {
|
|
const wrapper = await startWrapperProcess({
|
|
outputToStdout,
|
|
terminateGraceMs: 200,
|
|
env: {
|
|
PAPERCLIP_PROCESS_SESSION_LEASE_REFRESH_MS: "100",
|
|
PAPERCLIP_PROCESS_SESSION_LEASE_TTL_MS: "400",
|
|
},
|
|
});
|
|
const leasePath = path.join(wrapper.sessionDir, LEASE_FILE_NAME);
|
|
await mkdir(leasePath, { recursive: true });
|
|
|
|
await Promise.race([
|
|
wrapper.exited,
|
|
delay(4_000).then(() => {
|
|
throw new Error("The wrapper did not expire with a directory at the lease path.");
|
|
}),
|
|
]);
|
|
expect(wrapper.stderrText()).toMatch(/[Ll]ease expired/);
|
|
}
|
|
}, 20_000);
|
|
|
|
// Pins the accepted residual (PAP-5354): a sandbox peer that
|
|
// touches the lease path defers this wrapper's expiry. This is not a
|
|
// capability the peer lacked -- it can already spawn its own long-lived
|
|
// work -- so a later change must not silently alter this shape.
|
|
it("T26 a peer that keeps touching the lease file defers this wrapper's expiry", async () => {
|
|
const { utimes } = await import("node:fs/promises");
|
|
for (const outputToStdout of [false, true]) {
|
|
const wrapper = await startWrapperProcess({
|
|
outputToStdout,
|
|
terminateGraceMs: 200,
|
|
env: {
|
|
PAPERCLIP_PROCESS_SESSION_LEASE_REFRESH_MS: "100",
|
|
PAPERCLIP_PROCESS_SESSION_LEASE_TTL_MS: "400",
|
|
},
|
|
});
|
|
const leasePath = path.join(wrapper.sessionDir, LEASE_FILE_NAME);
|
|
await writeFile(leasePath, "", "utf8");
|
|
|
|
let churning = true;
|
|
const churn = (async () => {
|
|
while (churning) {
|
|
await utimes(leasePath, new Date(), new Date()).catch(() => undefined);
|
|
await delay(120);
|
|
}
|
|
})();
|
|
|
|
// Keep touching for more than twice the lease duration; a wrapper
|
|
// that ignored the touch would already have expired.
|
|
await delay(950);
|
|
churning = false;
|
|
await churn;
|
|
expect(wrapper.exitInfo().code).toBeNull();
|
|
|
|
// Stop touching and let the wrapper expire normally, so the test
|
|
// leaves no lingering process.
|
|
await Promise.race([
|
|
wrapper.exited,
|
|
delay(4_000).then(() => {
|
|
throw new Error("The wrapper did not expire once the peer stopped touching the lease.");
|
|
}),
|
|
]);
|
|
}
|
|
}, 20_000);
|
|
|
|
it("T27 each wrapper source has exactly one lease deadline declaration and one lease check call site, and the lease code adds no kill caller or numeric process identifier", () => {
|
|
for (const outputToStdout of [true, false]) {
|
|
const src = getProcessSessionRemoteSource({ outputToStdout });
|
|
const code = src
|
|
.split("\n")
|
|
.map((line) => line.replace(/\/\/.*$/, ""))
|
|
.join("\n");
|
|
|
|
const leaseDeadlineDeclarations = code.match(/\blet leaseDeadline = /g) ?? [];
|
|
expect(leaseDeadlineDeclarations.length).toBe(1);
|
|
|
|
const leaseCheckCallSites = code.match(/\bawait checkProcessSessionLease\(\)/g) ?? [];
|
|
expect(leaseCheckCallSites.length).toBe(1);
|
|
expect(code.indexOf("await checkProcessSessionLease();")).toBeLessThan(code.indexOf("await readStdinDirNames()"));
|
|
|
|
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(");
|
|
}
|
|
|
|
const leaseSectionStart = code.indexOf("const leaseFileName =");
|
|
const leaseSectionEnd = code.indexOf("async function terminate()");
|
|
expect(leaseSectionStart).toBeGreaterThan(0);
|
|
expect(leaseSectionEnd).toBeGreaterThan(leaseSectionStart);
|
|
const leaseSection = code.slice(leaseSectionStart, leaseSectionEnd);
|
|
expect(leaseSection).not.toContain("kill(");
|
|
expect(leaseSection).not.toMatch(/\bpid\b/i);
|
|
}
|
|
});
|
|
|
|
it("T28 an injected slow lease refresh in flight during stop() cannot recreate sessionDir after removal", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-lease-host-race-"));
|
|
cleanupDirs.push(rootDir);
|
|
const childPath = path.join(rootDir, "quiet-child.mjs");
|
|
await writeFile(childPath, "process.stdin.resume();\n", "utf8");
|
|
|
|
const completionOrder: string[] = [];
|
|
let counter = 0;
|
|
let sessionDir = "";
|
|
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] ?? "";
|
|
if (script.startsWith("mkdir -p") && !sessionDir) {
|
|
const dirsMatch = /mkdir -p '([^']+)' '([^']+)'/.exec(script);
|
|
if (dirsMatch) sessionDir = path.posix.dirname(dirsMatch[1]);
|
|
}
|
|
if (script.includes("nohup node")) {
|
|
return { exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "", pid: null, startedAt: null };
|
|
}
|
|
const isLeaseRefresh = script.includes("set -C") && script.includes(LEASE_FILE_NAME);
|
|
const isRemove = script.trim().startsWith("rm -rf");
|
|
if (isLeaseRefresh) {
|
|
// Simulate a slow remote exec for the refresh command: it is
|
|
// already in flight when stop() runs below.
|
|
await delay(500);
|
|
}
|
|
counter += 1;
|
|
const command =
|
|
input.command === "bash" ? "/bin/bash" : input.command === "sh" ? "/bin/sh" : input.command;
|
|
const result = await runChildProcess(`lease-race-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 () => {}),
|
|
});
|
|
if (isLeaseRefresh) completionOrder.push("refresh");
|
|
if (isRemove) completionOrder.push("remove");
|
|
return result;
|
|
},
|
|
};
|
|
const target: AdapterSandboxExecutionTarget = {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "local-test",
|
|
remoteCwd: rootDir,
|
|
timeoutMs: 30_000,
|
|
runner,
|
|
};
|
|
|
|
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
|
|
runId: "run-lease-race",
|
|
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();
|
|
|
|
// Give the initial lease write (fired right after launch) time to start
|
|
// before stop() runs, so this exercises a refresh already in flight, not
|
|
// one stop() outruns before it ever starts.
|
|
await waitFor(() => completionOrder.length === 0 && sessionDir !== "", 4_000);
|
|
await delay(50);
|
|
await bridge!.stop();
|
|
|
|
// stop() awaits the whole refresh chain before it removes sessionDir, so
|
|
// the delayed refresh's own `mv` always finishes first.
|
|
expect(completionOrder).toEqual(["refresh", "remove"]);
|
|
|
|
expect(sessionDir).not.toBe("");
|
|
const stillThere = await readdir(sessionDir).catch(() => null);
|
|
expect(stillThere).toBeNull();
|
|
}, 15_000);
|
|
|
|
it("T29 the lease refresh command carries bypassSession and contains no mkdir", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-lease-command-shape-"));
|
|
cleanupDirs.push(rootDir);
|
|
const childPath = path.join(rootDir, "quiet-child.mjs");
|
|
await writeFile(childPath, "process.stdin.resume();\n", "utf8");
|
|
|
|
const calls: Array<{ script: string; bypassSession?: boolean }> = [];
|
|
let counter = 0;
|
|
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>;
|
|
bypassSession?: boolean;
|
|
}): Promise<RunProcessResult> => {
|
|
const script = input.args?.[1] ?? "";
|
|
calls.push({ script, bypassSession: input.bypassSession });
|
|
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(`lease-shape-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-lease-shape",
|
|
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();
|
|
await waitFor(() => calls.some((call) => call.script.includes("set -C")), 4_000);
|
|
await bridge!.stop();
|
|
|
|
const refreshCalls = calls.filter((call) => call.script.includes("set -C"));
|
|
expect(refreshCalls.length).toBeGreaterThan(0);
|
|
for (const call of refreshCalls) {
|
|
expect(call.bypassSession).toBe(true);
|
|
expect(call.script).not.toContain("mkdir");
|
|
expect(call.script).toContain("mv ");
|
|
// The rename must run only when the exclusive create succeeds: the
|
|
// create and the `mv` sit on the same line, joined by `&&`, not as
|
|
// two separate unconditional statements.
|
|
const createAndRenameLine = call.script.split("\n").find((line) => line.includes("mv "));
|
|
expect(createAndRenameLine).toMatch(/^: > .+&&\s*mv /);
|
|
}
|
|
}, 10_000);
|
|
|
|
it("T30 validation: a zero, a negative, a non-numeric, and a D<=P lease override pair all fall back to the defaults on the host and in the wrapper", async () => {
|
|
const invalidPairs: Array<[string, string]> = [
|
|
["0", "900000"],
|
|
["-1000", "900000"],
|
|
["not-a-number", "900000"],
|
|
["600000", "300000"],
|
|
];
|
|
|
|
for (const [refreshRaw, ttlRaw] of invalidPairs) {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-lease-validate-"));
|
|
cleanupDirs.push(rootDir);
|
|
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 prevRefresh = process.env.PAPERCLIP_PROCESS_SESSION_LEASE_REFRESH_MS;
|
|
const prevTtl = process.env.PAPERCLIP_PROCESS_SESSION_LEASE_TTL_MS;
|
|
process.env.PAPERCLIP_PROCESS_SESSION_LEASE_REFRESH_MS = refreshRaw;
|
|
process.env.PAPERCLIP_PROCESS_SESSION_LEASE_TTL_MS = ttlRaw;
|
|
|
|
let bridge;
|
|
try {
|
|
bridge = await startAdapterExecutionTargetProcessSessionBridge({
|
|
runId: `run-validate-${refreshRaw}-${ttlRaw}`,
|
|
target,
|
|
runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
|
|
adapterKey: "acpx",
|
|
command: process.execPath,
|
|
args: ["-e", "process.stdin.resume();"],
|
|
cwd: rootDir,
|
|
env: {},
|
|
timeoutSec: 5,
|
|
onLog: async () => {},
|
|
});
|
|
} finally {
|
|
if (prevRefresh === undefined) delete process.env.PAPERCLIP_PROCESS_SESSION_LEASE_REFRESH_MS;
|
|
else process.env.PAPERCLIP_PROCESS_SESSION_LEASE_REFRESH_MS = prevRefresh;
|
|
if (prevTtl === undefined) delete process.env.PAPERCLIP_PROCESS_SESSION_LEASE_TTL_MS;
|
|
else process.env.PAPERCLIP_PROCESS_SESSION_LEASE_TTL_MS = prevTtl;
|
|
}
|
|
expect(bridge).not.toBeNull();
|
|
|
|
const nohupScript = scripts.find((script) => script.includes("nohup node"));
|
|
expect(nohupScript).toBeDefined();
|
|
expect(nohupScript).toContain("PAPERCLIP_PROCESS_SESSION_LEASE_REFRESH_MS='60000'");
|
|
expect(nohupScript).toContain("PAPERCLIP_PROCESS_SESSION_LEASE_TTL_MS='900000'");
|
|
|
|
await bridge!.stop();
|
|
}
|
|
|
|
// The wrapper side: an invalid override pair (P invalid, D alone would
|
|
// be a very short deadline if the wrapper only clamped D and ignored an
|
|
// invalid P) must fall the whole pair back to the 900s default, not a
|
|
// 50ms deadline.
|
|
const wrapper = await startWrapperProcess({
|
|
outputToStdout: false,
|
|
terminateGraceMs: 200,
|
|
env: {
|
|
PAPERCLIP_PROCESS_SESSION_LEASE_REFRESH_MS: "0",
|
|
PAPERCLIP_PROCESS_SESSION_LEASE_TTL_MS: "50",
|
|
},
|
|
});
|
|
await delay(400);
|
|
expect(wrapper.exitInfo().code).toBeNull();
|
|
// Force a clean exit through the existing session-identity latch,
|
|
// instead of leaving the wrapper alive for the full 900s default.
|
|
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.");
|
|
}),
|
|
]);
|
|
}, 30_000);
|
|
});
|