From 699f00927528666a906067064ec2ec33f9f10357 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 17 Aug 2026 20:59:54 +0000 Subject: [PATCH] feat(apps): support pasted MCP OAuth continuation Add a credential-safe opt-in generic Notion live smoke and focused regression coverage. Co-Authored-By: Paperclip --- doc/connections/GENERIC-REMOTE-MCP.md | 38 + package.json | 1 + scripts/smoke/notion-generic-live-lib.mjs | 343 ++++++++ scripts/smoke/notion-generic-live.mjs | 959 +++++++++++++++++++++ scripts/smoke/notion-generic-live.test.mjs | 187 ++++ ui/src/pages/tools/PasteConfigTab.test.tsx | 130 +++ ui/src/pages/tools/PasteConfigTab.tsx | 107 ++- 7 files changed, 1764 insertions(+), 1 deletion(-) create mode 100644 scripts/smoke/notion-generic-live-lib.mjs create mode 100644 scripts/smoke/notion-generic-live.mjs create mode 100644 scripts/smoke/notion-generic-live.test.mjs diff --git a/doc/connections/GENERIC-REMOTE-MCP.md b/doc/connections/GENERIC-REMOTE-MCP.md index 1fc40ed4357..d481bbe41af 100644 --- a/doc/connections/GENERIC-REMOTE-MCP.md +++ b/doc/connections/GENERIC-REMOTE-MCP.md @@ -182,3 +182,41 @@ in-process MCP server plus authorization server. It needs no network and no vendor credentials, and every case connects by URL without naming a gallery app. A credentialed vendor smoke (for example live PostHog OAuth) may be recorded by QA but is not required for deterministic verification. + +### Opt-in generic Notion live smoke + +`pnpm smoke:notion-generic-live` exercises the generic **Advanced → Paste a +config** route against `https://mcp.notion.com/mcp`. It is intentionally outside +the normal unit, browser, and CI-required suites. Run it only against an +already-running, browser-reachable HTTPS Paperclip instance with these bindings +provided by the execution environment: + +- `PAPERCLIP_E2E_BASE_URL`, `PAPERCLIP_E2E_EMAIL`, and + `PAPERCLIP_DEV_LOGIN_PASSWORD` for the target instance; +- `PAPERCLIP_API_URL`, `PAPERCLIP_API_KEY`, `PAPERCLIP_RUN_ID`, and + `PAPERCLIP_TASK_ID` for the control plane; +- the approved on-demand secret binding + `access.notion_generic_flow_test_account`, delivered by the agent secret API + under its normalized key `generic-flow-test-account`, for the existing Notion + test account. + +Check the URL, health endpoint, and binding metadata without retrieving the +credential value or opening a browser: + +```sh +pnpm smoke:notion-generic-live -- --dry-run +``` + +The live command retrieves the credential only after the safe preflight and +Paperclip login succeed. It disables trace, video, and HAR capture, takes only +post-callback screenshots, enables and invokes only `notion-get-self`, proves +`notion-create-pages` remains locally denied, and removes its uniquely named +connection in a `finally` cleanup. Its `summary.json` and PNG files contain +sanitized IDs, decisions, outcomes, and endpoint origins/paths only; they +default to `PAPERCLIP_RUN_SCRATCH_DIR`, or to `NOTION_EVIDENCE_DIR` when set. + +Run the credential-free harness checks with: + +```sh +node --test scripts/smoke/notion-generic-live.test.mjs +``` diff --git a/package.json b/package.json index 41531b6933b..5aa0d3cc7d8 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "smoke:openclaw-docker-ui": "./scripts/smoke/openclaw-docker-ui.sh", "smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh", "smoke:mcp-fixtures": "node scripts/smoke/mcp-fixture-harness.mjs", + "smoke:notion-generic-live": "node scripts/smoke/notion-generic-live.mjs", "smoke:posthog-live": "node scripts/smoke/posthog-live.mjs", "smoke:pipelines-tutorial": "./scripts/smoke/pipelines-tutorial-smoke.sh", "smoke:terminal-bench-loop-skill": "node scripts/smoke/terminal-bench-loop-skill-smoke.mjs", diff --git a/scripts/smoke/notion-generic-live-lib.mjs b/scripts/smoke/notion-generic-live-lib.mjs new file mode 100644 index 00000000000..e8901dd3865 --- /dev/null +++ b/scripts/smoke/notion-generic-live-lib.mjs @@ -0,0 +1,343 @@ +const REQUIRED_ENVIRONMENT = [ + "PAPERCLIP_E2E_BASE_URL", + "PAPERCLIP_E2E_EMAIL", + "PAPERCLIP_DEV_LOGIN_PASSWORD", + "PAPERCLIP_API_URL", + "PAPERCLIP_API_KEY", + "PAPERCLIP_RUN_ID", + "PAPERCLIP_TASK_ID", +]; + +// Agent secret APIs expose access.notion_generic_flow_test_account under this +// normalized delivery key; the live harness never reads any other binding. +export const NOTION_SECRET_BINDING_KEY = "generic-flow-test-account"; + +export class NotionGenericLivePreflightError extends Error { + constructor(code, details = {}) { + super(code); + this.name = "NotionGenericLivePreflightError"; + this.code = code; + this.details = details; + } +} + +function requiredValue(environment, key) { + const value = environment[key]; + return typeof value === "string" ? value.trim() : ""; +} + +function explicitHttpsOrigin(raw, code) { + let value; + try { + value = new URL(raw); + } catch { + throw new NotionGenericLivePreflightError(code); + } + if (value.protocol !== "https:" + || value.username + || value.password + || value.search + || value.hash + || (value.pathname !== "/" && value.pathname !== "/api" && value.pathname !== "/api/")) { + throw new NotionGenericLivePreflightError(code); + } + return value; +} + +export function preflightNotionGenericLive(environment = process.env) { + const missing = REQUIRED_ENVIRONMENT.filter((key) => requiredValue(environment, key) === ""); + if (missing.length > 0) { + throw new NotionGenericLivePreflightError("missing_environment", { missing }); + } + + const base = explicitHttpsOrigin(requiredValue(environment, "PAPERCLIP_E2E_BASE_URL"), "unsafe_base_url"); + const api = explicitHttpsOrigin(requiredValue(environment, "PAPERCLIP_API_URL"), "unsafe_api_url"); + + const email = requiredValue(environment, "PAPERCLIP_E2E_EMAIL"); + if (!email.includes("@")) throw new NotionGenericLivePreflightError("invalid_paperclip_email"); + + return { + baseUrl: base.origin, + apiBaseUrl: `${api.origin}/api`, + callbackUrl: `${base.origin}/api/tools/oauth/callback`, + paperclipEmail: email, + paperclipPassword: environment.PAPERCLIP_DEV_LOGIN_PASSWORD, + agentApiKey: environment.PAPERCLIP_API_KEY, + runId: environment.PAPERCLIP_RUN_ID, + taskId: environment.PAPERCLIP_TASK_ID, + secretBindingKey: NOTION_SECRET_BINDING_KEY, + }; +} + +async function responseJson(response, code) { + try { + return await response.json(); + } catch { + throw new NotionGenericLivePreflightError(code); + } +} + +async function fetchWithTimeout(fetchImpl, url, init) { + try { + return await fetchImpl(url, { ...init, signal: AbortSignal.timeout(15_000) }); + } catch { + throw new NotionGenericLivePreflightError("request_failed"); + } +} + +export async function prepareNotionGenericLiveSmoke({ + environment = process.env, + fetchImpl = globalThis.fetch, + loadBrowser, +}) { + const config = preflightNotionGenericLive(environment); + const healthResponse = await fetchWithTimeout( + fetchImpl, + new URL("/api/health", config.baseUrl), + { headers: { accept: "application/json" } }, + ); + if (!healthResponse.ok) { + throw new NotionGenericLivePreflightError("health_http_error", { status: healthResponse.status }); + } + const health = await responseJson(healthResponse, "health_invalid_json"); + if (health?.status !== "ok") throw new NotionGenericLivePreflightError("health_not_ok"); + + const secretsResponse = await fetchWithTimeout( + fetchImpl, + `${config.apiBaseUrl}/agents/me/secrets`, + { + headers: { + accept: "application/json", + authorization: `Bearer ${config.agentApiKey}`, + }, + }, + ); + if (!secretsResponse.ok) { + throw new NotionGenericLivePreflightError("secret_metadata_http_error", { status: secretsResponse.status }); + } + const secretMetadata = await responseJson(secretsResponse, "secret_metadata_invalid_json"); + const available = Array.isArray(secretMetadata?.secrets) + && secretMetadata.secrets.some((entry) => entry?.key === config.secretBindingKey && entry?.delivery === "api"); + if (!available) throw new NotionGenericLivePreflightError("secret_binding_unavailable"); + + return { config, browserModule: await loadBrowser() }; +} + +export async function fetchNotionTestCredentials(config, fetchImpl = globalThis.fetch) { + const response = await fetchWithTimeout( + fetchImpl, + `${config.apiBaseUrl}/agents/me/secrets/${encodeURIComponent(config.secretBindingKey)}/value`, + { + method: "POST", + headers: { + accept: "application/json", + authorization: `Bearer ${config.agentApiKey}`, + }, + }, + ); + if (!response.ok) { + throw new NotionGenericLivePreflightError("secret_value_http_error", { status: response.status }); + } + const body = await responseJson(response, "secret_value_invalid_json"); + if (typeof body?.value !== "string") { + throw new NotionGenericLivePreflightError("secret_value_missing"); + } + let credential; + try { + credential = JSON.parse(body.value); + } catch { + throw new NotionGenericLivePreflightError("secret_value_invalid_shape"); + } + const username = [credential?.email, credential?.username, credential?.login] + .find((value) => typeof value === "string" && value.trim()); + const password = typeof credential?.password === "string" ? credential.password : ""; + if (!username || !password) { + throw new NotionGenericLivePreflightError("secret_value_invalid_shape"); + } + return { username: username.trim(), password }; +} + +export function assertAutomaticRegistrationSource(source) { + if (source !== "cimd" && source !== "dcr") { + throw new NotionGenericLivePreflightError("unexpected_registration_source"); + } + return source; +} + +export function safeEndpointSummary(raw, label) { + let endpoint; + try { + endpoint = new URL(raw); + } catch { + throw new NotionGenericLivePreflightError(`unsafe_${label}_endpoint`); + } + if (endpoint.protocol !== "https:" + || endpoint.username + || endpoint.password + || endpoint.search + || endpoint.hash) { + throw new NotionGenericLivePreflightError(`unsafe_${label}_endpoint`); + } + return { origin: endpoint.origin, path: endpoint.pathname }; +} + +export function inspectAuthorizationUrl(raw, { + callbackUrl, + resource, + registrationSource, + baseUrl, +}) { + let target; + try { + target = new URL(raw); + } catch { + throw new NotionGenericLivePreflightError("unsafe_authorization_endpoint"); + } + if (target.protocol !== "https:" || target.username || target.password || target.hash) { + throw new NotionGenericLivePreflightError("unsafe_authorization_endpoint"); + } + const required = ["client_id", "state", "code_challenge", "redirect_uri", "resource"]; + if (required.some((key) => !target.searchParams.get(key))) { + throw new NotionGenericLivePreflightError("authorization_parameter_missing"); + } + if (target.searchParams.get("code_challenge_method") !== "S256") { + throw new NotionGenericLivePreflightError("pkce_s256_missing"); + } + if (target.searchParams.get("redirect_uri") !== callbackUrl) { + throw new NotionGenericLivePreflightError("callback_uri_mismatch"); + } + if (target.searchParams.get("resource") !== resource) { + throw new NotionGenericLivePreflightError("resource_mismatch"); + } + if (target.searchParams.get("response_type") !== "code") { + throw new NotionGenericLivePreflightError("response_type_mismatch"); + } + if (registrationSource === "cimd") { + const expectedClientId = new URL("/api/tools/oauth/client-metadata", baseUrl).toString(); + if (target.searchParams.get("client_id") !== expectedClientId) { + throw new NotionGenericLivePreflightError("cimd_client_id_mismatch"); + } + } + return { + endpoint: { origin: target.origin, path: target.pathname }, + parameters: { + clientId: true, + state: true, + pkceS256: true, + callbackUri: true, + resource: true, + }, + }; +} + +function parsedJsonString(value) { + const trimmed = value.trim(); + if (trimmed.length === 0 || trimmed.length > 200_000) return null; + const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i); + for (const candidate of fenced ? [fenced[1], trimmed] : [trimmed]) { + if (!candidate.startsWith("{") && !candidate.startsWith("[")) continue; + try { + return JSON.parse(candidate); + } catch { + // A later recursive branch may still contain structured content. + } + } + return null; +} + +export function extractNotionIdentity(value) { + const seen = new Set(); + const facts = { workspaceId: null, workspaceName: null, botId: null }; + const visit = (candidate, depth) => { + if (depth > 12 || candidate === null || candidate === undefined) return; + if (typeof candidate === "string") { + const parsed = parsedJsonString(candidate); + if (parsed !== null) visit(parsed, depth + 1); + return; + } + if (typeof candidate !== "object" || seen.has(candidate)) return; + seen.add(candidate); + if (!Array.isArray(candidate)) { + const workspaceId = candidate.workspace_id ?? candidate.workspaceId; + const workspaceName = candidate.workspace_name ?? candidate.workspaceName; + if (!facts.workspaceId && typeof workspaceId === "string" && workspaceId.trim()) facts.workspaceId = workspaceId.trim(); + if (!facts.workspaceName && typeof workspaceName === "string" && workspaceName.trim()) facts.workspaceName = workspaceName.trim(); + if (!facts.botId && candidate.type === "bot" && typeof candidate.id === "string" && candidate.id.trim()) { + facts.botId = candidate.id.trim(); + } + } + for (const child of Array.isArray(candidate) ? candidate : Object.values(candidate)) visit(child, depth + 1); + }; + visit(value, 0); + return (facts.workspaceId || facts.botId) && facts.workspaceName ? facts : null; +} + +export function parseSanitizedAgentProof(commentBody, expectedIdentity) { + if (typeof commentBody !== "string") return null; + const trimmed = commentBody.trim(); + const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i); + let parsed; + try { + parsed = JSON.parse(fenced ? fenced[1] : trimmed); + } catch { + return null; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + if (Object.keys(parsed).sort().join(",") !== "invocationId,workspaceId,workspaceName") return null; + if (parsed.workspaceId !== expectedIdentity.workspaceId || parsed.workspaceName !== expectedIdentity.workspaceName) return null; + if (typeof parsed.invocationId !== "string" || !parsed.invocationId.trim()) return null; + return { + workspaceId: parsed.workspaceId, + workspaceName: parsed.workspaceName, + invocationId: parsed.invocationId.trim(), + }; +} + +export function parseRuntimeAbsenceProof(commentBody, connectionId) { + if (typeof commentBody !== "string") return null; + let parsed; + try { + parsed = JSON.parse(commentBody.trim()); + } catch { + return null; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + if (Object.keys(parsed).sort().join(",") !== "connectionId,toolPresent") return null; + return parsed.connectionId === connectionId && parsed.toolPresent === false + ? { connectionId, toolPresent: false } + : null; +} + +const FORBIDDEN_EVIDENCE_KEYS = /(?:password|access[_-]?token|refresh[_-]?token|authorization|cookie|oauth[_-]?code|client[_-]?secret|session)/i; +const FORBIDDEN_EVIDENCE_TEXT = /(?:authorization:\s*bearer|cookie:|[?&](?:code|state|token|access_token|refresh_token)=)/i; + +export function assertSanitizedEvidence(value) { + const seen = new Set(); + const visit = (candidate, path) => { + if (candidate === null || candidate === undefined) return; + if (typeof candidate === "string") { + if (FORBIDDEN_EVIDENCE_TEXT.test(candidate)) throw new Error(`unsafe_evidence_text:${path}`); + return; + } + if (typeof candidate !== "object" || seen.has(candidate)) return; + seen.add(candidate); + for (const [key, child] of Object.entries(candidate)) { + if (FORBIDDEN_EVIDENCE_KEYS.test(key)) throw new Error(`unsafe_evidence_key:${path}.${key}`); + visit(child, `${path}.${key}`); + } + }; + visit(value, "$"); +} + +export function preflightFailureMessage(error) { + if (!(error instanceof NotionGenericLivePreflightError)) { + return "Notion generic live smoke preflight failed."; + } + if (error.code === "missing_environment") { + return `Notion generic live smoke preflight failed: missing ${error.details.missing.join(", ")}.`; + } + if (error.code === "health_http_error") { + return `Notion generic live smoke preflight failed: /api/health returned HTTP ${error.details.status}.`; + } + return `Notion generic live smoke preflight failed: ${error.code}.`; +} diff --git a/scripts/smoke/notion-generic-live.mjs b/scripts/smoke/notion-generic-live.mjs new file mode 100644 index 00000000000..2b186469d78 --- /dev/null +++ b/scripts/smoke/notion-generic-live.mjs @@ -0,0 +1,959 @@ +#!/usr/bin/env node + +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { + assertAutomaticRegistrationSource, + assertSanitizedEvidence, + extractNotionIdentity, + fetchNotionTestCredentials, + inspectAuthorizationUrl, + NotionGenericLivePreflightError, + parseRuntimeAbsenceProof, + parseSanitizedAgentProof, + 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; + +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", + ...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 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; +} + +async function completeNotionAuthorization(page, config, credential) { + const paperclipOrigin = new URL(config.baseUrl).origin; + const deadline = Date.now() + 6 * 60_000; + while (Date.now() < deadline) { + let current; + try { + current = new URL(page.url()); + } catch { + fail("C.oauth-callback", "invalid_navigation_url"); + } + if (current.origin === paperclipOrigin && current.pathname.includes("/apps/")) return; + + const bodyText = await page.locator("body").innerText().catch(() => ""); + if (/check your (?:email|inbox)|verification code|one-time code/i.test(bodyText)) { + fail("C.notion-login", "interactive_code_required"); + } + + 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) { + const removed = await apiJson( + request, + config, + "DELETE", + `/api/tool-connections/${connectionId}`, + undefined, + "F.cleanup", + ); + const receipt = removed.removal; + if (!receipt + || receipt.installsRemoved !== 1 + || receipt.appProfileBindingsRemoved < 1 + || receipt.credentialRefsCleared + receipt.secretsRevoked < 1 + || !["deleted", "archived"].includes(receipt.appProfile)) { + 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, + credentialsRemoved: receipt.credentialRefsCleared + receipt.secretsRevoked, + secretBindingsRemoved: receipt.secretBindingsRemoved, + grantsRevoked: receipt.grantsRevoked, + accessBindingsRemoved: receipt.appProfileBindingsRemoved, + installsRemoved: receipt.installsRemoved, + oauthStatesDiscarded: receipt.oauthStatesDiscarded, + runtimeSlotsStopped: receipt.runtimeSlotsStopped, + appProfile: receipt.appProfile, + 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; + + 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 page.goto(new URL("/auth?next=/", config.baseUrl).toString(), { waitUntil: "domcontentloaded" }); + const paperclipEmailInput = page.getByLabel(/^email$/i); + const paperclipPasswordInput = page.getByLabel(/^password$/i); + await expectVisible(paperclipEmailInput, "A.paperclip-login", "email_field_missing"); + 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()}`); + await page.waitForURL((url) => url.pathname !== "/auth", { timeout: 30_000 }).catch(() => { + fail("A.paperclip-login", "login_redirect_missing"); + }); + + 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; + 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"); + + 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. + credential = await fetchNotionTestCredentials(config); + + await page.goto( + new URL(`/${TARGET_COMPANY_PREFIX}/apps/advanced/paste-config`, config.baseUrl).toString(), + { waitUntil: "domcontentloaded" }, + ); + await expectVisible(page.getByRole("heading", { name: "Advanced setup" }), "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, + }; + + 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 connectResponsePromise = page.waitForResponse((response) => + response.request().method() === "POST" + && new URL(response.url()).pathname === `/api/companies/${companyId}/tools/apps/connect`, + ); + const oauthStartResponsePromise = page.waitForResponse((response) => + response.request().method() === "POST" + && /^\/api\/tools\/oauth\/[^/]+\/start$/.test(new URL(response.url()).pathname), + { timeout: 60_000 }, + ); + await page.getByRole("button", { name: /^check actions$/i }).click(); + const connectResponse = await connectResponsePromise; + if (!connectResponse.ok()) fail("B.generic-connect", `http_${connectResponse.status()}`); + const connectResult = await connectResponse.json().catch(() => fail("B.generic-connect", "invalid_json")); + connectionId = connectResult.connectionId; + if (typeof connectionId !== "string" || !connectionId) fail("B.generic-connect", "connection_id_missing"); + assertGenericProvenance(connectResult, connectionName); + if (connectResult.auth?.kind !== "oauth") fail("B.generic-connect", "oauth_challenge_missing"); + + const oauthStartResponse = await oauthStartResponsePromise.catch(() => null); + if (!oauthStartResponse || !oauthStartResponse.ok()) fail("C.oauth-start", "start_response_missing"); + const startResult = await oauthStartResponse.json().catch(() => fail("C.oauth-start", "invalid_json")); + if (startResult.connectionId !== connectionId) fail("C.oauth-start", "connection_id_changed"); + const registrationSource = assertAutomaticRegistrationSource(startResult.registrationSource); + const navigationProof = inspectAuthorizationUrl(startResult.authorizationUrl, { + callbackUrl: config.callbackUrl, + resource: NOTION_RESOURCE, + registrationSource, + baseUrl: config.baseUrl, + }); + + // Registration source and every URL/PKCE/state invariant are checked before + // the credential is entered into the provider page. + await completeNotionAuthorization(page, config, credential); + const cleanSetupPath = `/${TARGET_COMPANY_PREFIX}/apps/${connectionId}/setup`; + await page.goto(new URL(cleanSetupPath, 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"); + + let 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: connectResult.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); + + 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); + + 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); + + 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, + }; + + 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); + + summary.cleanup = await cleanupConnection(context.request, config, companyId, connectionId, connectionName); + cleanupComplete = true; + + 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("A.secret-binding", error.code) + : new SmokeFailure("unexpected", "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(); diff --git a/scripts/smoke/notion-generic-live.test.mjs b/scripts/smoke/notion-generic-live.test.mjs new file mode 100644 index 00000000000..6ed55fa77c0 --- /dev/null +++ b/scripts/smoke/notion-generic-live.test.mjs @@ -0,0 +1,187 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + assertAutomaticRegistrationSource, + assertSanitizedEvidence, + extractNotionIdentity, + inspectAuthorizationUrl, + NotionGenericLivePreflightError, + parseRuntimeAbsenceProof, + parseSanitizedAgentProof, + preflightNotionGenericLive, + prepareNotionGenericLiveSmoke, + safeEndpointSummary, +} from "./notion-generic-live-lib.mjs"; + +const COMPLETE_ENV = { + PAPERCLIP_E2E_BASE_URL: "https://paperclip.example.test", + PAPERCLIP_E2E_EMAIL: "operator@example.test", + PAPERCLIP_DEV_LOGIN_PASSWORD: "not-a-real-password", + PAPERCLIP_API_URL: "https://paperclip.example.test/api", + PAPERCLIP_API_KEY: "not-a-real-agent-key", + PAPERCLIP_RUN_ID: "run-123", + PAPERCLIP_TASK_ID: "issue-123", +}; + +test("preflight reports binding names without exposing supplied values", () => { + assert.throws( + () => preflightNotionGenericLive({ PAPERCLIP_DEV_LOGIN_PASSWORD: "present" }), + (error) => { + assert.ok(error instanceof NotionGenericLivePreflightError); + assert.equal(error.code, "missing_environment"); + assert.deepEqual(error.details.missing, [ + "PAPERCLIP_E2E_BASE_URL", + "PAPERCLIP_E2E_EMAIL", + "PAPERCLIP_API_URL", + "PAPERCLIP_API_KEY", + "PAPERCLIP_RUN_ID", + "PAPERCLIP_TASK_ID", + ]); + assert.doesNotMatch(error.message, /present/); + return true; + }, + ); +}); + +test("preflight requires explicit credential-free HTTPS target and control-plane URLs", () => { + for (const baseUrl of [ + "http://127.0.0.1:3100", + "http://paperclip.example.test", + "https://user:secret@paperclip.example.test", + "https://paperclip.example.test/?code=secret", + ]) { + assert.throws( + () => preflightNotionGenericLive({ ...COMPLETE_ENV, PAPERCLIP_E2E_BASE_URL: baseUrl }), + (error) => error instanceof NotionGenericLivePreflightError && error.code === "unsafe_base_url", + ); + } + const split = preflightNotionGenericLive({ + ...COMPLETE_ENV, + PAPERCLIP_API_URL: "https://control-plane.example.test/api", + }); + assert.equal(split.baseUrl, "https://paperclip.example.test"); + assert.equal(split.apiBaseUrl, "https://control-plane.example.test/api"); +}); + +test("health and binding metadata pass before browser loading, without fetching the value", async () => { + const requests = []; + let browserLoaded = false; + const prepared = await prepareNotionGenericLiveSmoke({ + environment: COMPLETE_ENV, + fetchImpl: async (url, init = {}) => { + requests.push({ url: String(url), method: init.method ?? "GET" }); + if (String(url).endsWith("/api/health")) { + return { ok: true, json: async () => ({ status: "ok" }) }; + } + return { + ok: true, + json: async () => ({ secrets: [{ key: "generic-flow-test-account", delivery: "api" }] }), + }; + }, + loadBrowser: async () => { + browserLoaded = true; + return { chromium: {} }; + }, + }); + assert.equal(prepared.config.callbackUrl, "https://paperclip.example.test/api/tools/oauth/callback"); + assert.equal(browserLoaded, true); + assert.deepEqual(requests.map((entry) => entry.method), ["GET", "GET"]); + assert.equal(requests.some((entry) => entry.url.includes("/value")), false); +}); + +test("an unavailable secret binding fails before browser or credential entry", async () => { + let browserLoaded = false; + await assert.rejects( + prepareNotionGenericLiveSmoke({ + environment: COMPLETE_ENV, + fetchImpl: async (url) => String(url).endsWith("/api/health") + ? { ok: true, json: async () => ({ status: "ok" }) } + : { ok: true, json: async () => ({ secrets: [] }) }, + loadBrowser: async () => { + browserLoaded = true; + }, + }), + (error) => error instanceof NotionGenericLivePreflightError && error.code === "secret_binding_unavailable", + ); + assert.equal(browserLoaded, false); +}); + +test("authorization proof requires automatic registration, PKCE, callback, resource, and safe endpoints", () => { + assert.equal(assertAutomaticRegistrationSource("cimd"), "cimd"); + assert.equal(assertAutomaticRegistrationSource("dcr"), "dcr"); + for (const source of ["manual", "preconfigured", null]) { + assert.throws( + () => assertAutomaticRegistrationSource(source), + (error) => error instanceof NotionGenericLivePreflightError && error.code === "unexpected_registration_source", + ); + } + + const baseUrl = "https://paperclip.example.test"; + const callbackUrl = `${baseUrl}/api/tools/oauth/callback`; + const resource = "https://mcp.notion.com/mcp"; + const url = new URL("https://mcp.notion.com/authorize"); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", `${baseUrl}/api/tools/oauth/client-metadata`); + url.searchParams.set("redirect_uri", callbackUrl); + url.searchParams.set("state", "not-recorded"); + url.searchParams.set("code_challenge", "not-recorded"); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("resource", resource); + + assert.deepEqual(inspectAuthorizationUrl(url.toString(), { + callbackUrl, + resource, + registrationSource: "cimd", + baseUrl, + }), { + endpoint: { origin: "https://mcp.notion.com", path: "/authorize" }, + parameters: { clientId: true, state: true, pkceS256: true, callbackUri: true, resource: true }, + }); + assert.deepEqual(safeEndpointSummary("https://mcp.notion.com/token", "token"), { + origin: "https://mcp.notion.com", + path: "/token", + }); + assert.throws( + () => safeEndpointSummary("http://mcp.notion.com/token", "token"), + (error) => error instanceof NotionGenericLivePreflightError && error.code === "unsafe_token_endpoint", + ); +}); + +test("workspace proof extraction and fresh-run comments retain only sanitized identity", () => { + const identity = extractNotionIdentity({ + result: { + content: [{ + type: "text", + text: JSON.stringify({ + id: "bot-123", + type: "bot", + bot: { workspace_id: "workspace-123", workspace_name: "Paperclip" }, + token: "discard-me", + }), + }], + }, + }); + assert.deepEqual(identity, { + workspaceId: "workspace-123", + workspaceName: "Paperclip", + botId: "bot-123", + }); + assert.deepEqual( + parseSanitizedAgentProof( + '{"workspaceId":"workspace-123","workspaceName":"Paperclip","invocationId":"inv-123"}', + identity, + ), + { workspaceId: "workspace-123", workspaceName: "Paperclip", invocationId: "inv-123" }, + ); + assert.deepEqual( + parseRuntimeAbsenceProof('{"connectionId":"conn-123","toolPresent":false}', "conn-123"), + { connectionId: "conn-123", toolPresent: false }, + ); +}); + +test("sanitized evidence rejects credential fields, sessions, and OAuth query values", () => { + assert.doesNotThrow(() => assertSanitizedEvidence({ workspaceId: "workspace-123", invocationId: "inv-123" })); + assert.throws(() => assertSanitizedEvidence({ accessToken: "secret" }), /unsafe_evidence_key/); + assert.throws(() => assertSanitizedEvidence({ sessionId: "secret" }), /unsafe_evidence_key/); + assert.throws(() => assertSanitizedEvidence({ note: "callback?code=secret" }), /unsafe_evidence_text/); +}); diff --git a/ui/src/pages/tools/PasteConfigTab.test.tsx b/ui/src/pages/tools/PasteConfigTab.test.tsx index 7607380dd27..8afd2caf9fc 100644 --- a/ui/src/pages/tools/PasteConfigTab.test.tsx +++ b/ui/src/pages/tools/PasteConfigTab.test.tsx @@ -11,10 +11,13 @@ import { PasteConfigTab } from "./PasteConfigTab"; const toolsApiMock = vi.hoisted(() => ({ importMcpJson: vi.fn(), connectApp: vi.fn(), + startOAuth: vi.fn(), finishApp: vi.fn(), })); const mockNavigate = vi.hoisted(() => vi.fn()); +const navigateTopLevelMock = vi.hoisted(() => vi.fn()); vi.mock("@/api/tools", () => ({ toolsApi: toolsApiMock })); +vi.mock("@/lib/browserNavigation", () => ({ navigateTopLevel: navigateTopLevelMock })); // The tab uses `useNavigate` from the app router (PAP-11088 draft hand-off), // which needs CompanyProvider; stub it so the copy hint renders in isolation. vi.mock("@/lib/router", () => ({ useNavigate: () => mockNavigate })); @@ -124,6 +127,47 @@ function connectResult(overrides: Partial = {}): ConnectTo }; } +const NOTION_CONFIG = '{ "mcpServers": { "notion": { "url": "https://mcp.notion.com/mcp" } } }'; + +const NOTION_PREVIEW: McpJsonImportPreview = { + drafts: [{ + name: "notion", + transport: "mcp_remote", + status: "draft", + config: { url: "https://mcp.notion.com/mcp" }, + credentialRefs: [], + credentialFields: [], + warnings: [], + }], +}; + +function oauthConnectResult(startUrl: string | null = null): ConnectToolAppResult { + const base = connectResult(); + return { + ...base, + application: { + ...base.application, + applicationKey: "app-gallery:link:notion-generic-test", + name: "notion", + metadata: { source: "link" }, + }, + connection: { + ...base.connection, + authKind: "oauth", + config: { url: "https://mcp.notion.com/mcp", unverifiedServer: true }, + transportConfig: { url: "https://mcp.notion.com/mcp", unverifiedServer: true }, + }, + catalog: [], + actions: { readOnly: [], canMakeChanges: [] }, + auth: { + kind: "oauth", + startUrl, + resource: "https://mcp.notion.com/mcp", + registrationSource: "cimd", + }, + }; +} + describe("PasteConfigTab — discoverability copy (PAP-11091)", () => { let container: HTMLDivElement; @@ -293,6 +337,92 @@ describe("PasteConfigTab — activation handoff (PAP-11092)", () => { }); }); + it("starts OAuth for the original generic connection instead of rendering an empty catalog", async () => { + await pasteAndCheck(NOTION_PREVIEW, NOTION_CONFIG); + const nameInput = container.querySelector('input[placeholder="notion"]') as HTMLInputElement; + await act(async () => setInputValue(nameInput, "Notion generic self-test 2026-08-17T20:00:00Z")); + await flushReact(); + toolsApiMock.connectApp.mockResolvedValue(oauthConnectResult()); + toolsApiMock.startOAuth.mockResolvedValue({ + connectionId: "conn-1", + provider: "mcp.notion.com", + authorizationUrl: "https://mcp.notion.com/authorize?state=redacted", + expiresAt: new Date(Date.now() + 60_000).toISOString(), + registrationSource: "cimd", + }); + + await act(async () => { + buttonStartingWith("Check actions")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + await flushReact(); + + expect(toolsApiMock.connectApp).toHaveBeenCalledTimes(1); + expect(toolsApiMock.connectApp).toHaveBeenCalledWith("company-1", { + link: "https://mcp.notion.com/mcp", + name: "Notion generic self-test 2026-08-17T20:00:00Z", + credentialValues: {}, + }); + expect(toolsApiMock.startOAuth).toHaveBeenCalledWith("conn-1"); + expect(navigateTopLevelMock).toHaveBeenCalledWith( + "https://mcp.notion.com/authorize?state=redacted", + ); + expect(container.textContent).toContain("Unverified server"); + expect(container.textContent).toContain("mcp.notion.com"); + expect(container.textContent).not.toContain("Review actions for notion"); + }); + + it("rejects an unsafe start URL and retries OAuth on the same connection", async () => { + await pasteAndCheck(NOTION_PREVIEW, NOTION_CONFIG); + toolsApiMock.connectApp.mockResolvedValue(oauthConnectResult("javascript:alert(1)")); + + await act(async () => { + buttonStartingWith("Check actions")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(navigateTopLevelMock).not.toHaveBeenCalled(); + expect(container.textContent).toContain("couldn’t connect"); + expect(container.textContent).not.toContain("javascript:alert(1)"); + + toolsApiMock.startOAuth.mockResolvedValue({ + connectionId: "conn-1", + provider: "mcp.notion.com", + authorizationUrl: "https://mcp.notion.com/authorize?state=retry-redacted", + expiresAt: new Date(Date.now() + 60_000).toISOString(), + registrationSource: "cimd", + }); + await act(async () => { + buttonStartingWith("Try again")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + await flushReact(); + + expect(toolsApiMock.connectApp).toHaveBeenCalledTimes(1); + expect(toolsApiMock.startOAuth).toHaveBeenCalledTimes(1); + expect(toolsApiMock.startOAuth).toHaveBeenCalledWith("conn-1"); + expect(navigateTopLevelMock).toHaveBeenCalledWith( + "https://mcp.notion.com/authorize?state=retry-redacted", + ); + }); + + it("cancels to the original connection setup route without creating another draft", async () => { + await pasteAndCheck(NOTION_PREVIEW, NOTION_CONFIG); + toolsApiMock.connectApp.mockResolvedValue(oauthConnectResult("javascript:alert(1)")); + + await act(async () => { + buttonStartingWith("Check actions")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + await act(async () => { + buttonStartingWith("Back to apps")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(mockNavigate).toHaveBeenCalledWith("/apps/conn-1/setup"); + expect(toolsApiMock.connectApp).toHaveBeenCalledTimes(1); + expect(toolsApiMock.startOAuth).not.toHaveBeenCalled(); + }); + it("does not offer Continue for a stdio draft (draft-only, no link to hand off)", async () => { await pasteAndCheck( { diff --git a/ui/src/pages/tools/PasteConfigTab.tsx b/ui/src/pages/tools/PasteConfigTab.tsx index ea14b7eb6eb..e62e6df52e2 100644 --- a/ui/src/pages/tools/PasteConfigTab.tsx +++ b/ui/src/pages/tools/PasteConfigTab.tsx @@ -13,6 +13,14 @@ import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { toolsApi } from "@/api/tools"; +import { resolveAuthorizationTarget } from "@/lib/authorizationUrl"; +import { navigateTopLevel } from "@/lib/browserNavigation"; +import { useNavigate } from "@/lib/router"; +import { + OAuthConnectStateScreen, + type OAuthConnectPhase, +} from "@/pages/apps/AppsConnect"; +import { endpointHost } from "@/pages/apps/generic-mcp-connect"; import { McpConfigHelpDialog } from "./McpConfigHelpDialog"; import { ErrorState } from "./shared"; @@ -92,34 +100,85 @@ function askFirstLevelsFrom(result: ConnectToolAppResult): string[] { * of the two M8 screens where "MCP" vocabulary is allowed (PAP-10827 vocab map). */ export function PasteConfigTab({ companyId }: { companyId: string }) { + const navigate = useNavigate(); const [draftText, setDraftText] = useState(""); const [preview, setPreview] = useState(null); + const [connectionNames, setConnectionNames] = useState>({}); const [credentialValues, setCredentialValues] = useState>({}); const [connectResult, setConnectResult] = useState(null); const [enabled, setEnabled] = useState>({}); const [activatedName, setActivatedName] = useState(null); + const [oauthPhase, setOAuthPhase] = useState("entry"); + const [oauthError, setOAuthError] = useState(null); + const [authorizationHost, setAuthorizationHost] = useState(null); const importMutation = useMutation({ mutationFn: (mcpJson: string) => toolsApi.importMcpJson(companyId, { mcpJson }), onSuccess: (result) => { setPreview(result); + setConnectionNames(Object.fromEntries(result.drafts.map((draft) => [draft.name, draft.name]))); setConnectResult(null); setActivatedName(null); }, }); + const openAuthorizationPage = (authorizationUrl: string) => { + const target = resolveAuthorizationTarget(authorizationUrl); + if (!target.ok) { + setOAuthPhase("error"); + setOAuthError(target.message); + return; + } + setAuthorizationHost(target.host); + setOAuthPhase("redirecting"); + navigateTopLevel(target.url); + }; + + const oauthStartMutation = useMutation({ + mutationFn: (connectionId: string) => toolsApi.startOAuth(connectionId), + onSuccess: ({ authorizationUrl }) => openAuthorizationPage(authorizationUrl), + onError: (error) => { + setOAuthPhase("error"); + setOAuthError( + error instanceof Error + ? error.message + : "Paperclip couldn’t start secure sign-in. Try again.", + ); + }, + }); + const connectMutation = useMutation({ mutationFn: (draft: McpJsonImportDraft) => { const url = draftConnectUrl(draft); if (!url) throw new Error("Only remote HTTP drafts can be checked and activated from pasted config."); return toolsApi.connectApp(companyId, { link: url, - name: draft.name, + name: connectionNames[draft.name]?.trim() || draft.name, credentialValues: credentialValuesForDraft(draft, credentialValues), }); }, onSuccess: (result) => { setConnectResult(result); + if (result.auth?.kind === "oauth") { + setActivatedName(null); + setAuthorizationHost(null); + setOAuthError(null); + if (result.auth.manualClientRequired) { + setOAuthPhase("error"); + setOAuthError( + "This server requires OAuth client details from its provider settings. Continue in setup to add them.", + ); + return; + } + const startUrl = result.auth.startUrl?.trim(); + if (startUrl) { + openAuthorizationPage(startUrl); + } else { + setOAuthPhase("starting"); + oauthStartMutation.mutate(result.connectionId); + } + return; + } const defaults: Record = {}; for (const action of result.actions.readOnly) defaults[action.catalogEntryId] = true; for (const action of result.actions.canMakeChanges) defaults[action.catalogEntryId] = false; @@ -158,6 +217,32 @@ export function PasteConfigTab({ companyId }: { companyId: string }) { } }, [draftText]); + if (connectResult?.auth?.kind === "oauth") { + const connectionUrl = typeof connectResult.connection.config?.url === "string" + ? connectResult.connection.config.url + : typeof connectResult.connection.transportConfig?.url === "string" + ? connectResult.connection.transportConfig.url + : ""; + return ( + { + setOAuthError(null); + setAuthorizationHost(null); + setOAuthPhase("starting"); + oauthStartMutation.mutate(connectResult.connectionId); + }} + onCancel={() => navigate(`/apps/${connectResult.connectionId}/setup`)} + /> + ); + } + return (
@@ -229,6 +314,10 @@ export function PasteConfigTab({ companyId }: { companyId: string }) { + setConnectionNames((previous) => ({ ...previous, [draft.name]: value })) + } credentialValues={credentialValues} onCredentialChange={(configPath, value) => setCredentialValues((prev) => ({ ...prev, [credentialValueKey(draft, configPath)]: value })) @@ -279,6 +368,8 @@ export function PasteConfigTab({ companyId }: { companyId: string }) { function DraftCard({ draft, + connectionName, + onConnectionNameChange, credentialValues, onCredentialChange, checking, @@ -286,6 +377,8 @@ function DraftCard({ onCheck, }: { draft: McpJsonImportDraft; + connectionName: string; + onConnectionNameChange: (value: string) => void; credentialValues: Record; onCredentialChange: (configPath: string, value: string) => void; checking: boolean; @@ -307,6 +400,18 @@ function DraftCard({ ) : null}
+ {onCheck ? ( + + ) : null} + {draft.credentialFields.length > 0 ? (
{draft.credentialFields.map((field) => (