mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-15 10:21:41 +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>
424 lines
13 KiB
TypeScript
424 lines
13 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import {
|
||
gradeChatgptSetup,
|
||
truthfulChatgptHandoff,
|
||
chatgptSetupCase,
|
||
} from "./external-chatgpt-setup.js";
|
||
import {
|
||
namedChatgptRequest,
|
||
exactAcceptedChatgptScopes,
|
||
chatgptConsentDisclosure,
|
||
validChatgptInput,
|
||
validChatgptReceipt,
|
||
createdChatgptAppId,
|
||
setupCheckComment,
|
||
type ChatgptInput,
|
||
type ChatgptReceipt,
|
||
} from "./external-chatgpt-contract.js";
|
||
import type { ExternalObservation } from "./external-support.js";
|
||
import type { McpConnectionRequest } from "../../../packages/shared/src/public-mcp.js";
|
||
const company = "11111111-1111-4111-8111-111111111111",
|
||
grant = "22222222-2222-4222-8222-222222222222",
|
||
comment = "33333333-3333-4333-8333-333333333333";
|
||
const scopes = ["paperclip:read", "paperclip:write", "offline_access"];
|
||
function input(): ChatgptInput {
|
||
return {
|
||
operation: "setup",
|
||
executionId:
|
||
"catalog.local.assistant-connections.connect-external-assistant",
|
||
attemptId: "a".repeat(32),
|
||
receiptPath: "/private/receipt.json",
|
||
backendOrigin: "http://127.0.0.1:3100",
|
||
publicOrigin: "https://fixture.example.test",
|
||
companyId: company,
|
||
companyName: "Synthetic Company",
|
||
prefix: "ACORN",
|
||
humanId: "person",
|
||
operatorRef: "qa-operator",
|
||
authorizationRef: "authorized-chatgpt",
|
||
publicSession: {
|
||
cookies: [
|
||
{
|
||
name: "session",
|
||
value: "synthetic-test-only",
|
||
domain: "fixture.example.test",
|
||
path: "/",
|
||
secure: true,
|
||
},
|
||
],
|
||
origins: [],
|
||
},
|
||
control: {
|
||
id: comment,
|
||
identifier: "ACORN-2",
|
||
title: "Assistant setup control",
|
||
ownerFact: "Mira Patel",
|
||
},
|
||
};
|
||
}
|
||
function receipt(): ChatgptReceipt {
|
||
return {
|
||
operation: "setup",
|
||
executionId: input().executionId,
|
||
attemptId: input().attemptId,
|
||
companyId: company,
|
||
humanId: "person",
|
||
processIsolation: true,
|
||
testRunnerAttached: false,
|
||
tracingEnabled: false,
|
||
videoEnabled: false,
|
||
operatorAvailable: true,
|
||
grantId: grant,
|
||
requestId: "request",
|
||
commentId: comment,
|
||
clientRequestId: "tool-request",
|
||
actualChatgptUi: true,
|
||
realHumanConsent: true,
|
||
actualProviderCallback: true,
|
||
boundMcpComment: true,
|
||
zeroNativeAdmissions: true,
|
||
requestedScopes: [...scopes],
|
||
acceptedScopes: [...scopes],
|
||
configureDisclosed: true,
|
||
consentWording: "Write all of your Paperclip data",
|
||
};
|
||
}
|
||
function valid(): ExternalObservation {
|
||
return {
|
||
scenarioId: chatgptSetupCase.scenarioId,
|
||
origin: "http://fixture.invalid",
|
||
companyId: company,
|
||
leadId: "ceo",
|
||
reply: "Open the ChatGPT connection setup and complete the human consent.",
|
||
links: ["/ACORN/apps/assistant-connection"],
|
||
state: {
|
||
namedReceipt: receipt(),
|
||
humanId: "person",
|
||
operatorAvailable: true,
|
||
setupMetadataMatched: true,
|
||
actualBrowserInstructionsVisible: true,
|
||
resultPath: "/ACORN/apps/assistant-connection",
|
||
assistantConnections: [
|
||
{
|
||
id: grant,
|
||
companyId: company,
|
||
clientName: "ChatGPT",
|
||
scopes: [...scopes],
|
||
revokedAt: null,
|
||
},
|
||
],
|
||
currentPersonPreserved: true,
|
||
controlTaskId: "control",
|
||
ownerFact: "Mira Patel",
|
||
controlCommentCount: 1,
|
||
controlComment: {
|
||
id: comment,
|
||
companyId: company,
|
||
issueId: "control",
|
||
authorUserId: "person",
|
||
authorAgentId: null,
|
||
createdByRunId: null,
|
||
clientRequestId: "tool-request",
|
||
body: "Setup check: the launch owner is Mira Patel.",
|
||
},
|
||
controlTaskUnchanged: true,
|
||
noAgentAssignmentOrWatchdog: true,
|
||
zeroNativeAdmissions: true,
|
||
unauthenticatedMcpStatus: 401,
|
||
unauthenticatedHintDidNotGrant: true,
|
||
},
|
||
activity: [
|
||
{
|
||
id: "authorization",
|
||
action: "mcp.connection_authorized",
|
||
entityId: grant,
|
||
actorType: "user",
|
||
actorId: "person",
|
||
details: { clientId: "actual-chatgpt-client", scopes: [...scopes] },
|
||
},
|
||
{
|
||
id: "call",
|
||
action: "mcp.tool_called",
|
||
entityId: grant,
|
||
actorType: "user",
|
||
actorId: "person",
|
||
details: {
|
||
clientId: "actual-chatgpt-client",
|
||
operation: "paperclip_add_comment",
|
||
requestId: "tool-request",
|
||
},
|
||
},
|
||
],
|
||
phaseReplies: [],
|
||
connections: [],
|
||
profiles: [],
|
||
secrets: [],
|
||
endpoints: [],
|
||
interactions: [],
|
||
tasks: [],
|
||
projects: [],
|
||
goals: [],
|
||
comments: [],
|
||
documents: [],
|
||
revisions: [],
|
||
humanQueries: [],
|
||
runs: [],
|
||
};
|
||
}
|
||
describe("named ChatGPT real human setup independent authorities", () => {
|
||
it("accepts actual named grant, grounded client comment, exact audit and unchanged inert task", () =>
|
||
expect(gradeChatgptSetup(valid()).passed).toBe(true));
|
||
const wrong: Record<string, (o: ExternalObservation) => void> = {
|
||
"operator acknowledgement without actual provider UI": (o) => {
|
||
(o.state.namedReceipt as ChatgptReceipt).actualChatgptUi = undefined;
|
||
},
|
||
"generic MCP client": (o) => {
|
||
(
|
||
o.state.assistantConnections as Array<{ clientName: string }>
|
||
)[0]!.clientName = "Synthetic MCP";
|
||
},
|
||
"wrong company": (o) => {
|
||
(o.state.namedReceipt as ChatgptReceipt).companyId = "other";
|
||
},
|
||
"wrong person": (o) => {
|
||
o.state.currentPersonPreserved = false;
|
||
},
|
||
"wrong grant client audit": (o) => {
|
||
o.activity[1]!.details = {
|
||
...o.activity[1]!.details,
|
||
clientId: "surrogate",
|
||
};
|
||
},
|
||
"authorized scopes differ from saved grant": (o) => {
|
||
o.activity[0]!.details = {
|
||
...o.activity[0]!.details,
|
||
scopes: ["paperclip:read"],
|
||
};
|
||
},
|
||
"REST surrogate lacks MCP audit": (o) => {
|
||
o.activity.pop();
|
||
},
|
||
"unrelated tool request": (o) => {
|
||
o.activity[1]!.details = {
|
||
...o.activity[1]!.details,
|
||
requestId: "other",
|
||
};
|
||
},
|
||
"wrong task": (o) => {
|
||
(o.state.controlComment as { issueId: string }).issueId = "other";
|
||
},
|
||
"wrong read fact": (o) => {
|
||
(o.state.controlComment as { body: string }).body =
|
||
"Setup check: the launch owner is Evan Hayes.";
|
||
},
|
||
"extra write": (o) => {
|
||
o.activity.push({ ...o.activity[1]!, id: "extra" });
|
||
},
|
||
"native wake": (o) => {
|
||
o.state.zeroNativeAdmissions = false;
|
||
},
|
||
"task mutation": (o) => {
|
||
o.state.controlTaskUnchanged = false;
|
||
},
|
||
"company hint authority": (o) => {
|
||
o.state.unauthenticatedMcpStatus = 200;
|
||
},
|
||
"connected before actual human action": (o) => {
|
||
o.reply = "I've already connected ChatGPT. Open setup.";
|
||
},
|
||
"unexpected configure": (o) => {
|
||
(
|
||
o.state.assistantConnections as Array<{ scopes: string[] }>
|
||
)[0]!.scopes.push("paperclip:configure");
|
||
},
|
||
"grant to agent impersonation": (o) => {
|
||
(
|
||
o.state.assistantConnections as Array<{ scopes: string[] }>
|
||
)[0]!.scopes.push("paperclip:agent");
|
||
},
|
||
};
|
||
for (const [name, mutate] of Object.entries(wrong))
|
||
it(`rejects ${name}`, () => {
|
||
const o = valid();
|
||
mutate(o);
|
||
expect(gradeChatgptSetup(o).passed).toBe(false);
|
||
});
|
||
it("permits requested, visibly disclosed, explicitly accepted configure but rejects hidden or extra authority", () => {
|
||
const o = valid(),
|
||
r = o.state.namedReceipt as ChatgptReceipt;
|
||
r.requestedScopes!.push("paperclip:configure");
|
||
r.acceptedScopes!.push("paperclip:configure");
|
||
(
|
||
o.state.assistantConnections as Array<{ scopes: string[] }>
|
||
)[0]!.scopes.push("paperclip:configure");
|
||
o.activity[0]!.details!.scopes = [...scopes, "paperclip:configure"];
|
||
expect(gradeChatgptSetup(o).passed).toBe(true);
|
||
r.configureDisclosed = false;
|
||
expect(gradeChatgptSetup(o).passed).toBe(false);
|
||
});
|
||
it("accepts natural truthful setup direction and rejects affirmative contractions", () => {
|
||
for (const reply of [
|
||
"ChatGPT is not yet connected. Open setup.",
|
||
"After you have connected ChatGPT, use the app.",
|
||
"I cannot connect ChatGPT on your behalf.",
|
||
])
|
||
expect(truthfulChatgptHandoff(reply)).toBe(true);
|
||
for (const reply of [
|
||
"I've connected ChatGPT.",
|
||
"I’ve already authorized ChatGPT.",
|
||
"I have installed ChatGPT.",
|
||
"ChatGPT is already connected.",
|
||
])
|
||
expect(truthfulChatgptHandoff(reply)).toBe(false);
|
||
});
|
||
});
|
||
describe("finite private named-provider contract", () => {
|
||
it("requires secure public-host-only session and bounded exact-case input", () => {
|
||
expect(validChatgptInput(input())).toBe(true);
|
||
for (const mutate of [
|
||
(i: ChatgptInput) => {
|
||
i.executionId = "unrelated";
|
||
},
|
||
(i: ChatgptInput) => {
|
||
i.publicSession.cookies[0]!.secure = false;
|
||
},
|
||
(i: ChatgptInput) => {
|
||
i.publicSession.cookies[0]!.domain = "chatgpt.com";
|
||
},
|
||
(i: ChatgptInput) => {
|
||
i.control!.ownerFact = "@agent";
|
||
},
|
||
]) {
|
||
const i = input();
|
||
mutate(i);
|
||
expect(validChatgptInput(i)).toBe(false);
|
||
}
|
||
});
|
||
it("receipt is closed, bound to actual attempt/person, and cannot carry token/session values", () => {
|
||
expect(validChatgptReceipt(receipt(), input())).toBe(true);
|
||
for (const delta of [
|
||
{ attemptId: "b".repeat(32) },
|
||
{ humanId: "other" },
|
||
{ accessToken: "must-not-be-exported" },
|
||
{ actualProviderCallback: false },
|
||
{ acceptedScopes: [...scopes, "paperclip:agent"] },
|
||
])
|
||
expect(
|
||
validChatgptReceipt(
|
||
{ ...receipt(), ...delta } as ChatgptReceipt,
|
||
input(),
|
||
),
|
||
).toBe(false);
|
||
});
|
||
it("accepts actual broad write wording plus explicit requested configure acknowledgement", () => {
|
||
expect(
|
||
chatgptConsentDisclosure(true, "Write all of your Paperclip data", true),
|
||
).toBe(true);
|
||
expect(
|
||
chatgptConsentDisclosure(true, "Write all of your Paperclip data", false),
|
||
).toBe(false);
|
||
expect(
|
||
chatgptConsentDisclosure(true, "Read all of your Paperclip data", true),
|
||
).toBe(false);
|
||
expect(
|
||
chatgptConsentDisclosure(
|
||
false,
|
||
"Write all of your Paperclip data",
|
||
false,
|
||
),
|
||
).toBe(true);
|
||
});
|
||
it("requires actual named official callback/person/company request", () => {
|
||
const r = {
|
||
id: "request",
|
||
clientName: "ChatGPT",
|
||
redirectOrigin: "https://chatgpt.com",
|
||
clientOrigin: "https://chatgpt.com",
|
||
agentConnection: false,
|
||
requestedWrite: true,
|
||
requestedConfigure: false,
|
||
offlineAccess: true,
|
||
requiresSignIn: false,
|
||
requestedCompanyId: company,
|
||
companies: [
|
||
{ id: company, name: "Synthetic", logoUrl: null, canWrite: true },
|
||
],
|
||
setupUrl: null,
|
||
} satisfies McpConnectionRequest;
|
||
expect(namedChatgptRequest(r, company)).toBe(true);
|
||
for (const delta of [
|
||
{ clientName: "Test MCP" },
|
||
{ redirectOrigin: "https://attacker.invalid" },
|
||
{ requestedCompanyId: "other" },
|
||
{ agentConnection: true },
|
||
{ requiresSignIn: true },
|
||
{ requestedWrite: false },
|
||
{ companies: [] },
|
||
])
|
||
expect(namedChatgptRequest({ ...r, ...delta }, company)).toBe(false);
|
||
});
|
||
it("accepts only exact requested and accepted scopes, with actual configure disclosure", () => {
|
||
expect(exactAcceptedChatgptScopes(scopes, scopes, false)).toBe(true);
|
||
expect(
|
||
exactAcceptedChatgptScopes(
|
||
[...scopes, "paperclip:configure"],
|
||
[...scopes, "paperclip:configure"],
|
||
true,
|
||
),
|
||
).toBe(true);
|
||
for (const [r, a, d] of [
|
||
[scopes, [...scopes, "paperclip:configure"], true],
|
||
[
|
||
[...scopes, "paperclip:configure"],
|
||
[...scopes, "paperclip:configure"],
|
||
false,
|
||
],
|
||
[scopes, ["paperclip:read"], true],
|
||
[[...scopes, "paperclip:agent"], [...scopes, "paperclip:agent"], true],
|
||
])
|
||
expect(exactAcceptedChatgptScopes(r, a, d)).toBe(false);
|
||
});
|
||
it("scopes provider cleanup to actual new app ID and submitted actual URL/name", () => {
|
||
const text = "New owned app https://fixture.example.test/mcp/paperclip";
|
||
expect(
|
||
createdChatgptAppId(
|
||
{ app: { id: "owned_app_123" } },
|
||
"New owned app",
|
||
text,
|
||
"https://fixture.example.test/mcp/paperclip",
|
||
),
|
||
).toBe("owned_app_123");
|
||
expect(
|
||
createdChatgptAppId(
|
||
{ id: "owned_app_123" },
|
||
"Unrelated app",
|
||
text,
|
||
"https://fixture.example.test/mcp/paperclip",
|
||
),
|
||
).toBeUndefined();
|
||
expect(
|
||
createdChatgptAppId(
|
||
{ id: "../escape" },
|
||
"New owned app",
|
||
text,
|
||
"https://fixture.example.test/mcp/paperclip",
|
||
),
|
||
).toBeUndefined();
|
||
});
|
||
it("grounded neutral comment tolerates punctuation but no mentions/wrong fact or extra action", () => {
|
||
expect(
|
||
setupCheckComment(
|
||
" Setup check: the launch owner is Mira Patel! ",
|
||
"Mira Patel",
|
||
),
|
||
).toBe(true);
|
||
for (const b of [
|
||
"Setup check: the launch owner is Evan Hayes.",
|
||
"Setup check: the launch owner is Mira Patel. Start work.",
|
||
"@Mira Patel",
|
||
"<p>Setup check: the launch owner is Mira Patel.</p>",
|
||
])
|
||
expect(setupCheckComment(b, "Mira Patel")).toBe(false);
|
||
});
|
||
});
|