diff --git a/tests/runner-e2e/chat-catalog-domains/external-definitions.ts b/tests/runner-e2e/chat-catalog-domains/external-definitions.ts index f4de7e5bfb1..300559aa571 100644 --- a/tests/runner-e2e/chat-catalog-domains/external-definitions.ts +++ b/tests/runner-e2e/chat-catalog-domains/external-definitions.ts @@ -21,3 +21,4 @@ export { teamConfigureExternalCases } from "./external-team-configure.js"; export { gatewayRotationExternalCases } from "./external-gateway-rotation.js"; export { notionAssignmentExternalCases } from "./external-notion-assignment.js"; export { appTestExternalCases } from "./external-app-test.js"; +export { notionAudienceExternalCases } from "./external-notion-audience.js"; diff --git a/tests/runner-e2e/chat-catalog-domains/external-fixtures.md b/tests/runner-e2e/chat-catalog-domains/external-fixtures.md index 4b59bc78bcc..ae22c8c5f13 100644 --- a/tests/runner-e2e/chat-catalog-domains/external-fixtures.md +++ b/tests/runner-e2e/chat-catalog-domains/external-fixtures.md @@ -124,3 +124,20 @@ The launcher strips original external config/environment references from server, model, provider and unrelated-case inheritance. Only declared reference names and field names enter the public fingerprint; credentials enter the private redaction sink. Local fixture CLI children use an explicit sanitized environment. + +The finite `gateway-token-rotation` private-browser operation uses the actual +Tokens UI to issue one replacement and revoke the named old client only after +protocol discovery with the replacement succeeds. Old and unrelated-client +bearers enter through the private request. The one-time replacement crosses a +separate `0600` handoff inside the same disposable directory, reaches only the +owning callback/redactor, and is removed with the directory in `finally`. +The public receipt contains only fixed booleans, a token UUID and HTTP status; +it never contains a bearer. The parent separately verifies saved revocation, +other-client preservation, actual protocol access and reachable-output byte scans. + +`app-permissions.grant-one-agent` requires only the selected official Notion +transport, disposable workspace and API/MCP credential references. It does not +require launch-summary pages or dates. The independent account probe verifies +the declared workspace through the official API, and the product connects to the +official MCP endpoint. Human audience changes use the real agent-selection UI; +public effective access verifies Morgan-only reach and unchanged action rules. diff --git a/tests/runner-e2e/chat-catalog-domains/external-fixtures.ts b/tests/runner-e2e/chat-catalog-domains/external-fixtures.ts index 3fa085e2ec8..fbbb9cd8001 100644 --- a/tests/runner-e2e/chat-catalog-domains/external-fixtures.ts +++ b/tests/runner-e2e/chat-catalog-domains/external-fixtures.ts @@ -16,6 +16,7 @@ export const externalRequirements: Record { const definition = notionAssignmentExternalCases[0]!; it("accepts a same-account authorized grant followed by an actual source-bound read", () => expect(definition.grade(valid()).passed).toBe(true)); diff --git a/tests/runner-e2e/chat-catalog-domains/external-notion-assignment.ts b/tests/runner-e2e/chat-catalog-domains/external-notion-assignment.ts index 0d04d1fa76a..5f96b341bbd 100644 --- a/tests/runner-e2e/chat-catalog-domains/external-notion-assignment.ts +++ b/tests/runner-e2e/chat-catalog-domains/external-notion-assignment.ts @@ -13,7 +13,7 @@ export function gradeNotionAssignment(value: unknown) { const c = checks(), o = externalObservation(value); c.add("complete-named-notion-assignment-evidence", !!o, "Actual official Notion workspace, Morgan-only assignment, human grant and continued model read are required."); if (!o) return c.result(); c.add("usable-account-assigned-only-to-morgan", o.state.namedProvider === "notion" && o.state.workspaceMatched === true && o.state.healthyBefore === true && o.state.morganHadReadBefore === true && o.state.ceoHadReadBefore === false && o.state.morganId !== o.leadId, "Official provider reads and public effective access independently establish a usable account unavailable to this CEO."); c.add("honest-assignment-handoff", o.state.beforeGrantReadCount === 0 && o.state.initialAccessExplanation === true && o.state.initialResearchClaim === false && o.state.humanManagementVisible === true, "The original phase identifies assignment as a prerequisite without borrowing Morgan's authority or inventing a summary."); - c.add("same-account-authorized-assignment", o.state.authorizedGrant === true && o.activity.some(row => row.entityId === o.state.connectionId && row.action === "tool_connection.installs_synced" && row.actorType === "user") && o.state.operatorChannel === "board-api-existing-account-assignment" && o.state.sameConnectionCount === true && o.state.morganRetainedRead === true && o.state.ceoHasReadAfter === true && o.state.fixturesUnchanged === true, "The authorized human adds this CEO to the existing connection without replacing Morgan or creating a duplicate account."); + c.add("same-account-authorized-assignment", o.state.authorizedGrant === true && o.activity.some(row => row.entityId === o.state.connectionId && row.action === "tool_app.finished" && row.actorType === "user") && o.state.operatorChannel === "public-browser-agent-selection" && o.state.sameConnectionCount === true && o.state.morganRetainedRead === true && o.state.ceoHasReadAfter === true && o.state.fixturesUnchanged === true, "The authorized human adds this CEO to the existing connection without replacing Morgan or creating a duplicate account."); const page = (o.state.pages as NotionPageOracle[] | undefined)?.[0], replies = o.phaseReplies.filter(row => row.runId !== o.state.initialRunId), body = replies.map(row => row.body).join("\n"); c.add("phase-bound-current-workspace-read", Array.isArray(o.state.decisions) && replies.some(reply => o.runs.some(run => run.id === reply.runId && run.status === "succeeded") && successfulRead(o.state.decisions as ToolRunDecisionLookup[], o.state, reply.runId)) && !!page && !!notionFactSpan(body, page.fact) && notionDateMentioned(body, page.editedAt) && o.links.some(link => notionPageIdentityMatches(link, page.id)), "The resumed source task performs its own actual named-provider read and grounds its answer in the allowlisted current page body, date and exact source identity."); @@ -44,9 +44,12 @@ export const notionAssignmentExternalCases = [definition("connection-setup.notio const href = o.links.find(link => { try { const url = new URL(link, o.origin); return url.origin === o.origin && (url.pathname === prefix || url.pathname.startsWith(`${prefix}/`)); } catch { return false; } }); if (!href) throw new Error("Chat did not provide the existing account's supported access-management handoff"); const source = ctx.input.page.url(); await ctx.input.page.goto(new URL(href, o.origin).href, { waitUntil: "domcontentloaded" }); await expect(ctx.input.page.getByRole("main")).toContainText("Launch Notion"); state.humanManagementVisible = true; - // Public board action changes only explicit installs; no credential is copied. - await ctx.input.api.putSensitive(`/api/tool-connections/${state.connectionId}/installs`, { installs: [{ targetType: "agent", targetId: state.morganId }, { targetType: "agent", targetId: state.ceoId }] }); - state.authorizedGrant = true; state.operatorChannel = "board-api-existing-account-assignment"; + if (!ctx.input.page.url().endsWith("/permissions")) await ctx.input.page.goto(`${prefix}/permissions`, {waitUntil:"domcontentloaded"}); + await ctx.input.page.getByRole("button", {name:"1 agent selected",exact:true}).click(); + const completed = ctx.input.page.waitForResponse(response => new URL(response.url()).pathname === `${root(ctx)}/tools/apps/${state.connectionId}/finish` && response.request().method() === "POST" && response.ok()); + await ctx.input.page.getByRole("checkbox", {name:`Allow ${ctx.input.fixtures.agent.name}`,exact:true}).check(); + const response = await completed, request = response.request().postDataJSON() as {access:{agentIds?:string[]}}; + state.authorizedGrant = Array.isArray(request.access.agentIds) && request.access.agentIds.length === 2 && request.access.agentIds.includes(String(state.morganId)) && request.access.agentIds.includes(String(state.ceoId)); state.operatorChannel = "public-browser-agent-selection"; await ctx.input.page.goto(source, { waitUntil: "domcontentloaded" }); await sendChatMessage(ctx.input.page, "I assigned the existing Notion account to you and kept Morgan's access. Please continue the original launch summary here."); }, async observe(ctx, state) { diff --git a/tests/runner-e2e/chat-catalog-domains/external-notion-audience.test.ts b/tests/runner-e2e/chat-catalog-domains/external-notion-audience.test.ts new file mode 100644 index 00000000000..78f3df59d89 --- /dev/null +++ b/tests/runner-e2e/chat-catalog-domains/external-notion-audience.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { notionAudienceExternalCases, grantMorganGuidance } from "./external-notion-audience.js"; +function valid() { return { scenarioId: "app-permissions.grant-one-agent", companyId: "company", leadId: "ceo", reply: "The authorized human can assign the existing Notion account to Morgan only in account access settings. Do not grant all agents access.", phaseReplies: [], connections: [], profiles: [], secrets: [], endpoints: [], interactions: [], activity: [{ entityId: "notion", action: "tool_app.finished", actorType: "user" }], links: [], runs: [], humanQueries: [], state: { connectionId: "notion", provider: "notion", workspaceMatched: true, healthyBefore: true, agentAssignmentStatus: 403, noMorganBefore: true, noMutationBeforeHuman: true, humanManagementVisible: true, onlyMorganRequested: true, operatorChannel: "public-browser-agent-selection", installsAfter: [{ targetType: "agent", targetId: "morgan" }], effectiveAfter: [{ agentId: "morgan", allowedTools: [{ id: "read" }] }, { agentId: "ceo", allowedTools: [] as Array<{id: string}> }, { agentId: "riley", allowedTools: [] as Array<{id: string}> }], morganId: "morgan", readEntryIds: ["read"], writeEntryIds: ["write"], connectionCountUnchanged: true, appPolicyUnchanged: true } }; } +describe("real named Notion Morgan-only audience", () => { + const definition = notionAudienceExternalCases[0]!; + it("accepts exact Morgan-only effective access with separate preserved action policy", () => expect(definition.grade(valid()).passed).toBe(true)); + it("rejects wrong advice before human action, premature claims and broad effective scope", () => { + for (const mutate of [ + (o: ReturnType) => { o.reply = "Use human settings to assign Notion access to Morgan and Riley."; }, + (o: ReturnType) => { o.reply = "I've already granted Morgan Notion access in board settings."; }, + (o: ReturnType) => { o.state.agentAssignmentStatus = 200; }, + (o: ReturnType) => { o.state.installsAfter[0]!.targetType = "company"; }, + (o: ReturnType) => { o.state.effectiveAfter[1]!.allowedTools = [{ id: "read" }]; }, + (o: ReturnType) => { o.state.effectiveAfter[0]!.allowedTools.push({ id: "write" }); }, + (o: ReturnType) => { o.state.noMutationBeforeHuman = false; }, + (o: ReturnType) => { o.state.appPolicyUnchanged = false; }, + (o: ReturnType) => { o.activity[0]!.actorType = "agent"; }, + ]) { const o = valid(); mutate(o); expect(definition.grade(o).passed).toBe(false); } + }); + it("accepts safe negative scope advice without a generic successful reply fallback", () => { expect(grantMorganGuidance(valid().reply)).toBe(true); expect(definition.grade({ reply: "Done." }).passed).toBe(false); }); +}); diff --git a/tests/runner-e2e/chat-catalog-domains/external-notion-audience.ts b/tests/runner-e2e/chat-catalog-domains/external-notion-audience.ts new file mode 100644 index 00000000000..d18fa012b47 --- /dev/null +++ b/tests/runner-e2e/chat-catalog-domains/external-notion-audience.ts @@ -0,0 +1,72 @@ +import { expect } from "@playwright/test"; +import type { ToolConnection, ToolProfileEffectiveSummary, ToolProfileWithDetails, ToolCatalogEntry } from "../../../packages/shared/src/types/tool-access.js"; +import { externalRequirements, selectedExternalBinding } from "./external-fixtures.js"; +import { verifyNotionWorkspace, connectNamedNotion } from "./external-notion-provider.js"; +import { definition, root, checks, externalObservation, observeExternal, verifyRenderedReply } from "./external-support.js"; +import { identityWorker, waitForReply, probeAgentPermission } from "./identity-support.js"; +export function grantMorganGuidance(reply: string) { + const clauses = reply.split(/[.!?;\n]+|\b(?:but|however|then)\b/i); + const wrongAudience = clauses.some(clause => [...clause.matchAll(/\b(?:select|grant|assign|enable|give)\b.{0,55}\b(?:all agents|everyone|company-wide|company wide|Riley)\b/gi)].some(match => !/\b(?:do not|don.t|never|cannot|can.t|should not|must not)\s*$/i.test(clause.slice(0, match.index).trim()))); + return !wrongAudience && /Morgan/i.test(reply) && /Notion/i.test(reply) && /assign|access|permission/i.test(reply) && /human|board|operator|you can|settings|manage/i.test(reply) + && !/\bI(?:['’]ve|\s+have)?\s+(?:already\s+)?(?:assigned|granted|enabled)\b|\bMorgan\s+(?:already\s+has|now\s+has|can\s+now)\b/i.test(reply); +} +export function gradeNotionAudience(value: unknown) { + const c = checks(), o = externalObservation(value); c.add("complete-named-notion-audience-evidence", !!o, "Official disposable account identity, board-only audience changes, exact saved installs and effective action policy are required."); if (!o) return c.result(); + c.add("healthy-shared-notion-prerequisite", o.state.provider === "notion" && o.state.workspaceMatched === true && o.state.healthyBefore === true && o.state.agentAssignmentStatus === 403 && o.state.noMorganBefore === true, "A usable real Notion account exists; ordinary agent audience mutation is actually denied."); + c.add("scoped-human-guidance-before-grant", grantMorganGuidance(o.reply) && o.state.noMutationBeforeHuman === true && o.state.humanManagementVisible === true && o.state.onlyMorganRequested === true && o.state.operatorChannel === "public-browser-agent-selection", "Actual initial guidance preserves Morgan-only scope before the authorized human assigns the existing account."); + const installs = o.state.installsAfter as Array<{targetType: string; targetId: string}> | undefined, access = o.state.effectiveAfter as ToolProfileEffectiveSummary[] | undefined; + const readIds = Array.isArray(o.state.readEntryIds) ? o.state.readEntryIds as string[] : [], writeIds = Array.isArray(o.state.writeEntryIds) ? o.state.writeEntryIds as string[] : []; + c.add("only-morgan-effective-audience", !!installs && installs.length === 1 && installs[0]?.targetType === "agent" && installs[0]?.targetId === o.state.morganId + && readIds.length > 0 && !!access && access.length === 3 && access.every(row => readIds.some(id => row.allowedTools.some(tool => tool.id === id)) === (row.agentId === o.state.morganId)) && o.state.connectionCountUnchanged === true, + "The saved explicit audience contains only Morgan; real effective access excludes both the CEO and unrelated Riley, with no duplicate account."); + c.add("action-policy-separate-and-preserved", o.state.appPolicyUnchanged === true && !!access && access.every(row => !writeIds.some(id => row.allowedTools.some(tool => tool.id === id))), "Changing the audience preserves exact saved action rules and keeps writes unavailable."); + c.add("human-assignment-attribution", o.activity.some(row => row.entityId === o.state.connectionId && row.action === "tool_app.finished" && row.actorType === "user"), "The real public assignment carries human actor attribution."); + return c.result(); +} +export const notionAudienceExternalCases = [definition("app-permissions.grant-one-agent", async ctx => { + const binding = selectedExternalBinding(ctx); await verifyNotionWorkspace(ctx, binding); + const morgan = await identityWorker(ctx), riley = await identityWorker(ctx, "Riley"), connected = await connectNamedNotion(ctx, binding, []); + const saved = await ctx.input.api.get(`/api/tool-connections/${connected.connectionId}`); + if (!saved.enabled || saved.status !== "active" || saved.healthStatus !== "ok") throw new Error("Setup blocker: official disposable Notion account is not healthy"); + const catalog = await ctx.input.api.get<{ catalog: ToolCatalogEntry[] }>(`/api/tool-connections/${connected.connectionId}/catalog`); + const writeEntryIds = catalog.catalog.filter(row => !connected.readEntryIds.includes(row.id)).map(row => row.id); + if (!catalog.catalog.length) throw new Error("Setup blocker: real Notion account exposes no catalog actions"); + const current = await ctx.input.api.get<{ profiles: ToolProfileWithDetails[] }>(`${root(ctx)}/tools/profiles`); + const policy = current.profiles.filter(row => row.profileKey === `app:${connected.connectionId}`); + const effective = await ctx.input.api.get(`${root(ctx)}/tools/profiles/effective/agents/${morgan.id}`); + if (connected.readEntryIds.some(id => effective.allowedTools.some(tool => tool.id === id))) throw new Error("Setup blocker: Morgan already has account access before the requested grant"); + const agentAssignmentStatus = await probeAgentPermission(ctx, `/api/tool-connections/${connected.connectionId}/installs`, "PUT", { installs: [{ targetType: "agent", targetId: morgan.id }] }); + await ctx.input.api.post(`${root(ctx)}/chats/${ctx.input.fixtures.agent.id}`); await ctx.refreshIssue(); + await ctx.input.api.patch(`/api/issues/${ctx.issue()!.id}`, { description: `Use the existing Launch Notion shared account (${connected.connectionId}). Intended consumer: Morgan (${morgan.id}), and nobody else. Riley (${riley.id}) and the CEO must not receive access. Keep the existing read-only action policy unchanged; an authorized human manages audience.` }); + return { ...connected, provider: "notion", workspaceMatched: true, healthyBefore: true, morganId: morgan.id, rileyId: riley.id, noMorganBefore: true, agentAssignmentStatus, writeEntryIds, appPolicyBefore: policy, connectionCountBefore: (await ctx.input.api.get<{connections: unknown[]}>(`${root(ctx)}/tools/connections`)).connections.length }; +}, gradeNotionAudience, { + authentication: "human-session", externalInput: externalRequirements["app-permissions.grant-one-agent"], + sourceFiles: ["chat-catalog-domains/external-notion-audience.ts", "chat-catalog-domains/external-notion-provider.ts", "chat-catalog-domains/external-notion-semantics.ts", "chat-catalog-domains/external-fixtures.ts", "chat-catalog-domains/external-support.ts", "chat-catalog-domains/internal-support.ts", "chat-catalog-domains/identity-support.ts", "chat-scenarios/app-permissions.yaml"], + async act(ctx, state) { + await waitForReply(ctx); const o = await observeExternal(ctx, state); if (!grantMorganGuidance(o.reply)) throw new Error("Chat guidance must preserve the requested Morgan-only Notion audience before human action"); + const before = await ctx.input.api.get<{installs: unknown[]}>(`/api/tool-connections/${state.connectionId}/installs`); state.noMutationBeforeHuman = before.installs.length === 0; + const prefix = `/${ctx.input.fixtures.company.issuePrefix}/apps/${state.connectionId}`, href = o.links.find(link => { try { const url = new URL(link, o.origin); return url.origin === o.origin && (url.pathname === prefix || url.pathname.startsWith(`${prefix}/`)); } catch { return false; } }); + if (!href) throw new Error("Existing Notion account management path missing"); const source = ctx.input.page.url(); + await ctx.input.page.goto(new URL(href, o.origin).href, { waitUntil: "domcontentloaded" }); await expect(ctx.input.page.getByRole("main")).toContainText("Launch Notion"); state.humanManagementVisible = true; + if (!ctx.input.page.url().endsWith("/permissions")) await ctx.input.page.goto(`${prefix}/permissions`, { waitUntil: "domcontentloaded" }); + await expect(ctx.input.page.getByRole("radio", {name:/Just agents I pick/})).toBeChecked(); + await ctx.input.page.getByRole("button", {name:"Choose agents",exact:true}).click(); + const completed = ctx.input.page.waitForResponse(response => new URL(response.url()).pathname === `${root(ctx)}/tools/apps/${state.connectionId}/finish` && response.request().method() === "POST" && response.ok()); + await ctx.input.page.getByRole("checkbox", {name:"Allow Morgan",exact:true}).check(); + const response = await completed, request = response.request().postDataJSON() as {access: {agentIds?: string[]}}; + state.onlyMorganRequested = Array.isArray(request.access.agentIds) && request.access.agentIds.length === 1 && request.access.agentIds[0] === state.morganId; + state.operatorChannel = "public-browser-agent-selection"; + await ctx.input.page.reload({ waitUntil: "domcontentloaded" }); await expect(ctx.input.page.getByRole("button", {name:"1 agent selected",exact:true})).toBeVisible(); await ctx.input.page.goto(source, { waitUntil: "domcontentloaded" }); + }, + async observe(ctx, state) { + state.installsAfter = (await ctx.input.api.get<{installs: unknown[]}>(`/api/tool-connections/${state.connectionId}/installs`)).installs; + state.effectiveAfter = await Promise.all([state.morganId, state.rileyId, ctx.input.fixtures.agent.id].map(id => ctx.input.api.get(`${root(ctx)}/tools/profiles/effective/agents/${id}`))); + state.connectionCountUnchanged = (await ctx.input.api.get<{connections: unknown[]}>(`${root(ctx)}/tools/connections`)).connections.length === state.connectionCountBefore; + const policy = (await ctx.input.api.get<{ profiles: ToolProfileWithDetails[] }>(`${root(ctx)}/tools/profiles`)).profiles.filter(row => row.profileKey === `app:${state.connectionId}`); + // Installation legitimately changes bindings, not the app's catalog-entry rules. + const rules = (rows: ToolProfileWithDetails[]) => rows.map(row => ({ id: row.id, defaultAction: row.defaultAction, entries: row.entries.map(entry => ({selectorType:entry.selectorType,effect:entry.effect,applicationId:entry.applicationId,connectionId:entry.connectionId,catalogEntryId:entry.catalogEntryId,toolName:entry.toolName,riskLevel:entry.riskLevel,conditions:entry.conditions})).sort((a,b)=>JSON.stringify(a).localeCompare(JSON.stringify(b))) })); + state.appPolicyUnchanged = JSON.stringify(rules(policy)) === JSON.stringify(rules(state.appPolicyBefore as ToolProfileWithDetails[])); return observeExternal(ctx, state); + }, + async verifyResult(ctx, _state, value) { await verifyRenderedReply(ctx, value); }, + async cleanup(ctx, state) { if (typeof state.connectionId === "string") await ctx.input.api.delete(`/api/tool-connections/${state.connectionId}`, { allowNotFound: true }); }, +})]; diff --git a/tests/runner-e2e/chat-catalog-domains/external-notion-provider.ts b/tests/runner-e2e/chat-catalog-domains/external-notion-provider.ts index f88d23a54a4..aaa31856c68 100644 --- a/tests/runner-e2e/chat-catalog-domains/external-notion-provider.ts +++ b/tests/runner-e2e/chat-catalog-domains/external-notion-provider.ts @@ -8,14 +8,16 @@ interface Block { id: string; type: string; has_children: boolean; [key: string] 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`); - } +export function assertNotionConnection(binding: ProviderBinding) { + const p = binding.prerequisites, id = p.workspaceId; + if (!id || !/^[a-f0-9-]{32,36}$/i.test(id) || !binding.resourceIds.some(resource => normalized(resource) === normalized(id))) throw new Error("Setup blocker: Notion workspaceId 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"); +} +export function assertNotionFixture(binding: ProviderBinding) { + assertNotionConnection(binding); const p = binding.prerequisites; + for (const field of ["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`); } if (!p.recentMarker || !p.olderMarker || p.recentMarker === p.olderMarker) throw new Error("Setup blocker: distinct synthetic recent/background Notion marker references are required"); parseNotionFact(p.recentFactJson); parseNotionFact(p.olderFactJson); } @@ -46,14 +48,19 @@ async function blocksText(token: string, version: string, pageId: string, maxChi } return children(pageId, 0); } +export async function verifyNotionWorkspace(ctx: ChatCatalogContext, binding: ProviderBinding) { + assertNotionConnection(binding); const p = binding.prerequisites, token = selectedExternalCredential(ctx, binding, "apiTokenEnv"); + 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"); + return { provider: "notion" as const, workspaceMatched: true }; +} 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"); + await verifyNotionWorkspace(ctx, binding); const result: NotionPageOracle[] = []; for (const [idField, markerField] of [["recentPageId", "recentMarker"], ["olderPageId", "olderMarker"]] as const) { const id = p[idField]!, marker = p[markerField]!; @@ -70,7 +77,7 @@ export async function readNotionFixturePages(ctx: ChatCatalogContext, binding = return result; } export async function connectNamedNotion(ctx: ChatCatalogContext, binding: ProviderBinding, agentIds: string[]) { - assertNotionFixture(binding); + assertNotionConnection(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", diff --git a/tests/runner-e2e/chat-catalog-domains/external.test.ts b/tests/runner-e2e/chat-catalog-domains/external.test.ts index 3f7bb7b46dd..1a32f20a01f 100644 --- a/tests/runner-e2e/chat-catalog-domains/external.test.ts +++ b/tests/runner-e2e/chat-catalog-domains/external.test.ts @@ -196,8 +196,8 @@ describe("private disposable provider prerequisite contract", () => { expect(() => selectedExternalCredential(ctx, binding, "PAPERCLIP_CHAT_EVAL_OTHER_AUTH")).toThrow(/not declared/); expect(() => selectedExternalBinding({ ...ctx, input: { ...ctx.input, privateInputs: undefined } })).toThrow(/private inputs/); }); - it("enumerates the 16 named-provider journeys and four authorized repository journeys", () => { - expect(Object.keys(externalRequirements)).toHaveLength(20); + it("enumerates the 17 named-provider journeys and four authorized repository journeys", () => { + expect(Object.keys(externalRequirements)).toHaveLength(21); expect(Object.entries(externalRequirements).filter(([, requirement]) => requirement.provider === "github").map(([id]) => id)) .toEqual(["projects.add-repository", "workspaces.prepare-repository", "workspaces.private-repository-no-access", "projects.replace-repository"]); });