test(evals): author named Notion read proof and viewer boundary

This commit is contained in:
Dotta committed 2026-10-26 20:34:39 -05:00
1 parent 81e03353d5
commit 079d05e893
8 files changed
+242 -11

No files matched your search

@@ -18,6 +18,9 @@ export function gradeViewerConsent(value: unknown) {
&& connections?.length === 1 && connections[0]?.companyId === o.companyId && connections[0]?.scopes.includes("paperclip:read")
&& !connections[0]?.scopes.includes("paperclip:write") && o.state.tokenScopesReadOnly === true,
"The actual signed-in viewer sees the intended company and requested write scope but cannot accept writes, including via the public consent API.");
c.add("explicit-consenting-person-boundary", o.state.phaseBoundary === "operator-chat-to-viewer-browser-consent" && typeof o.state.chatCallerId === "string"
&& o.state.chatCallerId !== o.state.viewerId && o.state.viewerSessionBefore === o.state.viewerId && o.state.viewerSessionAtConsent === o.state.viewerId && o.state.viewerSessionAfter === o.state.viewerId,
"This tests viewer authority in the explicitly separate consent phase; the operator's canonical chat is not represented as viewer chat coverage.");
c.add("only-explicit-human-grant", o.state.viewerGrantId === connections?.[0]?.id && o.state.humanConsented === true
&& o.activity.some(row => row.action === "mcp.connection_authorized" && row.entityId === o.state.viewerGrantId && row.actorType === "user" && row.actorId === o.state.viewerId),
"Only the invited viewer's actual browser consent authorizes this grant, with independent actor attribution.");
@@ -34,10 +37,11 @@ export const assistantConsentExternalCases = [definition("assistant-connections.
if (!browser || !secrets) throw new Error("Viewer consent requires an isolated signed-in person and private redactor");
const viewer = await createAuthenticatedPerson({ api: ctx.input.api, browser, secrets, name: "Viewer Casey" }); people.set(ctx, viewer);
try {
const caller = await ctx.input.api.get<{ user: { id: string } }>("/api/auth/get-session");
await enrollAuthenticatedPerson(ctx.input.api, ctx.input.fixtures.company.id, viewer, secrets, "viewer");
const marker = `Viewer allowed fixture ${randomBytes(12).toString("hex")}`;
const task = await ctx.input.api.post<{ id: string; title: string; description: string }>(`${root(ctx)}/issues`, { title: "Viewer consent sample task", description: marker, status: "backlog" });
return { viewerId: viewer.id, taskId: task.id, taskBefore: task, marker, forbiddenTitle: `Forbidden viewer task ${ctx.input.nonce}` };
return { viewerId: viewer.id, chatCallerId: caller.user.id, phaseBoundary: "operator-chat-to-viewer-browser-consent", taskId: task.id, taskBefore: task, marker, forbiddenTitle: `Forbidden viewer task ${ctx.input.nonce}` };
} catch (error) { people.delete(ctx); await viewer.dispose(); throw error; }
}, gradeViewerConsent, {
authentication: "human-session",
@@ -46,9 +50,12 @@ export const assistantConsentExternalCases = [definition("assistant-connections.
await waitForReply(ctx); const viewer = people.get(ctx); if (!viewer || !ctx.input.privateSecrets) throw new Error("Viewer consent fixture missing");
const page = await viewer.context.newPage();
try {
async function signedInPerson() { const response = await page.context().request.get(`${ctx.input.api.baseURL}/api/auth/get-session`); if (!response.ok()) throw new Error("Viewer browser session check failed"); return (await response.json() as { user: { id: string } }).user.id; }
state.viewerSessionBefore = await signedInPerson();
const connection = await connect(viewer.context, { ...ctx.input.fixtures.company, issuePrefix: ctx.input.fixtures.company.issuePrefix ?? "EVAL" }, false, page, ctx.input.privateSecrets, false, {
requestedWrite: true,
async inspectConsent(consent) {
state.viewerSessionAtConsent = await signedInPerson();
await expect(consent.getByRole("radio", { name: ctx.input.fixtures.company.name, exact: true })).toBeChecked(); state.companyVisible = true;
await expect(consent.getByText("Your role in this organization is read-only.", { exact: true })).toBeVisible(); state.readOnlyRoleVisible = true;
const writes = consent.getByRole("checkbox", { name: "Write all of your Paperclip data", exact: true });
@@ -62,6 +69,8 @@ export const assistantConsentExternalCases = [definition("assistant-connections.
},
});
state.humanConsented = true;
state.viewerSessionAfter = await signedInPerson();
await ctx.input.evidence("chat-catalog-viewer-consent-boundary.json", { phaseBoundary: state.phaseBoundary, chatCallerId: state.chatCallerId, consentingViewerId: state.viewerId, viewerSessionBefore: state.viewerSessionBefore, viewerSessionAtConsent: state.viewerSessionAtConsent, viewerSessionAfter: state.viewerSessionAfter });
state.tokenScopesReadOnly = connection.tokens.scope.split(/\s+/).includes("paperclip:read") && !connection.tokens.scope.split(/\s+/).includes("paperclip:write");
const client = await mcp(connection.tokens);
try {
@@ -14,3 +14,4 @@ export { gatewayClientExternalCases } from "./external-gateway-client.js";
export { pluginInstallExternalCases } from "./external-plugin-install.js";
export { mcpFailureExternalCases } from "./external-mcp-failure.js";
export { assistantConsentExternalCases } from "./external-assistant-consent.js";
export { notionReadExternalCases } from "./external-notion-read.js";
@@ -11,13 +11,18 @@ of credential environment variables belong there. The file schema is:
"providers": {
"notion": {
"disposable": true,
"resourceIds": ["authorized-disposable-workspace-id"],
"credentialEnv": ["PAPERCLIP_CHAT_EVAL_NOTION_AUTH"],
"resourceIds": ["authorized-disposable-workspace-id", "synthetic-launch-update-page-id", "synthetic-background-page-id"],
"credentialEnv": ["PAPERCLIP_CHAT_EVAL_NOTION_API", "PAPERCLIP_CHAT_EVAL_NOTION_MCP"],
"prerequisites": {
"workspaceId": "authorized-disposable-workspace-id",
"recentPageId": "synthetic-launch-update-page-id",
"olderPageId": "synthetic-background-page-id",
"mcpUrl": "https://mcp.notion.com/mcp"
"mcpUrl": "https://mcp.notion.com/mcp",
"apiVersion": "2022-06-28",
"apiTokenEnv": "PAPERCLIP_CHAT_EVAL_NOTION_API",
"mcpTokenEnv": "PAPERCLIP_CHAT_EVAL_NOTION_MCP",
"recentMarker": "approved synthetic current launch fact",
"olderMarker": "approved synthetic older background fact"
}
}
}
@@ -64,6 +69,20 @@ The remaining catalog prose is unchanged. The fixture creates a company-owned en
connection, verifies current repository discovery, and deletes both on cleanup.
Ambient `gh` authentication and personal credentials are never adopted.
The named `notion-connected` case requires separate references for the official
Notion REST oracle and official Notion MCP authorization. The API version is an
explicit coordinator choice; its validity is not inferred from the example.
Workspace and both page UUIDs must occur in `resourceIds`. At execution, the
official [token bot-user endpoint](https://developers.notion.com/reference/get-self)
must report the selected workspace, and both allowlisted page bodies must contain
their distinct approved synthetic facts with the current page actually newer.
Only minimal page/source/date projections and workspace-match booleans enter
case observations. Raw account receipts, names and email remain private.
The model must independently perform a successful MCP read in its own source run;
the oracle's REST reads cannot satisfy that requirement. No generic or synthetic
MCP host is accepted as a named Notion proof. Missing bindings remain executable
setup blockers; source-reference availability is not provider validity evidence.
Synthetic notes apps, MCP gateways, local plugins, team packages, local encrypted
secrets and test endpoints do not require this file. They use real public
Paperclip APIs and controlled fixture implementations; they cannot count as live
@@ -14,7 +14,7 @@ export interface ExternalFixtureConfig { schemaVersion: 1; providers: Partial<Re
export const externalRequirements: Record<string, { provider: ExternalProvider; fields: readonly string[] }> = {
"projects.add-repository": { provider: "github", fields: ["siteRepositoryId", "siteRepositoryFullName", "tokenEnv"] },
"projects.replace-repository": { provider: "github", fields: ["oldRepositoryId", "oldRepositoryFullName", "newRepositoryId", "newRepositoryFullName", "tokenEnv"] },
"connection-setup.notion-connected": { provider: "notion", fields: ["workspaceId", "recentPageId", "olderPageId", "mcpUrl"] },
"connection-setup.notion-connected": { provider: "notion", fields: ["workspaceId", "recentPageId", "olderPageId", "mcpUrl", "apiVersion", "apiTokenEnv", "mcpTokenEnv", "recentMarker", "olderMarker"] },
"connection-setup.notion-missing": { provider: "notion", fields: ["workspaceId", "recentPageId", "oauthBrowserSession"] },
"connection-setup.notion-wrong-account": { provider: "notion", fields: ["sharedWorkspaceId", "personalWorkspaceId", "personalPageId", "oauthBrowserSession"] },
"connection-setup.notion-agent-unassigned": { provider: "notion", fields: ["workspaceId", "recentPageId", "mcpUrl"] },
@@ -0,0 +1,39 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ChatCatalogContext } from "../chat-catalog-contract.js";
import { readNotionFixturePages } from "./external-notion-provider.js";
const workspaceId = "11111111-1111-4111-8111-111111111111", recent = "22222222-2222-4222-8222-222222222222", older = "33333333-3333-4333-8333-333333333333";
function context() {
const binding = { disposable: true, resourceIds: [workspaceId, recent, older], credentialEnv: ["PAPERCLIP_CHAT_EVAL_NOTION_API", "PAPERCLIP_CHAT_EVAL_NOTION_MCP"], prerequisites: {
workspaceId, recentPageId: recent, olderPageId: older, mcpUrl: "https://mcp.notion.com/mcp", apiVersion: "2026-03-11", apiTokenEnv: "PAPERCLIP_CHAT_EVAL_NOTION_API", mcpTokenEnv: "PAPERCLIP_CHAT_EVAL_NOTION_MCP", recentMarker: "Current fixture fact", olderMarker: "Earlier fixture fact",
} };
return { scenario: { id: "connection-setup.notion-connected" }, input: { privateSecrets: [], privateInputs: { read: (name: string) => name === "PAPERCLIP_CHAT_EVAL_NOTION_API" ? "private-api-fixture" : "private-mcp-fixture", configuration: () => ({ schemaVersion: 1, providers: { notion: binding } }) } } } as unknown as ChatCatalogContext;
}
function provider(options: { wrongWorkspace?: boolean; missingBody?: boolean; reverseDates?: boolean } = {}) {
const seen: string[] = [];
vi.stubGlobal("fetch", vi.fn(async (url: URL, input: RequestInit) => {
expect(url.origin).toBe("https://api.notion.com"); expect(input.method ?? "GET").toBe("GET"); expect(input.redirect).toBe("error"); seen.push(url.pathname);
if (url.pathname === "/v1/users/me") return Response.json({ object: "user", type: "bot", bot: { workspace_id: options.wrongWorkspace ? older : workspaceId, workspace_name: "Not retained in oracle projection" } });
const id = url.pathname.split("/")[3], isRecent = id === recent;
if (url.pathname.endsWith("/children")) return Response.json({ results: [{ id, type: "paragraph", has_children: false, paragraph: { rich_text: [{ plain_text: options.missingBody ? "Unrelated content with a convincing title" : isRecent ? "Current fixture fact" : "Earlier fixture fact" }] } }], has_more: false });
return Response.json({ object: "page", id, url: `https://www.notion.so/${id}`, last_edited_time: isRecent !== !!options.reverseDates ? "2026-10-25T00:00:00Z" : "2026-10-01T00:00:00Z", properties: { title: { title: [{ plain_text: "Launch notes" }] } } });
}));
return seen;
}
afterEach(() => vi.unstubAllGlobals());
describe("named Notion callback calibration, no live provider operations", () => {
it("reads only the fixed official identity and allowlisted page trees and projects no raw account metadata", async () => {
const seen = provider(); const pages = await readNotionFixturePages(context()); expect(pages).toHaveLength(2); expect(pages[0]).toMatchObject({ id: recent, marker: "Current fixture fact" });
expect(JSON.stringify(pages)).not.toContain("Not retained"); expect(seen).toEqual(["/v1/users/me", `/v1/pages/${recent}`, `/v1/blocks/${recent}/children`, `/v1/pages/${older}`, `/v1/blocks/${older}/children`]);
});
it("rejects a wrong token workspace before reading any page", async () => {
const seen = provider({ wrongWorkspace: true }); await expect(readNotionFixturePages(context())).rejects.toThrow(/workspace differs/); expect(seen).toEqual(["/v1/users/me"]);
});
it("title-only content and stale date assumptions cannot satisfy the fixture prerequisite", async () => {
provider({ missingBody: true }); await expect(readNotionFixturePages(context())).rejects.toThrow(/no longer contains/);
provider({ reverseDates: true }); await expect(readNotionFixturePages(context())).rejects.toThrow(/actually be newer/);
});
it("missing selected private references block before provider traffic", async () => {
const call = vi.fn(); vi.stubGlobal("fetch", call);
await expect(readNotionFixturePages({ scenario: { id: "connection-setup.notion-connected" }, input: {} } as ChatCatalogContext)).rejects.toThrow(/private inputs/); expect(call).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,81 @@
import type { ChatCatalogContext } from "../chat-catalog-contract.js";
import { selectedExternalBinding, selectedExternalCredential, type ProviderBinding } from "./external-fixtures.js";
import { root } from "./external-support.js";
export interface NotionPageOracle { id: string; url: string; editedAt: string; marker: string; title: string }
interface Page { object: string; id: string; url: string; last_edited_time: string; parent?: { type?: string; page_id?: string; workspace?: boolean }; properties?: Record<string, { type?: string; title?: Array<{ plain_text?: string }> }> }
interface Block { id: string; type: string; has_children: boolean; [key: string]: unknown }
const normalized = (id: string) => id.replaceAll("-", "").toLowerCase();
/** Only the selected disposable resource allowlist is accepted. This does not
* enumerate an account, search workspaces, or infer permission from a token. */
export function assertNotionFixture(binding: ProviderBinding) {
const p = binding.prerequisites;
for (const field of ["workspaceId", "recentPageId", "olderPageId"]) {
const id = p[field]; if (!id || !/^[a-f0-9-]{32,36}$/i.test(id) || !binding.resourceIds.some(resource => normalized(resource) === normalized(id))) throw new Error(`Setup blocker: Notion ${field} must be an explicitly allowlisted disposable UUID`);
}
const url = new URL(p.mcpUrl!);
if (url.protocol !== "https:" || url.hostname !== "mcp.notion.com" || url.pathname !== "/mcp" || url.username || url.password || url.search || url.hash) throw new Error("Setup blocker: named Notion proof requires the actual official Notion MCP resource");
if (!/^\d{4}-\d{2}-\d{2}$/.test(p.apiVersion ?? "")) throw new Error("Setup blocker: Notion API version reference is missing");
if (!p.recentMarker || !p.olderMarker || p.recentMarker === p.olderMarker) throw new Error("Setup blocker: distinct synthetic recent/background Notion marker references are required");
}
async function notionJson<T>(token: string, version: string, pathname: string): Promise<T> {
const url = new URL(pathname, "https://api.notion.com");
if (url.origin !== "https://api.notion.com" || !/^\/v1\/(?:users\/me|pages\/[a-f0-9-]+|blocks\/[a-f0-9-]+\/children)$/.test(url.pathname)) throw new Error("Notion oracle escaped its read-only fixture resource");
const response = await fetch(url, { redirect: "error", signal: AbortSignal.timeout(20_000), headers: { Authorization: `Bearer ${token}`, "Notion-Version": version, "content-type": "application/json" } });
if (!response.ok) { await response.body?.cancel(); throw new Error(`Setup blocker: authorized disposable Notion resource read returned HTTP ${response.status}; contents withheld`); }
const bytes = Buffer.from(await response.arrayBuffer()); if (bytes.length > 2 * 1024 * 1024) throw new Error("Setup blocker: Notion oracle response exceeds bounded fixture size");
try { return JSON.parse(bytes.toString("utf8")) as T; } catch { throw new Error("Notion oracle returned invalid JSON; contents withheld"); }
}
function richText(value: unknown): string {
if (!value || typeof value !== "object") return "";
if (Array.isArray(value)) return value.map(richText).join(" ");
const row = value as Record<string, unknown>;
if (typeof row.plain_text === "string") return row.plain_text;
return Object.values(row).filter(value => value && typeof value === "object").map(richText).join(" ");
}
async function blocksText(token: string, version: string, pageId: string, maxChildren = 100) {
let blocks = 0; const visited = new Set<string>();
async function children(id: string, depth: number): Promise<string> {
if (depth > 3 || visited.has(id)) throw new Error("Setup blocker: Notion page exceeds bounded fixture tree"); visited.add(id);
const page = await notionJson<{ results: Block[]; has_more: boolean }>(token, version, `/v1/blocks/${id}/children?page_size=100`);
if (!Array.isArray(page.results) || page.has_more || (blocks += page.results.length) > maxChildren) throw new Error("Setup blocker: Notion fixture page exceeds bounded child count");
const contents: string[] = [];
for (const block of page.results) { contents.push(richText(block[block.type])); if (block.has_children) contents.push(await children(block.id, depth + 1)); }
return contents.join("\n");
}
return children(pageId, 0);
}
export async function readNotionFixturePages(ctx: ChatCatalogContext, binding = selectedExternalBinding(ctx)) {
assertNotionFixture(binding); const p = binding.prerequisites;
const token = selectedExternalCredential(ctx, binding, "apiTokenEnv");
// Provider identity is checked privately; names, email and the raw account
// receipt never enter state/evidence. Provider contract:
// https://developers.notion.com/reference/user#bots
const self = await notionJson<{ object: string; type: string; bot?: { workspace_id?: string } }>(token, p.apiVersion!, "/v1/users/me");
if (self.object !== "user" || self.type !== "bot" || !self.bot?.workspace_id || normalized(self.bot.workspace_id) !== normalized(p.workspaceId!)) throw new Error("Setup blocker: actual Notion token workspace differs from its authorized disposable workspace reference");
const result: NotionPageOracle[] = [];
for (const [idField, markerField] of [["recentPageId", "recentMarker"], ["olderPageId", "olderMarker"]] as const) {
const id = p[idField]!, marker = p[markerField]!;
const page = await notionJson<Page>(token, p.apiVersion!, `/v1/pages/${id}`);
if (page.object !== "page" || normalized(page.id) !== normalized(id) || !Number.isFinite(Date.parse(page.last_edited_time))) throw new Error("Setup blocker: independent Notion page identity/date differs from its allowlisted fixture");
const url = new URL(page.url); if (url.protocol !== "https:" || !/(^|\.)notion\.(?:so|site)$/.test(url.hostname)) throw new Error("Setup blocker: Notion page returned an unexpected source host");
const body = await blocksText(token, p.apiVersion!, id);
if (!body.includes(marker)) throw new Error(`Setup blocker: ${idField} no longer contains its authorized synthetic marker`);
result.push({ id, url: page.url, editedAt: page.last_edited_time, marker, title: Object.values(page.properties ?? {}).flatMap(row => row.title ?? []).map(row => row.plain_text ?? "").join("") });
}
if (Date.parse(result[0]!.editedAt) <= Date.parse(result[1]!.editedAt)) throw new Error("Setup blocker: recent Notion fixture must actually be newer than the background page");
return result;
}
export async function connectNamedNotion(ctx: ChatCatalogContext, binding: ProviderBinding, agentIds: string[]) {
assertNotionFixture(binding);
const token = selectedExternalCredential(ctx, binding, "mcpTokenEnv");
const result = await ctx.input.api.postSensitive<{ connectionId: string; actions: { readOnly: Array<{ catalogEntryId: string }>; canMakeChanges: Array<{ catalogEntryId: string }> } }>(`${root(ctx)}/tools/apps/connect`, {
link: binding.prerequisites.mcpUrl, name: "Launch Notion", authMode: "bearer", credentialValues: { "credentials.authorization": token }, grantKind: "organization",
});
const read = result.actions.readOnly.map(row => row.catalogEntryId);
if (!read.length) throw new Error("Setup blocker: actual Notion MCP connection did not expose any classified read actions");
try {
await ctx.input.api.post(`${root(ctx)}/tools/apps/${result.connectionId}/finish`, { enabledCatalogEntryIds: read, askFirstCatalogEntryIds: [], access: { agentIds } });
await ctx.input.api.putSensitive(`/api/tool-connections/${result.connectionId}/installs`, { installs: agentIds.map(targetId => ({ targetType: "agent", targetId })) });
return { connectionId: result.connectionId, readEntryIds: read };
} catch (error) { await ctx.input.api.delete(`/api/tool-connections/${result.connectionId}`, { allowNotFound: true }); throw error; }
}
@@ -0,0 +1,58 @@
import { expect } from "@playwright/test";
import type { ToolConnection, ToolRunDecisionLookup, ToolProfileEffectiveSummary } from "../../../packages/shared/src/types/tool-access.js";
import { externalRequirements, selectedExternalBinding } from "./external-fixtures.js";
import { connectNamedNotion, readNotionFixturePages, type NotionPageOracle } from "./external-notion-provider.js";
import { definition, externalObservation, observeExternal, root, checks, verifyRenderedReply } from "./external-support.js";
export function gradeNotionRead(value: unknown) {
const c = checks(), o = externalObservation(value);
c.add("complete-named-notion-evidence", !!o, "Real official Notion account/resource checks, provider-backed catalog admission and source-grounded summary are required."); if (!o) return c.result();
const pages = o.state.pages as NotionPageOracle[] | undefined; const recent = pages?.[0], older = pages?.[1];
c.add("actual-current-provider-fixtures", o.state.provider === "notion" && o.state.providerOrigin === "https://mcp.notion.com" && o.state.workspaceIndependentlyMatched === true
&& !!recent && !!older && recent.id !== older.id && Date.parse(recent.editedAt) > Date.parse(older.editedAt) && o.state.fixturesUnchanged === true,
"An independent official Notion API checks token workspace identity and actual current and older allowlisted page bodies/dates before and after the query.");
const effective = o.state.effective as ToolProfileEffectiveSummary | undefined;
const readIds = o.state.readEntryIds as string[] | undefined;
c.add("existing-assigned-healthy-connection", o.state.healthyBeforeQuery === true && o.connections.filter(row => row.id === o.state.connectionId).length === 1
&& o.connections.length === o.state.connectionCountBefore && effective?.agentId === o.leadId && Array.isArray(readIds)
&& readIds.some(id => effective.allowedTools.some(tool => tool.id === id)), "The same pre-existing official Notion connection remains usable for this CEO without duplicate setup or repaired credentials.");
const decisions = o.state.decisions as ToolRunDecisionLookup[] | undefined;
c.add("actual-model-notion-read", Array.isArray(decisions) && decisions.some(row => row.decisions.some(decision => decision.invocation.runId === row.runId
&& decision.invocation.agentId === o.leadId && decision.invocation.connectionId === o.state.connectionId && decision.invocation.issueId === o.state.sourceIssueId
&& readIds?.includes(decision.invocation.catalogEntryId ?? "") && decision.invocation.status === "succeeded")),
"The actual canonical-query model run successfully invokes a read action from this named provider connection; independent oracle reads cannot substitute.");
const recentDate = recent?.editedAt.slice(0, 10);
c.add("latest-body-grounded-summary", !!recent && o.reply.includes(recent.marker) && !!recentDate && o.reply.includes(recentDate)
&& o.links.some(link => { try { return new URL(link).href.replace(/\/$/, "") === recent.url.replace(/\/$/, ""); } catch { return false; } })
&& (!older || !o.reply.includes(older.marker) || /older|background|previous|earlier/i.test(o.reply)),
"The answer contains the freshly observed body fact, its real source and update date, and distinguishes older background if mentioned.");
c.add("no-needless-reconnect", !o.interactions.some(row => row.kind === "connection_intent" && row.payload?.serviceSlug === "notion")
&& !/\b(?:must|need to|please) (?:re)?connect (?:your |the )?notion/i.test(o.reply), "An already usable account is used without a needless setup request.");
return c.result();
}
export const notionReadExternalCases = [definition("connection-setup.notion-connected", async ctx => {
const binding = selectedExternalBinding(ctx); const pages = await readNotionFixturePages(ctx, binding);
const connected = await connectNamedNotion(ctx, binding, [ctx.input.fixtures.agent.id]);
const connection = await ctx.input.api.get<ToolConnection>(`/api/tool-connections/${connected.connectionId}`);
if (connection.status !== "active" || !connection.enabled || connection.healthStatus !== "ok") throw new Error("Setup blocker: authorized official Notion fixture did not produce a healthy active connection");
const connections = await ctx.input.api.get<{ connections: ToolConnection[] }>(`${root(ctx)}/tools/connections`);
return { ...connected, pages, provider: "notion", providerOrigin: "https://mcp.notion.com", workspaceIndependentlyMatched: true, healthyBeforeQuery: true, connectionCountBefore: connections.connections.length };
}, gradeNotionRead, {
externalInput: externalRequirements["connection-setup.notion-connected"],
sourceFiles: ["chat-catalog-domains/external-notion-read.ts", "chat-catalog-domains/external-notion-provider.ts", "chat-catalog-domains/external-fixtures.ts", "chat-catalog-domains/external-support.ts", "chat-catalog-domains/internal-support.ts", "chat-scenarios/connection-setup.yaml"],
async observe(ctx, state) {
const current = await readNotionFixturePages(ctx); const before = state.pages as NotionPageOracle[];
state.fixturesUnchanged = current.length === before.length && current.every((page, index) => page.id === before[index]?.id && page.editedAt === before[index]?.editedAt && page.marker === before[index]?.marker);
state.effective = await ctx.input.api.get<ToolProfileEffectiveSummary>(`${root(ctx)}/tools/profiles/effective/agents/${ctx.input.fixtures.agent.id}`);
const runs = await ctx.allRuns(); state.decisions = await Promise.all(runs.map(run => ctx.input.api.get<ToolRunDecisionLookup>(`${root(ctx)}/tools/runs/${run.id}/decisions`)));
state.sourceIssueId = ctx.issue()?.id; return observeExternal(ctx, state);
},
async verifyResult(ctx, state, value) {
await verifyRenderedReply(ctx, value); const page = (state.pages as NotionPageOracle[])[0]!;
const anchor = ctx.input.page.locator(`[id^="comment-"] a[href=${JSON.stringify(page.url)}]`).first(); await expect(anchor).toBeVisible();
// External page browsers can require a second login. The actual source was
// independently retrieved above; verify the rendered exact link identity.
await expect(anchor).toHaveAttribute("href", page.url);
await ctx.input.evidence("chat-catalog-notion-source.json", { provider: "notion", pageId: page.id, url: page.url, editedAt: page.editedAt, workspaceMatched: true });
},
async cleanup(ctx, state) { if (typeof state.connectionId === "string") await ctx.input.api.delete(`/api/tool-connections/${state.connectionId}`, { allowNotFound: true }); },
})];
@@ -5,6 +5,8 @@ import { gatewayClientExternalCases } from "./external-gateway-client.js";
import { pluginInstallExternalCases } from "./external-plugin-install.js";
import { mcpFailureExternalCases } from "./external-mcp-failure.js";
import { assistantConsentExternalCases } from "./external-assistant-consent.js";
import { notionReadExternalCases } from "./external-notion-read.js";
import { assertNotionFixture } from "./external-notion-provider.js";
import { assistantDeviceExternalCases } from "./external-assistant-device.js";
import { pluginExternalCases } from "./external-plugins.js";
import { appCatalogExternalCases } from "./external-app-catalog.js";
@@ -16,7 +18,7 @@ import { connectionSetupExternalCases } from "./external-connection-setup.js";
import { externalPrerequisiteReceipt, externalRequirements, validateExternalConfig, selectedExternalBinding, selectedExternalCredential } from "./external-fixtures.js";
import type { ExternalObservation } from "./external-support.js";
const definitions = [...appPermissionExternalCases, ...teamExternalCases, ...secretExternalCases, ...gatewayExternalCases, ...connectionSetupExternalCases, ...assistantConnectionExternalCases, ...chatChannelExternalCases, ...appCatalogExternalCases, ...pluginExternalCases, ...assistantDeviceExternalCases, ...gatewayClientExternalCases, ...pluginInstallExternalCases, ...mcpFailureExternalCases, ...assistantConsentExternalCases];
const definitions = [...appPermissionExternalCases, ...teamExternalCases, ...secretExternalCases, ...gatewayExternalCases, ...connectionSetupExternalCases, ...assistantConnectionExternalCases, ...chatChannelExternalCases, ...appCatalogExternalCases, ...pluginExternalCases, ...assistantDeviceExternalCases, ...gatewayClientExternalCases, ...pluginInstallExternalCases, ...mcpFailureExternalCases, ...assistantConsentExternalCases, ...notionReadExternalCases];
function base(scenarioId: string): ExternalObservation {
return { scenarioId, origin: "http://fixture.invalid", companyId: "company", leadId: "ceo", state: { initialRunId: "initial" },
phaseReplies: [], connections: [], profiles: [], secrets: [], endpoints: [], interactions: [], tasks: [], projects: [], goals: [], comments: [],
@@ -24,10 +26,17 @@ function base(scenarioId: string): ExternalObservation {
}
function valid(id: string) {
const o = base(id);
if (id === "assistant-connections.consent-read-only") {
if (id === "connection-setup.notion-connected") {
Object.assign(o.state, { provider: "notion", providerOrigin: "https://mcp.notion.com", workspaceIndependentlyMatched: true,
pages: [{ id: "recent", url: "https://www.notion.so/recent", editedAt: "2026-10-25T12:00:00Z", marker: "New observed launch fact" }, { id: "old", url: "https://www.notion.so/old", editedAt: "2026-10-01T12:00:00Z", marker: "Old background" }], fixturesUnchanged: true,
connectionId: "notion", healthyBeforeQuery: true, connectionCountBefore: 1, effective: { agentId: "ceo", allowedTools: [{ id: "read" }] }, readEntryIds: ["read"], sourceIssueId: "source",
decisions: [{ runId: "initial", decisions: [{ invocation: { runId: "initial", agentId: "ceo", connectionId: "notion", issueId: "source", catalogEntryId: "read", status: "succeeded" } }] }] });
o.connections = [{ id: "notion" }]; o.links = ["https://www.notion.so/recent"]; o.reply = "New observed launch fact (updated 2026-10-25).";
} else if (id === "assistant-connections.consent-read-only") {
Object.assign(o.state, { requestedWrite: true, readOnlyRoleVisible: true, writeCheckboxDisabled: true, writeCheckboxUnchecked: true, companyVisible: true, viewerEscalationStatus: 403,
viewerConnections: [{ id: "grant", companyId: "company", scopes: ["paperclip:read", "offline_access"] }], tokenScopesReadOnly: true, viewerGrantId: "grant", humanConsented: true, viewerId: "viewer",
readTaskId: "task", taskId: "task", readTaskCompanyId: "company", readMarkerMatched: true, createDeniedForScope: true, updateDeniedForScope: true, taskPreserved: true, forbiddenTaskCount: 0 });
readTaskId: "task", taskId: "task", readTaskCompanyId: "company", readMarkerMatched: true, createDeniedForScope: true, updateDeniedForScope: true, taskPreserved: true, forbiddenTaskCount: 0,
phaseBoundary: "operator-chat-to-viewer-browser-consent", chatCallerId: "operator", viewerSessionBefore: "viewer", viewerSessionAtConsent: "viewer", viewerSessionAfter: "viewer" });
o.activity = [{ id: "authorization", action: "mcp.connection_authorized", entityId: "grant", actorType: "user", actorId: "viewer" }];
} else if (id === "connection-setup.unreachable-mcp") {
Object.assign(o.state, { serverUrl: "http://127.0.0.1:8848/", fixtureServerClosed: true, setupStatus: 400, visibleConnectivityError: true, checkAgainAvailable: true, displayedServerPreserved: true, editableUrlPreserved: true, abandonedDraft: true, noUnexpectedConnection: true });
@@ -100,6 +109,7 @@ function valid(id: string) {
return o;
}
const wrong: Record<string, (o: ExternalObservation) => void> = {
"connection-setup.notion-connected": o => { o.reply = "The launch remains on schedule, according to the page title."; },
"assistant-connections.consent-read-only": o => { o.state.viewerConnections = [{ id: "grant", companyId: "company", scopes: ["paperclip:read", "paperclip:write"] }]; },
"connection-setup.unreachable-mcp": o => { o.connections = [{ id: "phantom", status: "active" }]; },
"plugins.install-plugin": o => { o.state.pluginAfter = { id: "plugin", pluginKey: "synthetic.preview", status: "error" }; },
@@ -120,6 +130,12 @@ const wrong: Record<string, (o: ExternalObservation) => void> = {
"connection-setup.notion-declined": o => { o.interactions[0]!.resolvedByUserId = "someone-else"; },
};
describe("external domain independent oracles", () => {
it("viewer consent cannot borrow the operator's session or silently claim viewer chat coverage", () => {
for (const field of ["viewerSessionBefore", "viewerSessionAtConsent", "viewerSessionAfter", "phaseBoundary"]) {
const o = valid("assistant-connections.consent-read-only"); o.state[field] = "operator";
expect(assistantConsentExternalCases[0]!.grade(o).passed).toBe(false);
}
});
it("an inactive or expired gateway session cannot prove profile write denial", () => {
for (const reason of ["run_inactive", "session_run_inactive", "gateway_token_expired", "tool_not_found", undefined]) {
const o = valid("gateways-profiles.connect-client"); o.state.writeProbeReason = reason;
@@ -149,7 +165,7 @@ describe("private disposable provider prerequisite contract", () => {
it("owning runtime callbacks read only selected private references and never ambient env", () => {
const binding = { disposable: true as const, resourceIds: ["workspace"], credentialEnv: ["PAPERCLIP_CHAT_EVAL_NOTION_AUTH"], prerequisites: { workspaceId: "workspace", recentPageId: "recent", olderPageId: "old", mcpUrl: "https://fixture.invalid" } };
const redactions: string[] = [];
const ctx = { scenario: { id: "connection-setup.notion-connected" }, input: { privateSecrets: redactions, privateInputs: {
const ctx = { scenario: { id: "connection-setup.notion-agent-unassigned" }, input: { privateSecrets: redactions, privateInputs: {
configuration: () => ({ schemaVersion: 1, providers: { notion: binding } }), read: (name: string) => name === "PAPERCLIP_CHAT_EVAL_NOTION_AUTH" ? "private-fixture-token" : undefined,
} } } as unknown as import("../chat-catalog-contract.js").ChatCatalogContext;
expect(selectedExternalBinding(ctx)).toEqual(binding);
@@ -166,10 +182,18 @@ describe("private disposable provider prerequisite contract", () => {
it("missing input produces field-only receipts, never a configured pass", () => {
for (const id of Object.keys(externalRequirements)) { const receipt = externalPrerequisiteReceipt(id, undefined, {}); expect(receipt.configured).toBe(false); expect(receipt.missing.length).toBeGreaterThan(1); }
});
it("local fixtures need no unrelated provider account", () => { for (const definition of definitions) expect(externalPrerequisiteReceipt(definition.scenarioId, undefined, {}).configured).toBe(true); });
it("local fixtures need no unrelated provider account", () => { for (const definition of definitions.filter(row => !row.externalInput)) expect(externalPrerequisiteReceipt(definition.scenarioId, undefined, {}).configured).toBe(true); });
it("named Notion fixtures require official transport and every resource in the disposable allowlist", () => {
const workspaceId = "11111111-1111-4111-8111-111111111111", recentPageId = "22222222-2222-4222-8222-222222222222", olderPageId = "33333333-3333-4333-8333-333333333333";
const binding = { disposable: true as const, resourceIds: [workspaceId, recentPageId, olderPageId], credentialEnv: ["PAPERCLIP_CHAT_EVAL_NOTION_API", "PAPERCLIP_CHAT_EVAL_NOTION_MCP"], prerequisites: { workspaceId, recentPageId, olderPageId, mcpUrl: "https://mcp.notion.com/mcp", apiVersion: "2026-03-11", apiTokenEnv: "PAPERCLIP_CHAT_EVAL_NOTION_API", mcpTokenEnv: "PAPERCLIP_CHAT_EVAL_NOTION_MCP", recentMarker: "Recent synthetic fact", olderMarker: "Background fact" } };
expect(() => assertNotionFixture(binding)).not.toThrow();
expect(() => assertNotionFixture({ ...binding, resourceIds: [workspaceId] })).toThrow(/allowlisted/);
expect(() => assertNotionFixture({ ...binding, prerequisites: { ...binding.prerequisites, mcpUrl: "https://synthetic.invalid/mcp" } })).toThrow(/actual official Notion/);
expect(externalPrerequisiteReceipt("connection-setup.notion-connected", undefined, {}).missing).toContain("apiTokenEnv");
});
it("requires explicitly disposable allowlisted resources and namespaced env references", () => {
const config = { schemaVersion: 1, providers: { notion: { disposable: true, resourceIds: ["workspace"], credentialEnv: ["PAPERCLIP_CHAT_EVAL_NOTION_AUTH"],
prerequisites: { workspaceId: "workspace", recentPageId: "recent", olderPageId: "old", mcpUrl: "https://fixture.invalid" } } } };
prerequisites: { workspaceId: "workspace", recentPageId: "recent", olderPageId: "old", mcpUrl: "https://fixture.invalid", apiVersion: "2026-03-11", apiTokenEnv: "PAPERCLIP_CHAT_EVAL_NOTION_AUTH", mcpTokenEnv: "PAPERCLIP_CHAT_EVAL_NOTION_AUTH", recentMarker: "Recent", olderMarker: "Older" } } } };
const parsed = validateExternalConfig(config);
const env = { PAPERCLIP_CHAT_EVAL_NOTION_AUTH: "secret-value-not-for-receipts" };
expect(externalPrerequisiteReceipt("connection-setup.notion-connected", parsed, env)).toEqual({ scenarioId: "connection-setup.notion-connected", provider: "notion", configured: true, missing: [] });