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>
195 lines
17 KiB
TypeScript
195 lines
17 KiB
TypeScript
import { createSdkArtifactScanner } from "./source-sdk-public-type.js";
|
|
import { constants } from "node:fs";
|
|
import { assertPrivateCellDirectories, privateCellEvidenceOwner } from "./manual-private-report-assets.js";
|
|
import { createHash } from "node:crypto";
|
|
import { chmod, lstat, mkdir, open, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { diagnosticSecrets, readPrivateDiagnosticSecrets } from "./private-diagnostics.js";
|
|
import { findSecretLeak } from "./redaction.js";
|
|
import { isStandardEvidenceFile } from "./evidence.js";
|
|
|
|
const selectedIds = new Set(["plugins.configure-plugin", "plugins.disable-plugin", "plugins.install-plugin", "plugins.use-plugin-tool", "routines.plugin-managed-routine", "secrets.least-scope-delivery"].map(id => `chat-features.runner-opencode.local.${id}`));
|
|
export const SOURCE_SDK_DIAGNOSTICS = "diagnostics/source-plugin-sdk";
|
|
const targets = ["packages/shared/dist", "packages/plugins/sdk/dist"] as const;
|
|
const hash = (bytes: string | Buffer) => createHash("sha256").update(bytes).digest("hex");
|
|
const hex = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
const staging = (root: string) => path.join(root, "artifacts-private", "source-sdk-build");
|
|
interface BuiltFile { file: string; size: number; sha256: string }
|
|
// Retained bytes must use a name the private result sealer admits as a
|
|
// standard carrier: declarations (.d.ts) and source maps (.map) are retained
|
|
// byte-for-byte under a .txt suffix instead of failing the whole attempt.
|
|
export const retainedSourceSdkPath = (file: string) => {
|
|
const named = file.replace(/\/\.paperclip-build-complete$/, "/build-completion-receipt.json");
|
|
return isStandardEvidenceFile(`html-report/${SOURCE_SDK_DIAGNOSTICS}/${named}`) ? named : `${named}.txt`;
|
|
};
|
|
const retainedPath = retainedSourceSdkPath;
|
|
interface RetainedBuild {
|
|
schema: "paperclip.source-plugin-sdk/v1" | "paperclip.source-plugin-sdk/v2"; sourceRootSha256?: string; scanAudit?: Awaited<ReturnType<ReturnType<typeof createSdkArtifactScanner>["audit"]>>; executionId: string; sourceRevision: string; retainedAt: string;
|
|
buildHelperSha256: string; lockSha256: string; files: Array<BuiltFile & { retainedPath: string; retainedSha256: string }>;
|
|
certifiedTargets: Array<{ directory: string; sources: string; outputs: string }>;
|
|
}
|
|
function selected(ids: readonly string[]) { return ids.length === 1 && selectedIds.has(ids[0]!); }
|
|
function checkSecret(bytes: Buffer, secrets: readonly string[]) {
|
|
if (findSecretLeak(bytes.toString("utf8"), secrets)) throw new Error("Source SDK evidence privacy check failed; bytes withheld");
|
|
}
|
|
async function retainScanAudit(root: string, executionId: string, audit: unknown, complete: boolean, secrets: readonly string[]) {
|
|
const bytes = Buffer.from(JSON.stringify({ schema: "paperclip.source-sdk-privacy-observation/v1", complete, audit }, null, 2) + "\n");
|
|
checkSecret(bytes, secrets);
|
|
const directory = path.join(root, "artifacts-private", "cases", executionId.slice("chat-features.runner-opencode.local.".length), "snapshots");
|
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
await writeFile(path.join(directory, "source-sdk-privacy-observations.json"), bytes, { flag: "wx", mode: 0o600 });
|
|
}
|
|
async function boundedTree(root: string) {
|
|
const files: Array<BuiltFile & { bytes: Buffer }> = []; let total = 0;
|
|
async function visit(directory: string) {
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
const filename = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) await visit(filename);
|
|
else {
|
|
const info = await lstat(filename);
|
|
if (!info.isFile() || info.isSymbolicLink() || info.size > 8 * 1024 * 1024) throw new Error("Source SDK output custody invalid");
|
|
const bytes = await readFile(filename); total += bytes.length;
|
|
if (files.length >= 4096 || total > 64 * 1024 * 1024) throw new Error("Source SDK output exceeds evidence bound");
|
|
files.push({ file: path.relative(root, filename).split(path.sep).join("/"), size: bytes.length, sha256: hash(bytes), bytes });
|
|
}
|
|
}
|
|
}
|
|
const info = await lstat(root); if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("Source SDK output root invalid");
|
|
await visit(root); return files;
|
|
}
|
|
function outputHash(files: Array<{ file: string; bytes: Buffer }>) {
|
|
const digest = createHash("sha256");
|
|
for (const file of files.filter(row => row.file !== ".paperclip-build-complete")) {
|
|
digest.update(JSON.stringify([file.file, file.bytes.length])); digest.update(file.bytes);
|
|
}
|
|
return digest.digest("hex");
|
|
}
|
|
export async function retainPreparedSourceSdk(input: { repositoryRoot: string; temporaryRoot: string; executionIds: readonly string[]; sourceRevision: string | null; installed: boolean }) {
|
|
if (input.installed || !selected(input.executionIds)) return;
|
|
if (!/^[a-f0-9]{40}$/.test(input.sourceRevision ?? "")) throw new Error("Source SDK retention requires exact source revision");
|
|
const root = await realpath(input.temporaryRoot), repository = await realpath(input.repositoryRoot);
|
|
// External private capture need not create the local SDK staging parent.
|
|
// Own its initialization before retention instead of relying on later server-log setup.
|
|
const privateRoot = path.join(root, "artifacts-private");
|
|
try { await mkdir(privateRoot, { mode: 0o700 }); }
|
|
catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
|
|
if (await realpath(privateRoot) !== privateRoot) throw new Error("Source SDK staging custody invalid");
|
|
const secrets = diagnosticSecrets(readPrivateDiagnosticSecrets(path.join(root, "selected-private-inputs", "diagnostic-redaction.json"), root)).values;
|
|
const scanner = createSdkArtifactScanner(repository, secrets);
|
|
const buffers: Array<BuiltFile & { bytes: Buffer }> = [], certifiedTargets: RetainedBuild["certifiedTargets"] = [];
|
|
for (const directory of targets) {
|
|
const files = await boundedTree(path.join(repository, directory));
|
|
const marker = files.find(row => row.file === ".paperclip-build-complete"), entry = files.find(row => row.file === "index.js");
|
|
if (!marker || !entry) throw new Error("Source SDK build completion evidence missing");
|
|
const certificate = JSON.parse(marker.bytes.toString("utf8"));
|
|
if (!hex(certificate.sources) || !hex(certificate.outputs) || outputHash(files) !== certificate.outputs) throw new Error("Source SDK build output no longer matches its completed compiler receipt");
|
|
certifiedTargets.push({ directory, sources: certificate.sources, outputs: certificate.outputs });
|
|
for (const file of files) {
|
|
if (!await scanner.scan(`${directory}/${file.file}`, file.bytes)) {
|
|
await retainScanAudit(root, input.executionIds[0]!, await scanner.audit(), false, secrets);
|
|
throw new Error("Source SDK evidence privacy check failed; bytes withheld");
|
|
}
|
|
buffers.push({ ...file, file: `${directory}/${file.file}` });
|
|
}
|
|
}
|
|
if (buffers.length >= 4096 || buffers.reduce((total, file) => total + file.size, 0) > 63 * 1024 * 1024) throw new Error("Source SDK output exceeds combined evidence bound");
|
|
const scanAudit = await scanner.audit();
|
|
await retainScanAudit(root, input.executionIds[0]!, scanAudit, true, secrets);
|
|
const syntax = scanAudit.observations.some(row => row.classification === "public_schema_type");
|
|
const receipt: RetainedBuild = { schema: syntax ? "paperclip.source-plugin-sdk/v2" : "paperclip.source-plugin-sdk/v1", ...(syntax ? { sourceRootSha256: hash(repository), scanAudit } : {}), executionId: input.executionIds[0]!, sourceRevision: input.sourceRevision!, retainedAt: new Date().toISOString(),
|
|
buildHelperSha256: hash(await readFile(path.join(repository, "scripts/ensure-plugin-build-deps.mjs"))), lockSha256: hash(await readFile(path.join(repository, "pnpm-lock.yaml"))),
|
|
certifiedTargets, files: buffers.map(({ bytes: _, ...row }) => ({ ...row, retainedPath: retainedPath(row.file), retainedSha256: row.sha256 })) };
|
|
if (new Set(receipt.files.map(file => file.retainedPath)).size !== receipt.files.length) throw new Error("Source SDK retained filename collision");
|
|
const receiptBytes = Buffer.from(JSON.stringify(receipt, null, 2) + "\n"); checkSecret(receiptBytes, secrets);
|
|
const destination = staging(root); await mkdir(destination, { mode: 0o700 });
|
|
try {
|
|
for (const file of buffers) {
|
|
const filename = path.join(destination, file.file); await mkdir(path.dirname(filename), { recursive: true, mode: 0o700 });
|
|
await writeFile(filename, file.bytes, { flag: "wx", mode: 0o600 });
|
|
}
|
|
await writeFile(path.join(destination, "receipt.json"), receiptBytes, { flag: "wx", mode: 0o600 });
|
|
} catch (error) { await rm(destination, { recursive: true, force: true }); throw error; }
|
|
}
|
|
export async function verifyRetainedSourceSdk(root: string, executionId: string, sourceRevision: string | null, knownSecrets: readonly string[], repositoryRoot = path.resolve(import.meta.dirname, "../..")) {
|
|
if (!selected([executionId])) throw new Error("Source SDK retained selection mismatch");
|
|
if (await realpath(staging(root)) !== staging(root)) throw new Error("Source SDK retained custody invalid");
|
|
const secrets = diagnosticSecrets(knownSecrets).values, files = await boundedTree(staging(root));
|
|
const receiptFile = files.find(row => row.file === "receipt.json"); if (!receiptFile) throw new Error("Source SDK retained receipt missing");
|
|
const receipt = JSON.parse(receiptFile.bytes.toString("utf8")) as RetainedBuild;
|
|
if (!["paperclip.source-plugin-sdk/v1", "paperclip.source-plugin-sdk/v2"].includes(receipt.schema) || receipt.executionId !== executionId || receipt.sourceRevision !== sourceRevision
|
|
|| !hex(receipt.buildHelperSha256) || !hex(receipt.lockSha256) || !Array.isArray(receipt.files)) throw new Error("Source SDK retained identity mismatch");
|
|
const actual = files.filter(row => row.file !== "receipt.json");
|
|
if (actual.length !== receipt.files.length || actual.some(row => !targets.some(target => row.file.startsWith(`${target}/`))
|
|
|| !receipt.files.some(expected => expected.file === row.file && expected.retainedPath === retainedPath(row.file) && expected.size === row.size && expected.sha256 === row.sha256 && expected.retainedSha256 === row.sha256))) throw new Error("Source SDK retained bytes changed");
|
|
if (receipt.schema === "paperclip.source-plugin-sdk/v2") {
|
|
if (!receipt.scanAudit || receipt.sourceRootSha256 !== hash(await realpath(repositoryRoot))) throw new Error("Source SDK public source custody changed");
|
|
const scanner = createSdkArtifactScanner(await realpath(repositoryRoot), secrets);
|
|
for (const file of actual) if (!await scanner.scan(file.file, file.bytes)) throw new Error("Source SDK evidence privacy check failed; bytes withheld");
|
|
if (JSON.stringify(await scanner.audit()) !== JSON.stringify(receipt.scanAudit)) throw new Error("Source SDK public observations changed");
|
|
checkSecret(receiptFile.bytes, secrets);
|
|
} else for (const file of files) checkSecret(file.bytes, secrets);
|
|
return { receipt, files };
|
|
}
|
|
export async function copyRetainedSourceSdk(root: string, executionId: string, sourceRevision: string | null, uploadDir: string, knownSecrets: readonly string[], repositoryRoot = path.resolve(import.meta.dirname, "../..")) {
|
|
if (!selected([executionId])) return;
|
|
const { files } = await verifyRetainedSourceSdk(root, executionId, sourceRevision, knownSecrets, repositoryRoot);
|
|
const destination = path.join(uploadDir, SOURCE_SDK_DIAGNOSTICS); await mkdir(destination, { recursive: true, mode: 0o700 });
|
|
for (const file of files) {
|
|
// Verification proved every secret-shaped span in these bytes is a public
|
|
// schema type, but the private result sealer scans packaged text for key
|
|
// shapes without that proof (e.g. `OPENROUTER_API_KEY: z.ZodOptional` in
|
|
// validators/agent.d.ts) and rejects the whole attempt. Keep such files out
|
|
// of the upload; the retained receipt still records their size and hash.
|
|
if (file.file !== "receipt.json" && findSecretLeak(file.bytes, [])) continue;
|
|
const filename = path.join(destination, retainedPath(file.file)); await mkdir(path.dirname(filename), { recursive: true, mode: 0o700 });
|
|
await writeFile(filename, file.bytes, { flag: "wx", mode: 0o600 }); await chmod(filename, 0o600);
|
|
if (hash(await readFile(filename)) !== file.sha256) throw new Error("Source SDK published bytes changed");
|
|
}
|
|
}
|
|
|
|
export interface SourceSdkAuditCapture { executionId: string; context: Parameters<typeof privateCellEvidenceOwner>[0] }
|
|
function closedSdkAudit(value: unknown): boolean {
|
|
const object = (v: any, keys: string[]) => !!v && typeof v === "object" && !Array.isArray(v) && Object.keys(v).sort().join() === [...keys].sort().join();
|
|
const v = value as any, a = v?.audit;
|
|
if (!object(v, ["schema", "complete", "audit"]) || v.schema !== "paperclip.source-sdk-privacy-observation/v1" || typeof v.complete !== "boolean"
|
|
|| !object(a, ["schema", "suppressedFindings", "bounded", "publicSource", "observations"]) || a.schema !== "paperclip.source-sdk-scan/v1" || a.suppressedFindings !== 0
|
|
|| typeof a.bounded !== "boolean" || !Array.isArray(a.observations) || a.observations.length > 4096) return false;
|
|
if (a.publicSource !== null) {
|
|
const hashes = ["manifestSha256", "lockSha256", "compilerPackageSha256", "parserPackageSha256", "parserEntrySha256", "zodPackageSha256", "zodEntrySha256", "zodTypeGraphSha256"];
|
|
if (!object(a.publicSource, ["inputs", ...hashes]) || !Number.isSafeInteger(a.publicSource.inputs) || a.publicSource.inputs < 1 || a.publicSource.inputs > 20000 || !hashes.every(k => hex(a.publicSource[k]))) return false;
|
|
}
|
|
return a.observations.every((r: any) => object(r, ["pathSha256", "fileSha256", "detector", "start", "end", "classification"])
|
|
&& hex(r.pathSha256) && hex(r.fileSha256) && Number.isInteger(r.detector) && r.detector >= -1 && r.detector <= 3
|
|
&& Number.isSafeInteger(r.start) && Number.isSafeInteger(r.end) && r.start >= 0 && r.end >= r.start && r.end <= 8 * 1024 * 1024
|
|
&& (r.detector === -1 ? r.classification === "private_exact" : r.detector === 3 ? ["unresolved_context", "public_schema_type"].includes(r.classification) : r.classification === "direct_token"));
|
|
}
|
|
/** Copy only the already-produced typed audit, never rejected SDK output. The
|
|
* private producer owner binds external capture even when server setup failed. */
|
|
export async function copyPreparedSourceSdkAudit(temporaryRoot: string, casePrivateDir: string, secrets: readonly string[], input: SourceSdkAuditCapture) {
|
|
if (!selected([input.executionId])) return;
|
|
const owner = await privateCellEvidenceOwner(input.context, input.executionId);
|
|
const scenario = input.executionId.slice("chat-features.runner-opencode.local.".length);
|
|
if (path.resolve(casePrivateDir) !== path.join(owner.root, "capture", "cases", scenario)) throw new Error("SDK audit capture does not match its exact private cell");
|
|
await assertPrivateCellDirectories(owner, casePrivateDir);
|
|
const root = await realpath(temporaryRoot), rootInfo = await lstat(temporaryRoot);
|
|
if (root !== path.resolve(temporaryRoot) || !rootInfo.isDirectory() || rootInfo.isSymbolicLink() || rootInfo.mode & 0o077
|
|
|| typeof process.getuid === "function" && rootInfo.uid !== process.getuid()) throw new Error("SDK audit source custody invalid");
|
|
const source = path.join(root, "artifacts-private", "cases", scenario, "snapshots", "source-sdk-privacy-observations.json");
|
|
const info = await lstat(source).catch((error: NodeJS.ErrnoException) => { if (error.code === "ENOENT") return; throw error; });
|
|
if (!info) return;
|
|
if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024 || await realpath(source) !== source) throw new Error("SDK audit source custody invalid");
|
|
const handle = await open(source, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
let bytes: Buffer;
|
|
try {
|
|
const before = await handle.stat(); bytes = await handle.readFile(); const after = await handle.stat();
|
|
if (!before.isFile() || before.ino !== info.ino || before.dev !== info.dev || before.size !== info.size || bytes.length !== info.size
|
|
|| before.ino !== after.ino || before.dev !== after.dev || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs || await realpath(source) !== source) throw new Error("SDK audit changed during capture");
|
|
} finally { await handle.close(); }
|
|
checkSecret(bytes, secrets);
|
|
if (!closedSdkAudit(JSON.parse(bytes.toString("utf8")))) throw new Error("SDK audit has an unsupported observation shape");
|
|
const destination = path.join(casePrivateDir, "snapshots"); await mkdir(destination, { recursive: true, mode: 0o700 });
|
|
await assertPrivateCellDirectories(owner, destination);
|
|
await writeFile(path.join(destination, "source-sdk-privacy-observations.json"), bytes, { flag: "wx", mode: 0o600 });
|
|
}
|