mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The restart chaos harness (`tests/restart-chaos/`) runs real agent tasks against an isolated control plane and injects faults > - Cloud servers run in the volume-free mode (`PAPERCLIP_EPHEMERAL_SERVER=true`, durable agent files, run logs in object storage) > - The harness never set that mode, so every wave ran a different server mode from staging. Paths that exist only in that mode were never tested. One of them is the per-agent directory operation lock, so `--fail-recovery-step directory-lock` could never reach its step with any workload > - The re-wake workloads W2 and W2C also lost coverage: an agent that reported phase 1 with disposition `done` closed the task, and the harness never sent the re-wake (four of eight Claude cells on 2026-11-11) > - This pull request adds `--ephemeral-server` and makes the W2/W2C brief ask for a `yielded` phase-1 report > - The benefit is that chaos waves can run in staging's server mode and the re-wake cells test the re-wake on every provider ## Linked Issues or Issue Description **What is the problem?** The restart chaos harness cannot run the control plane in the volume-free mode that Cloud and staging use. The `directory-lock` recovery step (`agent_directory.operation_lock.before_acquire`) is reached only for disposable remote copies, which need `PAPERCLIP_EPHEMERAL_SERVER=true`. W2 and W2C cells also stop before the re-wake when the agent reports phase 1 as `done`. **What is the expected behavior?** A wave can opt in to the volume-free mode on Daytona, and every restart of the wave keeps that mode. The W2 and W2C cells reach the re-wake. **Steps to reproduce** Run `--fail-recovery-step agent_directory.operation_lock.before_acquire:1` on Daytona with any workload: the check `recovery_step_failed_once` reports "never reached". Run W2 on Claude: the first run ends with the task `done` and `rewake_sent` is "never sent". ## What Changed - `assertIsolatedServerEnvironment` (`tests/runner-e2e/harness-env.ts`) takes `expected.generatedSecretsMasterKey`. It admits `PAPERCLIP_SECRETS_MASTER_KEY` only when that parameter is set and equals the value exactly. `PAPERCLIP_SECRETS_MASTER_KEY_FILE` stays refused. The check runs over the final server environment. - `--k15-fresh-home` implies `--ephemeral-server` and defaults `--storage` to `s3`. A fresh instance home without the volume-free mode loses the agents' instruction bundles, which staging keeps in Postgres and object storage. - `--ephemeral-server` (`config.ts`, `types.ts`, `main.ts`, `supervisor.ts`, new `ephemeral-server.ts` with its unit test `ephemeral-server.test.ts`): it needs `--storage s3` and `--env daytona` and refuses K14. Every server of the wave gets `PAPERCLIP_EPHEMERAL_SERVER`, `PAPERCLIP_DURABLE_AGENT_FILES`, `PAPERCLIP_SHARED_UI_ASSETS`, the wave's Postgres as `PAPERCLIP_DATABASE_LISTEN_URL`, one stable `PAPERCLIP_SECRETS_MASTER_KEY` (also written to the config key file) and `RUN_LOG_S3_*` in the wave's object store. - `rewake.ts`: the W2/W2C phase-1 brief asks for `paperclip_finish` with disposition `yielded` and a `response_wake` continuation. - W2/W2C tasks are created in `backlog` with their assignee, and a board comment starts phase 1 (`startComment` in `workloads.ts`, used by `observer.ts`). A passive board wait needs a comment-started run; without it the yield queues an immediate continuation and phase 1 wakes itself. The start comment is posted once (saved-comment check plus a stable `clientRequestId`), and the brief keeps phase 2 out of blocking `remainingWork`. - Volume-free mode drops the repository workloads (B1, B5, F1, R1) from the default list and refuses them when listed: their fixture is a `local_path` project source, which a volume-free server refuses. - `README.md`: a section for the new flag and a note on the brief. - `shape-cells.test.ts`: option parsing and refusals for the new flag. ## Verification - `pnpm exec vitest run --config tests/restart-chaos/vitest.config.ts tests/restart-chaos/shape-cells.test.ts` passes (16 tests). - `node scripts/typecheck-test-projects.mjs tests/restart-chaos` passes. - Live proof wave (Daytona, Codex, W2, K1 and K15 with a fresh home, `--storage s3 --ephemeral-server`): wave `w-20261111T205259-s6301` (2 tasks). The first server and both replacements (K15 with a fresh home, then K1) started and reached ready in the volume-free mode. The server process had `PAPERCLIP_EPHEMERAL_SERVER=true`, `PAPERCLIP_DURABLE_AGENT_FILES=true` and `RUN_LOG_S3_BUCKET` set. Both W2 tasks left phase 1 `in_progress` (yielded), received the re-wake and ended `done`. The wave also found product faults (reported separately, not fixed here): a re-wake after K1 failed with `runner_harness_state_mismatch: backup_without_reusable_lease` and needed Retry. - `pnpm exec vitest run --config tests/runner-e2e/vitest.config.ts tests/runner-e2e/support.test.ts` passes, including the new master-key admission test (a matching wave key passes; an inherited key, a mismatched key and `PAPERCLIP_SECRETS_MASTER_KEY_FILE` are refused). ## Risks - Low risk. The flag is opt-in and the harness is test-only. The brief change alters the W2/W2C prompt, so results from older W2/W2C seeds are not directly comparable. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use and code execution. ## 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 (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [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 any risks above - [ ] All Paperclip CI gates are 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 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
304 lines
13 KiB
TypeScript
304 lines
13 KiB
TypeScript
import { PUBLIC_ORIGIN_CONTROL_ENV, PUBLIC_ORIGIN_RECEIPT_ENV } from "./chat-public-origin.js";
|
|
import { scrubChatPrivateEnvironment } from "./chat-private-inputs.js";
|
|
import { CHAT_ANNOUNCEMENT_FEED_URL, CHAT_ANNOUNCEMENT_SCENARIO } from "./chat-announcement-fixture.js";
|
|
import { PRIVATE_DIAGNOSTICS_ENV } from "./private-diagnostics.js";
|
|
import { installedDaytonaPluginKeys } from "./installed-daytona-plugin.js";
|
|
import { QUALIFIED_ACPX_PROFILES } from "../../packages/paperclip-runner/src/drivers/acpx/qualified-profiles.js";
|
|
import { assertInstalledCliSelection, installedCliKeys, usesInstalledCli } from "./installed-cli.js";
|
|
import path from "node:path";
|
|
import { isEvalRemoteOrigin } from "./eval-remote-gateway.js";
|
|
import { chatNeedsApiTools, isManagedHiringCase } from "./chat-cases.js";
|
|
import { CREDENTIAL_NAMES } from "./types.js";
|
|
import type { MatrixExecution } from "./types.js";
|
|
|
|
const DATABASE_KEYS = ["DATABASE_URL", "DATABASE_MIGRATION_URL", "PAPERCLIP_RUNNER_E2E_POSTGRES_ADMIN_URL"] as const;
|
|
const AMBIENT_PAPERCLIP_CREDENTIAL_KEYS = [
|
|
"PAPERCLIP_API_KEY",
|
|
"PAPERCLIP_ACPX_CANDIDATE_ADMISSION",
|
|
"PAPERCLIP_AGENT_API_KEY",
|
|
"PAPERCLIP_TASK_BRIDGE_TOKEN",
|
|
"PAPERCLIP_E2E_GATEWAY_TOKEN",
|
|
"PAPERCLIP_E2E_GATEWAY_PHASE_PATH",
|
|
"PAPERCLIP_E2E_OTLP_RELAY_URL",
|
|
"PAPERCLIP_E2E_REMOTE_GATEWAY_ORIGIN",
|
|
"PAPERCLIP_SETUP_TOKEN",
|
|
"PAPERCLIP_SECRETS_MASTER_KEY",
|
|
"PAPERCLIP_SECRETS_MASTER_KEY_FILE",
|
|
] as const;
|
|
const GENERATED_SERVER_SECRET_KEYS = [
|
|
"PAPERCLIP_AGENT_JWT_SECRET",
|
|
"PAPERCLIP_DECISION_SIGNING_SECRET",
|
|
"PAPERCLIP_TOOL_ACTION_SIGNING_SECRET",
|
|
"BETTER_AUTH_SECRET",
|
|
] as const;
|
|
const AMBIENT_EXTERNAL_STATE_KEYS = [
|
|
"PAPERCLIP_STORAGE_S3_BUCKET",
|
|
"PAPERCLIP_STORAGE_S3_REGION",
|
|
"PAPERCLIP_STORAGE_S3_ENDPOINT",
|
|
"PAPERCLIP_STORAGE_S3_PREFIX",
|
|
"PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE",
|
|
] as const;
|
|
const PROVIDER_SECRET_KEY = /^(?:OPENAI|ANTHROPIC|OPENROUTER|BRAINTRUST|DAYTONA|XAI|GROK|KIMI|CURSOR|COPILOT|GITHUB|GH)(?:_|$)/;
|
|
|
|
/** Vault SDK authority is never ambient chat input. An explicitly selected
|
|
* server-only vault handoff supplies its own finite overrides after scrubbing. */
|
|
export function scrubAmbientVaultEnvironment(env: NodeJS.ProcessEnv) {
|
|
for (const name of Object.keys(env)) if (name.startsWith("AWS_") || name.startsWith("PAPERCLIP_SECRETS_AWS_")) delete env[name];
|
|
return env;
|
|
}
|
|
|
|
export function applyEvalGatewayEnvironment(env: NodeJS.ProcessEnv, credential: string, gateway: {
|
|
token: string; baseUrl: string; phasePath: string; observability?: { token: string; baseUrl: string };
|
|
}) {
|
|
for (const name of Object.keys(env)) {
|
|
if (CREDENTIAL_NAMES.includes(name as typeof CREDENTIAL_NAMES[number])
|
|
|| PROVIDER_SECRET_KEY.test(name) && /KEY|TOKEN|SECRET|PASSWORD/.test(name)) delete env[name];
|
|
}
|
|
delete env.PAPERCLIP_E2E_OTLP_RELAY_URL;
|
|
delete env.PAPERCLIP_E2E_REMOTE_GATEWAY_ORIGIN;
|
|
if (isEvalRemoteOrigin(gateway.baseUrl)) env.PAPERCLIP_E2E_REMOTE_GATEWAY_ORIGIN = gateway.baseUrl;
|
|
env[credential] = gateway.token;
|
|
env.PAPERCLIP_E2E_GATEWAY_BASE_URL = gateway.baseUrl;
|
|
env.PAPERCLIP_E2E_GATEWAY_TOKEN = gateway.token;
|
|
env.PAPERCLIP_E2E_GATEWAY_PHASE_PATH = gateway.phasePath;
|
|
if (gateway.observability) {
|
|
env.BRAINTRUST_API_KEY = gateway.observability.token;
|
|
env.PAPERCLIP_E2E_OTLP_RELAY_URL = gateway.observability.baseUrl;
|
|
}
|
|
}
|
|
|
|
export function runnerE2EServerControlPaths(temporaryRoot: string) {
|
|
const controlDirectory = path.join(temporaryRoot, "control");
|
|
return {
|
|
controlDirectory,
|
|
restartRequestPath: path.join(
|
|
controlDirectory,
|
|
"server-restart.request.json",
|
|
),
|
|
restartAcknowledgementPath: path.join(
|
|
controlDirectory,
|
|
"server-restart.ack.json",
|
|
),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Native cells use the debug binary produced once by build:runner-binaries.
|
|
* Preserve an explicit override for release builds and developer workflows.
|
|
*/
|
|
export function resolvePaperclipRunnerBinaryForHarness(
|
|
executions: readonly MatrixExecution[],
|
|
repositoryRoot: string,
|
|
configuredPath = process.env.PAPERCLIP_RUNNER_BINARY,
|
|
platform: NodeJS.Platform = process.platform,
|
|
): string | undefined {
|
|
if (configuredPath?.trim()) return configuredPath;
|
|
if (
|
|
!executions.some((execution) => execution.profile.generation === "native")
|
|
) {
|
|
return undefined;
|
|
}
|
|
|
|
return path.join(
|
|
repositoryRoot,
|
|
"packages",
|
|
"paperclip-runner",
|
|
"runner",
|
|
"target",
|
|
"debug",
|
|
platform === "win32" ? "paperclip-runnerd.exe" : "paperclip-runnerd",
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Remote native cells stage the same controller-owned binary whose digest is
|
|
* authorized by the PRP control plane. Local cells launch it directly.
|
|
*/
|
|
export function resolvePaperclipRemoteRunnerBinaryForHarness(
|
|
executions: readonly MatrixExecution[],
|
|
runnerBinary: string | undefined,
|
|
configuredPath = process.env.PAPERCLIP_RUNNER_REMOTE_BINARY_PATH,
|
|
platform: NodeJS.Platform = process.platform,
|
|
architecture: NodeJS.Architecture = process.arch,
|
|
): string | undefined {
|
|
if (configuredPath?.trim()) return configuredPath;
|
|
if (!runnerBinary) return undefined;
|
|
// The pinned Daytona runtime is Linux/x64. Other controller binaries cannot
|
|
// run there; discover its verified runnerd unless an explicit cross-build is set.
|
|
if (platform !== "linux" || architecture !== "x64") return undefined;
|
|
return executions.some(
|
|
(execution) =>
|
|
execution.profile.generation === "native" &&
|
|
execution.environment.expectedExecutionTarget.kind === "remote",
|
|
)
|
|
? runnerBinary
|
|
: undefined;
|
|
}
|
|
|
|
/**
|
|
* Keep fixture-only provider switches scoped to the one isolated harness that
|
|
* needs them. In particular, the pinned legacy OpenCode model is routed by the
|
|
* paid gateway and may not appear in OpenCode's public model catalog.
|
|
*/
|
|
export function buildRunnerE2EProcessEnvironment(
|
|
source: NodeJS.ProcessEnv,
|
|
executions: readonly MatrixExecution[],
|
|
): NodeJS.ProcessEnv {
|
|
const installed = usesInstalledCli(source);
|
|
if (installed) assertInstalledCliSelection(source, executions);
|
|
const result = { ...source };
|
|
if (executions.some(execution => execution.suite.id === "chat-features")) scrubAmbientVaultEnvironment(result);
|
|
delete result[PUBLIC_ORIGIN_CONTROL_ENV]; delete result[PUBLIC_ORIGIN_RECEIPT_ENV];
|
|
if (executions.some(execution => execution.suite.id === "chat-features")) scrubChatPrivateEnvironment(result, executions);
|
|
else scrubChatPrivateEnvironment(result, []);
|
|
delete result.PAPERCLIP_RUNNER_E2E_POSTGRES_ADMIN_URL;
|
|
// The supervisor's read-only recovery observer must use the same disposable
|
|
// instance key as the serving child, never a launching shell's vault key.
|
|
delete result.PAPERCLIP_SECRETS_MASTER_KEY;
|
|
delete result.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
|
delete result.PAPERCLIP_E2E_GATEWAY_PHASE_PATH;
|
|
delete result.PAPERCLIP_E2E_OTLP_RELAY_URL;
|
|
delete result.PAPERCLIP_E2E_REMOTE_GATEWAY_ORIGIN;
|
|
// Only the isolated dismissal journey consumes the reviewed public staging feed.
|
|
// Other cells keep announcements disabled so ambient notices cannot obscure evidence.
|
|
const announcement = executions.length === 1 && executions[0]!.suite.id === "chat-features"
|
|
&& executions[0]!.task.id === CHAT_ANNOUNCEMENT_SCENARIO;
|
|
result.PAPERCLIP_ANNOUNCEMENTS_ENABLED = announcement ? "true" : "false";
|
|
if (announcement) result.PAPERCLIP_ANNOUNCEMENTS_FEED_URL = CHAT_ANNOUNCEMENT_FEED_URL;
|
|
delete result.OPENCODE_ALLOW_ALL_MODELS;
|
|
// Discard ambient admission. Only explicit candidate cells authorize the
|
|
// exact model in their isolated server; credentials still use company secrets.
|
|
delete result.PAPERCLIP_RUNNER_ACPX_QUALIFICATION;
|
|
delete result.PAPERCLIP_ACPX_CANDIDATE_ADMISSION;
|
|
const candidates = new Map<string, string>();
|
|
for (const execution of executions) {
|
|
const agent = execution.profile.qualificationCandidate;
|
|
if (!agent) continue;
|
|
const admittedSuite = agent === "kimi"
|
|
? execution.suite.id === "kimi-qualification" && execution.profile.id === "runner-acpx-kimi"
|
|
: execution.suite.id === "extended-harnesses"
|
|
|| execution.suite.id === "rich-acp-warm-continuity"
|
|
|| (execution.suite.id === "pi-native" && agent === "pi")
|
|
|| (execution.suite.id === "pi-controls" && agent === "pi")
|
|
|| (["cursor-native", "native-provider-loss"].includes(execution.suite.id) && agent === "cursor")
|
|
|| (["copilot-protection", "copilot-provider-death"].includes(execution.suite.id) && agent === "copilot")
|
|
|| (execution.suite.id === "native-active-stop" && (agent === "cursor" || agent === "copilot"));
|
|
if (!admittedSuite || !execution.suite.manualOnly) {
|
|
throw new Error("Candidate qualification requires an explicit provider qualification suite");
|
|
}
|
|
if (QUALIFIED_ACPX_PROFILES[agent].qualificationStatus !== "pending") {
|
|
continue;
|
|
}
|
|
if (agent === "kimi") continue;
|
|
const prior = candidates.get(agent);
|
|
if (prior !== undefined && prior !== execution.profile.model) throw new Error("Conflicting candidate models");
|
|
candidates.set(agent, execution.profile.model);
|
|
}
|
|
// Installed lanes must prove normal production admission without candidate overrides.
|
|
if (candidates.size > 0 && !installed) {
|
|
result.PAPERCLIP_RUNNER_ACPX_QUALIFICATION = JSON.stringify(
|
|
[...candidates].map(([agent, model]) => ({ agent, model })),
|
|
);
|
|
}
|
|
// These stories explicitly require the native API surface. Other suites
|
|
// retain the server default or any supplied operator restriction.
|
|
if (executions.some((e) => isManagedHiringCase(e.suite.id, e.task.id) || chatNeedsApiTools(e.suite.id, e.task.id))) {
|
|
result.PAPERCLIP_RUNNER_API_TOOLS_ENABLED = "true";
|
|
}
|
|
if (
|
|
executions.length > 0 &&
|
|
executions.every(
|
|
(execution) =>
|
|
execution.profile.generation === "legacy" &&
|
|
execution.profile.provider === "opencode",
|
|
)
|
|
) {
|
|
result.OPENCODE_ALLOW_ALL_MODELS = "true";
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Build the environment inherited by the Paperclip server. Paid credentials
|
|
* deliberately stay in the launcher/Playwright process and cross the server
|
|
* boundary through encrypted company secrets. Explicit subscription fixtures
|
|
* stage their login in the disposable company's private credential home.
|
|
*/
|
|
export function buildPaperclipServerEnvironment(
|
|
source: NodeJS.ProcessEnv,
|
|
overrides: NodeJS.ProcessEnv = {},
|
|
): NodeJS.ProcessEnv {
|
|
const result = scrubChatPrivateEnvironment({ ...source }, [], true);
|
|
scrubAmbientVaultEnvironment(result);
|
|
delete result[PUBLIC_ORIGIN_CONTROL_ENV]; delete result[PUBLIC_ORIGIN_RECEIPT_ENV];
|
|
delete result[PRIVATE_DIAGNOSTICS_ENV];
|
|
delete result.PAPERCLIP_RUNNER_E2E_POSTGRES_ADMIN_URL;
|
|
for (const key of Object.keys(result)) {
|
|
if (PROVIDER_SECRET_KEY.test(key)) delete result[key];
|
|
}
|
|
for (const key of [
|
|
...CREDENTIAL_NAMES,
|
|
...DATABASE_KEYS,
|
|
...AMBIENT_PAPERCLIP_CREDENTIAL_KEYS,
|
|
...AMBIENT_EXTERNAL_STATE_KEYS,
|
|
]) {
|
|
delete result[key];
|
|
}
|
|
for (const key of [...GENERATED_SERVER_SECRET_KEYS, ...installedCliKeys, ...installedDaytonaPluginKeys, "PAPERCLIP_RUNNER_E2E_UPGRADE_ARTIFACTS"]) delete result[key];
|
|
Object.assign(result, overrides);
|
|
delete result[PRIVATE_DIAGNOSTICS_ENV];
|
|
return result;
|
|
}
|
|
|
|
export function assertIsolatedServerEnvironment(
|
|
env: NodeJS.ProcessEnv,
|
|
expected: {
|
|
temporaryRoot: string;
|
|
paperclipHome: string;
|
|
configPath: string;
|
|
workspaceStorage?: NodeJS.ProcessEnv;
|
|
generatedSecretsMasterKey?: string;
|
|
},
|
|
) {
|
|
const home = env.PAPERCLIP_HOME;
|
|
const config = env.PAPERCLIP_CONFIG;
|
|
if (home !== expected.paperclipHome || config !== expected.configPath) {
|
|
throw new Error(
|
|
"Paperclip server environment does not use the allocated home/config paths",
|
|
);
|
|
}
|
|
if (
|
|
!home.startsWith(`${expected.temporaryRoot}/`) ||
|
|
!config.startsWith(`${expected.temporaryRoot}/`)
|
|
) {
|
|
throw new Error(
|
|
"Paperclip server paths escape the isolated temporary root",
|
|
);
|
|
}
|
|
if (env.XDG_CACHE_HOME !== path.join(expected.temporaryRoot, "xdg-cache")) {
|
|
throw new Error(
|
|
"Paperclip server cache does not use the allocated temporary root",
|
|
);
|
|
}
|
|
for (const key of [
|
|
...CREDENTIAL_NAMES,
|
|
...DATABASE_KEYS,
|
|
...AMBIENT_PAPERCLIP_CREDENTIAL_KEYS,
|
|
...AMBIENT_EXTERNAL_STATE_KEYS,
|
|
]) {
|
|
if (key === "PAPERCLIP_SECRETS_MASTER_KEY" && expected.generatedSecretsMasterKey && env[key] === expected.generatedSecretsMasterKey) continue;
|
|
if (env[key] && !(AMBIENT_EXTERNAL_STATE_KEYS.includes(key as typeof AMBIENT_EXTERNAL_STATE_KEYS[number])
|
|
&& expected.workspaceStorage?.[key] === env[key] && expected.workspaceStorage?.PAPERCLIP_STORAGE_PROVIDER === "s3"))
|
|
throw new Error(
|
|
`Paperclip server environment unexpectedly contains ${key}`,
|
|
);
|
|
}
|
|
if (expected.workspaceStorage && Object.entries(expected.workspaceStorage).some(([key, value]) => env[key] !== value)) {
|
|
throw new Error("Paperclip server storage does not match its explicit synthetic workspace qualification");
|
|
}
|
|
for (const key of GENERATED_SERVER_SECRET_KEYS) {
|
|
if (!env[key])
|
|
throw new Error(`Paperclip server environment is missing ${key}`);
|
|
}
|
|
}
|