mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 00:26:28 +01:00
## 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>
204 lines
15 KiB
TypeScript
204 lines
15 KiB
TypeScript
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
import { createRequire } from "node:module";
|
|
import { createServer } from "node:http";
|
|
import { spawn } from "node:child_process";
|
|
import { once } from "node:events";
|
|
import path from "node:path";
|
|
import { expect, type BrowserContext, type Page } from "@playwright/test";
|
|
|
|
const require = createRequire(path.resolve(import.meta.dirname, "../../server/package.json"));
|
|
const { Client } = await import(require.resolve("@modelcontextprotocol/sdk/client/index.js"));
|
|
const { StreamableHTTPClientTransport } = await import(require.resolve("@modelcontextprotocol/sdk/client/streamableHttp.js"));
|
|
export const origin = `http://127.0.0.1:${process.env.PAPERCLIP_RUNNER_E2E_PORT ?? process.env.PAPERCLIP_PUBLIC_MCP_EVAL_PORT ?? 3298}`;
|
|
export const resource = origin + "/mcp/paperclip";
|
|
export interface Team { id: string; name: string; issuePrefix: string }
|
|
export interface Agent { id: string; name: string; status: string }
|
|
export interface Task { id: string; companyId: string; title: string; status: string; assigneeAgentId: string | null }
|
|
export interface Run { id: string; agentId: string; status: string }
|
|
export interface Document { body: string; createdByAgentId: string | null; latestRevisionId: string }
|
|
export interface Comment { id: string; body: string; authorUserId: string | null; authorAgentId: string | null }
|
|
export interface Tokens { access_token: string; refresh_token: string; expires_in: number; scope: string }
|
|
export interface ToolResult { isError?: boolean; structuredContent?: Record<string, unknown>; content: Array<{ type: string; text?: string }> }
|
|
|
|
export async function api<T>(context: BrowserContext, method: string, route: string, data?: unknown): Promise<T> {
|
|
const response = await context.request.fetch(origin + route, { method, data, headers: { Origin: origin } });
|
|
// Never include response bodies, cookies or OAuth codes in diagnostic errors.
|
|
if (!response.ok()) throw new Error(`Public API ${method} ${route.split("?")[0]} returned HTTP ${response.status()}`);
|
|
return response.status() === 204 ? undefined as T : await response.json() as T;
|
|
}
|
|
export async function oauthPost(route: string, body: unknown) {
|
|
return fetch(origin + "/mcp/oauth/" + route, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
|
}
|
|
|
|
export async function beginConnection(write = true, redirect = origin + "/eval-client-callback", configure = false, clientName = "Paperclip acceptance client") {
|
|
if (!clientName.trim() || clientName.length > 160 || /[\u0000-\u001f\u007f]/.test(clientName)) throw new Error("Disposable client label is invalid");
|
|
const registration = await oauthPost("register", { client_name: clientName, redirect_uris: [redirect] });
|
|
expect(registration.status, "public client registration").toBe(201);
|
|
const { client_id: clientId } = await registration.json() as { client_id: string };
|
|
const verifier = randomBytes(32).toString("base64url");
|
|
const state = randomBytes(32).toString("base64url");
|
|
const params = new URLSearchParams({ client_id: clientId, redirect_uri: redirect, response_type: "code", resource, code_challenge: createHash("sha256").update(verifier).digest("base64url"), code_challenge_method: "S256", state, scope: `paperclip:read ${write ? "paperclip:write " : ""}${configure ? "paperclip:configure " : ""}offline_access` });
|
|
const response = await fetch(origin + "/mcp/oauth/authorize?" + params, { redirect: "manual" });
|
|
expect(response.status, "authorization request").toBe(303);
|
|
const consentUrl = response.headers.get("location")!;
|
|
return { clientId, verifier, state, redirect, params, consentUrl, requestId: new URL(consentUrl).pathname.split("/").at(-1)! };
|
|
}
|
|
|
|
export async function connect(context: BrowserContext, team: Team, write = true, page?: Page, secrets: string[] = [], configure = false,
|
|
options: { requestedWrite?: boolean; inspectConsent?: (page: Page) => Promise<void> } = {}) {
|
|
// A real assistant callback has its own origin. A same-origin fake callback
|
|
// would be intercepted by Paperclip's service worker and render the board.
|
|
const callbackServer = page ? createServer((_req, res) => { res.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-store", "Referrer-Policy": "no-referrer" }); res.end("<h1>Assistant connected</h1>"); }) : undefined;
|
|
if (callbackServer) { callbackServer.listen(0, "127.0.0.1"); await once(callbackServer, "listening"); }
|
|
const address = callbackServer?.address();
|
|
const redirect = address && typeof address !== "string" ? `http://127.0.0.1:${address.port}/callback` : undefined;
|
|
try {
|
|
const request = await beginConnection(options.requestedWrite ?? write, redirect, configure);
|
|
let redirectUrl: string;
|
|
if (page) {
|
|
await page.goto(request.consentUrl);
|
|
await expect(page.getByRole("heading", { name: /^Connect .+ to Paperclip$/ })).toBeVisible();
|
|
await page.getByRole("radio", { name: team.name, exact: true }).check();
|
|
if (write || configure) await page.getByRole("checkbox", { name: "Write all of your Paperclip data", exact: true }).check();
|
|
if (!write && !configure && options.requestedWrite) {
|
|
const writes = page.getByRole("checkbox", { name: "Write all of your Paperclip data", exact: true });
|
|
if (await writes.isEnabled()) await writes.uncheck();
|
|
}
|
|
await options.inspectConsent?.(page);
|
|
await page.getByRole("button", { name: "Connect organization", exact: true }).click();
|
|
await expect(page.getByRole("heading", { name: "Assistant connected" })).toBeVisible();
|
|
redirectUrl = page.url();
|
|
// Leave the secret-bearing callback before any later assertions/evidence.
|
|
await page.goto(origin + "/assistant-connections");
|
|
} else {
|
|
({ redirectUrl } = await api<{ redirectUrl: string }>(context, "POST", `/api/mcp/requests/${request.requestId}/consent`, { decision: "approve", companyId: team.id, allowWrites: write, allowConfiguration: configure }));
|
|
}
|
|
const callback = new URL(redirectUrl);
|
|
secrets.push(request.verifier, callback.searchParams.get("code") ?? "");
|
|
expect(callback.searchParams.get("state") === request.state, "OAuth state returned intact").toBe(true);
|
|
const exchange = { grant_type: "authorization_code", client_id: request.clientId, redirect_uri: request.redirect, resource, code: callback.searchParams.get("code"), code_verifier: request.verifier };
|
|
const tokenResponse = await oauthPost("token", exchange);
|
|
expect(tokenResponse.status, "code exchange").toBe(200);
|
|
const tokens = await tokenResponse.json() as Tokens;
|
|
secrets.push(tokens.access_token, tokens.refresh_token);
|
|
return { ...request, tokens, exchange };
|
|
} finally {
|
|
if (callbackServer) { callbackServer.closeAllConnections(); await new Promise<void>(resolve => callbackServer.close(() => resolve())); }
|
|
}
|
|
}
|
|
|
|
export async function mcp(tokens: Tokens) {
|
|
const client = new Client({ name: "paperclip-product-eval", version: "1.0.0" });
|
|
await client.connect(new StreamableHTTPClientTransport(new URL(resource), { requestInit: { headers: { Authorization: `Bearer ${tokens.access_token}` } } }));
|
|
return {
|
|
list: async (options?: { signal?: AbortSignal }) => await client.listTools({}, options) as { tools: Array<{ name: string; description: string; inputSchema: Record<string, unknown>; annotations: { readOnlyHint: boolean } }> },
|
|
call: async (name: string, args: Record<string, unknown> = {}, options?: { signal?: AbortSignal }) => await client.callTool({ name, arguments: args }, undefined, options) as ToolResult,
|
|
close: async () => { await client.close(); },
|
|
};
|
|
}
|
|
export function content<T>(result: ToolResult): T {
|
|
expect(result.isError === true, "MCP operation succeeded").toBe(false);
|
|
expect(result.structuredContent, "MCP returned structured outcome").toBeTruthy();
|
|
return result.structuredContent as T;
|
|
}
|
|
|
|
|
|
/** Host-owned Events transport. Models never see callback signing or OAuth material. */
|
|
export async function mcpEventRpc(tokens: Tokens, method: string, args: Record<string, unknown> = {}) {
|
|
const response = await fetch(resource, { method: "POST", headers: { Authorization: `Bearer ${tokens.access_token}`, "Content-Type": "application/json", "MCP-Protocol-Version": "2026-07-28", "Mcp-Method": method },
|
|
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params: { ...args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {} } } }) });
|
|
const body = await response.json() as { result?: Record<string, unknown>; error?: { code: number; data?: { reason?: string } } };
|
|
if (!response.ok || body.error || !body.result) throw new Error(`MCP Events ${method} failed: HTTP ${response.status}, code ${body.error?.code ?? "missing_result"}, reason ${body.error?.data?.reason ?? "unavailable"}; details withheld`);
|
|
return body.result;
|
|
}
|
|
|
|
export interface ReceivedTaskEvent { eventId: string; name: string; timestamp: string; data: { companyId: string; taskId: string; status?: string }; cursor: null }
|
|
interface EventReceiver {
|
|
url: string; secret: string; events: ReceivedTaskEvent[]; readonly verified: number;
|
|
readonly duplicateCount: number; startupAttempts: number; startupFailures: string[]; close: () => Promise<void>;
|
|
}
|
|
export async function eventReceiver(secrets: string[], startupFailures: string[] = []): Promise<EventReceiver> {
|
|
const key = randomBytes(32);
|
|
const secret = "whsec_" + key.toString("base64");
|
|
const path = "/events/" + randomUUID();
|
|
const readinessNonce = randomUUID();
|
|
const events: ReceivedTaskEvent[] = [];
|
|
const seen = new Set<string>();
|
|
let verified = 0;
|
|
let duplicateCount = 0;
|
|
const server = createServer(async (req, res) => {
|
|
try {
|
|
if (req.url === path && req.method === "GET") { res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" }); res.end(JSON.stringify({ nonce: readinessNonce })); return; }
|
|
if (req.url !== path || req.method !== "POST") { res.writeHead(404); res.end(); return; }
|
|
const chunks: Buffer[] = []; let size = 0;
|
|
for await (const chunk of req) { size += Buffer.byteLength(chunk); if (size > 262144) throw new Error(); chunks.push(Buffer.from(chunk)); }
|
|
const raw = Buffer.concat(chunks).toString();
|
|
const id = req.headers["webhook-id"];
|
|
const timestamp = req.headers["webhook-timestamp"];
|
|
const signature = req.headers["webhook-signature"];
|
|
if (typeof id !== "string" || typeof timestamp !== "string" || typeof signature !== "string" || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) throw new Error();
|
|
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${raw}`).digest();
|
|
const valid = signature.split(" ").some(part => { const [version, encoded] = part.split(","); const actual = Buffer.from(encoded ?? "", "base64"); return version === "v1" && actual.length === expected.length && timingSafeEqual(actual, expected); });
|
|
if (!valid) throw new Error();
|
|
const body = JSON.parse(raw);
|
|
if (body.type === "verification" && typeof body.challenge === "string") {
|
|
verified++; res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ challenge: body.challenge })); return;
|
|
}
|
|
if (body.eventId !== id || !body.data || body.type) throw new Error();
|
|
if (seen.has(id)) duplicateCount++; else { seen.add(id); events.push(body); }
|
|
res.writeHead(204); res.end();
|
|
} catch { res.writeHead(400); res.end(); }
|
|
});
|
|
server.listen(0, "127.0.0.1"); await once(server, "listening");
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") throw new Error("Event receiver did not start");
|
|
const tunnel = spawn(process.env.PAPERCLIP_EVAL_CLOUDFLARED ?? "cloudflared", ["tunnel", "--no-autoupdate", "--protocol", "http2", "--url", `http://127.0.0.1:${address.port}`], { stdio: ["ignore", "pipe", "pipe"] });
|
|
const close = async () => {
|
|
if (tunnel.pid && tunnel.exitCode === null && tunnel.signalCode === null) {
|
|
const exited = once(tunnel, "exit").catch(() => {});
|
|
tunnel.kill("SIGTERM");
|
|
const timer = setTimeout(() => tunnel.kill("SIGKILL"), 5000);
|
|
try { await exited; } finally { clearTimeout(timer); }
|
|
}
|
|
server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve()));
|
|
};
|
|
secrets.push(secret);
|
|
try {
|
|
const origin = await new Promise<string>((resolve, reject) => {
|
|
let output = "";
|
|
const timer = setTimeout(() => { reject(new Error("HTTPS event tunnel startup timed out")); }, 60_000);
|
|
const finish = (error?: Error, url?: string) => { clearTimeout(timer); if (error) reject(error); else resolve(url!); };
|
|
tunnel.once("error", () => finish(new Error("cloudflared is required for the live HTTPS event receiver")));
|
|
tunnel.once("exit", () => finish(new Error("HTTPS event tunnel exited before startup")));
|
|
const read = (chunk: Buffer) => { output = (output + chunk.toString()).slice(-32768); const url = output.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/)?.[0]; if (url && output.includes("Registered tunnel connection")) finish(undefined, url); };
|
|
tunnel.stdout!.on("data", read); tunnel.stderr!.on("data", read);
|
|
});
|
|
secrets.push(origin + path);
|
|
// A registered tunnel connection can precede public DNS/edge readiness.
|
|
// Prove that this exact fixture receiver is reachable before paying for work.
|
|
const readyBy = Date.now() + 60_000;
|
|
let ready = false;
|
|
let lastFailure = "unreachable";
|
|
while (Date.now() < readyBy) {
|
|
try {
|
|
const response = await fetch(origin + path, { redirect: "error", signal: AbortSignal.timeout(5000) });
|
|
if (response.ok && (await response.json() as { nonce?: string }).nonce === readinessNonce) { ready = true; break; }
|
|
lastFailure = `http_${response.status}`;
|
|
await response.body?.cancel();
|
|
} catch (error) {
|
|
const cause = (error as { cause?: { code?: string } }).cause?.code;
|
|
lastFailure = cause === "ENOTFOUND" ? "dns_not_found" : "network_unavailable";
|
|
}
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
}
|
|
if (!ready) throw new Error(`HTTPS event receiver did not become publicly reachable (${lastFailure})`);
|
|
return { url: origin + path, secret, events, startupAttempts: startupFailures.length + 1, startupFailures, get verified() { return verified; }, get duplicateCount() { return duplicateCount; }, close };
|
|
} catch (error) {
|
|
await close();
|
|
const reason = error instanceof Error && error.message.includes("dns_not_found") ? "dns_not_found" : "tunnel_startup_unavailable";
|
|
const failures = [...startupFailures, reason];
|
|
if (failures.length < 3) return eventReceiver(secrets, failures);
|
|
throw new Error(`HTTPS event receiver failed after ${failures.length} setup attempts (${reason}); no model call started`);
|
|
}
|
|
}
|