Files
PaperClipAI/tests/runner-e2e/harness-env.ts
T
f4337bb5fb test(evals): land the chat catalog eval harness on master (#19614)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The chat-feature evals measure whether agents complete real chat
requests through the product
> - The chat catalog eval harness and its repairs live on the
`chat-eval/master-candidate` branch, not on master
> - Today's work made 139 of 242 chat evals pass in verified single-case
live runs. Each product fix landed on master as its own pull request
> - The eval-side changes (setups, graders, fault seams, evidence
capture) still exist only on the candidate
> - This pull request lands the harness on master. Master stays the
source of truth for product behavior
> - The benefit is one tree: evals run against master commits, and
product fixes reach the evals as soon as they merge

## Linked Issues or Issue Description

Refs #19441, Refs #19599, Refs #20270, Refs #20334, Refs #20342, Refs
#20344, Refs #20354, Refs #20360, Refs #20373, Refs #20382, Refs #20409

**What existing behavior does this improve?**
The chat catalog eval harness and the product it evaluates live on a
long-running branch outside master. Eval fixes do not reach master, so
master-based eval runs use old setups and graders.

**Subsystem affected**
`tests/runner-e2e` chat catalog harness, plus a small set of product
changes that exist only on the candidate.

**Proposed change**
Land the harness on master. Every product fix from this eval work
already merged to master as its own pull request. Conflicts keep
master's product behavior.

## What Changed

- Most of the diff (808 of 851 files) is under `tests/runner-e2e/`:
chat-catalog domains, scenarios, descriptors, the TestOps manifest,
private evidence tooling, and their tests.
- Graders now check saved state, setup literals and rendered or clicked
links. Many keyword and regex checks on free-form replies are removed.
Each changed grader has a bumped `oracleVersion` and calibration tests
(a valid reply, a reworded valid reply, and broken variants that must
fail).
- The shared link helper `hasLink` no longer treats a list link (for
example `/COM/issues?view=mine`) as a link to every task without a URL
key.
- Setup fixes build real state through supported product routes: routine
history, agent grants through the board grant route, budget holds with a
real cap, and a storage-layer archive fault for
`inbox-search.archive-partial-failure`.
- Browser boundary journeys (sign-in, chat-disabled, onboarding) now
write the standard pass evidence files.
- Retargeted cases, approved by the maintainer:
`agent-chat.chat-disabled` (Conference Room gate),
`agents.restore-instructions` (no saved instruction history),
`access.manage-membership` (member permission edits stay board-only).
- The private evidence trusted files (`manual-private-handoff.ts`,
`manual-private-retention.ts`, `manual-private-report-assets.ts`) are
byte-identical to master.
- Product files outside `tests/` (43 files, +1,328 −49) carry
candidate-only changes that master does not yet have. Most are tests.
The main code changes are in `request-human-input.ts`,
`heartbeat-run-summary.ts`, `native-completion-feedback.ts`,
`native-run-finalizer.ts`, `openapi.ts`, `secrets.ts` and
`IssueChatLongThreadPerf.tsx`.

## Verification

- `pnpm vitest run chat-catalog-domains chat-catalog-contract.test.ts
chat-catalog-consistency.test.ts unit-launcher.test.mjs
--testTimeout=180000` in `tests/runner-e2e`: 269 files pass, 3,550 tests
pass.
- `node --test chat-full-catalog-testops-manifest.test.mjs`: 10 pass.
- `node tests/runner-e2e/source-sdk-public-inputs.mjs <HEAD> --check`:
no change.
- `tsc` for `server` and `tests/runner-e2e`: clean.
- Live single-case verification: 139 of 242 cases passed on paid runs,
and a person read the saved evidence for each. These are single-case
runs, not one full-cohort run.

## Risks

- Large diff. Most of it is test-only, but the 43 non-test files change
product code paths. Review those files first.
- Some graders are now less strict on reply wording. A person must read
replies for meaning on those cases.
- 42 cases need disposable outside accounts (Notion, Slack, GitHub,
Daytona and others) and stay blocked. `access.invite-teammate` stays
failing until local agent isolation lands, because the runner's durable
state keeps a live invite token.
- The paid workflow compares the trusted private-evidence files with
master. They are unchanged, so this risk is low.

## Model Used

Anthropic Claude Opus 5.5 (`claude-opus-5-5`), with tool use and code
execution, in Claude Code. Subagents used the same model.

## 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>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-10 08:02:11 -06:00

302 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;
},
) {
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 (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}`);
}
}