mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 21:05:21 +02:00
## Thinking Path > - Paperclip is the control plane for agents that perform work. > - Paperclip Runner connects durable provider sessions to individual task runs through PRP. > - Provider continuity and per-run authority are different lifetimes. > - The existing implementation mixed those lifetimes and lost event metadata between provider frames, runnerd, persistence, API sanitization, and the task thread. > - That caused failed continuation, missing progress and Plans, duplicate replies, hidden failures, and unsafe recovery. > - This repair gives every heartbeat fresh authority, preserves qualified provider-session continuity, and restores one lossless presentation path without changing direct adapters. ## Linked Issues or Issue Description **What happened?** A second native heartbeat could reuse tickets, leases, command receipts, sequence state, and run identity from the first heartbeat. Provider phase and item identity could be lost before the UI read them. Redaction could corrupt protocol discriminators while still missing malformed credential tails. The task thread could fold progress into the final response, hide failures, or show more than one final answer. Native Codex also exposed approval modes that do not yet have a durable approval bridge. **Expected behavior** Each heartbeat uses a new PRP authority epoch. Codex and OpenCode preserve exact qualified provider sessions; ACPX emits an explicit continuity event when its qualified process-replacement policy is used. Every accepted provider event is presented, classified as internal, or surfaced as unsupported. The task page shows chronological progress, reasoning summaries, activity, Plans, interactions, terminal failures, and exactly one final reply. Direct adapters retain their existing path. **Steps to reproduce** 1. Enable the unified experimental Paperclip Runner setting. 2. Create a local native Codex, OpenCode, ACPX Claude, or ACPX Codex agent. 3. Run response, Plan, structured-question/resume, restart, cancellation, and failure scenarios. 4. Reload the task while active, waiting, failed, and settled. 5. On the old implementation, observe stale run authority, missing classifications, incomplete output, or duplicated/folded replies. **Paperclip version or commit** The repair is based directly on `master` at `87d05e194b643810d16d20612115acd01d735d43`. **Deployment mode** Local development with the embedded database. Related work: Refs #12616, #12646, #12666, #12685, and #12700. ## What Changed - Rotates PRP control-plane, outbox, ticket, lease, command, receipt, and sequence authority for each heartbeat while carrying forward only a validated provider-session identity. - Reads `control-plane-state.json`, validates both durable schemas and lifecycle values, resumes coherent current runs, archives qualified settled authority, and quarantines malformed or mismatched scoped state without moving ambiguous live legacy state. - Preserves Codex provider phase and stable item identities so commentary remains progress and only `final_answer` becomes final. - Adds raw OpenCode HTTP/SSE boundary coverage and canonical reasoning lifecycle mapping. - Makes ACPX normalization lossless for visible reasoning, tool lifecycle metadata, stable bounded identities, Plan revisions, structured requests, failures, and qualified process replacement. Only the compatible terminal assistant message is promoted as final. - Applies schema-aware redaction before generic JWT-shaped detection and scans every diagnostic string leaf. Malformed raw/escaped quoted credential tails are redacted in both server and durable Rust state. - Restores snapshot-style chronological task presentation, expandable tool activity, inline Plan cards, visible waiting/resume/cancel/failure states, and exactly one final answer. - Makes `never` the only qualified native Codex permission mode and rejects unsupported persisted native modes with remediation. OpenCode and ACPX policies remain intact. - Keeps the unified experimental Runner setting as the only enablement flag. Onboarding and direct Codex, Claude, and OpenCode stay on their legacy execution/finalization paths. - Adds cross-language goldens, authority/recovery/fault coverage, exact response/count assertions, and native plus legacy acceptance scenarios. ## Verification - Pull-request GitHub Actions run Rust formatting/tests, TypeScript checks, server/UI tests, builds, protocol drift checks, browser E2E, and security scans. - A separate workflow-only validation ref is pinned directly on this PR head and runs the 35-cell paid local matrix: three core scenarios plus structured-question resume and restart/resume for native Codex, native OpenCode, ACPX Claude, ACPX Codex, and direct Codex/Claude/OpenCode. Run: https://github.com/paperclipai/paperclip/actions/runs/33682434315 - Acceptance requires exact single visible replies, monotonic sequences, matching envelope discriminators, one semantic terminal, one run terminal, no unresolved interaction, no duplicate mutation, no secret leakage, provider continuity, and zero native rows for direct adapters. - Per maintainer direction, tests are running in GitHub Actions rather than on the slower local host. Only formatters and static diff checks were run locally. ## Risks - Recovery from old or partial filesystem state is sensitive. The repair fails closed, preserves active or unverifiable authority, and quarantines only state whose scoped ownership is safe to move. - Provider event formats can change. Closed validators and boundary goldens turn new or malformed events into visible diagnostics instead of silent drops. - Shared task presentation could affect direct adapters. Runtime-fact gating plus the direct-adapter matrix protect the existing path. - Managed and remote providers are not qualified here. Shared code continues to compile and fail safely, but live qualification is deferred. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex based on GPT-5. The exact deployed snapshot and context-window size are not exposed to this task. It used agentic reasoning, repository inspection, code editing, Git, parallel subagents, and GitHub Actions. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change and contains no internal Paperclip ticket id - [ ] I have run tests locally and they pass (intentionally deferred to GitHub Actions) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented risks above - [ ] All Paperclip CI gates are green - [ ] The paid local-provider matrix is green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
927 lines
30 KiB
TypeScript
927 lines
30 KiB
TypeScript
import { randomBytes } from "node:crypto";
|
|
import { spawn } from "node:child_process";
|
|
import { createWriteStream } from "node:fs";
|
|
import { createRequire } from "node:module";
|
|
import { createServer } from "node:net";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import {
|
|
chmod,
|
|
cp,
|
|
lstat,
|
|
mkdir,
|
|
mkdtemp,
|
|
readFile,
|
|
readdir,
|
|
rm,
|
|
symlink,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { isImmutableDaytonaImage, runnerMatrix } from "./catalog.js";
|
|
import { renderRunnerE2EDashboard } from "./dashboard.js";
|
|
import { packageEvidence } from "./evidence.js";
|
|
import { classifyFailure, shouldRetryFailure } from "./failure-classifier.js";
|
|
import { buildRunnerCampaign } from "./history.js";
|
|
import { resolvePaperclipRunnerBinaryForHarness } from "./harness-env.js";
|
|
import { assertEmbeddedDatabaseIsolation } from "./instance-isolation.js";
|
|
import {
|
|
assertSecretFree,
|
|
findSecretLeakInDirectory,
|
|
isEphemeralCodexRuntimeAuthFile,
|
|
normalizedSecrets,
|
|
sanitizeJson,
|
|
} from "./redaction.js";
|
|
import {
|
|
buildMatrixJobs,
|
|
parseRunnerSelectors,
|
|
RunnerSelectorError,
|
|
selectRunnerExecutions,
|
|
} from "./selectors.js";
|
|
import {
|
|
CREDENTIAL_NAMES,
|
|
type MatrixExecution,
|
|
type RunnerE2EResult,
|
|
} from "./types.js";
|
|
import {
|
|
reapNewDetachedDarwinSharedMemory,
|
|
snapshotDarwinSharedMemory,
|
|
} from "./shared-memory.js";
|
|
|
|
const repositoryRoot = path.resolve(import.meta.dirname, "../..");
|
|
const localEnvPath = path.join(repositoryRoot, ".env.runner-e2e.local");
|
|
const resultsRoot = path.join(repositoryRoot, "tests/runner-e2e/results");
|
|
const activeProcessGroups = new Set<number>();
|
|
const activeProcessCleanup = new Map<number, Promise<string | null>>();
|
|
let cancelled = false;
|
|
|
|
function cleanId(value: string) {
|
|
const result = value
|
|
.replace(/[^A-Za-z0-9_.-]+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
if (!result)
|
|
throw new Error(
|
|
`Invalid empty identifier derived from ${JSON.stringify(value)}`,
|
|
);
|
|
return result;
|
|
}
|
|
|
|
function secret(bytes = 32) {
|
|
return randomBytes(bytes).toString("base64url");
|
|
}
|
|
|
|
async function makeDirectoryTreeRemovable(directory: string): Promise<void> {
|
|
await chmod(directory, 0o700);
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
await Promise.all(
|
|
entries
|
|
.filter((entry) => entry.isDirectory() && !entry.isSymbolicLink())
|
|
.map((entry) =>
|
|
makeDirectoryTreeRemovable(path.join(directory, entry.name)),
|
|
),
|
|
);
|
|
}
|
|
|
|
function stopProcessGroup(pid: number, signal: NodeJS.Signals = "SIGTERM") {
|
|
try {
|
|
if (process.platform === "win32") process.kill(pid, signal);
|
|
else process.kill(-pid, signal);
|
|
} catch {
|
|
// The process tree already exited.
|
|
}
|
|
}
|
|
|
|
function processGroupIsAlive(pid: number) {
|
|
try {
|
|
process.kill(process.platform === "win32" ? pid : -pid, 0);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function terminateProcessGroup(pid: number) {
|
|
stopProcessGroup(pid, "SIGTERM");
|
|
const gracefulDeadline = Date.now() + 5_000;
|
|
while (processGroupIsAlive(pid) && Date.now() < gracefulDeadline) {
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
}
|
|
if (!processGroupIsAlive(pid)) return null;
|
|
stopProcessGroup(pid, "SIGKILL");
|
|
const forcedDeadline = Date.now() + 5_000;
|
|
while (processGroupIsAlive(pid) && Date.now() < forcedDeadline) {
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
}
|
|
return processGroupIsAlive(pid)
|
|
? `Paperclip/Playwright process group ${pid} survived SIGKILL`
|
|
: null;
|
|
}
|
|
|
|
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
|
|
process.on(signal, () => {
|
|
cancelled = true;
|
|
for (const pid of activeProcessGroups) {
|
|
if (activeProcessCleanup.has(pid)) {
|
|
stopProcessGroup(pid, "SIGKILL");
|
|
continue;
|
|
}
|
|
activeProcessCleanup.set(pid, terminateProcessGroup(pid));
|
|
}
|
|
});
|
|
}
|
|
|
|
async function loadLocalEnvironment(target: NodeJS.ProcessEnv) {
|
|
const contents = await readFile(localEnvPath, "utf8").catch(
|
|
(error: NodeJS.ErrnoException) => {
|
|
if (error.code === "ENOENT") return null;
|
|
throw error;
|
|
},
|
|
);
|
|
if (contents === null) return;
|
|
for (const [index, rawLine] of contents.split(/\r?\n/).entries()) {
|
|
const line = rawLine.trim();
|
|
if (!line || line.startsWith("#")) continue;
|
|
const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line);
|
|
if (!match)
|
|
throw new Error(
|
|
`Invalid ${path.basename(localEnvPath)} line ${index + 1}`,
|
|
);
|
|
if (target[match[1]] !== undefined) continue;
|
|
let value = match[2].trim();
|
|
if (
|
|
(value.startsWith('"') && value.endsWith('"')) ||
|
|
(value.startsWith("'") && value.endsWith("'"))
|
|
) {
|
|
value = value.slice(1, -1);
|
|
}
|
|
target[match[1]] = value;
|
|
}
|
|
}
|
|
|
|
async function reservePort() {
|
|
const server = createServer();
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once("error", reject);
|
|
server.listen(0, "127.0.0.1", resolve);
|
|
});
|
|
const address = server.address();
|
|
if (!address || typeof address === "string")
|
|
throw new Error("Failed to reserve a loopback port");
|
|
await new Promise<void>((resolve, reject) =>
|
|
server.close((error) => (error ? reject(error) : resolve())),
|
|
);
|
|
return address.port;
|
|
}
|
|
|
|
async function prepareProviderPath(
|
|
temporaryRoot: string,
|
|
inheritedPath: string | undefined,
|
|
) {
|
|
const toolBin = path.join(temporaryRoot, "provider-bin");
|
|
await mkdir(toolBin, { recursive: true });
|
|
const runnerRequire = createRequire(
|
|
path.join(repositoryRoot, "packages/paperclip-runner/package.json"),
|
|
);
|
|
const codexAcpPackage = runnerRequire.resolve(
|
|
"@agentclientprotocol/codex-acp/package.json",
|
|
);
|
|
const codexRequire = createRequire(codexAcpPackage);
|
|
const codexPackage = codexRequire.resolve("@openai/codex/package.json");
|
|
const codexManifest = JSON.parse(await readFile(codexPackage, "utf8")) as {
|
|
bin?: string | Record<string, string>;
|
|
};
|
|
const codexBin =
|
|
typeof codexManifest.bin === "string"
|
|
? codexManifest.bin
|
|
: codexManifest.bin?.codex;
|
|
if (!codexBin)
|
|
throw new Error(
|
|
"Production Codex ACP dependency does not expose its pinned Codex executable",
|
|
);
|
|
await symlink(
|
|
path.resolve(path.dirname(codexPackage), codexBin),
|
|
path.join(toolBin, "codex"),
|
|
);
|
|
const packageBin = path.join(
|
|
repositoryRoot,
|
|
"packages/paperclip-runner/node_modules/.bin",
|
|
);
|
|
return [toolBin, packageBin, inheritedPath]
|
|
.filter(Boolean)
|
|
.join(path.delimiter);
|
|
}
|
|
|
|
async function runProcess(
|
|
args: string[],
|
|
env: NodeJS.ProcessEnv,
|
|
timeoutMs: number | null,
|
|
logPath: string,
|
|
) {
|
|
const log = createWriteStream(logPath, { flags: "a", mode: 0o600 });
|
|
const child = spawn("pnpm", args, {
|
|
cwd: repositoryRoot,
|
|
env,
|
|
stdio: ["inherit", "pipe", "pipe"],
|
|
detached: process.platform !== "win32",
|
|
});
|
|
if (!child.pid) throw new Error("Failed to start Playwright");
|
|
activeProcessGroups.add(child.pid);
|
|
let outputTail = "";
|
|
const recordOutput = (chunk: Buffer, destination: NodeJS.WriteStream) => {
|
|
destination.write(chunk);
|
|
log.write(chunk);
|
|
outputTail += chunk.toString("utf8");
|
|
if (outputTail.length > 1024 * 1024)
|
|
outputTail = outputTail.slice(-1024 * 1024);
|
|
};
|
|
child.stdout?.on("data", (chunk: Buffer) =>
|
|
recordOutput(chunk, process.stdout),
|
|
);
|
|
child.stderr?.on("data", (chunk: Buffer) =>
|
|
recordOutput(chunk, process.stderr),
|
|
);
|
|
let timedOut = false;
|
|
const timer =
|
|
timeoutMs === null
|
|
? undefined
|
|
: setTimeout(() => {
|
|
timedOut = true;
|
|
stopProcessGroup(child.pid!, "SIGTERM");
|
|
setTimeout(
|
|
() => stopProcessGroup(child.pid!, "SIGKILL"),
|
|
10_000,
|
|
).unref();
|
|
}, timeoutMs);
|
|
timer?.unref();
|
|
let spawnError: string | null = null;
|
|
const exitCode = await new Promise<number>((resolve, reject) => {
|
|
child.once("error", reject);
|
|
child.once("exit", (code) => resolve(code ?? 1));
|
|
}).catch((error) => {
|
|
spawnError = error instanceof Error ? error.message : String(error);
|
|
return 1;
|
|
});
|
|
if (timer) clearTimeout(timer);
|
|
const processCleanupError = child.pid
|
|
? await (activeProcessCleanup.get(child.pid) ??
|
|
terminateProcessGroup(child.pid))
|
|
: null;
|
|
if (child.pid) {
|
|
activeProcessGroups.delete(child.pid);
|
|
activeProcessCleanup.delete(child.pid);
|
|
}
|
|
await new Promise<void>((resolve) => log.end(resolve));
|
|
return {
|
|
exitCode,
|
|
timedOut,
|
|
processCleanupError,
|
|
spawnError,
|
|
outputTail,
|
|
};
|
|
}
|
|
|
|
function syntheticResult(
|
|
execution: MatrixExecution,
|
|
attempt: number,
|
|
startedAtMs: number,
|
|
error: string,
|
|
failureClass: RunnerE2EResult["failureClass"] = "transient_infrastructure",
|
|
): RunnerE2EResult {
|
|
const finishedAtMs = Date.now();
|
|
return {
|
|
schema: "paperclip.runner-e2e.result/v2",
|
|
executionId: execution.id,
|
|
suiteId: execution.suite.id,
|
|
suiteDefinitionHash: execution.suiteDefinitionHash,
|
|
source: {
|
|
sha: process.env.GITHUB_SHA ?? null,
|
|
ref: process.env.GITHUB_REF ?? null,
|
|
workflowRunUrl:
|
|
process.env.GITHUB_SERVER_URL &&
|
|
process.env.GITHUB_REPOSITORY &&
|
|
process.env.GITHUB_RUN_ID
|
|
? `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`
|
|
: null,
|
|
},
|
|
...(execution.profile.ranking
|
|
? { rankingSnapshot: execution.profile.ranking }
|
|
: {}),
|
|
attempt,
|
|
status: "failed",
|
|
failureClass,
|
|
error,
|
|
profileId: execution.profile.id,
|
|
environmentId: execution.environment.id,
|
|
caseId: execution.task.id,
|
|
provider: execution.profile.provider,
|
|
model: execution.profile.model,
|
|
runtimeMode: execution.profile.expectedRuntimeMode,
|
|
startedAt: new Date(startedAtMs).toISOString(),
|
|
finishedAt: new Date(finishedAtMs).toISOString(),
|
|
durationMs: finishedAtMs - startedAtMs,
|
|
cleanup: "not_started",
|
|
};
|
|
}
|
|
|
|
async function readResult(resultPath: string, fallback: RunnerE2EResult) {
|
|
try {
|
|
return JSON.parse(await readFile(resultPath, "utf8")) as RunnerE2EResult;
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
async function copySharedEvidence(privateRoot: string, casePrivateDir: string) {
|
|
for (const relative of [
|
|
"server.log",
|
|
"playwright.log",
|
|
"junit.xml",
|
|
"html-report",
|
|
"blob-report",
|
|
"playwright-output",
|
|
]) {
|
|
await cp(
|
|
path.join(privateRoot, relative),
|
|
path.join(casePrivateDir, relative),
|
|
{ recursive: true, force: true },
|
|
).catch((error: NodeJS.ErrnoException) => {
|
|
if (error.code !== "ENOENT") throw error;
|
|
});
|
|
}
|
|
}
|
|
|
|
async function runAttempt(input: {
|
|
executions: readonly MatrixExecution[];
|
|
attempt: number;
|
|
campaignId: string;
|
|
options: ReturnType<typeof parseRunnerSelectors>;
|
|
}): Promise<RunnerE2EResult[]> {
|
|
const { executions, attempt, campaignId, options } = input;
|
|
const execution = executions[0];
|
|
if (!execution) throw new Error("A runner E2E cell must contain a task case");
|
|
if (
|
|
executions.some(
|
|
(candidate) =>
|
|
candidate.profile.id !== execution.profile.id ||
|
|
candidate.environment.id !== execution.environment.id,
|
|
)
|
|
) {
|
|
throw new Error(
|
|
"One isolated runner E2E harness cannot mix profiles or environments",
|
|
);
|
|
}
|
|
const startedAtMs = Date.now();
|
|
const sharedMemoryBaseline = snapshotDarwinSharedMemory();
|
|
const temporaryRoot = await mkdtemp(
|
|
path.join(os.tmpdir(), "paperclip-runner-e2e-"),
|
|
);
|
|
const publishedResults: RunnerE2EResult[] = [];
|
|
const publishedResultPaths = new Map<string, string>();
|
|
let attemptSecrets: string[] = [];
|
|
try {
|
|
const paperclipHome = path.join(temporaryRoot, "paperclip-home");
|
|
const workspace = path.join(temporaryRoot, "workspace");
|
|
const privateDir = path.join(temporaryRoot, "artifacts-private");
|
|
const instanceId = `runner-e2e-${randomBytes(8).toString("hex")}`;
|
|
const configPath = path.join(
|
|
paperclipHome,
|
|
"instances",
|
|
instanceId,
|
|
"config.json",
|
|
);
|
|
const port = await reservePort();
|
|
await Promise.all([
|
|
mkdir(paperclipHome, { recursive: true }),
|
|
mkdir(workspace, { recursive: true }),
|
|
mkdir(privateDir, { recursive: true }),
|
|
]);
|
|
const providerPath = await prepareProviderPath(
|
|
temporaryRoot,
|
|
process.env.PATH,
|
|
);
|
|
const agentJwtSecret = secret(48);
|
|
const decisionSigningSecret = secret(48);
|
|
const toolActionSigningSecret = secret(48);
|
|
const betterAuthSecret = secret(48);
|
|
const credentials = normalizedSecrets([
|
|
...CREDENTIAL_NAMES.map((name) => process.env[name]),
|
|
agentJwtSecret,
|
|
decisionSigningSecret,
|
|
toolActionSigningSecret,
|
|
betterAuthSecret,
|
|
]);
|
|
attemptSecrets = credentials;
|
|
const childEnv: NodeJS.ProcessEnv = {
|
|
...process.env,
|
|
PATH: providerPath,
|
|
PAPERCLIP_RUNNER_E2E_EXECUTION_IDS: JSON.stringify(
|
|
executions.map((candidate) => candidate.id),
|
|
),
|
|
PAPERCLIP_RUNNER_E2E_ATTEMPT: String(attempt),
|
|
PAPERCLIP_RUNNER_E2E_PORT: String(port),
|
|
PAPERCLIP_RUNNER_E2E_TEMP_ROOT: temporaryRoot,
|
|
PAPERCLIP_RUNNER_E2E_PRIVATE_DIR: privateDir,
|
|
PAPERCLIP_RUNNER_E2E_WORKSPACE: workspace,
|
|
PAPERCLIP_RUNNER_E2E_SERVER_LOG: path.join(privateDir, "server.log"),
|
|
PAPERCLIP_RUNNER_BINARY: resolvePaperclipRunnerBinaryForHarness(
|
|
executions,
|
|
repositoryRoot,
|
|
),
|
|
// Vite's optimized dependency cache embeds revision query strings. A
|
|
// private per-attempt cache prevents an earlier cell or local rebuild
|
|
// from producing `504 Outdated Optimize Dep` during browser bootstrap.
|
|
PAPERCLIP_VITE_CACHE_DIR: path.join(temporaryRoot, "vite-cache"),
|
|
PAPERCLIP_RUNNER_E2E_TEST_TIMEOUT_MS: String(
|
|
Math.max(
|
|
...executions.map(
|
|
(candidate) =>
|
|
candidate.task.attemptTimeoutMs[candidate.environment.id],
|
|
),
|
|
) + 90_000,
|
|
),
|
|
PAPERCLIP_HOME: paperclipHome,
|
|
PAPERCLIP_INSTANCE_ID: instanceId,
|
|
PAPERCLIP_CONFIG: configPath,
|
|
PAPERCLIP_AGENT_JWT_SECRET: agentJwtSecret,
|
|
PAPERCLIP_DECISION_SIGNING_SECRET: decisionSigningSecret,
|
|
PAPERCLIP_TOOL_ACTION_SIGNING_SECRET: toolActionSigningSecret,
|
|
BETTER_AUTH_SECRET: betterAuthSecret,
|
|
};
|
|
// The database URLs are stripped here and again at the Playwright web-server
|
|
// boundary so a developer's shell can never redirect this paid test.
|
|
delete childEnv.DATABASE_URL;
|
|
delete childEnv.DATABASE_MIGRATION_URL;
|
|
|
|
const playwrightArgs = [
|
|
"exec",
|
|
"playwright",
|
|
"test",
|
|
"--config",
|
|
"tests/runner-e2e/playwright.config.ts",
|
|
...(options.headed ? ["--headed"] : []),
|
|
...(options.ui ? ["--ui"] : []),
|
|
...(options.debug ? ["--debug"] : []),
|
|
];
|
|
const watchdog =
|
|
options.ui || options.debug
|
|
? null
|
|
: executions.reduce(
|
|
(total, candidate) =>
|
|
total +
|
|
candidate.task.attemptTimeoutMs[candidate.environment.id] +
|
|
90_000,
|
|
0,
|
|
) +
|
|
5 * 60_000;
|
|
const processResult = await runProcess(
|
|
playwrightArgs,
|
|
childEnv,
|
|
watchdog,
|
|
path.join(privateDir, "playwright.log"),
|
|
);
|
|
const processFailure = processResult.spawnError
|
|
? `Playwright failed to start: ${processResult.spawnError}`
|
|
: processResult.timedOut
|
|
? `Harness process exceeded ${Math.round((watchdog ?? 0) / 1000)} seconds`
|
|
: `Playwright exited ${processResult.exitCode} before writing a result`;
|
|
const processFailureClass =
|
|
processResult.spawnError || processResult.timedOut
|
|
? "transient_infrastructure"
|
|
: classifyFailure(
|
|
new Error(
|
|
processResult.outputTail
|
|
? `${processFailure}\n${processResult.outputTail}`
|
|
: processFailure,
|
|
),
|
|
);
|
|
const results = await Promise.all(
|
|
executions.map(async (candidate) => {
|
|
const resultPath = path.join(
|
|
privateDir,
|
|
"cases",
|
|
candidate.task.id,
|
|
"result.json",
|
|
);
|
|
const fallback = syntheticResult(
|
|
candidate,
|
|
attempt,
|
|
startedAtMs,
|
|
processFailure,
|
|
processFailureClass,
|
|
);
|
|
const result = await readResult(resultPath, fallback);
|
|
return processResult.processCleanupError
|
|
? {
|
|
...result,
|
|
status: "failed" as const,
|
|
failureClass: "cleanup_failure" as const,
|
|
error: processResult.processCleanupError,
|
|
cleanup: "failed" as const,
|
|
}
|
|
: result;
|
|
}),
|
|
);
|
|
let isolationError: unknown;
|
|
try {
|
|
await assertEmbeddedDatabaseIsolation(configPath, temporaryRoot);
|
|
} catch (error) {
|
|
isolationError = error;
|
|
}
|
|
let persistedStateError: unknown;
|
|
try {
|
|
const expectedEphemeralCredentials = new Set<string>();
|
|
for (const [label, directory] of [
|
|
["Paperclip home", paperclipHome],
|
|
["workspace", workspace],
|
|
] as const) {
|
|
while (true) {
|
|
// The managed Codex home may legitimately contain upstream source-code
|
|
// fixtures with fake `sk-*` strings. Reject exact campaign credentials.
|
|
const leak = await findSecretLeakInDirectory(directory, credentials, {
|
|
includeShapes: false,
|
|
ignoreFile: (file) => expectedEphemeralCredentials.has(file),
|
|
});
|
|
if (!leak) break;
|
|
const isManagedCodexRuntimeAuth =
|
|
label === "Paperclip home" &&
|
|
isEphemeralCodexRuntimeAuthFile(paperclipHome, leak.file);
|
|
if (isManagedCodexRuntimeAuth) {
|
|
const metadata = await lstat(leak.file);
|
|
if (metadata.isFile() && (metadata.mode & 0o777) === 0o600) {
|
|
// Codex CLI API-key mode requires this one runtime auth file. It
|
|
// lives only in the disposable cell root, is never published,
|
|
// must be owner-only, and is removed with the root below.
|
|
expectedEphemeralCredentials.add(leak.file);
|
|
continue;
|
|
}
|
|
}
|
|
throw new Error(
|
|
`Secret leak in persisted ${label} state at ${path.relative(temporaryRoot, leak.file)}: ${leak.reason}`,
|
|
);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
persistedStateError = error;
|
|
}
|
|
for (const [index, candidate] of executions.entries()) {
|
|
let result = results[index];
|
|
if (
|
|
isolationError &&
|
|
result.failureClass !== "cleanup_failure" &&
|
|
result.failureClass !== "secret_leak"
|
|
) {
|
|
const isolationMessage =
|
|
isolationError instanceof Error
|
|
? isolationError.message
|
|
: String(isolationError);
|
|
const configWasUnavailableDuringTransientBootstrap =
|
|
result.failureClass === "transient_infrastructure" &&
|
|
typeof isolationError === "object" &&
|
|
isolationError !== null &&
|
|
"code" in isolationError &&
|
|
isolationError.code === "ENOENT";
|
|
result = {
|
|
...result,
|
|
status: "failed",
|
|
failureClass: configWasUnavailableDuringTransientBootstrap
|
|
? result.failureClass
|
|
: "permanent_infrastructure",
|
|
error: configWasUnavailableDuringTransientBootstrap
|
|
? `${result.error}; isolated config could not be inspected after bootstrap failure: ${isolationMessage}`
|
|
: isolationMessage,
|
|
};
|
|
}
|
|
if (persistedStateError) {
|
|
const persistedStateMessage =
|
|
persistedStateError instanceof Error
|
|
? persistedStateError.message
|
|
: String(persistedStateError);
|
|
const persistedStateClass = classifyFailure(persistedStateError);
|
|
if (
|
|
persistedStateClass === "secret_leak" ||
|
|
(result.failureClass !== "secret_leak" &&
|
|
result.failureClass !== "cleanup_failure")
|
|
) {
|
|
result = {
|
|
...result,
|
|
status: "failed",
|
|
failureClass:
|
|
persistedStateClass === "secret_leak"
|
|
? "secret_leak"
|
|
: "permanent_infrastructure",
|
|
error: persistedStateMessage,
|
|
};
|
|
} else {
|
|
result = {
|
|
...result,
|
|
error: `${result.error}; persisted-state scan also failed: ${persistedStateMessage}`,
|
|
};
|
|
}
|
|
}
|
|
const casePrivateDir = path.join(privateDir, "cases", candidate.task.id);
|
|
const resultPath = path.join(casePrivateDir, "result.json");
|
|
const uploadDir = path.join(
|
|
resultsRoot,
|
|
campaignId,
|
|
candidate.suite.id,
|
|
candidate.profile.id,
|
|
candidate.environment.id,
|
|
candidate.task.id,
|
|
`attempt-${attempt}`,
|
|
);
|
|
await mkdir(casePrivateDir, { recursive: true });
|
|
await copySharedEvidence(privateDir, casePrivateDir);
|
|
await writeFile(
|
|
resultPath,
|
|
`${JSON.stringify(sanitizeJson(result, credentials), null, 2)}\n`,
|
|
"utf8",
|
|
);
|
|
let evidence = await packageEvidence({
|
|
privateDir: casePrivateDir,
|
|
uploadDir,
|
|
secrets: credentials,
|
|
expectPassScreenshot: result.status === "passed",
|
|
});
|
|
if (evidence.leaks.length > 0 || evidence.missing.length > 0) {
|
|
result = {
|
|
...result,
|
|
status: "failed",
|
|
failureClass:
|
|
evidence.leaks.length > 0
|
|
? "secret_leak"
|
|
: "permanent_infrastructure",
|
|
error:
|
|
evidence.leaks.length > 0
|
|
? `Secret leak rejected from evidence: ${evidence.leaks.map((leak) => leak.file).join(", ")}`
|
|
: `Required evidence missing: ${evidence.missing.join(", ")}`,
|
|
};
|
|
await writeFile(
|
|
resultPath,
|
|
`${JSON.stringify(sanitizeJson(result, credentials), null, 2)}\n`,
|
|
"utf8",
|
|
);
|
|
evidence = await packageEvidence({
|
|
privateDir: casePrivateDir,
|
|
uploadDir,
|
|
secrets: credentials,
|
|
expectPassScreenshot: false,
|
|
});
|
|
}
|
|
console.log(
|
|
`${result.status === "passed" ? "PASS" : "FAIL"} ${candidate.id} attempt ${attempt} -> ${uploadDir}`,
|
|
);
|
|
publishedResults.push(result);
|
|
publishedResultPaths.set(
|
|
candidate.id,
|
|
path.join(uploadDir, "result.json"),
|
|
);
|
|
}
|
|
return [...publishedResults];
|
|
} finally {
|
|
reapNewDetachedDarwinSharedMemory(sharedMemoryBaseline);
|
|
let cleanupError: unknown;
|
|
if (
|
|
temporaryRoot.startsWith(`${os.tmpdir()}${path.sep}paperclip-runner-e2e-`)
|
|
) {
|
|
for (let cleanupAttempt = 1; cleanupAttempt <= 3; cleanupAttempt += 1) {
|
|
try {
|
|
await rm(temporaryRoot, { recursive: true, force: true });
|
|
cleanupError = undefined;
|
|
break;
|
|
} catch (error) {
|
|
cleanupError = error;
|
|
if (cleanupAttempt < 3) {
|
|
await makeDirectoryTreeRemovable(temporaryRoot).catch(() => {});
|
|
await new Promise((resolve) =>
|
|
setTimeout(resolve, cleanupAttempt * 250),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
cleanupError = new Error(
|
|
`Refusing to remove unexpected temporary path ${temporaryRoot}`,
|
|
);
|
|
}
|
|
if (cleanupError) {
|
|
const message = `Temporary runner E2E state cleanup failed at ${temporaryRoot}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`;
|
|
for (const publishedResult of publishedResults) {
|
|
const publishedResultPath = publishedResultPaths.get(
|
|
publishedResult.executionId,
|
|
);
|
|
if (!publishedResultPath) continue;
|
|
Object.assign(publishedResult, {
|
|
status: "failed",
|
|
failureClass: "cleanup_failure",
|
|
error: message,
|
|
cleanup: "failed",
|
|
} satisfies Partial<RunnerE2EResult>);
|
|
const safeResult = `${JSON.stringify(
|
|
sanitizeJson(publishedResult, attemptSecrets),
|
|
null,
|
|
2,
|
|
)}\n`;
|
|
assertSecretFree(safeResult, attemptSecrets, publishedResultPath);
|
|
await writeFile(publishedResultPath, safeResult, "utf8");
|
|
}
|
|
// Cleanup failure is a failed cell, but must not suppress later cells in
|
|
// the same campaign. The result above carries the terminal failure.
|
|
console.error(message);
|
|
}
|
|
}
|
|
}
|
|
|
|
function printList(executions: readonly MatrixExecution[]) {
|
|
console.log("ID\tSUITE\tGENERATION\tPROVIDER\tMODEL\tCREDENTIALS");
|
|
for (const execution of executions) {
|
|
console.log(
|
|
[
|
|
execution.id,
|
|
execution.suite.id,
|
|
execution.profile.generation,
|
|
execution.profile.provider,
|
|
execution.profile.model,
|
|
execution.requiredCredentials.join(","),
|
|
].join("\t"),
|
|
);
|
|
}
|
|
}
|
|
|
|
async function runExecutionWithRetry(input: {
|
|
execution: MatrixExecution;
|
|
campaignId: string;
|
|
options: ReturnType<typeof parseRunnerSelectors>;
|
|
}): Promise<RunnerE2EResult> {
|
|
const { execution, campaignId, options } = input;
|
|
const [firstResult] = await runAttempt({
|
|
executions: [execution],
|
|
attempt: 1,
|
|
campaignId,
|
|
options,
|
|
});
|
|
if (!firstResult) throw new Error(`No result produced for ${execution.id}`);
|
|
if (
|
|
options.ui ||
|
|
options.debug ||
|
|
firstResult.status !== "failed" ||
|
|
!firstResult.failureClass ||
|
|
!shouldRetryFailure(firstResult.failureClass)
|
|
) {
|
|
return firstResult;
|
|
}
|
|
if (cancelled) throw new Error("Runner E2E campaign cancelled");
|
|
console.warn(
|
|
`Retrying ${execution.id} in a fresh isolated harness after transient infrastructure failure`,
|
|
);
|
|
const [retryResult] = await runAttempt({
|
|
executions: [execution],
|
|
attempt: 2,
|
|
campaignId,
|
|
options,
|
|
});
|
|
if (!retryResult)
|
|
throw new Error(`No retry result produced for ${execution.id}`);
|
|
return retryResult;
|
|
}
|
|
|
|
async function runWithConcurrency<T, R>(
|
|
values: readonly T[],
|
|
concurrency: number,
|
|
worker: (value: T) => Promise<R>,
|
|
): Promise<R[]> {
|
|
const results = new Array<R>(values.length);
|
|
let cursor = 0;
|
|
const workers = Array.from(
|
|
{ length: Math.min(concurrency, values.length) },
|
|
async () => {
|
|
while (true) {
|
|
const index = cursor;
|
|
cursor += 1;
|
|
if (index >= values.length) return;
|
|
if (cancelled) throw new Error("Runner E2E campaign cancelled");
|
|
results[index] = await worker(values[index]!);
|
|
}
|
|
},
|
|
);
|
|
await Promise.all(workers);
|
|
return results;
|
|
}
|
|
|
|
async function main() {
|
|
let options: ReturnType<typeof parseRunnerSelectors>;
|
|
try {
|
|
options = parseRunnerSelectors(process.argv.slice(2));
|
|
} catch (error) {
|
|
if (error instanceof RunnerSelectorError)
|
|
throw new Error(`${error.message}\nUse --list to inspect valid cells.`);
|
|
throw error;
|
|
}
|
|
const executions = selectRunnerExecutions(options, runnerMatrix);
|
|
if (options.list) {
|
|
printList(executions);
|
|
return;
|
|
}
|
|
if (options.matrixJson) {
|
|
const jobs = buildMatrixJobs(executions);
|
|
console.log(
|
|
JSON.stringify({
|
|
include: jobs,
|
|
needsDaytona: jobs.some((job) => job.needsDaytona),
|
|
executionIds: executions.map((execution) => execution.id),
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
await loadLocalEnvironment(process.env);
|
|
const missingCredentials = [
|
|
...new Set(
|
|
executions.flatMap((execution) => execution.requiredCredentials),
|
|
),
|
|
].filter((name) => !process.env[name]?.trim());
|
|
if (missingCredentials.length > 0) {
|
|
throw new Error(
|
|
`Missing runner E2E credentials: ${missingCredentials.join(", ")}`,
|
|
);
|
|
}
|
|
if (
|
|
executions.some((execution) => execution.environment.id === "daytona") &&
|
|
!isImmutableDaytonaImage(process.env.PAPERCLIP_E2E_DAYTONA_IMAGE)
|
|
) {
|
|
throw new Error(
|
|
"PAPERCLIP_E2E_DAYTONA_IMAGE must be an immutable image@sha256 digest for Daytona cells",
|
|
);
|
|
}
|
|
|
|
const campaignId = cleanId(
|
|
process.env.PAPERCLIP_E2E_CAMPAIGN_ID ??
|
|
`local-${new Date().toISOString().replace(/[:.]/g, "-")}`,
|
|
);
|
|
const requestedParallelism =
|
|
options.headed || options.ui || options.debug ? 1 : options.maxParallel;
|
|
console.log(
|
|
`Running ${executions.length} isolated execution(s) with max parallelism ${requestedParallelism}`,
|
|
);
|
|
const finalResults = await runWithConcurrency(
|
|
executions,
|
|
requestedParallelism,
|
|
(execution) => runExecutionWithRetry({ execution, campaignId, options }),
|
|
);
|
|
|
|
const generatedAt = new Date().toISOString();
|
|
const campaign = buildRunnerCampaign({
|
|
campaignId,
|
|
generatedAt,
|
|
expected: executions.map((execution) => execution.id),
|
|
results: finalResults,
|
|
});
|
|
const summaryDir = path.join(resultsRoot, campaignId);
|
|
await mkdir(summaryDir, { recursive: true });
|
|
const campaignSecrets = normalizedSecrets(
|
|
CREDENTIAL_NAMES.map((name) => process.env[name]),
|
|
);
|
|
const safeCampaign = sanitizeJson(
|
|
campaign,
|
|
campaignSecrets,
|
|
) as typeof campaign;
|
|
const campaignText = `${JSON.stringify(safeCampaign, null, 2)}\n`;
|
|
assertSecretFree(campaignText, campaignSecrets, "campaign.json");
|
|
await writeFile(path.join(summaryDir, "campaign.json"), campaignText, "utf8");
|
|
const dashboard = renderRunnerE2EDashboard({
|
|
title: `Runner E2E · ${campaignId}`,
|
|
generatedAt,
|
|
expected: executions.map((execution) => execution.id),
|
|
catalog: runnerMatrix,
|
|
campaign: safeCampaign,
|
|
entries: safeCampaign.results.map((result) => ({
|
|
result,
|
|
valid: result.status === "passed" && result.cleanup === "passed",
|
|
errors:
|
|
result.status === "passed" && result.cleanup === "passed"
|
|
? []
|
|
: [
|
|
result.error ??
|
|
result.failureClass ??
|
|
`cleanup=${result.cleanup}`,
|
|
],
|
|
evidenceBaseHref: [
|
|
result.suiteId ?? "core-compatibility",
|
|
result.profileId,
|
|
result.environmentId,
|
|
result.caseId,
|
|
`attempt-${result.attempt}`,
|
|
].join("/"),
|
|
})),
|
|
});
|
|
assertSecretFree(dashboard, campaignSecrets, "dashboard.html");
|
|
await writeFile(path.join(summaryDir, "dashboard.html"), dashboard, "utf8");
|
|
console.log(
|
|
`Campaign ${campaignId}: ${safeCampaign.passed}/${safeCampaign.selected} passed`,
|
|
);
|
|
if (safeCampaign.failed > 0) process.exitCode = 1;
|
|
}
|
|
|
|
await main().catch((error) => {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exitCode = 1;
|
|
});
|