mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 10:48:12 +02:00
## Thinking Path > - Paperclip is the control plane for companies that use AI agents. > - Apps give humans and agents controlled access to external services. > - The existing app detail flow split permissions, tests, setup, and activity across separate pages. > - The split made access rules harder to understand and made reconnect work hard to find. > - New write actions also defaulted to Ask first, which did not match the intended connection policy. > - This pull request combines permission control and action testing, removes the setup page, and moves connection activity into Audit. > - The benefit is one clear place to configure, test, reconnect, and review each app. ## Linked Issues or Issue Description **What existing behavior does this improve?** The installed app Permissions, Test, Setup, and Activity views. **Subsystem affected** Cross-cutting. This change updates the React UI, shared app defaults, server permission behavior, tests, smoke scripts, and connection documentation. **Current behavior** App access and action testing use separate pages. The app detail view also links to a setup page after installation. Connection activity uses a separate tab. New write actions default to Ask first. **Proposed behavior** Permissions uses the connection access language from the initial flow. It includes searchable Read and Write sections, a three-state permission control, and a Test dialog for each action. Reconnect appears below a Needs attention header on Permissions and Review. Old Setup and Test links redirect to Permissions. Old Activity links redirect to the filtered company Audit feed. New write actions default to Allowed. **Reason and benefit** A person can understand and test app access without moving between several pages. Reconnect work stays visible where the person reviews the connection. Audit events use one consistent feed and filter model. New connections have the intended default policy. **Breaking changes** The Setup, Test, and app Activity tabs are removed. Existing deep links redirect to their replacement pages. Existing saved action permissions do not change. Only defaults for new write actions change. **Additional context** This builds on the managed app connection work in #12728. A search found no duplicate open pull request or issue. ## What Changed - Combined action testing with Permissions. - Added searchable Read and Write action groups. - Added Off, Ask first, and Allowed controls with tooltips. - Added an action Test dialog with agent selection, arguments, and formatted results. - Removed the installed-app Setup and Activity tabs. - Added reconnect guidance to Permissions and Review when a connection needs attention. - Routed connection activity into the company Audit feed and preserved the Apps & tools filter in streamlined Audit. - Moved connection removal to the Connectors-page management menu. - Made new write actions default to Allowed across connection creation paths. - Updated regression tests, browser suites, smoke scripts, and connection documentation. ## Verification - `pnpm check:token-gates` - `pnpm exec vitest run packages/shared/src/app-definitions.test.ts server/src/__tests__/generic-mcp-connection.test.ts server/src/__tests__/tool-access-service.test.ts ui/src/components/AppConnectionSidebar.test.tsx ui/src/pages/apps/AppDetail.test.tsx ui/src/pages/apps/AppNotConnected.test.tsx ui/src/pages/apps/AppsConnect.test.tsx ui/src/pages/apps/Browse.test.tsx ui/src/pages/apps/Connections.test.tsx ui/src/pages/apps/composio-services.test.ts ui/src/pages/audit/AuditFeed.test.tsx ui/src/pages/tools/PasteConfigTab.test.tsx` (517 tests passed) - `pnpm exec vitest run ui/src/pages/apps/app-detail/TestPanel.test.tsx ui/src/pages/audit/AuditHub.test.tsx ui/src/pages/audit/AuditFeed.test.tsx ui/src/pages/apps/AppDetail.test.tsx ui/src/pages/apps/Browse.test.tsx` (96 tests passed) - Targeted Playwright verification for connection removal, rename on Permissions, inline action testing, and Smoke Lab Audit evidence (5 flows passed) - `pnpm -r typecheck` - `pnpm build` - `pnpm test:run` completed with 5,755 passing tests and 20 unrelated macOS harness failures. The failures use `/tmp` versus `/private/tmp`, invalid ports above 65535, and workspace fixtures outside this change. ## Risks - Low migration risk. This change has no database migration. - Old app-detail URLs depend on redirect compatibility. - New connections grant write actions by default. Finalization remains configure-authorized and audited, Ask first and Off remain available per action, and existing connections keep their saved policy. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected - check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, exact model ID `gpt-5`. The client does not expose the context-window size. The model used reasoning, repository tools, code execution, and browser verification. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1180 lines
48 KiB
JavaScript
1180 lines
48 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import {
|
|
assertAutomaticRegistrationSource,
|
|
assertSanitizedEvidence,
|
|
connectionRemovalFacts,
|
|
extractNotionIdentity,
|
|
extractNotionVerificationCode,
|
|
fetchNotionTestCredentials,
|
|
inspectAuthorizationUrl,
|
|
isFreshNotionVerificationMessage,
|
|
NotionGenericLivePreflightError,
|
|
notionVerificationAuthenticationPassed,
|
|
parseRuntimeAbsenceProof,
|
|
parseSanitizedAgentProof,
|
|
persistedOAuthStartResult,
|
|
preflightFailureMessage,
|
|
prepareNotionGenericLiveSmoke,
|
|
safeEndpointSummary,
|
|
} from "./notion-generic-live-lib.mjs";
|
|
|
|
const TARGET_COMPANY_PREFIX = "PAP";
|
|
const TARGET_AGENT_NAME = "CodexCoderPro";
|
|
const NOTION_RESOURCE = "https://mcp.notion.com/mcp";
|
|
const NOTION_GET_SELF = "notion-get-self";
|
|
const NOTION_CREATE_PAGES = "notion-create-pages";
|
|
const DEFAULT_AGENT_TIMEOUT_MS = 15 * 60_000;
|
|
const DEFAULT_VERIFICATION_TIMEOUT_MS = 2 * 60_000;
|
|
|
|
class SmokeFailure extends Error {
|
|
constructor(checkpoint, code) {
|
|
super(`${checkpoint}:${code}`);
|
|
this.name = "SmokeFailure";
|
|
this.checkpoint = checkpoint;
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
function fail(checkpoint, code) {
|
|
throw new SmokeFailure(checkpoint, code);
|
|
}
|
|
|
|
function asArray(value, key) {
|
|
if (Array.isArray(value)) return value;
|
|
if (value && typeof value === "object" && Array.isArray(value[key])) return value[key];
|
|
return [];
|
|
}
|
|
|
|
function escapeRegex(value) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
function screenshotFile(outputDirectory, name) {
|
|
return path.join(outputDirectory, name);
|
|
}
|
|
|
|
function assertNoCredentialMaterial(value, credentials, checkpoint) {
|
|
const serialized = JSON.stringify(value);
|
|
for (const credential of credentials) {
|
|
if (credential && serialized.includes(credential)) fail(checkpoint, "credential_material_visible");
|
|
}
|
|
if (/[?&](?:code|state|access_token|refresh_token)=/i.test(serialized)) {
|
|
fail(checkpoint, "oauth_query_material_visible");
|
|
}
|
|
const forbiddenValueKey = /^(?:password|accessToken|access_token|refreshToken|refresh_token|oauthCode|oauth_code|clientSecret|client_secret|cookie|authorizationHeader)$/i;
|
|
const visit = (candidate, seen = new Set()) => {
|
|
if (!candidate || typeof candidate !== "object" || seen.has(candidate)) return;
|
|
seen.add(candidate);
|
|
for (const [key, child] of Object.entries(candidate)) {
|
|
if (forbiddenValueKey.test(key) && child !== null && child !== "") {
|
|
fail(checkpoint, "raw_credential_field_visible");
|
|
}
|
|
visit(child, seen);
|
|
}
|
|
};
|
|
visit(value);
|
|
}
|
|
|
|
function issueMutationHeaders(config, method, pathname) {
|
|
if (method === "GET" || !pathname.startsWith("/api/issues/")) return {};
|
|
return { "X-Paperclip-Run-Id": config.runId };
|
|
}
|
|
|
|
async function apiJson(request, config, method, pathname, data, checkpoint, expectedStatuses = [200]) {
|
|
let response;
|
|
try {
|
|
response = await request.fetch(new URL(pathname, config.baseUrl).toString(), {
|
|
method,
|
|
...(data === undefined ? {} : { data }),
|
|
headers: {
|
|
accept: "application/json",
|
|
...(method === "GET" ? {} : { origin: config.baseUrl }),
|
|
...issueMutationHeaders(config, method, pathname),
|
|
},
|
|
timeout: 30_000,
|
|
});
|
|
} catch {
|
|
fail(checkpoint, "request_failed");
|
|
}
|
|
if (!expectedStatuses.includes(response.status())) {
|
|
fail(checkpoint, `http_${response.status()}`);
|
|
}
|
|
try {
|
|
return await response.json();
|
|
} catch {
|
|
fail(checkpoint, "invalid_json");
|
|
}
|
|
}
|
|
|
|
async function waitFor(checkpoint, fn, { timeoutMs = 120_000, intervalMs = 2_000 } = {}) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
let lastValue;
|
|
while (Date.now() < deadline) {
|
|
lastValue = await fn();
|
|
if (lastValue) return lastValue;
|
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
}
|
|
fail(checkpoint, "timed_out");
|
|
return lastValue;
|
|
}
|
|
|
|
async function expectVisible(locator, checkpoint, code, timeout = 30_000) {
|
|
try {
|
|
await locator.waitFor({ state: "visible", timeout });
|
|
} catch {
|
|
fail(checkpoint, code);
|
|
}
|
|
}
|
|
|
|
async function gotoWithVisibleMarker(
|
|
page,
|
|
url,
|
|
marker,
|
|
checkpoint,
|
|
code,
|
|
{ attempts = 2, timeout = 15_000 } = {},
|
|
) {
|
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
try {
|
|
const response = await page.goto(url, { waitUntil: "domcontentloaded" });
|
|
if (response?.ok()) {
|
|
await marker().waitFor({ state: "visible", timeout });
|
|
return;
|
|
}
|
|
} catch {
|
|
// A credential-free Paperclip navigation is safe to repeat once. Do not
|
|
// retry provider pages or any mutation from this helper.
|
|
}
|
|
if (attempt < attempts) await page.waitForTimeout(500);
|
|
}
|
|
fail(checkpoint, code);
|
|
}
|
|
|
|
async function loginPaperclipBoard(page, context, config) {
|
|
const loginUrl = new URL("/auth?next=/", config.baseUrl).toString();
|
|
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
|
await gotoWithVisibleMarker(
|
|
page,
|
|
loginUrl,
|
|
() => page.getByLabel(/^email$/i),
|
|
"A.paperclip-login",
|
|
"email_field_missing",
|
|
);
|
|
const paperclipEmailInput = page.getByLabel(/^email$/i);
|
|
const paperclipPasswordInput = page.getByLabel(/^password$/i);
|
|
await expectVisible(paperclipPasswordInput, "A.paperclip-login", "password_field_missing");
|
|
await paperclipEmailInput.fill(config.paperclipEmail);
|
|
await paperclipPasswordInput.fill(config.paperclipPassword);
|
|
const loginResponsePromise = page.waitForResponse((response) =>
|
|
response.request().method() === "POST" && new URL(response.url()).pathname === "/api/auth/sign-in/email",
|
|
);
|
|
await page.getByRole("button", { name: /^sign in$/i }).click();
|
|
const loginResponse = await loginResponsePromise;
|
|
if (!loginResponse.ok()) fail("A.paperclip-login", `http_${loginResponse.status()}`);
|
|
|
|
// A redirect is a UI implementation detail; the authenticated session is
|
|
// the prerequisite the smoke actually needs. A successful sign-in response
|
|
// without a usable cookie has occurred intermittently through public dev
|
|
// proxies, so verify the cookie and repeat the idempotent sign-in once.
|
|
const session = await context.request.get(new URL("/api/auth/get-session", config.baseUrl).toString(), {
|
|
headers: { accept: "application/json" },
|
|
});
|
|
if (session.ok()) return;
|
|
if (attempt < 2) await page.waitForTimeout(500);
|
|
}
|
|
fail("A.paperclip-login", "session_cookie_missing");
|
|
}
|
|
|
|
async function clickVisibleButton(page, names) {
|
|
for (const name of names) {
|
|
const button = page.getByRole("button", { name, exact: false }).filter({ visible: true }).first();
|
|
if (!await button.count()) continue;
|
|
try {
|
|
await button.click({ timeout: 2_000 });
|
|
return true;
|
|
} catch {
|
|
// Provider pages replace controls while advancing between login steps.
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function verificationTimeoutMs() {
|
|
const configured = Number(process.env.NOTION_VERIFICATION_TIMEOUT_MS || DEFAULT_VERIFICATION_TIMEOUT_MS);
|
|
return Number.isFinite(configured)
|
|
? Math.min(Math.max(configured, 30_000), 5 * 60_000)
|
|
: DEFAULT_VERIFICATION_TIMEOUT_MS;
|
|
}
|
|
|
|
async function fetchNotionVerificationCodeFromAgentMail({ notBefore }) {
|
|
const apiKey = process.env.AGENTMAIL_API_KEY?.trim();
|
|
const inboxId = process.env.NOTION_AGENTMAIL_INBOX_ID?.trim();
|
|
if (!apiKey || !inboxId) fail("C.notion-login", "verification_inbox_unavailable");
|
|
|
|
let AgentMailClient;
|
|
try {
|
|
({ AgentMailClient } = await import("agentmail"));
|
|
} catch {
|
|
fail("C.notion-login", "agentmail_sdk_unavailable");
|
|
}
|
|
const client = new AgentMailClient({ apiKey });
|
|
try {
|
|
const inbox = await client.inboxes.get(inboxId);
|
|
if (inbox.inboxId !== inboxId && inbox.email !== inboxId) {
|
|
fail("C.notion-login", "verification_inbox_mismatch");
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof SmokeFailure) throw error;
|
|
fail("C.notion-login", "verification_inbox_request_failed");
|
|
}
|
|
|
|
const deadline = Date.now() + verificationTimeoutMs();
|
|
const after = new Date(new Date(notBefore).getTime() - 5_000);
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
let pageToken;
|
|
let inspected = 0;
|
|
do {
|
|
const response = await client.inboxes.messages.list(inboxId, {
|
|
limit: 50,
|
|
after,
|
|
...(pageToken ? { pageToken } : {}),
|
|
});
|
|
for (const item of response.messages) {
|
|
inspected += 1;
|
|
if (!isFreshNotionVerificationMessage(item, { notBefore: after })) continue;
|
|
const message = await client.inboxes.messages.get(inboxId, item.messageId);
|
|
if (!notionVerificationAuthenticationPassed(message)) continue;
|
|
const code = extractNotionVerificationCode(message);
|
|
if (code) return code;
|
|
}
|
|
pageToken = response.nextPageToken;
|
|
} while (pageToken && inspected < 100);
|
|
} catch (error) {
|
|
if (error instanceof SmokeFailure) throw error;
|
|
fail("C.notion-login", "verification_inbox_request_failed");
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
|
}
|
|
fail("C.notion-login", "verification_code_timed_out");
|
|
}
|
|
|
|
async function completeNotionAuthorization(page, config, credential, connectionId) {
|
|
const paperclipOrigin = new URL(config.baseUrl).origin;
|
|
const permissionsPath = `/${TARGET_COMPANY_PREFIX}/apps/${connectionId}/permissions`;
|
|
const deadline = Date.now() + 6 * 60_000;
|
|
const verificationNotBefore = new Date();
|
|
let providerSeen = false;
|
|
let verificationCodeSubmitted = false;
|
|
while (Date.now() < deadline) {
|
|
let current;
|
|
try {
|
|
current = new URL(page.url());
|
|
} catch {
|
|
fail("C.oauth-callback", "invalid_navigation_url");
|
|
}
|
|
if (current.origin === paperclipOrigin) {
|
|
if (providerSeen && current.pathname === permissionsPath) return;
|
|
await page.waitForTimeout(300);
|
|
continue;
|
|
}
|
|
providerSeen = true;
|
|
|
|
const bodyText = await page.locator("body").innerText().catch(() => "");
|
|
if (/check your (?:email|inbox)|verification code|one-time code/i.test(bodyText)) {
|
|
if (verificationCodeSubmitted) fail("C.notion-login", "verification_code_rejected");
|
|
const codeInput = page.locator([
|
|
'input[autocomplete="one-time-code"]',
|
|
'input[name*="code" i]',
|
|
'input[inputmode="numeric"]',
|
|
].join(",")).filter({ visible: true }).first();
|
|
if (!await codeInput.count()) {
|
|
await page.waitForTimeout(300);
|
|
continue;
|
|
}
|
|
let code = await fetchNotionVerificationCodeFromAgentMail({ notBefore: verificationNotBefore });
|
|
await codeInput.fill(code);
|
|
code = "";
|
|
if (!await clickVisibleButton(page, [/^verify$/i, /^continue$/i, /^submit$/i, /^sign in$/i])) {
|
|
await codeInput.press("Enter");
|
|
}
|
|
verificationCodeSubmitted = true;
|
|
await page.waitForTimeout(600);
|
|
continue;
|
|
}
|
|
|
|
const usernameInput = page.locator([
|
|
'input[type="email"]',
|
|
'input[name="email"]',
|
|
'input[name="username"]',
|
|
'input[autocomplete="username"]',
|
|
].join(",")).filter({ visible: true }).first();
|
|
if (await usernameInput.count()) {
|
|
const currentValue = await usernameInput.inputValue().catch(() => "");
|
|
if (!currentValue) await usernameInput.fill(credential.username);
|
|
}
|
|
|
|
const passwordInput = page.locator([
|
|
'input[type="password"]',
|
|
'input[name="password"]',
|
|
'input[autocomplete="current-password"]',
|
|
].join(",")).filter({ visible: true }).first();
|
|
if (await passwordInput.count()) {
|
|
const currentValue = await passwordInput.inputValue().catch(() => "");
|
|
if (!currentValue) await passwordInput.fill(credential.password);
|
|
await clickVisibleButton(page, [/^sign in$/i, /^log in$/i, /^continue$/i]);
|
|
} else if (await usernameInput.count()) {
|
|
await clickVisibleButton(page, [/continue with email/i, /^continue$/i, /^next$/i, /^sign in$/i]);
|
|
} else {
|
|
await clickVisibleButton(page, [
|
|
/^authorize$/i,
|
|
/^allow$/i,
|
|
/allow access/i,
|
|
/^approve$/i,
|
|
/^grant access$/i,
|
|
/^accept$/i,
|
|
/^continue$/i,
|
|
/^select$/i,
|
|
]);
|
|
}
|
|
await page.waitForTimeout(600);
|
|
}
|
|
fail("C.oauth-callback", "provider_authorization_timed_out");
|
|
}
|
|
|
|
async function safeScreenshot(page, outputPath, config, credential, checkpoint) {
|
|
const current = new URL(page.url());
|
|
if (current.origin !== new URL(config.baseUrl).origin) fail(checkpoint, "screenshot_not_on_paperclip");
|
|
for (const queryKey of ["code", "state", "token", "access_token", "refresh_token"]) {
|
|
if (current.searchParams.has(queryKey)) fail(checkpoint, "credential_query_in_screenshot_url");
|
|
}
|
|
const bodyText = await page.locator("body").innerText();
|
|
if (bodyText.includes(config.paperclipPassword)
|
|
|| bodyText.includes(credential.password)
|
|
|| /[?&](?:code|state|access_token|refresh_token)=/i.test(bodyText)) {
|
|
fail(checkpoint, "credential_material_in_screenshot");
|
|
}
|
|
await page.screenshot({
|
|
path: outputPath,
|
|
fullPage: true,
|
|
animations: "disabled",
|
|
mask: [
|
|
page.getByText(config.paperclipEmail, { exact: false }),
|
|
page.getByText(credential.username, { exact: false }),
|
|
],
|
|
});
|
|
}
|
|
|
|
function assertGenericProvenance(connectResult, connectionName) {
|
|
if (connectResult?.application?.name !== connectionName) fail("B.generic-provenance", "application_name_mismatch");
|
|
if (!String(connectResult?.application?.applicationKey ?? "").startsWith("app-gallery:link:")) {
|
|
fail("B.generic-provenance", "application_key_not_generic");
|
|
}
|
|
if (connectResult?.application?.metadata?.source !== "link") fail("B.generic-provenance", "metadata_source_not_link");
|
|
const config = connectResult?.connection?.config;
|
|
if (connectResult?.connection?.transport !== "mcp_remote"
|
|
|| config?.url !== NOTION_RESOURCE
|
|
|| config?.unverifiedServer !== true
|
|
|| config?.galleryKey === "notion"
|
|
|| config?.sourceTemplateKey === "notion") {
|
|
fail("B.generic-provenance", "connection_not_unverified_generic");
|
|
}
|
|
}
|
|
|
|
function oauthEndpointProof(connection, startResult, config) {
|
|
const source = assertAutomaticRegistrationSource(startResult.registrationSource);
|
|
const oauth = connection?.config?.oauth;
|
|
if (!oauth || typeof oauth !== "object" || Array.isArray(oauth)) fail("C.oauth-proof", "oauth_config_missing");
|
|
if (connection.authKind !== "oauth" || oauth.clientRegistrationSource !== source) {
|
|
fail("C.oauth-proof", "registration_source_not_persisted");
|
|
}
|
|
if (oauth.resource !== NOTION_RESOURCE || startResult.resource !== NOTION_RESOURCE) {
|
|
fail("C.oauth-proof", "resource_mismatch");
|
|
}
|
|
if (oauth.clientRedirectUri !== config.callbackUrl) fail("C.oauth-proof", "client_callback_mismatch");
|
|
if (source === "cimd" && oauth.clientIdMetadataDocumentSupported !== true) {
|
|
fail("C.oauth-proof", "cimd_support_not_persisted");
|
|
}
|
|
const endpoints = {
|
|
issuer: safeEndpointSummary(oauth.issuer, "issuer"),
|
|
metadata: safeEndpointSummary(oauth.metadataUrl, "metadata"),
|
|
authorize: safeEndpointSummary(oauth.authorizationUrl, "authorize"),
|
|
exchange: safeEndpointSummary(oauth.tokenUrl, "exchange"),
|
|
...(oauth.registrationUrl ? { registration: safeEndpointSummary(oauth.registrationUrl, "registration") } : {}),
|
|
};
|
|
return { source, endpoints };
|
|
}
|
|
|
|
function catalogFacts(catalog, checkpoint) {
|
|
const active = catalog.filter((entry) => entry.status !== "removed");
|
|
const getSelf = active.find((entry) => entry.toolName === NOTION_GET_SELF);
|
|
const createPages = active.find((entry) => entry.toolName === NOTION_CREATE_PAGES);
|
|
if (!getSelf || !getSelf.isReadOnly) fail(checkpoint, "notion_get_self_missing_or_not_read_only");
|
|
if (!createPages || createPages.isReadOnly) fail(checkpoint, "notion_create_pages_missing_or_not_write");
|
|
return { active, getSelf, createPages };
|
|
}
|
|
|
|
async function finishAgentOnlySetup(request, config, companyId, connectionId, catalog, agentId) {
|
|
const facts = catalogFacts(catalog, "D.catalog-policy");
|
|
await apiJson(
|
|
request,
|
|
config,
|
|
"POST",
|
|
`/api/companies/${companyId}/tools/apps/${connectionId}/finish`,
|
|
{
|
|
enabledCatalogEntryIds: [facts.getSelf.id],
|
|
askFirstCatalogEntryIds: [],
|
|
reviewedCatalogEntryIds: facts.active
|
|
.filter((entry) => entry.status === "quarantined")
|
|
.map((entry) => entry.id),
|
|
access: { agentIds: [agentId] },
|
|
},
|
|
"D.catalog-policy",
|
|
);
|
|
await apiJson(
|
|
request,
|
|
config,
|
|
"PUT",
|
|
`/api/tool-connections/${connectionId}/installs`,
|
|
{ installs: [{ targetType: "agent", targetId: agentId }] },
|
|
"D.agent-install",
|
|
);
|
|
return facts;
|
|
}
|
|
|
|
async function createAndWaitForProofIssue(request, config, input, checkpoint) {
|
|
const child = await apiJson(
|
|
request,
|
|
config,
|
|
"POST",
|
|
`/api/issues/${config.taskId}/children`,
|
|
input,
|
|
`${checkpoint}.create`,
|
|
[201],
|
|
);
|
|
if (child.status !== "todo") fail(`${checkpoint}.create`, "child_not_created_todo");
|
|
const observedStatuses = new Set(["todo"]);
|
|
const finished = await waitFor(`${checkpoint}.run`, async () => {
|
|
const issue = await apiJson(request, config, "GET", `/api/issues/${child.id}`, undefined, `${checkpoint}.run`);
|
|
observedStatuses.add(issue.status);
|
|
if (["blocked", "cancelled"].includes(issue.status)) fail(`${checkpoint}.run`, `child_${issue.status}`);
|
|
return issue.status === "done" ? issue : null;
|
|
}, {
|
|
timeoutMs: Number(process.env.NOTION_AGENT_TIMEOUT_MS || DEFAULT_AGENT_TIMEOUT_MS),
|
|
intervalMs: 3_000,
|
|
});
|
|
if (!finished.startedAt || !finished.completedAt) fail(`${checkpoint}.run`, "transition_timestamps_missing");
|
|
const commentsResponse = await apiJson(
|
|
request,
|
|
config,
|
|
"GET",
|
|
`/api/issues/${child.id}/comments`,
|
|
undefined,
|
|
`${checkpoint}.comment`,
|
|
);
|
|
return { child, finished, observedStatuses, comments: asArray(commentsResponse, "comments") };
|
|
}
|
|
|
|
async function cleanupConnection(
|
|
request,
|
|
config,
|
|
companyId,
|
|
connectionId,
|
|
connectionName,
|
|
{ requireInstalled = false } = {},
|
|
) {
|
|
const removed = await apiJson(
|
|
request,
|
|
config,
|
|
"DELETE",
|
|
`/api/tool-connections/${connectionId}`,
|
|
undefined,
|
|
"F.cleanup",
|
|
);
|
|
const receipt = removed.removal;
|
|
const removalFacts = connectionRemovalFacts(receipt, { requireInstalled });
|
|
if (!removalFacts) fail("F.cleanup", "incomplete_removal_receipt");
|
|
|
|
const connections = await apiJson(
|
|
request,
|
|
config,
|
|
"GET",
|
|
`/api/companies/${companyId}/tools/connections`,
|
|
undefined,
|
|
"F.cleanup-verification",
|
|
);
|
|
if (asArray(connections, "connections").some((candidate) =>
|
|
candidate.id === connectionId
|
|
&& candidate.name === connectionName
|
|
&& candidate.status !== "archived")) {
|
|
fail("F.cleanup-verification", "test_connection_remains");
|
|
}
|
|
const profiles = await apiJson(
|
|
request,
|
|
config,
|
|
"GET",
|
|
`/api/companies/${companyId}/tools/profiles`,
|
|
undefined,
|
|
"F.cleanup-verification",
|
|
);
|
|
if (asArray(profiles, "profiles").some((profile) => profile.profileKey === `app:${connectionId}` && profile.status === "active")) {
|
|
fail("F.cleanup-verification", "active_profile_remains");
|
|
}
|
|
const pending = await apiJson(
|
|
request,
|
|
config,
|
|
"GET",
|
|
`/api/companies/${companyId}/tools/action-requests?status=pending`,
|
|
undefined,
|
|
"F.cleanup-verification",
|
|
);
|
|
if (asArray(pending, "actionRequests").some((item) => (item.connectionId ?? item.request?.connectionId) === connectionId)) {
|
|
fail("F.cleanup-verification", "pending_action_remains");
|
|
}
|
|
return {
|
|
completed: true,
|
|
...removalFacts,
|
|
pendingActions: 0,
|
|
remainingConnections: 0,
|
|
};
|
|
}
|
|
|
|
async function runSmoke({ config, chromium }) {
|
|
const startedAt = new Date();
|
|
const runKey = startedAt.toISOString().replace(/[:.]/g, "-");
|
|
const connectionName = `Notion generic self-test ${startedAt.toISOString()}`;
|
|
const outputDirectory = process.env.NOTION_EVIDENCE_DIR
|
|
? path.resolve(process.env.NOTION_EVIDENCE_DIR)
|
|
: path.join(process.env.PAPERCLIP_RUN_SCRATCH_DIR || process.cwd(), `notion-generic-live-${runKey}`);
|
|
await mkdir(outputDirectory, { recursive: true });
|
|
|
|
const summary = {
|
|
schemaVersion: 1,
|
|
smoke: "notion_generic_mcp_live",
|
|
passed: false,
|
|
startedAt: startedAt.toISOString(),
|
|
completedAt: null,
|
|
target: { companyPrefix: TARGET_COMPANY_PREFIX, resource: NOTION_RESOURCE },
|
|
importPreview: null,
|
|
connection: null,
|
|
oauthProof: null,
|
|
catalog: null,
|
|
boardTest: null,
|
|
freshRun: null,
|
|
cleanup: null,
|
|
postCleanupRun: null,
|
|
screenshots: [],
|
|
failure: null,
|
|
};
|
|
|
|
let browser;
|
|
let context;
|
|
let credential = null;
|
|
let connectionId = null;
|
|
let companyId = null;
|
|
let cleanupComplete = false;
|
|
let caughtFailure = null;
|
|
let activeCheckpoint = "A.paperclip-login";
|
|
|
|
try {
|
|
browser = await chromium.launch({ headless: process.env.NOTION_SMOKE_HEADED !== "1" });
|
|
context = await browser.newContext({
|
|
viewport: { width: 1440, height: 1000 },
|
|
acceptDownloads: false,
|
|
serviceWorkers: "block",
|
|
});
|
|
const page = await context.newPage();
|
|
|
|
await loginPaperclipBoard(page, context, config);
|
|
|
|
activeCheckpoint = "A.company-selection";
|
|
const companies = await apiJson(context.request, config, "GET", "/api/companies", undefined, "A.company-selection");
|
|
const company = asArray(companies, "companies").find((candidate) => candidate.issuePrefix === TARGET_COMPANY_PREFIX);
|
|
if (!company) fail("A.company-selection", "pap_company_missing");
|
|
companyId = company.id;
|
|
activeCheckpoint = "A.agent-selection";
|
|
const agents = await apiJson(
|
|
context.request,
|
|
config,
|
|
"GET",
|
|
`/api/companies/${companyId}/agents`,
|
|
undefined,
|
|
"A.agent-selection",
|
|
);
|
|
const agent = asArray(agents, "agents").find((candidate) => candidate.name === TARGET_AGENT_NAME);
|
|
if (!agent) fail("A.agent-selection", "codex_coder_pro_missing");
|
|
|
|
activeCheckpoint = "A.connection-isolation";
|
|
const existingConnections = await apiJson(
|
|
context.request,
|
|
config,
|
|
"GET",
|
|
`/api/companies/${companyId}/tools/connections`,
|
|
undefined,
|
|
"A.connection-isolation",
|
|
);
|
|
const existingIds = new Set(asArray(existingConnections, "connections").map((entry) => entry.id));
|
|
if (asArray(existingConnections, "connections").some((entry) => entry.name === connectionName && entry.status !== "archived")) {
|
|
fail("A.connection-isolation", "connection_name_collision");
|
|
}
|
|
|
|
// Fetch only after URL, health, Paperclip login, company, agent, and binding
|
|
// metadata have all passed. The value remains in this process and is never
|
|
// written to browser artifacts or command arguments.
|
|
activeCheckpoint = "A.secret-binding";
|
|
credential = await fetchNotionTestCredentials(config);
|
|
|
|
activeCheckpoint = "B.paste-config";
|
|
await gotoWithVisibleMarker(
|
|
page,
|
|
new URL(`/${TARGET_COMPANY_PREFIX}/apps/advanced/paste-config`, config.baseUrl).toString(),
|
|
() => page.getByRole("heading", { name: "Advanced setup", exact: true }),
|
|
"B.paste-config",
|
|
"advanced_setup_missing",
|
|
);
|
|
const configTextarea = page.locator("textarea").first();
|
|
await expectVisible(configTextarea, "B.paste-config", "config_textarea_missing");
|
|
const exactConfig = JSON.stringify({ mcpServers: { notion: { url: NOTION_RESOURCE } } }, null, 2);
|
|
await configTextarea.fill(exactConfig);
|
|
const importResponsePromise = page.waitForResponse((response) =>
|
|
response.request().method() === "POST"
|
|
&& new URL(response.url()).pathname === `/api/companies/${companyId}/tools/mcp/import-json`,
|
|
);
|
|
await page.getByRole("button", { name: /^check config$/i }).click();
|
|
const importResponse = await importResponsePromise;
|
|
if (!importResponse.ok()) fail("B.import-preview", `http_${importResponse.status()}`);
|
|
const preview = await importResponse.json().catch(() => fail("B.import-preview", "invalid_json"));
|
|
const drafts = asArray(preview, "drafts");
|
|
if (drafts.length !== 1
|
|
|| drafts[0].name !== "notion"
|
|
|| drafts[0].transport !== "mcp_remote"
|
|
|| drafts[0].config?.url !== NOTION_RESOURCE
|
|
|| drafts[0].credentialFields?.length !== 0
|
|
|| drafts[0].credentialRefs?.length !== 0) {
|
|
fail("B.import-preview", "unexpected_draft");
|
|
}
|
|
const afterPreview = await apiJson(
|
|
context.request,
|
|
config,
|
|
"GET",
|
|
`/api/companies/${companyId}/tools/connections`,
|
|
undefined,
|
|
"B.import-preview",
|
|
);
|
|
if (asArray(afterPreview, "connections").some((entry) => !existingIds.has(entry.id))) {
|
|
fail("B.import-preview", "preview_created_connection");
|
|
}
|
|
summary.importPreview = {
|
|
draftCount: 1,
|
|
name: "notion",
|
|
transport: "mcp_remote",
|
|
resource: NOTION_RESOURCE,
|
|
credentialFieldCount: 0,
|
|
credentialRefCount: 0,
|
|
};
|
|
|
|
activeCheckpoint = "B.generic-connect";
|
|
const connectionNameInput = page.getByText("Connection name", { exact: true }).locator("input");
|
|
await expectVisible(connectionNameInput, "B.generic-connect", "connection_name_input_missing");
|
|
await connectionNameInput.fill(connectionName);
|
|
const paperclipOrigin = new URL(config.baseUrl).origin;
|
|
const authorizationRequestPromise = page.waitForRequest((request) => {
|
|
if (!request.isNavigationRequest() || request.frame() !== page.mainFrame()) return false;
|
|
try {
|
|
const target = new URL(request.url());
|
|
return target.protocol === "https:" && target.origin !== paperclipOrigin;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}, { timeout: 90_000 });
|
|
await page.getByRole("button", { name: /^check actions$/i }).click({ noWaitAfter: true });
|
|
|
|
// `connect` returns the authorization URL inline, so the UI immediately
|
|
// replaces this page with the provider. That navigation can abort a
|
|
// Playwright response waiter even though the server committed successfully.
|
|
// Persisted connection state is the durable evidence and gives cleanup the
|
|
// ID before any provider credential entry.
|
|
const createdConnection = await waitFor("B.generic-connect", async () => {
|
|
const response = await apiJson(
|
|
context.request,
|
|
config,
|
|
"GET",
|
|
`/api/companies/${companyId}/tools/connections`,
|
|
undefined,
|
|
"B.generic-connect",
|
|
);
|
|
const matches = asArray(response, "connections").filter((candidate) =>
|
|
candidate.name === connectionName && !existingIds.has(candidate.id) && candidate.status !== "archived");
|
|
if (matches.length > 1) fail("B.generic-connect", "duplicate_connection_detected");
|
|
return matches[0] ?? null;
|
|
}, { timeoutMs: 90_000, intervalMs: 500 });
|
|
connectionId = createdConnection.id;
|
|
// Inspect the initial authorization request, not the provider's eventual
|
|
// login page after redirects (which legitimately omits OAuth parameters).
|
|
const authorizationRequest = await authorizationRequestPromise.catch(() =>
|
|
fail("C.oauth-navigation", "authorization_navigation_missing"));
|
|
const authorizationUrl = authorizationRequest.url();
|
|
let connection = await apiJson(
|
|
context.request,
|
|
config,
|
|
"GET",
|
|
`/api/tool-connections/${connectionId}`,
|
|
undefined,
|
|
"B.generic-connect",
|
|
);
|
|
const applications = await apiJson(
|
|
context.request,
|
|
config,
|
|
"GET",
|
|
`/api/companies/${companyId}/tools/applications`,
|
|
undefined,
|
|
"B.generic-connect",
|
|
);
|
|
const applicationMatches = asArray(applications, "applications").filter((candidate) =>
|
|
candidate.id === connection.applicationId && candidate.name === connectionName && candidate.status !== "archived");
|
|
if (applicationMatches.length !== 1) fail("B.generic-connect", "application_not_unique");
|
|
const application = applicationMatches[0];
|
|
assertGenericProvenance({ application, connection }, connectionName);
|
|
if (connection.authKind !== "oauth") fail("B.generic-connect", "oauth_challenge_missing");
|
|
summary.connection = {
|
|
id: connectionId,
|
|
applicationId: application.id,
|
|
name: connectionName,
|
|
applicationKey: application.applicationKey,
|
|
metadataSource: "link",
|
|
transport: "mcp_remote",
|
|
unverifiedServer: true,
|
|
status: connection.status,
|
|
healthStatus: connection.healthStatus,
|
|
};
|
|
|
|
const startResult = persistedOAuthStartResult(connection, authorizationUrl);
|
|
if (!startResult) fail("C.oauth-start", "persisted_start_missing");
|
|
if (startResult.connectionId !== connectionId) fail("C.oauth-start", "connection_id_changed");
|
|
activeCheckpoint = "C.oauth-proof";
|
|
const registrationSource = assertAutomaticRegistrationSource(startResult.registrationSource);
|
|
const navigationProof = inspectAuthorizationUrl(startResult.authorizationUrl, {
|
|
callbackUrl: config.callbackUrl,
|
|
resource: NOTION_RESOURCE,
|
|
registrationSource,
|
|
baseUrl: config.baseUrl,
|
|
});
|
|
summary.oauthProof = {
|
|
registrationSource,
|
|
resource: NOTION_RESOURCE,
|
|
callbackUriExact: true,
|
|
pkceS256: true,
|
|
statePresent: true,
|
|
endpoint: navigationProof.endpoint,
|
|
parameters: navigationProof.parameters,
|
|
discoveredEndpoints: null,
|
|
};
|
|
|
|
// Registration source and every URL/PKCE/state invariant are checked before
|
|
// the credential is entered into the provider page.
|
|
activeCheckpoint = "C.notion-login";
|
|
await completeNotionAuthorization(page, config, credential, connectionId);
|
|
activeCheckpoint = "C.oauth-callback";
|
|
const permissionsPath = `/${TARGET_COMPANY_PREFIX}/apps/${connectionId}/permissions`;
|
|
await page.goto(new URL(permissionsPath, config.baseUrl).toString(), { waitUntil: "domcontentloaded" });
|
|
await expectVisible(page.getByText("OAuth connected", { exact: true }), "C.oauth-callback", "connected_state_missing", 45_000);
|
|
await expectVisible(page.getByText("Unverified server", { exact: true }), "C.oauth-callback", "unverified_badge_missing");
|
|
|
|
connection = await apiJson(
|
|
context.request,
|
|
config,
|
|
"GET",
|
|
`/api/tool-connections/${connectionId}`,
|
|
undefined,
|
|
"C.connection-detail",
|
|
);
|
|
if (connection.name !== connectionName
|
|
|| connection.config?.unverifiedServer !== true
|
|
|| connection.config?.sourceTemplateKey === "notion"
|
|
|| connection.authKind !== "oauth") {
|
|
fail("C.connection-detail", "generic_provenance_lost");
|
|
}
|
|
const endpointProof = oauthEndpointProof(connection, startResult, config);
|
|
assertNoCredentialMaterial(connection, [config.paperclipPassword, credential.username, credential.password], "C.connection-detail");
|
|
summary.connection = {
|
|
id: connectionId,
|
|
applicationId: connection.applicationId,
|
|
name: connectionName,
|
|
applicationKey: application.applicationKey,
|
|
metadataSource: "link",
|
|
transport: "mcp_remote",
|
|
unverifiedServer: true,
|
|
status: connection.status,
|
|
healthStatus: connection.healthStatus,
|
|
};
|
|
summary.oauthProof = {
|
|
registrationSource,
|
|
resource: NOTION_RESOURCE,
|
|
callbackUriExact: true,
|
|
pkceS256: true,
|
|
statePresent: true,
|
|
endpoint: navigationProof.endpoint,
|
|
parameters: navigationProof.parameters,
|
|
discoveredEndpoints: endpointProof.endpoints,
|
|
};
|
|
const connectedShot = "01-generic-connected.png";
|
|
await safeScreenshot(page, screenshotFile(outputDirectory, connectedShot), config, credential, "F.connected-screenshot");
|
|
summary.screenshots.push(connectedShot);
|
|
|
|
activeCheckpoint = "D.health-check";
|
|
const health = await apiJson(
|
|
context.request,
|
|
config,
|
|
"POST",
|
|
`/api/tool-connections/${connectionId}/health-check`,
|
|
{},
|
|
"D.health-check",
|
|
);
|
|
if (health.connection?.healthStatus !== "healthy") fail("D.health-check", "connection_not_healthy");
|
|
const refreshed = await apiJson(
|
|
context.request,
|
|
config,
|
|
"POST",
|
|
`/api/tool-connections/${connectionId}/catalog/refresh`,
|
|
{},
|
|
"D.catalog-refresh",
|
|
);
|
|
const catalog = asArray(refreshed, "catalog");
|
|
const facts = await finishAgentOnlySetup(context.request, config, companyId, connectionId, catalog, agent.id);
|
|
|
|
activeCheckpoint = "D.connection-active";
|
|
connection = await apiJson(context.request, config, "GET", `/api/tool-connections/${connectionId}`, undefined, "D.connection-active");
|
|
if (connection.status !== "active" || connection.healthStatus !== "healthy" || connection.config?.unverifiedServer !== true) {
|
|
fail("D.connection-active", "connection_not_active_healthy_generic");
|
|
}
|
|
const uniqueConnections = await apiJson(
|
|
context.request,
|
|
config,
|
|
"GET",
|
|
`/api/companies/${companyId}/tools/connections`,
|
|
undefined,
|
|
"D.connection-active",
|
|
);
|
|
if (asArray(uniqueConnections, "connections").filter((candidate) => candidate.name === connectionName).length !== 1) {
|
|
fail("D.connection-active", "duplicate_connection_detected");
|
|
}
|
|
const installs = await apiJson(
|
|
context.request,
|
|
config,
|
|
"GET",
|
|
`/api/tool-connections/${connectionId}/installs`,
|
|
undefined,
|
|
"D.agent-install",
|
|
);
|
|
const installRows = asArray(installs, "installs");
|
|
if (installRows.length !== 1 || installRows[0].targetType !== "agent" || installRows[0].targetId !== agent.id) {
|
|
fail("D.agent-install", "install_not_agent_only");
|
|
}
|
|
const testAgents = await apiJson(
|
|
context.request,
|
|
config,
|
|
"GET",
|
|
`/api/tool-connections/${connectionId}/test-agents`,
|
|
undefined,
|
|
"D.effective-policy",
|
|
);
|
|
const testAgent = asArray(testAgents, "agents").find((candidate) => candidate.id === agent.id);
|
|
const getSelfAccess = testAgent?.effectiveAccess?.tools?.find((tool) => tool.toolName === NOTION_GET_SELF);
|
|
const createPagesAccess = testAgent?.effectiveAccess?.tools?.find((tool) => tool.toolName === NOTION_CREATE_PAGES);
|
|
if (getSelfAccess?.decision !== "allowed" || createPagesAccess?.decision !== "off") {
|
|
fail("D.effective-policy", "unexpected_effective_decision");
|
|
}
|
|
const deniedWrite = await apiJson(
|
|
context.request,
|
|
config,
|
|
"POST",
|
|
`/api/tool-connections/${connectionId}/test-calls`,
|
|
{ agentId: agent.id, toolName: NOTION_CREATE_PAGES, parameters: {} },
|
|
"D.write-denial",
|
|
);
|
|
if (deniedWrite.decision !== "off" || typeof deniedWrite.invocationId !== "string" || !deniedWrite.error) {
|
|
fail("D.write-denial", "write_action_not_denied");
|
|
}
|
|
summary.catalog = {
|
|
discoveredCount: refreshed.discoveredCount,
|
|
advertisedReadTools: facts.active
|
|
.filter((entry) => [NOTION_GET_SELF, "notion-get-users", "notion-fetch"].includes(entry.toolName))
|
|
.map((entry) => entry.toolName),
|
|
getSelf: { catalogEntryId: facts.getSelf.id, decision: "allowed" },
|
|
createPages: {
|
|
catalogEntryId: facts.createPages.id,
|
|
decision: "off",
|
|
localDenialInvocationId: deniedWrite.invocationId,
|
|
upstreamSent: false,
|
|
},
|
|
accessAgentId: agent.id,
|
|
installAgentId: agent.id,
|
|
enabledCatalogEntryCount: 1,
|
|
healthCheck: "healthy",
|
|
catalogRefresh: "succeeded",
|
|
};
|
|
|
|
await page.goto(new URL(`/${TARGET_COMPANY_PREFIX}/apps/${connectionId}/permissions`, config.baseUrl).toString(), { waitUntil: "domcontentloaded" });
|
|
await expectVisible(page.getByText("Who can use it", { exact: true }), "D.permissions-ui", "permissions_panel_missing");
|
|
const getSelfPermission = page.locator(`[data-action-id="${facts.getSelf.id}"] select`);
|
|
const createPagesPermission = page.locator(`[data-action-id="${facts.createPages.id}"] select`);
|
|
await expectVisible(getSelfPermission, "D.permissions-ui", "get_self_permission_missing");
|
|
await expectVisible(createPagesPermission, "D.permissions-ui", "create_pages_permission_missing");
|
|
if (await getSelfPermission.inputValue() !== "allowed" || await createPagesPermission.inputValue() !== "off") {
|
|
fail("D.permissions-ui", "permissions_ui_mismatch");
|
|
}
|
|
const permissionsShot = "02-read-only-policy.png";
|
|
await safeScreenshot(page, screenshotFile(outputDirectory, permissionsShot), config, credential, "F.permissions-screenshot");
|
|
summary.screenshots.push(permissionsShot);
|
|
|
|
activeCheckpoint = "D.test-panel";
|
|
await page.goto(new URL(`/${TARGET_COMPANY_PREFIX}/apps/${connectionId}/test`, config.baseUrl).toString(), { waitUntil: "domcontentloaded" });
|
|
await expectVisible(page.getByLabel("Choose which agent to test as"), "D.test-panel", "agent_picker_missing");
|
|
await page.getByLabel("Choose which agent to test as").click();
|
|
await page.getByLabel("Search agents").fill(TARGET_AGENT_NAME);
|
|
await page.getByRole("button", { name: new RegExp(`^${escapeRegex(TARGET_AGENT_NAME)}`) }).click();
|
|
await page.getByLabel("Find an action").fill(NOTION_GET_SELF);
|
|
const getSelfTitle = facts.getSelf.title ?? facts.getSelf.toolName;
|
|
const actionRow = page.locator("button").filter({ hasText: getSelfTitle }).filter({ hasText: "Allowed" }).first();
|
|
await expectVisible(actionRow, "D.test-panel", "get_self_allowed_row_missing");
|
|
await actionRow.click();
|
|
await expectVisible(page.getByText("This action takes no inputs."), "D.test-panel", "empty_input_form_missing");
|
|
const boardStartedAt = Date.now();
|
|
const testCallResponsePromise = page.waitForResponse((response) =>
|
|
response.request().method() === "POST"
|
|
&& new URL(response.url()).pathname === `/api/tool-connections/${connectionId}/test-calls`,
|
|
);
|
|
await page.getByRole("button", { name: /^run$/i }).click();
|
|
const testCallResponse = await testCallResponsePromise;
|
|
if (!testCallResponse.ok()) fail("D.board-get-self", `http_${testCallResponse.status()}`);
|
|
const testCall = await testCallResponse.json().catch(() => fail("D.board-get-self", "invalid_json"));
|
|
const testCallInput = testCallResponse.request().postDataJSON();
|
|
if (testCallInput?.agentId !== agent.id
|
|
|| testCallInput?.toolName !== NOTION_GET_SELF
|
|
|| !testCallInput.parameters
|
|
|| Object.keys(testCallInput.parameters).length !== 0) {
|
|
fail("D.board-get-self", "nonempty_or_unexpected_input");
|
|
}
|
|
if (testCall.decision !== "allowed" || testCall.error || typeof testCall.invocationId !== "string") {
|
|
fail("D.board-get-self", "gateway_call_not_allowed");
|
|
}
|
|
const boardIdentity = extractNotionIdentity(testCall.result);
|
|
if (!boardIdentity) fail("D.board-get-self", "workspace_identity_missing");
|
|
await expectVisible(page.getByText(/^Worked\./), "D.board-get-self", "success_result_missing");
|
|
const boardShot = "03-board-notion-get-self.png";
|
|
await safeScreenshot(page, screenshotFile(outputDirectory, boardShot), config, credential, "F.board-test-screenshot");
|
|
summary.screenshots.push(boardShot);
|
|
summary.boardTest = {
|
|
catalogEntryId: facts.getSelf.id,
|
|
toolName: NOTION_GET_SELF,
|
|
invocationId: testCall.invocationId,
|
|
decision: "allowed",
|
|
resultStatus: "succeeded",
|
|
workspace: boardIdentity,
|
|
durationMs: Date.now() - boardStartedAt,
|
|
};
|
|
|
|
activeCheckpoint = "E.fresh-agent";
|
|
const proofIssue = await createAndWaitForProofIssue(context.request, config, {
|
|
title: `Notion generic installed-tool proof ${startedAt.toISOString()}`,
|
|
description: [
|
|
"Invoke exactly one installed action: the read-only `notion-get-self` tool with an empty `{}` input.",
|
|
"Make no Notion mutation and do not invoke any other Notion action.",
|
|
`Require workspace ID ${boardIdentity.workspaceId} and workspace name ${boardIdentity.workspaceName}.`,
|
|
"Post exactly one JSON object with keys `workspaceId`, `workspaceName`, and `invocationId` (the Paperclip invocation ID), then mark this issue done.",
|
|
"Do not report tokens, cookies, headers, authorization data, raw payloads, or any other fields.",
|
|
].join("\n\n"),
|
|
status: "todo",
|
|
workMode: "standard",
|
|
priority: "medium",
|
|
assigneeAgentId: agent.id,
|
|
acceptanceCriteria: [
|
|
"The installed notion-get-self action succeeds with empty input.",
|
|
"Only sanitized workspace ID/name and Paperclip invocation ID are reported.",
|
|
"No Notion mutation is attempted.",
|
|
],
|
|
}, "E.fresh-agent");
|
|
for (const comment of proofIssue.comments) {
|
|
assertNoCredentialMaterial(comment.body, [credential.username, credential.password], "E.agent-proof-comment");
|
|
}
|
|
const proof = proofIssue.comments
|
|
.filter((comment) => comment.authorAgentId === agent.id || comment.derivedAuthorAgentId === agent.id)
|
|
.map((comment) => parseSanitizedAgentProof(comment.body, boardIdentity))
|
|
.find(Boolean);
|
|
if (!proof) fail("E.agent-proof-comment", "sanitized_proof_missing");
|
|
|
|
let finalActivity;
|
|
const agentEvent = await waitFor("E.agent-audit", async () => {
|
|
const activity = await apiJson(
|
|
context.request,
|
|
config,
|
|
"GET",
|
|
`/api/tool-connections/${connectionId}/activity?limit=100`,
|
|
undefined,
|
|
"E.agent-audit",
|
|
);
|
|
assertNoCredentialMaterial(activity, [credential.username, credential.password], "E.agent-audit");
|
|
finalActivity = activity;
|
|
return asArray(activity, "events").find((event) =>
|
|
event.issueId === proofIssue.child.id
|
|
&& event.agentId === agent.id
|
|
&& event.toolName === NOTION_GET_SELF
|
|
&& event.invocationId === proof.invocationId
|
|
&& event.outcome === "success") ?? null;
|
|
}, { timeoutMs: 60_000, intervalMs: 2_000 });
|
|
if (!agentEvent.runId) fail("E.agent-audit", "run_id_missing");
|
|
if (agentEvent.requestSummary?.summary !== "{}") fail("E.agent-audit", "get_self_input_not_empty");
|
|
const childToolEvents = asArray(finalActivity, "events").filter((event) =>
|
|
event.issueId === proofIssue.child.id && event.invocationId);
|
|
if (childToolEvents.length !== 1 || childToolEvents[0].toolName !== NOTION_GET_SELF) {
|
|
fail("E.agent-audit", "unexpected_upstream_action");
|
|
}
|
|
const agentRun = await waitFor("E.agent-run-status", async () => {
|
|
const run = await apiJson(context.request, config, "GET", `/api/heartbeat-runs/${agentEvent.runId}`, undefined, "E.agent-run-status");
|
|
if (["failed", "cancelled", "timed_out"].includes(run.status)) fail("E.agent-run-status", `run_${run.status}`);
|
|
return run.status === "succeeded" ? run : null;
|
|
}, { timeoutMs: 60_000, intervalMs: 2_000 });
|
|
summary.freshRun = {
|
|
issueId: proofIssue.child.id,
|
|
issueIdentifier: proofIssue.child.identifier,
|
|
transition: {
|
|
created: "todo",
|
|
enteredInProgress: proofIssue.observedStatuses.has("in_progress") || Boolean(proofIssue.finished.startedAt),
|
|
completed: "done",
|
|
},
|
|
runId: agentEvent.runId,
|
|
runStatus: agentRun.status,
|
|
invocationId: proof.invocationId,
|
|
workspace: { id: proof.workspaceId, name: proof.workspaceName },
|
|
connectionId,
|
|
decision: agentEvent.decision ?? "allowed",
|
|
outcome: agentEvent.outcome,
|
|
durationMs: agentEvent.latencyMs,
|
|
};
|
|
|
|
await page.goto(new URL(`/${TARGET_COMPANY_PREFIX}/issues/${proofIssue.child.identifier}`, config.baseUrl).toString(), { waitUntil: "domcontentloaded" });
|
|
await expectVisible(page.getByText(proofIssue.child.title, { exact: true }).first(), "F.child-screenshot", "child_issue_missing");
|
|
const childShot = "04-fresh-agent-proof.png";
|
|
await safeScreenshot(page, screenshotFile(outputDirectory, childShot), config, credential, "F.child-screenshot");
|
|
summary.screenshots.push(childShot);
|
|
|
|
await page.goto(new URL(`/${TARGET_COMPANY_PREFIX}/apps/${connectionId}/activity`, config.baseUrl).toString(), { waitUntil: "domcontentloaded" });
|
|
await expectVisible(page.getByText(NOTION_GET_SELF, { exact: false }).first(), "F.activity-screenshot", "get_self_activity_missing");
|
|
const activityShot = "05-redacted-audit.png";
|
|
await safeScreenshot(page, screenshotFile(outputDirectory, activityShot), config, credential, "F.activity-screenshot");
|
|
summary.screenshots.push(activityShot);
|
|
|
|
activeCheckpoint = "F.cleanup";
|
|
summary.cleanup = await cleanupConnection(
|
|
context.request,
|
|
config,
|
|
companyId,
|
|
connectionId,
|
|
connectionName,
|
|
{ requireInstalled: true },
|
|
);
|
|
cleanupComplete = true;
|
|
|
|
activeCheckpoint = "F.post-cleanup-runtime";
|
|
const absenceIssue = await createAndWaitForProofIssue(context.request, config, {
|
|
title: `Notion generic cleanup runtime proof ${startedAt.toISOString()}`,
|
|
description: [
|
|
`Inspect the tools available in this fresh runtime after generic connection ${connectionId} was removed.`,
|
|
"Do not call any Notion tool. Confirm `notion-get-self` from that removed connection is absent.",
|
|
`Post exactly {\"connectionId\":\"${connectionId}\",\"toolPresent\":false} and mark this issue done.`,
|
|
"Do not report any other fields or credential material.",
|
|
].join("\n\n"),
|
|
status: "todo",
|
|
workMode: "standard",
|
|
priority: "medium",
|
|
assigneeAgentId: agent.id,
|
|
acceptanceCriteria: [
|
|
"A fresh runtime does not expose the removed generic connection's notion-get-self tool.",
|
|
"No Notion action is invoked.",
|
|
],
|
|
}, "F.post-cleanup-runtime");
|
|
const absenceProof = absenceIssue.comments
|
|
.filter((comment) => comment.authorAgentId === agent.id || comment.derivedAuthorAgentId === agent.id)
|
|
.map((comment) => parseRuntimeAbsenceProof(comment.body, connectionId))
|
|
.find(Boolean);
|
|
if (!absenceProof) fail("F.post-cleanup-runtime", "absence_proof_missing");
|
|
const afterCleanupActivity = await apiJson(
|
|
context.request,
|
|
config,
|
|
"GET",
|
|
`/api/tool-connections/${connectionId}/activity?limit=100`,
|
|
undefined,
|
|
"F.post-cleanup-runtime",
|
|
);
|
|
if (asArray(afterCleanupActivity, "events").some((event) => event.issueId === absenceIssue.child.id && event.invocationId)) {
|
|
fail("F.post-cleanup-runtime", "post_cleanup_tool_invocation_detected");
|
|
}
|
|
summary.postCleanupRun = {
|
|
issueId: absenceIssue.child.id,
|
|
issueIdentifier: absenceIssue.child.identifier,
|
|
connectionId,
|
|
toolPresent: false,
|
|
invocationCount: 0,
|
|
completed: "done",
|
|
};
|
|
summary.passed = true;
|
|
} catch (error) {
|
|
caughtFailure = error instanceof SmokeFailure
|
|
? error
|
|
: error instanceof NotionGenericLivePreflightError
|
|
? new SmokeFailure(activeCheckpoint, error.code)
|
|
: new SmokeFailure(activeCheckpoint, "unexpected_error");
|
|
} finally {
|
|
if (connectionId && companyId && context && !cleanupComplete) {
|
|
try {
|
|
summary.cleanup = await cleanupConnection(context.request, config, companyId, connectionId, connectionName);
|
|
cleanupComplete = true;
|
|
} catch {
|
|
summary.cleanup = { completed: false, code: "cleanup_failed" };
|
|
if (!caughtFailure) caughtFailure = new SmokeFailure("F.cleanup", "cleanup_failed");
|
|
}
|
|
}
|
|
await context?.close().catch(() => {});
|
|
await browser?.close().catch(() => {});
|
|
}
|
|
|
|
summary.completedAt = new Date().toISOString();
|
|
if (caughtFailure) summary.failure = { checkpoint: caughtFailure.checkpoint, code: caughtFailure.code };
|
|
assertSanitizedEvidence(summary);
|
|
const summaryPath = path.join(outputDirectory, "summary.json");
|
|
await writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, { mode: 0o600 });
|
|
if (caughtFailure) throw caughtFailure;
|
|
return { outputDirectory, summaryPath, screenshots: summary.screenshots };
|
|
}
|
|
|
|
async function main() {
|
|
const dryRun = process.argv.includes("--dry-run");
|
|
let prepared;
|
|
try {
|
|
prepared = await prepareNotionGenericLiveSmoke({
|
|
loadBrowser: dryRun ? async () => null : () => import("@playwright/test"),
|
|
});
|
|
} catch (error) {
|
|
process.stderr.write(`${preflightFailureMessage(error)}\n`);
|
|
process.exitCode = error instanceof NotionGenericLivePreflightError ? 2 : 1;
|
|
return;
|
|
}
|
|
if (dryRun) {
|
|
process.stdout.write("Notion generic live smoke dry-run passed; no credential value was fetched.\n");
|
|
return;
|
|
}
|
|
try {
|
|
const result = await runSmoke({ config: prepared.config, chromium: prepared.browserModule.chromium });
|
|
process.stdout.write(`Notion generic live smoke passed. Sanitized evidence: ${result.outputDirectory}\n`);
|
|
} catch (error) {
|
|
const failure = error instanceof SmokeFailure ? error : new SmokeFailure("unexpected", "unexpected_error");
|
|
process.stderr.write(`Notion generic live smoke failed at ${failure.checkpoint} (${failure.code}).\n`);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
await main();
|