From 3ce8d976a9222bbf39e04c1ab0464df362bcdb48 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:07:18 -0500 Subject: [PATCH] Add experimental Paperclip Notebook MCP connection (#16984) ## Thinking Path > - Paperclip connects agents to external tools through saved accounts and per-action permissions. > - Notebook exposes artifact management through an ordinary remote MCP server with OAuth. > - A private pilot needs a hidden, default-off catalog and execution gate. > - Local file publishing belongs to Notebook, which can accept bounded file bytes through ordinary MCP tools. > - Paperclip therefore needs the connector registration and generic availability checks, with no file-transfer architecture. ## Linked Issues or Issue Description **Subsystem affected** Apps catalog, experimental feature metadata, connection setup, and connector availability. **Problem or motivation** Users need to connect Notebook as their Cloud account, assign its tools to agents, and retain Paperclip's normal Allowed / Ask first / Off rules. The private pilot must remain hidden until an operator enables it. Notebook should own its upload protocol and local publishing script. **Proposed solution** Register a personal remote MCP OAuth connection at Notebook's canonical endpoint. Reuse existing OAuth discovery, credential storage, refresh, reconnect, and removal. Enforce availability through generic catalog feature metadata and saved application identity. Keep Notebook's standalone uploader in its own service repository; it calls the ordinary gateway and receives no saved OAuth credential. **Alternatives considered** A gallery-only filter permits retained tools and deep links while disabled. A shared workspace publisher and private upload-ticket transport add runtime behavior that a normal MCP connector does not require. Notebook can receive file bytes directly through its own tools instead. **Roadmap alignment** This extends Connected Apps and supports Artifacts and Work Products. ROADMAP.md and related PRs were checked. This PR is standalone on master. The separate OAuth lifecycle and file-transfer proposals (#17085 and #17086) are closed and are not dependencies. ## What Changed - Register Notebook's personal MCP OAuth method, canonical endpoint, scopes, catalog metadata, and branding. - Declare managed `enablePaperclipNotebook` with false defaults and no user-facing settings toggle or staging opt-in. - Use generic availability metadata for gallery/setup, retained connection identity, OAuth completion, health, tool/context discovery, and dispatch. Resources/prompts recheck before credentials, including retained handles. Removal remains available while disabled. - Resolve catalog application identity from the saved application key, so editable connection labels cannot bypass the hidden gate. - Select a method's sole supported credential kind when no kind is supplied. - Keep UI changes to metadata-driven availability and existing setup/reconnect controls. - Document Notebook's service-owned upload script, inline publication, ordinary action policies, and credential behavior. - Add ordinary MCP OAuth, assignment, action-policy, disabled-state, settings, and catalog tests. No workspace contribution registry, private transfer endpoint, native publisher, Runner file-reader change, or Paperclip CLI upload command is included. The entire native-runtime and CLI trees match master. Upload code lives in `paperclip-cloud/notebook/`. ## Verification Source head: `65ef741e4f476c67425b57d2fb2d6d4c00e99b06`, directly on master `53732c811bb69655eb43d3d12f2063ae4ca5ad67`. The diff is 42 files. Shared execution changes only enforce generic catalog availability; tool access also resolves saved application identity and the sole supported credential kind. Neither module contains Notebook upload logic. - Catalog/shared/settings/UI suites: 592 tests passed across 12 files. Shared/server/UI typechecks passed using installed compilers. - Fresh real PostgreSQL generic MCP suite: 84/84 passed at final source; server typecheck and module boundaries pass. Coverage includes personal OAuth, ordinary refresh/reconnect, assignments, Allowed / Ask first / Off, hidden feature enforcement, and local credential removal. Four new resources/prompts cases failed against the prior code and pass after filtering saved application identity and rechecking before credentials. They prove missing/relabelled setup markers and stale eligibility cannot dispatch or read secrets while disabled; an ordinary provider remains available with the flag off. - The full tool-access service suite passed 463/464 cases. One unchanged Google Workspace `slides.write` case timed out at 15 seconds; it passed on one isolated retry without source or timeout changes. Its exact test block matches master. The cause is not established; final-head CI must qualify the complete suite. - Maps, module boundaries, token gates, and whitespace checks passed. Byte comparisons confirm the former transfer/runtime/CLI surfaces match master. - Fresh exact-head Greptile is 5/5 with zero unresolved findings after an exhaustive feedback audit. Current-head CI workflow [39625691137](https://github.com/paperclipai/paperclip/actions/runs/39625691137) succeeded, including both required checks (`ci / verify`, `ci / e2e`), Build, and browser E2E. Supplementary security/advisory scan status is recorded in the PR Checks panel and qualification receipt; prior-head scans are not counted. The superseded `4bd7af9b2e` completed CI successfully, but Greptile found the valid resources/prompts availability gap described above. It was fixed, replied to, and resolved; earlier-head results do not qualify this revision. Preserved environment failures: the first socket-restricted UI/route run had 93 listener setup failures, and the same 93 cases passed with local-socket access. The package-manager launcher could not fetch its signature metadata; installed compilers passed without dependency or configuration changes. The full service timeout and isolated retry remain recorded separately. The superseded `585e870e73` CI also failed an unchanged agent-chat project-plan revision assertion after task creation; the complete spec and relevant runtime sources match master, and a same-master prior run passed. Its cause is not established. Logs are preserved; no test or timeout was weakened and no old-head rerun was requested. The subsequent heads run full CI independently; no prior-head pass substitutes for current qualification. The Notebook uploader requires a separate service release. This public PR adds no uploader runtime to Paperclip and claims no newly deployed browser/agent acceptance. The Notebook-owned upload path is qualified separately. Code-owner review remains required before merge. ## Risks - The feature flag gates the curated connector; it does not create a Notebook service-side team allowlist or block arbitrary standard MCP URLs. - Publishing, sharing, and deletion retain the existing assignment and action-policy boundaries. - The hidden feature must be checked for retained tools and saved identity, including when connection labels change. - Disconnect removes local credentials. It does not revoke the remote Notebook authorization; the owner can revoke it in Notebook. - Ordinary refresh uses the existing client. A lost response after refresh-token rotation can require reconnect. - The standalone Notebook script and its bounded upload tools must ship together in the Notebook service before that documented convenience path is available. - No database schema, workflow, lockfile, shared transfer, or Runner change is included. No merge, deployment, or rollout is performed by this update. ## Model Used OpenAI GPT-6 in Codex, with reasoning, repository inspection, code execution, and tool use. The runtime did not expose a more specific model ID or context-window size. ## 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 --- doc/connections/CONNECTOR-PLAYBOOK.md | 6 + doc/connections/PAPERCLIP-NOTEBOOK.md | 101 +++++ .../tool-method-permission-reviews.json | 20 + maps/agent-access-map/connection-setup.md | 18 + maps/feature-map/connection-setup.md | 17 + .../shared/src/app-definitions-url.test.ts | 7 + .../shared/src/app-definitions.generated.ts | 5 +- packages/shared/src/app-definitions.test.ts | 3 +- packages/shared/src/app-definitions.ts | 1 + .../app-definitions/paperclip-notebook.json | 50 +++ .../shared/src/connector-availability.test.ts | 27 ++ packages/shared/src/connector-availability.ts | 9 + .../shared/src/connector-identity.test.ts | 13 + packages/shared/src/connector-identity.ts | 8 + packages/shared/src/feature-catalog.test.ts | 6 + packages/shared/src/feature-catalog.ts | 7 + packages/shared/src/index.ts | 2 + packages/shared/src/types/app-definition.ts | 2 +- packages/shared/src/types/instance.ts | 1 + .../shared/src/validators/app-definition.ts | 3 +- .../shared/src/validators/instance.test.ts | 12 + packages/shared/src/validators/instance.ts | 1 + .../paperclip-notebook.json | 50 +++ .../__tests__/generic-mcp-connection.test.ts | 365 +++++++++++++++++- .../instance-settings-managed-overlay.test.ts | 37 ++ .../instance-settings-routes.test.ts | 32 ++ .../instance-settings-service.test.ts | 13 + .../src/__tests__/tool-access-service.test.ts | 25 +- server/src/routes/tool-access.ts | 6 +- server/src/services/connection-intents.ts | 16 +- server/src/services/instance-settings.ts | 2 + server/src/services/tool-access.ts | 37 +- server/src/services/tool-gateway.ts | 30 +- ui/public/brands/apps/manifest.json | 12 +- .../brands/apps/paperclip-notebook-dark.svg | 1 + ui/public/brands/apps/paperclip-notebook.svg | 1 + .../connections/ConnectionSetupFlow.tsx | 30 +- ui/src/hooks/useConnectorAvailability.ts | 15 + .../InstanceExperimentalSettings.test.tsx | 15 + ui/src/pages/apps/AppsConnect.test.tsx | 58 +++ ui/src/pages/apps/Browse.test.tsx | 62 +++ ui/src/pages/apps/Browse.tsx | 17 +- 42 files changed, 1095 insertions(+), 48 deletions(-) create mode 100644 doc/connections/PAPERCLIP-NOTEBOOK.md create mode 100644 packages/shared/src/app-definitions/paperclip-notebook.json create mode 100644 packages/shared/src/connector-availability.test.ts create mode 100644 packages/shared/src/connector-availability.ts create mode 100644 packages/shared/src/connector-identity.test.ts create mode 100644 packages/shared/src/connector-identity.ts create mode 100644 scripts/app-definition-overrides/paperclip-notebook.json create mode 100644 ui/public/brands/apps/paperclip-notebook-dark.svg create mode 100644 ui/public/brands/apps/paperclip-notebook.svg create mode 100644 ui/src/hooks/useConnectorAvailability.ts diff --git a/doc/connections/CONNECTOR-PLAYBOOK.md b/doc/connections/CONNECTOR-PLAYBOOK.md index f924f9aa9e4..8f4b2566b33 100644 --- a/doc/connections/CONNECTOR-PLAYBOOK.md +++ b/doc/connections/CONNECTOR-PLAYBOOK.md @@ -721,6 +721,12 @@ as `agentmail_send` rather than extending core tools with provider-specific branches. Existing MCP connectors continue to use their normal MCP tool catalog; they do not need a duplicate native wrapper just to supply a skill. +For a gated catalog connector, declare `experimentalFeatureKey` in its +`AppDefinition`. Shared availability checks resolve the saved application key +and evaluate that feature before setup, discovery, and execution. Keep the +feature's settings visibility separate from this enforcement. Do not add +provider-name checks to the gateway or setup screens. + **Resolve eligibility from current assignments and access.** An AgentMail account credential alone does not give an agent email capabilities. An active inbox assigned to that agent does, provided both the inbox connection and saved diff --git a/doc/connections/PAPERCLIP-NOTEBOOK.md b/doc/connections/PAPERCLIP-NOTEBOOK.md new file mode 100644 index 00000000000..aa970293e88 --- /dev/null +++ b/doc/connections/PAPERCLIP-NOTEBOOK.md @@ -0,0 +1,101 @@ +# Paperclip Notebook + +Paperclip Notebook uses the existing remote HTTP MCP connection, authorization-code +OAuth with PKCE, Dynamic Client Registration, and instance credential vault. +Hosted and self-hosted Paperclip use the same connection protocol; Cloud broker +enrollment is unnecessary. Cloud authenticates the person, while Notebook owns +pages, reader policies, deployments, and its resource-specific OAuth grants. + +## Connection and rollout + +The curated connection uses `https://notebook.paperclip.app/mcp`. It is hidden +behind the default-off `enablePaperclipNotebook` flag, with no Settings toggle, +including developer mode. Operators can enable it through managed configuration +(`features.enablePaperclipNotebook: true`); self-hosted operators can use the +existing instance experimental-settings API. Cloud tenants cannot self-enable +this hidden flag, including through staging tenant opt-ins. + +Catalog metadata and the saved application identity govern availability. While +disabled, setup, discovery, reconnect, OAuth completion, and curated Notebook +calls are denied. Local removal and grant revocation remain available. This +rollout gate does not blacklist independent generic remote MCP endpoints. + +The personal connection requests `notebook:read`, `notebook:write`, +`notebook:share`, and `notebook:delete`. Sharing includes publishing a page +publicly in Explore. Notebook enforces ownership and reader policy; Paperclip +continues to enforce current agent assignment and Allowed / Ask first / Off +controls. Connection labels use the normal catalog name. Reconnect preserves +assignments and action restrictions. + +## Publishing local files + +A remote MCP server cannot read an agent-local filesystem path. Ordinary MCP +clients can use `notebook_publish` for inline UTF-8 or base64 content up to 1 MiB +decoded. Clients that upload directly can continue to use the existing scoped +S3-ticket protocol; upload URLs are temporary authority and must not be logged. + +For a local file or built directory, Notebook provides a standalone Node.js +script. It is owned and served by Notebook; Paperclip adds no publishing CLI +command, native runtime tool, private transfer channel, or credential flow. +The script requires a Notebook release advertising `uploadLimits` through +`notebook_get_account` and supporting `notebook_begin_upload` with +`transport: "mcp"` plus `notebook_upload_chunk`. The script and these tools must +be deployed together; this source addition does not establish that deployment. + +Run it only in an approved agent shell that already has `PAPERCLIP_API_URL`, +`PAPERCLIP_API_KEY`, and `PAPERCLIP_RUN_ID`. These are the existing Paperclip run +credentials, not Notebook credentials. It creates no login or API key. Download +and inspect the trusted script before running it: + +```sh +curl --fail --silent --show-error --proto '=https' \ + https://notebook.paperclip.app/client/publish.mjs \ + -o /tmp/paperclip-notebook-publish.mjs +node /tmp/paperclip-notebook-publish.mjs ./dist \ + --connection CONNECTION_ID --title "Weekly report" \ + --idempotency-key weekly-report-build +``` + +Build client applications before publishing. Use `--entry-file` and `--spa` +when needed. Updates require `--page-id` and `--expected-version`. The script +reads only the selected local source under the shell's existing filesystem +authority. It rejects symbolic links, nonregular files, noncanonical paths, and advertised +size/file limits before publication mutations, then verifies declared content +again before sending chunks. +Agents without those shell credentials or filesystem access use the ordinary +MCP tools available to their runtime; the script grants neither authority. + +The script opens an ordinary run-bound tool gateway session and sends every +operation through `/api/tool-gateway/tools/call` on the selected connection. +Notebook OAuth credentials remain in Paperclip's vault. File bytes travel as +bounded base64 tool arguments under normal gateway handling, not through signed +upload URLs or a private data channel. Notebook verifies chunk identity, +checksums, ownership, scopes, and the original upload window before publication. + +If an action requires approval, stop and wait for the normal task approval. +Rerun with the same publication key and unchanged files after approval or a lost +response; Notebook reports the missing chunk indexes so completed chunks are +not repeated. Off actions, removed assignments, disabled connections, and the +rollout gate continue to prevent dispatch. Throttles are honored within a +bounded attempt. Uploads expire after 15 minutes; an expired upload requires a +new publication key. A validating operation is not a working link: use +`notebook_get_operation` until Notebook reports success and the effective audience. + +## Verification and limits + +The connection fixtures exercise personal OAuth, registration, PKCE/resource +binding, tool discovery, assignment, refresh, reconnect policy preservation, +and local removal. Uploader tests use real local files and controlled HTTP +services; final acceptance must additionally exercise the ordinary authenticated +Paperclip gateway against Notebook. These checks do not establish deployed +hosted/self-hosted browser acceptance or maximum-path AWS publication. Notebook +advertises accepted upload limits; clients do not infer its S3 key layout. + +Removing a connection disables local use and clears saved credentials through +Paperclip's existing removal flow. It does not automatically revoke Notebook's +remote authorization; revoke that separately in Notebook's Connected apps. +Automatic upstream revocation, account-email labels, and custom rotating-refresh +response recovery remain deferred. A lost refresh response may require reconnecting. + +Notebook artwork uses the repository's first-party Paperclip mark, with fixed +light and dark variants for the connector icon renderer. diff --git a/doc/connections/tool-method-permission-reviews.json b/doc/connections/tool-method-permission-reviews.json index b22e6554de4..b81e206d17f 100644 --- a/doc/connections/tool-method-permission-reviews.json +++ b/doc/connections/tool-method-permission-reviews.json @@ -2746,6 +2746,26 @@ "reviewedAt": "2026-10-15", "liveProof": "not-run", "keyPermissions": "Create an organization key with the read, write, and delete permissions for the resources you intend to manage. Daytona toolbox access is available to every valid organization key, even without selected scopes. Some user or administration endpoints require provider user authentication and will reject an API key." + }, + { + "app": "paperclip-notebook", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "notebook:read", + "notebook:write", + "notebook:share", + "notebook:delete" + ], + "capability": "write", + "supportedActions": "Read available pages; create and update owned pages; change sharing including public discovery; manage versions; delete and restore owned pages.", + "evidence": [ + "doc/connections/PAPERCLIP-NOTEBOOK.md" + ], + "reviewedAt": "2026-10-20", + "liveProof": "not-run", + "evidenceLimit": "First-party implementation contract; canonical service availability and live OAuth qualification are deployment prerequisites. Uses the existing personal OAuth flow with default connection labels; automatic remote revocation and custom keyed refresh recovery are deferred." } ] } diff --git a/maps/agent-access-map/connection-setup.md b/maps/agent-access-map/connection-setup.md index ee64513a67a..0d3c322f2a8 100644 --- a/maps/agent-access-map/connection-setup.md +++ b/maps/agent-access-map/connection-setup.md @@ -43,6 +43,24 @@ remaining operations before qualifying the whole capability. Gaps: Runtime tool broker searches available services without granting connection administration. +Notebook is internally gated by `enablePaperclipNotebook` (default off, no settings toggle). Disabled instances hide setup/discovery and deny curated Notebook dispatch and publishing; local removal and grant revocation remain available. + +The connector catalog declares the feature key. Shared availability checks use +the saved application identity. Notebook owns publication and file uploads; +Paperclip uses its existing MCP tools and OAuth flow. + +Notebook source addition: `paperclip-notebook` is discoverable through +`connections_search` and requestable through `connection_request`. Its standard +personal authorization requests read, write, share, and delete scopes. Notebook +page ownership remains enforced by the remote service; a client-supplied agent +label grants no authority. The direct MCP connection requires no Cloud instance +enrollment. Notebook fixture evidence: `verified` against disposable PostgreSQL 18 +with controlled MCP responses — [Notebook integration](../../doc/connections/PAPERCLIP-NOTEBOOK.md) +and [generic OAuth fixture](../../server/src/__tests__/generic-mcp-connection.test.ts). +Ordinary MCP calls retain assignment and Allowed / Ask first / Off policy. +Live deployed publication and hosted/self-hosted OAuth remain release checks. + + ## `identity-and-access` Capability ID: `connection-setup/identity-and-access`. diff --git a/maps/feature-map/connection-setup.md b/maps/feature-map/connection-setup.md index b3017e5db66..3efb0d3713b 100644 --- a/maps/feature-map/connection-setup.md +++ b/maps/feature-map/connection-setup.md @@ -99,6 +99,23 @@ saved connection. Run a permitted read-only action with an eligible agent and verify its useful result. Try an ineligible agent and confirm denial. Repeat with a user who cannot install company-wide; the UI must not imply broad access. +Notebook is internally gated by `enablePaperclipNotebook` (default off, no settings toggle). Disabled instances hide setup/discovery and deny curated Notebook dispatch and publishing; local removal and grant revocation remain available. + +Availability is catalog metadata evaluated by shared connector checks. Notebook +publication and file uploads stay in the Notebook service. + +Paperclip Notebook uses the existing personal-account MCP OAuth setup at +`https://notebook.paperclip.app/mcp`. Sign in through Cloud, assign agents, and +verify tool discovery. The connection keeps its default catalog name. +Reconnect must preserve agent assignments and Allowed / Ask first / Off choices. +Removal disables local use and clears saved credentials. Remote authorization +can be revoked separately in Notebook's Connected apps screen; automatic remote +cleanup and custom keyed refresh recovery are deferred. A lost rotating-token +response may require reconnecting. Notebook tools use the selected connection's +ordinary action gateway. See [Notebook](../../doc/connections/PAPERCLIP-NOTEBOOK.md) +for the Notebook-owned uploader and fixture coverage. Live Notebook qualification +still requires the deployed service and both hosted and self-hosted clients. + Daytona follows this same key/access wizard. Its native method exposes the published platform and Toolbox HTTP actions using provider sandbox IDs, without a Paperclip task or lease requirement. [Daytona browser setup](../../tests/e2e/daytona-connector.spec.ts) diff --git a/packages/shared/src/app-definitions-url.test.ts b/packages/shared/src/app-definitions-url.test.ts index 7a8e327ceae..4070f908512 100644 --- a/packages/shared/src/app-definitions-url.test.ts +++ b/packages/shared/src/app-definitions-url.test.ts @@ -53,6 +53,13 @@ describe("tool app gallery URL matching", () => { } }); + it("uses Notebook's canonical endpoint without per-instance configuration", () => { + const notebook = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "paperclip-notebook"); + expect(notebook?.availability?.available).toBe(true); + expect(notebook?.methods[0]?.defaults?.serverUrl).toBe("https://notebook.paperclip.app/mcp"); + expect(getAppDefinitionForUrl("https://notebook.paperclip.app/p/page-id")?.slug).toBe("paperclip-notebook"); + }); + it("offers region and custom model connections without claiming arbitrary URLs", () => { for (const slug of ["bedrock", "responses-api", "messages-api", "chat-completions-api", "local"]) { const app = CONNECTABLE_APP_DEFINITIONS.find(app => app.slug === slug); diff --git a/packages/shared/src/app-definitions.generated.ts b/packages/shared/src/app-definitions.generated.ts index 467412e6ea1..cc57bf2b612 100644 --- a/packages/shared/src/app-definitions.generated.ts +++ b/packages/shared/src/app-definitions.generated.ts @@ -102,6 +102,7 @@ import a100 from "./app-definitions/chat-completions-api.json" with { type: "jso import a101 from "./app-definitions/local.json" with { type: "json" }; import a102 from "./app-definitions/daytona.json" with { type: "json" }; import a103 from "./app-definitions/monday.json" with { type: "json" }; -import a104 from "./app-definitions/speko.json" with { type: "json" }; +import a104 from "./app-definitions/paperclip-notebook.json" with { type: "json" }; +import a105 from "./app-definitions/speko.json" with { type: "json" }; import type { AppDefinition } from "./types/app-definition.js"; -export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61,a62,a63,a64,a65,a66,a67,a68,a69,a70,a71,a72,a73,a74,a75,a76,a77,a78,a79,a80,a81,a82,a83,a84,a85,a86,a87,a88,a89,a90,a91,a92,a93,a94,a95,a96,a97,a98,a99,a100,a101,a102,a103,a104] as AppDefinition[]; +export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61,a62,a63,a64,a65,a66,a67,a68,a69,a70,a71,a72,a73,a74,a75,a76,a77,a78,a79,a80,a81,a82,a83,a84,a85,a86,a87,a88,a89,a90,a91,a92,a93,a94,a95,a96,a97,a98,a99,a100,a101,a102,a103,a104,a105] as AppDefinition[]; diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index a7c6bf0cc7d..7629aadb6e0 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -32,6 +32,7 @@ import { appDefinitionSchema, appDefinitionsSchema } from "./validators/app-defi const googleScope = (scope: string) => `https://www.googleapis.com/auth/${scope}`; + const GOOGLE_WORKSPACE_PROFILE_EXPECTATIONS = [ { profile: "gmail.read", @@ -885,7 +886,7 @@ describe("AppDefinition catalog", () => { "ticktick", "xero", ]); - expect(APP_STORE_DEFINITIONS).toHaveLength(82); + expect(APP_STORE_DEFINITIONS).toHaveLength(83); const connectableSlugs = new Set( CONNECTABLE_APP_DEFINITIONS.map((entry) => entry.slug), ); diff --git a/packages/shared/src/app-definitions.ts b/packages/shared/src/app-definitions.ts index d88f8509157..4f325b202e3 100644 --- a/packages/shared/src/app-definitions.ts +++ b/packages/shared/src/app-definitions.ts @@ -18,6 +18,7 @@ export const CONNECTABLE_APP_SLUGS = new Set([ "executor", "slack", "notion", + "paperclip-notebook", "railway", "posthog", "linear", diff --git a/packages/shared/src/app-definitions/paperclip-notebook.json b/packages/shared/src/app-definitions/paperclip-notebook.json new file mode 100644 index 00000000000..4bdf194d6ba --- /dev/null +++ b/packages/shared/src/app-definitions/paperclip-notebook.json @@ -0,0 +1,50 @@ +{ + "schemaVersion": 1, + "slug": "paperclip-notebook", + "experimentalFeatureKey": "enablePaperclipNotebook", + "name": "Paperclip Notebook", + "description": "Publish, find, and share reports, files, and interactive pages with your Paperclip Cloud account.", + "categories": [ + "content", + "productivity" + ], + "featured": true, + "branding": { + "logoUrl": "/brands/apps/paperclip-notebook.svg", + "darkLogoUrl": "/brands/apps/paperclip-notebook-dark.svg" + }, + "urlPatterns": [ + "https://notebook.paperclip.app/*" + ], + "availability": { + "available": true + }, + "methods": [ + { + "key": "mcp-oauth", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "dcr" + ], + "oauthClientRegistration": "dcr", + "grantKinds": [ + "user" + ], + "whenToUse": "Sign in with Paperclip Cloud and let your assigned agents publish and manage your Notebook pages.", + "defaults": { + "serverUrl": "https://notebook.paperclip.app/mcp", + "scopesHint": [ + "notebook:read", + "notebook:write", + "notebook:share", + "notebook:delete" + ], + "mcpSessionRequired": true + }, + "guidanceMd": "Connect your Paperclip Cloud account. Assigned agents can read available pages, publish and update your pages, change sharing including making pages public, manage versions, and delete or restore your pages. Manage individual actions in Permissions.", + "riskTier": "S3" + } + ], + "redirectConstraints": "https-or-loopback-http" +} diff --git a/packages/shared/src/connector-availability.test.ts b/packages/shared/src/connector-availability.test.ts new file mode 100644 index 00000000000..a1e17eb4072 --- /dev/null +++ b/packages/shared/src/connector-availability.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { getConnectableAppDefinition } from "./app-definitions.js"; +import { isConnectorFeatureEnabled } from "./connector-availability.js"; +import { appDefinitionSchema } from "./validators/app-definition.js"; + +describe("connector availability", () => { + const ordinary = getConnectableAppDefinition("notion")!; + const gated = { ...ordinary, slug: "private-research", name: "Private Research", experimentalFeatureKey: "enableCases" as const }; + + it("honors feature metadata for an arbitrary connector and leaves ungated connectors alone", () => { + for (const settings of [undefined, null, {}, { enableCases: false }]) { + expect(isConnectorFeatureEnabled(gated, settings)).toBe(false); + expect(isConnectorFeatureEnabled(ordinary, settings)).toBe(true); + } + expect(isConnectorFeatureEnabled(gated, { enablePaperclipNotebook: true })).toBe(false); + expect(isConnectorFeatureEnabled(gated, { enableCases: true })).toBe(true); + }); + + it("validates the feature key and declares Notebook's hidden rollout in its manifest", () => { + expect(appDefinitionSchema.parse(gated).experimentalFeatureKey).toBe("enableCases"); + expect(appDefinitionSchema.safeParse({ ...gated, experimentalFeatureKey: "typoFeature" }).success).toBe(false); + const notebook = getConnectableAppDefinition("paperclip-notebook")!; + expect(notebook.experimentalFeatureKey).toBe("enablePaperclipNotebook"); + expect(isConnectorFeatureEnabled(notebook, {})).toBe(false); + expect(isConnectorFeatureEnabled(notebook, { enablePaperclipNotebook: true })).toBe(true); + }); +}); diff --git a/packages/shared/src/connector-availability.ts b/packages/shared/src/connector-availability.ts new file mode 100644 index 00000000000..1e1b5c0a340 --- /dev/null +++ b/packages/shared/src/connector-availability.ts @@ -0,0 +1,9 @@ +import type { AppDefinition } from "./types/app-definition.js"; +import type { InstanceExperimentalSettings } from "./types/instance.js"; + +export function isConnectorFeatureEnabled( + app: Pick | null | undefined, + settings: Partial | null | undefined, +): boolean { + return !app?.experimentalFeatureKey || settings?.[app.experimentalFeatureKey] === true; +} diff --git a/packages/shared/src/connector-identity.test.ts b/packages/shared/src/connector-identity.test.ts new file mode 100644 index 00000000000..d58357707fa --- /dev/null +++ b/packages/shared/src/connector-identity.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { getAppDefinitionForApplicationKey } from "./connector-identity.js"; + +describe("saved connector identity", () => { + it("resolves known catalog keys and company-scoped gallery keys", () => { + expect(getAppDefinitionForApplicationKey("fireflies")?.slug).toBe("fireflies"); + expect(getAppDefinitionForApplicationKey("app-gallery:fireflies:44444444-4444-4444-8444-444444444444")?.slug).toBe("fireflies"); + }); + + it.each([null, undefined, {}, "custom-mcp", "app-gallery:unknown:44444444-4444-4444-8444-444444444444", "app-gallery:fireflies:anything", "app-gallery:fireflies:44444444-4444-4444-8444-444444444444:extra", "fireflies.example.test"])("does not infer a catalog identity from %j", value => { + expect(getAppDefinitionForApplicationKey(value)).toBeNull(); + }); +}); diff --git a/packages/shared/src/connector-identity.ts b/packages/shared/src/connector-identity.ts new file mode 100644 index 00000000000..5ede83c9c52 --- /dev/null +++ b/packages/shared/src/connector-identity.ts @@ -0,0 +1,8 @@ +import { getConnectableAppDefinition } from "./app-definitions.js"; +import type { AppDefinition } from "./types/app-definition.js"; + +export function getAppDefinitionForApplicationKey(key: unknown): AppDefinition | null { + if (typeof key !== "string") return null; + const gallery = /^app-gallery:([a-z0-9]+(?:-[a-z0-9]+)*):[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.exec(key); + return getConnectableAppDefinition(gallery ? gallery[1].toLowerCase() : key); +} diff --git a/packages/shared/src/feature-catalog.test.ts b/packages/shared/src/feature-catalog.test.ts index 5b63525b553..d3e000c0394 100644 --- a/packages/shared/src/feature-catalog.test.ts +++ b/packages/shared/src/feature-catalog.test.ts @@ -30,6 +30,12 @@ function schemaBooleanFlagKeys(): string[] { } describe("INSTANCE_FEATURE_CATALOG", () => { + it("keeps Notebook a managed rollout disabled for Cloud and self-hosted instances", () => { + expect(INSTANCE_FEATURE_CATALOG.enablePaperclipNotebook).toMatchObject({ + tier: "managed", cloudDefault: false, selfHostedDefault: false, + }); + expect(buildFeatureCatalogArtifact("2026.1020.0").features.enablePaperclipNotebook).toEqual({ tier: "managed" }); + }); it("covers exactly the boolean flag keys of the experimental settings schema", () => { expect([...INSTANCE_FEATURE_KEYS]).toEqual(schemaBooleanFlagKeys()); }); diff --git a/packages/shared/src/feature-catalog.ts b/packages/shared/src/feature-catalog.ts index 583aae340d1..d66bbd9cacc 100644 --- a/packages/shared/src/feature-catalog.ts +++ b/packages/shared/src/feature-catalog.ts @@ -161,6 +161,13 @@ export const INSTANCE_FEATURE_CATALOG: Record; transport?:{location:"query"|"header";name:string;format?:"string"|"csv"|"boolean";omitFalse?:boolean} } export interface ConnectionMethodDef { ai?: import("../ai-connections.js").AiConnectionMetadata; key:string; label?:string; purpose?:ToolConnectionPurpose; provider?:"slack"|"github"|"discord"|"microsoft-teams"|"telegram"|"agentmail"|"imessage-photon"|"speko"; transport:ToolConnectionTransport; auth:"oauth"|"api_key"|"none"; oauthStrategy?:"paperclip_cloud_connector"|"paperclip_id_connector"; connectorProfile?:string; oauthClientSecretRequired?:boolean; oauthClientRegistration?:"dcr"; capabilityProfile?:{key:string;label:string;description?:string}; grantKinds?:ConnectionGrantKind[]; ownershipModes:ToolConnectionOwnership[]; whenToUse:string; defaults?:{serverUrl?:string;serverUrlTemplate?:string;discoveryUrl?:string|null;serviceHost?:string;templateKey?:string;authorizationEndpoint?:string;tokenEndpoint?:string;metadataUrl?:string;scopesHint?:string[];oauthAuthorizationParams?:{access_type?:"offline";prompt?:"consent"};toolArgumentDefaults?:Record;mcpSessionRequired?:boolean}; tenantFields?:FieldDef[]; extensionFields?:FieldDef[]; configRequirements?:{atLeastOneOf?:string[]}; credentialFields?:FieldDef[]; keyPlacement?:{location:"header"|"query"|"body_json"|"env";name:string;prefix?:string|null}; credentialSources?:{vercelConnect?:{services:string[];principalModes:VercelConnectPrincipalMode[];scopes:string[];header:{name:string;prefix?:string|null}}}; guidanceMd:string; consoleLinks?:{register?:string;keys?:string;settings?:string;docs?:string}; warnings?:string[]; variants?:Array<{key:string;label:string;whenToUse:string;tenantFields?:FieldDef[]}>; riskTier:"S1"|"S2"|"S3"|"S4"; requiredResourceFilters?:string[] } -export interface AppDefinition { aiConnectionRouter?: { pluginKey: string }; agentInstructions?: import("../connection-instructions.js").ConnectionInstructionTemplate; schemaVersion:1; slug:string; name:string; description:string; categories:AppCategory[]; tags?:string[]; featured?:boolean; branding:{logoUrl:string;darkLogoUrl?:string;backgroundColor?:string;accentColor?:string}; urlPatterns:string[]; docsUrl?:string; setupPrerequisite?:{title:string;description:string;steps?:string[];actionLabel:string;actionUrl:string}; redirectConstraints?:OAuthRedirectConstraints; methods:ConnectionMethodDef[]; suggestable?:boolean; availability?:{available:boolean;reason?:string;robotEmail?:string}; ownershipAvailability?:Partial> } +export interface AppDefinition { experimentalFeatureKey?: import("../feature-catalog.js").InstanceFeatureKey; aiConnectionRouter?: { pluginKey: string }; agentInstructions?: import("../connection-instructions.js").ConnectionInstructionTemplate; schemaVersion:1; slug:string; name:string; description:string; categories:AppCategory[]; tags?:string[]; featured?:boolean; branding:{logoUrl:string;darkLogoUrl?:string;backgroundColor?:string;accentColor?:string}; urlPatterns:string[]; docsUrl?:string; setupPrerequisite?:{title:string;description:string;steps?:string[];actionLabel:string;actionUrl:string}; redirectConstraints?:OAuthRedirectConstraints; methods:ConnectionMethodDef[]; suggestable?:boolean; availability?:{available:boolean;reason?:string;robotEmail?:string}; ownershipAvailability?:Partial> } export type SelfServeMcpAuthMode = | "dcr" diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index c41e1e26f85..916ff72b8eb 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -84,6 +84,7 @@ export interface InstanceExperimentalSettings { enableMcpAggregators: boolean; /** Show experimental memory connection setup. Existing connections remain usable. */ enableMemoryConnectors: boolean; + enablePaperclipNotebook: boolean; enableCases: boolean; enableAgentChat: boolean; /** Reorganized left nav, Inbox-as-Tasks-views, and the chat agent rail + task cards. */ diff --git a/packages/shared/src/validators/app-definition.ts b/packages/shared/src/validators/app-definition.ts index cbb0879cbbc..9fb660249ea 100644 --- a/packages/shared/src/validators/app-definition.ts +++ b/packages/shared/src/validators/app-definition.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { INSTANCE_FEATURE_KEYS } from "../feature-catalog.js"; import { connectionInstructionTemplateSchema } from "../connection-instructions.js"; import { AI_CONNECTION_CAPABILITIES, aiConnectionMetadataSchema } from "../ai-connections.js"; import { connectionGrantKindSchema, toolConnectionOwnershipSchema, toolConnectionPurposeSchema, toolConnectionTransportSchema } from "./tool-access.js"; @@ -8,7 +9,7 @@ const appBrandAssetUrlSchema=z.string().refine((value)=>{ },{message:"Brand assets must be HTTPS URLs or local /brands/apps SVG/PNG paths"}); const field=z.object({key:z.string().min(1),label:z.string().min(1),type:z.enum(["text","password","textarea","datetime","select","checkbox"]),required:z.boolean().optional(),advanced:z.boolean().optional(),hidden:z.boolean().optional(),placeholder:z.string().optional(),helperMd:z.string().optional(),secret:z.boolean().optional(),prefix:z.string().optional(),defaultValue:z.union([z.string(),z.boolean()]).optional(),validation:z.object({pattern:z.string().optional(),maxLength:z.number().int().positive().optional()}).optional(),options:z.array(z.object({value:z.string(),label:z.string()})).optional(),transport:z.object({location:z.enum(["query","header"]),name:z.string().min(1),format:z.enum(["string","csv","boolean"]).optional(),omitFalse:z.boolean().optional()}).optional()}).superRefine((v,c)=>{if(v.required&&v.type!=="checkbox"&&!v.placeholder)c.addIssue({code:"custom",message:"Required fields need placeholders",path:["placeholder"]});if(v.type==="select"&&(!v.options||v.options.length===0))c.addIssue({code:"custom",message:"Select fields need options",path:["options"]});if(v.hidden&&v.defaultValue===undefined)c.addIssue({code:"custom",message:"Hidden fields need defaults",path:["defaultValue"]})}); export const connectionMethodDefSchema=z.object({key:z.string().min(1),label:z.string().min(1).optional(),purpose:z.union([toolConnectionPurposeSchema,z.literal("ai")]).optional(),provider:z.enum(["slack","github","discord","microsoft-teams","telegram","speko","agentmail","imessage-photon"]).optional(),transport:z.union([toolConnectionTransportSchema,z.literal("runtime_auth")]),ai:aiConnectionMetadataSchema.optional(),auth:z.enum(["oauth","api_key","none"]),oauthStrategy:z.enum(["paperclip_cloud_connector","paperclip_id_connector"]).optional(),connectorProfile:z.string().regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/).optional(),oauthClientSecretRequired:z.boolean().optional(),oauthClientRegistration:z.literal("dcr").optional(),capabilityProfile:z.object({key:z.string().min(1),label:z.string().min(1),description:z.string().min(1).optional()}).optional(),grantKinds:z.array(connectionGrantKindSchema).min(1).optional(),ownershipModes:z.array(toolConnectionOwnershipSchema).min(1),whenToUse:z.string().min(1),defaults:z.object({serverUrl:z.string().url().optional(),serverUrlTemplate:z.string().regex(/^https:\/\//).optional(),discoveryUrl:z.string().url().nullable().optional(),serviceHost:z.string().optional(),templateKey:z.string().optional(),authorizationEndpoint:z.string().url().optional(),tokenEndpoint:z.string().url().optional(),metadataUrl:z.string().url().optional(),scopesHint:z.array(z.string()).optional(),oauthAuthorizationParams:z.object({access_type:z.literal("offline").optional(),prompt:z.literal("consent").optional()}).optional(),toolArgumentDefaults:z.record(z.string(),z.unknown()).optional(),mcpSessionRequired:z.boolean().optional()}).optional(),tenantFields:z.array(field).optional(),extensionFields:z.array(field).optional(),configRequirements:z.object({atLeastOneOf:z.array(z.string().min(1)).min(1).optional()}).optional(),credentialFields:z.array(field).optional(),keyPlacement:z.object({location:z.enum(["header","query","body_json","env"]),name:z.string().min(1),prefix:z.string().nullable().optional()}).optional(),credentialSources:z.object({vercelConnect:z.object({services:z.array(z.string().min(1)).min(1),principalModes:z.array(z.enum(["app","user"])).min(1),scopes:z.array(z.string().min(1)).min(1),header:z.object({name:z.string().min(1),prefix:z.string().nullable().optional()})}).optional()}).optional(),guidanceMd:z.string().min(1),consoleLinks:z.object({register:z.string().url().optional(),keys:z.string().url().optional(),settings:z.string().url().optional(),docs:z.string().url().optional()}).optional(),warnings:z.array(z.string()).optional(),variants:z.array(z.object({key:z.string(),label:z.string(),whenToUse:z.string(),tenantFields:z.array(field).optional()})).optional(),riskTier:z.enum(["S1","S2","S3","S4"]),requiredResourceFilters:z.array(z.string()).optional()}).superRefine((v,c)=>{if((v.transport==="runtime_auth")!==Boolean(v.ai))c.addIssue({code:"custom",message:"Runtime authentication requires AI metadata and AI metadata requires runtime_auth",path:["ai"]});if(v.ai&&!AI_CONNECTION_CAPABILITIES[v.ai.provider].methods[v.ai.method])c.addIssue({code:"custom",message:"Unsupported AI sign-in method",path:["ai","method"]});if(v.ai&&v.auth!==(v.ai.method==="subscription"?"oauth":"api_key"))c.addIssue({code:"custom",message:"AI sign-in method must match authentication",path:["auth"]});if(v.oauthClientRegistration&&(v.auth!=="oauth"||v.transport!=="mcp_remote"||!v.ownershipModes.includes("dcr")))c.addIssue({code:"custom",message:"DCR client registration requires a remote MCP OAuth method with DCR ownership",path:["oauthClientRegistration"]});if(v.defaults?.mcpSessionRequired&&v.transport!=="mcp_remote")c.addIssue({code:"custom",message:"MCP session initialization preferences require remote MCP transport",path:["defaults","mcpSessionRequired"]});const purpose=v.purpose??"tool";if((purpose==="ai")!==(v.transport==="runtime_auth"))c.addIssue({code:"custom",message:"AI methods require runtime_auth and runtime_auth requires AI purpose",path:["purpose"]});if(["chat_sdk","voice"].includes(v.transport)&&purpose!=="channel")c.addIssue({code:"custom",message:"Chat SDK methods must be channel connections",path:["purpose"]});if(purpose==="channel"&&!["chat_sdk","voice"].includes(v.transport)&&!(v.provider==="agentmail"&&v.transport==="rest_api"))c.addIssue({code:"custom",message:"Channel connections must use the Chat SDK transport",path:["transport"]});if(purpose==="channel"&&((v.provider==="speko")!==(v.transport==="voice")))c.addIssue({code:"custom",message:"Speko channels require the voice transport",path:["transport"]});if(purpose==="channel"&&!v.provider)c.addIssue({code:"custom",message:"Channel connections require a chat provider",path:["provider"]});if(v.auth==="api_key"&&!v.keyPlacement&&purpose!=="channel")c.addIssue({code:"custom",message:"API-key tool methods require keyPlacement",path:["keyPlacement"]});if(v.oauthStrategy&&v.auth!=="oauth")c.addIssue({code:"custom",message:"OAuth strategies require OAuth auth",path:["oauthStrategy"]});if(v.oauthStrategy&&!v.connectorProfile)c.addIssue({code:"custom",message:"Paperclip Cloud connector methods require connectorProfile",path:["connectorProfile"]});if(v.connectorProfile&&!v.oauthStrategy)c.addIssue({code:"custom",message:"connectorProfile requires a Paperclip Cloud OAuth strategy",path:["connectorProfile"]});if(v.credentialSources?.vercelConnect&&(v.transport!=="mcp_remote"||v.auth==="none"))c.addIssue({code:"custom",message:"Vercel Connect requires an authenticated remote MCP method",path:["credentialSources","vercelConnect"]});const keys=new Set([...(v.tenantFields??[]),...(v.extensionFields??[])].map((entry)=>entry.key));for(const key of v.configRequirements?.atLeastOneOf??[])if(!keys.has(key))c.addIssue({code:"custom",message:"Config requirement references an unknown field",path:["configRequirements","atLeastOneOf"]});if(v.defaults?.serverUrl&&v.defaults.serverUrlTemplate)c.addIssue({code:"custom",message:"Use either serverUrl or serverUrlTemplate",path:["defaults"]});for(const placeholder of v.defaults?.serverUrlTemplate?.matchAll(/\{([a-zA-Z0-9_-]+)\}/g)??[])if(!keys.has(placeholder[1]))c.addIssue({code:"custom",message:"Server URL template references an unknown field",path:["defaults","serverUrlTemplate"]})}); -export const appDefinitionSchema=z.object({agentInstructions:connectionInstructionTemplateSchema.optional(),aiConnectionRouter:z.object({pluginKey:z.string().min(1)}).optional(),schemaVersion:z.literal(1),slug:z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),name:z.string().min(1),description:z.string().min(1),categories:z.array(z.enum(["ai","analytics","commerce","communication","content","data","developer","productivity","other"])).min(1),tags:z.array(z.string().min(1)).optional(),featured:z.boolean().optional(),branding:z.object({logoUrl:appBrandAssetUrlSchema,darkLogoUrl:appBrandAssetUrlSchema.optional(),backgroundColor:z.string().optional(),accentColor:z.string().optional()}),urlPatterns:z.array(z.string()),docsUrl:z.string().url().optional(),setupPrerequisite:z.object({title:z.string().min(1),description:z.string().min(1),steps:z.array(z.string().min(1)).min(1).optional(),actionLabel:z.string().min(1),actionUrl:z.string().url()} ).optional(),redirectConstraints:z.enum(["https-or-loopback-http"]).optional(),methods:z.array(connectionMethodDefSchema),suggestable:z.boolean().optional(),availability:z.object({available:z.boolean(),reason:z.string().optional(),robotEmail:z.string().optional()}).optional(),ownershipAvailability:z.object({platform_shared:z.boolean().optional(),platform_provisioned:z.boolean().optional(),customer:z.boolean().optional(),dcr:z.boolean().optional()}).optional()}).superRefine((value, ctx) => { +export const appDefinitionSchema=z.object({experimentalFeatureKey:z.enum(INSTANCE_FEATURE_KEYS).optional(),agentInstructions:connectionInstructionTemplateSchema.optional(),aiConnectionRouter:z.object({pluginKey:z.string().min(1)}).optional(),schemaVersion:z.literal(1),slug:z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),name:z.string().min(1),description:z.string().min(1),categories:z.array(z.enum(["ai","analytics","commerce","communication","content","data","developer","productivity","other"])).min(1),tags:z.array(z.string().min(1)).optional(),featured:z.boolean().optional(),branding:z.object({logoUrl:appBrandAssetUrlSchema,darkLogoUrl:appBrandAssetUrlSchema.optional(),backgroundColor:z.string().optional(),accentColor:z.string().optional()}),urlPatterns:z.array(z.string()),docsUrl:z.string().url().optional(),setupPrerequisite:z.object({title:z.string().min(1),description:z.string().min(1),steps:z.array(z.string().min(1)).min(1).optional(),actionLabel:z.string().min(1),actionUrl:z.string().url()} ).optional(),redirectConstraints:z.enum(["https-or-loopback-http"]).optional(),methods:z.array(connectionMethodDefSchema),suggestable:z.boolean().optional(),availability:z.object({available:z.boolean(),reason:z.string().optional(),robotEmail:z.string().optional()}).optional(),ownershipAvailability:z.object({platform_shared:z.boolean().optional(),platform_provisioned:z.boolean().optional(),customer:z.boolean().optional(),dcr:z.boolean().optional()}).optional()}).superRefine((value, ctx) => { if (value.aiConnectionRouter) { if (value.methods.length !== 0) ctx.addIssue({ code: "custom", message: "Pooled connectors use existing connections, not authentication methods", path: ["methods"] }); if (!value.categories.includes("ai")) ctx.addIssue({ code: "custom", message: "Pooled connectors must use the AI category", path: ["categories"] }); diff --git a/packages/shared/src/validators/instance.test.ts b/packages/shared/src/validators/instance.test.ts index 1a8eca32c6c..f1db216ab7b 100644 --- a/packages/shared/src/validators/instance.test.ts +++ b/packages/shared/src/validators/instance.test.ts @@ -2,9 +2,21 @@ import { describe, expect, it } from "vitest"; import { instanceExperimentalSettingsSchema, patchInstanceExperimentalSettingsSchema, + instanceExperimentalSettingsWithManagedSchema, } from "./instance.js"; describe("instance experimental settings validators", () => { + it("keeps Notebook disabled by default and accepts only explicit boolean rollout values", () => { + expect(instanceExperimentalSettingsSchema.parse({ enableApps: true, enableMemoryConnectors: true }).enablePaperclipNotebook).toBe(false); + expect(patchInstanceExperimentalSettingsSchema.parse({})).toEqual({}); + for (const enabled of [true, false]) { + expect(patchInstanceExperimentalSettingsSchema.parse({ enablePaperclipNotebook: enabled })).toEqual({ enablePaperclipNotebook: enabled }); + expect(instanceExperimentalSettingsWithManagedSchema.parse({ enablePaperclipNotebook: enabled, + managedKeys: { enablePaperclipNotebook: { managed: true, managedBy: "paperclip-cloud" } }, + }).enablePaperclipNotebook).toBe(enabled); + } + expect(patchInstanceExperimentalSettingsSchema.safeParse({ enablePaperclipNotebook: "true" }).success).toBe(false); + }); it("defaults GitHub review bots off independently of chat and accepts explicit changes", () => { expect(instanceExperimentalSettingsSchema.parse({ enableChatConnectors: true }).enableGitHubReviewBots).toBe(false); expect(patchInstanceExperimentalSettingsSchema.parse({ enableGitHubReviewBots: true })).toEqual({ enableGitHubReviewBots: true }); diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 1512366e6bb..66298470985 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -60,6 +60,7 @@ export const instanceExperimentalSettingsSchema = z.object({ // Compatibility only: old stored and managed values must still parse. enableMcpAggregators: z.boolean().default(true), enableMemoryConnectors: z.boolean().default(false), + enablePaperclipNotebook: z.boolean().default(false), enableCases: z.boolean().default(false), enableAgentChat: z.boolean().default(false), enableCombinedInboxTasks: z.boolean().default(false), diff --git a/scripts/app-definition-overrides/paperclip-notebook.json b/scripts/app-definition-overrides/paperclip-notebook.json new file mode 100644 index 00000000000..4bdf194d6ba --- /dev/null +++ b/scripts/app-definition-overrides/paperclip-notebook.json @@ -0,0 +1,50 @@ +{ + "schemaVersion": 1, + "slug": "paperclip-notebook", + "experimentalFeatureKey": "enablePaperclipNotebook", + "name": "Paperclip Notebook", + "description": "Publish, find, and share reports, files, and interactive pages with your Paperclip Cloud account.", + "categories": [ + "content", + "productivity" + ], + "featured": true, + "branding": { + "logoUrl": "/brands/apps/paperclip-notebook.svg", + "darkLogoUrl": "/brands/apps/paperclip-notebook-dark.svg" + }, + "urlPatterns": [ + "https://notebook.paperclip.app/*" + ], + "availability": { + "available": true + }, + "methods": [ + { + "key": "mcp-oauth", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "dcr" + ], + "oauthClientRegistration": "dcr", + "grantKinds": [ + "user" + ], + "whenToUse": "Sign in with Paperclip Cloud and let your assigned agents publish and manage your Notebook pages.", + "defaults": { + "serverUrl": "https://notebook.paperclip.app/mcp", + "scopesHint": [ + "notebook:read", + "notebook:write", + "notebook:share", + "notebook:delete" + ], + "mcpSessionRequired": true + }, + "guidanceMd": "Connect your Paperclip Cloud account. Assigned agents can read available pages, publish and update your pages, change sharing including making pages public, manage versions, and delete or restore your pages. Manage individual actions in Permissions.", + "riskTier": "S3" + } + ], + "redirectConstraints": "https-or-loopback-http" +} diff --git a/server/src/__tests__/generic-mcp-connection.test.ts b/server/src/__tests__/generic-mcp-connection.test.ts index fb166deabd0..4e8075c9a1d 100644 --- a/server/src/__tests__/generic-mcp-connection.test.ts +++ b/server/src/__tests__/generic-mcp-connection.test.ts @@ -7,6 +7,9 @@ import pino from "pino"; import request from "supertest"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { + EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, + applyPendingMigrations, + closeRegisteredClients, activityLog, agents, authUsers, @@ -17,6 +20,7 @@ import { companySecrets, companySecretVersions, createDb, + heartbeatRuns, issueThreadInteractions, issues, principalPermissionGrants, @@ -24,6 +28,9 @@ import { toolAccessAuditEvents, toolCallEvents, toolInvocations, + toolGatewaySessions, + toolMcpGateways, + toolMcpGatewayTokens, toolActionRequests, toolApplications, toolCatalogEntries, @@ -35,13 +42,16 @@ import { toolProfiles, toolRuntimeSlots, } from "@paperclipai/db"; -import { and, eq, sql } from "drizzle-orm"; +import { and, eq, inArray, sql } from "drizzle-orm"; import { APP_DEFINITIONS, MCP_CONFIG_HELP_PROMPT } from "@paperclipai/shared"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; import { toolAccessService } from "../services/tool-access.js"; +import { instanceSettingsService } from "../services/instance-settings.js"; +import * as instanceSettingsModule from "../services/instance-settings.js"; +import { connectionIntentService } from "../services/connection-intents.js"; import { ComposioApiError, type ComposioClient } from "../services/composio.js"; import { createComposioSessionManager } from "../services/composio-session-manager.js"; import { createToolGatewayService } from "../services/tool-gateway.js"; @@ -52,7 +62,8 @@ import { createHttpLogger } from "../middleware/logger.js"; import { HTTP_LOG_REDACT_PATHS } from "../middleware/http-log-redaction.js"; import { startOAuthRefreshFixture } from "../../../scripts/mcp-fixtures/servers/oauth-refresh-fixture.mjs"; -const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const externalTestDatabaseUrl = process.env.PAPERCLIP_TEST_DATABASE_URL?.trim(); +const embeddedPostgresSupport = externalTestDatabaseUrl ? { supported: true } : await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; /** @@ -81,6 +92,11 @@ const FIXTURE_TOOLS = [ ]; type FixtureOptions = { + notebookAccount?: { id: string; email: string; emailVerified: boolean }; + contextResults?: Record; + scopes?: string[]; + rotateRefreshTokens?: boolean; + dropFirstRefreshResponse?: boolean; /** How the MCP endpoint authenticates. */ auth?: "public" | "oauth" | "header"; /** For `auth: "header"`, the header the endpoint requires and its value. */ @@ -169,18 +185,23 @@ function installMcpOAuthFixture(options: FixtureOptions = {}) { const requests: FixtureRequest[] = []; const issuedCodes = new Map(); let accessToken: string | null = null; + let refreshToken = "fixture-refresh"; + let refreshResponseDropped = false; + const tokenResponses: { access_token: string; refresh_token: string }[] = []; const tools = options.tools ?? FIXTURE_TOOLS; + const scopes = options.scopes ?? (options.notebookAccount ? ["notebook:read", "notebook:write", "notebook:share", "notebook:delete"] : null); const resourceMetadataUrl = `${MCP_ORIGIN}/.well-known/oauth-protected-resource/mcp`; const authorizationServerMetadata = () => ({ issuer: ISSUER, authorization_endpoint: options.authorizationEndpoint ?? `${ISSUER}/authorize`, token_endpoint: options.tokenEndpoint ?? `${ISSUER}/token`, + ...(options.notebookAccount ? { revocation_endpoint: `${MCP_ORIGIN}/oauth/revoke` } : {}), ...(options.dcr === false ? {} : { registration_endpoint: `${ISSUER}/register` }), ...(options.cimd ? { client_id_metadata_document_supported: true } : {}), code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: options.tokenEndpointAuthMethods ?? ["none"], - scopes_supported: ["mcp:read", "mcp:write"], + scopes_supported: scopes ?? ["mcp:read", "mcp:write"], }); const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { @@ -204,6 +225,16 @@ function installMcpOAuthFixture(options: FixtureOptions = {}) { if (supplied !== options.requiredHeader.value) return unauthorizedMcpResponse(resourceMetadataUrl); } const rpc = parsedBody as Record; + if (options.notebookAccount && rpc.method === "initialize") { + return jsonResponse({ jsonrpc: "2.0", id: rpc.id, result: { protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "Notebook fixture", version: "1" } } }); + } + if (options.notebookAccount && rpc.method === "notifications/initialized") return jsonResponse({}, 202); + if (options.notebookAccount && rpc.method === "tools/call" && (rpc.params as Record)?.name === "notebook_get_account") { + return jsonResponse({ jsonrpc: "2.0", id: rpc.id, result: { structuredContent: options.notebookAccount, content: [] } }); + } + if (typeof rpc.method === "string" && options.contextResults && Object.hasOwn(options.contextResults, rpc.method)) { + return jsonResponse({ jsonrpc: "2.0", id: rpc.id, result: options.contextResults[rpc.method] }); + } if (rpc.method === "tools/call") { return jsonResponse({ jsonrpc: "2.0", id: rpc.id, result: { content: [{ type: "text", text: "Fixture meeting data" }], @@ -254,14 +285,24 @@ function installMcpOAuthFixture(options: FixtureOptions = {}) { const issued = issuedCodes.get(body.get("code") ?? ""); if (!issued) return jsonResponse({ error: "invalid_grant" }, 400); } + if (grantType === "refresh_token" && options.rotateRefreshTokens && body.get("refresh_token") !== refreshToken) { + return jsonResponse({ error: "invalid_grant" }, 400); + } accessToken = `fixture-access-${randomUUID()}`; - return jsonResponse({ + if (options.rotateRefreshTokens) refreshToken = `fixture-refresh-${randomUUID()}`; + const response = { access_token: accessToken, - refresh_token: "fixture-refresh", + refresh_token: refreshToken, expires_in: 3600, token_type: "Bearer", - scope: "mcp:read", - }); + scope: scopes?.join(" ") ?? "mcp:read", + }; + tokenResponses.push(response); + if (grantType === "refresh_token" && options.dropFirstRefreshResponse && !refreshResponseDropped) { + refreshResponseDropped = true; + throw new TypeError("Fixture lost the committed rotating refresh response"); + } + return jsonResponse(response); } // 404 rather than throw: discovery legitimately probes several well-known @@ -272,6 +313,7 @@ function installMcpOAuthFixture(options: FixtureOptions = {}) { return { fetchMock, requests, + tokenResponses, /** Pretend the operator approved the consent screen and got a code back. */ issueAuthorizationCode(authorizationUrl: string) { const parsed = new URL(authorizationUrl); @@ -339,15 +381,23 @@ function createRouteApp( describeEmbeddedPostgres("generic remote MCP connections", () => { let db!: ReturnType; let tempDb: Awaited> | null = null; + const restoreCatalogEndpoints: (() => void)[] = []; beforeAll(async () => { + if (externalTestDatabaseUrl) { + db = createDb(externalTestDatabaseUrl); + await applyPendingMigrations(externalTestDatabaseUrl); + return; + } tempDb = await startEmbeddedPostgresTestDatabase("paperclip-generic-mcp-"); db = createDb(tempDb.connectionString); - }, 20_000); + }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); afterEach(async () => { + for (const restore of restoreCatalogEndpoints.splice(0)) restore(); vi.restoreAllMocks(); vi.unstubAllEnvs(); + await instanceSettingsService(db).updateExperimental({ enablePaperclipNotebook: false }); await db.delete(toolCallEvents); await db.delete(toolInvocations); await db.delete(toolActionRequests); @@ -358,6 +408,9 @@ describeEmbeddedPostgres("generic remote MCP connections", () => { await db.delete(companySecrets); await db.delete(activityLog); await db.delete(toolAccessAuditEvents); + await db.delete(toolGatewaySessions); + await db.delete(toolMcpGatewayTokens); + await db.delete(toolMcpGateways); await db.delete(toolRuntimeSlots); await db.delete(toolConnectionInstalls); await db.delete(toolProfileBindings); @@ -367,6 +420,7 @@ describeEmbeddedPostgres("generic remote MCP connections", () => { await db.delete(toolConnections); await db.delete(toolApplications); await db.delete(issueThreadInteractions); + await db.delete(heartbeatRuns); await db.delete(issues); await db.delete(principalPermissionGrants); await db.delete(companyMemberships); @@ -376,6 +430,7 @@ describeEmbeddedPostgres("generic remote MCP connections", () => { }); afterAll(async () => { + if (externalTestDatabaseUrl) await closeRegisteredClients(externalTestDatabaseUrl); await tempDb?.cleanup(); }); @@ -397,6 +452,292 @@ describeEmbeddedPostgres("generic remote MCP connections", () => { return false; } + function useNotebookFixtureEndpoint() { + const method = APP_DEFINITIONS.find(app => app.slug === "paperclip-notebook")!.methods.find(method => method.key === "mcp-oauth")!; + const originalUrl = method.defaults!.serverUrl; + method.defaults!.serverUrl = MCP_URL; + restoreCatalogEndpoints.push(() => { method.defaults!.serverUrl = originalUrl; }); + } + + async function notebookContextFixture() { + const settings = instanceSettingsService(db); + await settings.updateExperimental({ enablePaperclipNotebook: true }); + useNotebookFixtureEndpoint(); + const fixture = installMcpOAuthFixture({ auth: "oauth", notebookAccount: { id: "cloud-owner", email: "owner@example.test", emailVerified: true }, + tools: [{ name: "notebook_get_account", annotations: { readOnlyHint: true } }], + contextResults: { + "resources/list": { resources: [{ uri: "notes://one", name: "Note one" }] }, + "resources/read": { contents: [{ uri: "notes://one", text: "resource body" }] }, + "prompts/list": { prompts: [{ name: "summarize", title: "Summarize note" }] }, + "prompts/get": { messages: [{ role: "user", content: { type: "text", text: "Summarize it" } }] }, + } }); + const company = await createCompany(db), service = toolAccessService(db, { paperclipCloudConnector: null }); + const actor = { actorType: "user" as const, actorId: "board-user" }; + const connected = await service.connectGalleryApp(company.id, { galleryKey: "paperclip-notebook" }, actor); + const start = await service.startOAuth(company.id, connected.connectionId, { redirectUri: REDIRECT_URI, actor }); + const completed = await service.completeOAuthCallback({ state: new URL(start.authorizationUrl).searchParams.get("state")!, + code: fixture.issueAuthorizationCode(start.authorizationUrl), iss: ISSUER, redirectUri: REDIRECT_URI, actor }); + const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Context reader", role: "engineer", status: "active", adapterType: "process", adapterConfig: {}, runtimeConfig: {} }).returning(); + const [issue] = await db.insert(issues).values({ companyId: company.id, title: "Read Notebook context", status: "in_progress", assigneeAgentId: agent!.id }).returning(); + const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent!.id, invocationSource: "assignment", status: "running", responsibleUserId: "board-user", contextSnapshot: { issueId: issue!.id } }).returning(); + await service.finishGalleryAppConnection(company.id, connected.connectionId, { enabledCatalogEntryIds: completed.catalog.map(entry => entry.id), askFirstCatalogEntryIds: [], access: { agentIds: [agent!.id] } }, actor); + const [profile] = await db.select().from(toolProfiles).where(eq(toolProfiles.profileKey, `app:${connected.connectionId}`)); + await db.insert(toolProfileEntries).values({ companyId: company.id, profileId: profile!.id, selectorType: "connection", effect: "include", connectionId: connected.connectionId }); + const gateway = createToolGatewayService(db); + const named = await gateway.createNamedGateway({ companyId: company.id, body: { name: "Notebook context", profileId: profile!.id, agentId: agent!.id, issueId: issue!.id } }); + const token = await gateway.createNamedGatewayToken({ companyId: company.id, gatewayId: named.id, body: { name: "Context run", subjectType: "heartbeat_run", subjectId: run!.id } }); + const context = (method: "resources/list" | "resources/read" | "prompts/list" | "prompts/get", params?: Record) => + gateway.executeContextForNamedGateway({ gatewayPublicId: named.gatewayPublicId, bearerToken: token.token, method, params }); + const resources = await context("resources/list"), prompts = await context("prompts/list"); + const uri = (resources.resources as { uri: string }[])[0]!.uri, name = (prompts.prompts as { name: string }[])[0]!.name; + expect(await context("resources/read", { uri })).toMatchObject({ contents: [{ uri, text: "resource body" }] }); + expect(await context("prompts/get", { name })).toMatchObject({ messages: [{ role: "user" }] }); + const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, connected.connectionId)); + await db.update(connectionGrants).set({ providerTenant: { ...grant!.providerTenant, oauth: { + ...grant!.providerTenant?.oauth, accessTokenExpiresAt: new Date(0).toISOString(), + } } }).where(eq(connectionGrants.id, grant!.id)); + return { settings, fixture, service, connected, context, uri, name }; + } + + it.each(["missing", "relabelled"])("omits disabled Notebook resources and prompts and denies retained handles with %s markers", async marker => { + const f = await notebookContextFixture(), saved = await f.service.getConnection(f.connected.connectionId); + const config = { ...saved.config }; + if (marker === "missing") delete config.sourceTemplateKey; + else config.sourceTemplateKey = "fireflies"; + await f.service.updateConnection(f.connected.connectionId, { config }); + await f.settings.updateExperimental({ enablePaperclipNotebook: false }); + const calls = f.fixture.requests.length, secretAccess = await db.select().from(secretAccessEvents); + await expect(f.context("resources/list")).resolves.toEqual({ resources: [] }); + await expect(f.context("prompts/list")).resolves.toEqual({ prompts: [] }); + await expect(f.context("resources/read", { uri: f.uri })).rejects.toMatchObject({ reasonCode: "mcp_resource_not_found" }); + await expect(f.context("prompts/get", { name: f.name })).rejects.toMatchObject({ reasonCode: "mcp_prompt_not_found" }); + expect(f.fixture.requests).toHaveLength(calls); + expect(await db.select().from(secretAccessEvents)).toEqual(secretAccess); + await db.update(toolApplications).set({ applicationKey: "fireflies" }).where(eq(toolApplications.id, f.connected.application.id)); + await expect(f.context("resources/list")).resolves.toMatchObject({ resources: [{ uri: f.uri }] }); + await expect(f.context("prompts/list")).resolves.toMatchObject({ prompts: [{ name: f.name }] }); + await expect(f.context("resources/read", { uri: f.uri })).resolves.toMatchObject({ contents: [{ uri: f.uri }] }); + await expect(f.context("prompts/get", { name: f.name })).resolves.toMatchObject({ messages: [{ role: "user" }] }); + }); + + it.each(["resources/read", "prompts/get"] as const)("rechecks saved availability before credentials when %s eligibility becomes stale", async method => { + const f = await notebookContextFixture(), actualService = instanceSettingsModule.instanceSettingsService; + let settingsReads = 0; + vi.spyOn(instanceSettingsModule, "instanceSettingsService").mockImplementation((...args) => { + const service = actualService(...args); + return { ...service, async getExperimental() { + const settings = await service.getExperimental(); + if (++settingsReads === 1) await f.settings.updateExperimental({ enablePaperclipNotebook: false }); + return settings; + } }; + }); + const calls = f.fixture.requests.length, secretAccess = await db.select().from(secretAccessEvents); + await expect(f.context(method, method === "resources/read" ? { uri: f.uri } : { name: f.name })).rejects.toMatchObject({ reasonCode: "connector_unavailable" }); + expect(settingsReads).toBe(2); + expect(f.fixture.requests).toHaveLength(calls); + expect(await db.select().from(secretAccessEvents)).toEqual(secretAccess); + }); + + it.each(["missing", "relabelled"])("keeps Notebook setup internal after %s markers, denying pending OAuth and finalization while allowing removal", async marker => { + const company = await createCompany(db), settings = instanceSettingsService(db); + await settings.updateExperimental({ enablePaperclipNotebook: false }); + const service = toolAccessService(db, { paperclipCloudConnector: null }); + const actor = { actorType: "user" as const, actorId: "board-user" }; + const app = createRouteApp(db); + const gallery = await request(app).get(`/api/companies/${company.id}/tools/gallery`).expect(200); + expect(gallery.body.apps.some((item: { slug: string }) => item.slug === "paperclip-notebook")).toBe(false); + await expect(service.connectGalleryApp(company.id, { galleryKey: "paperclip-notebook", grantKind: "user" }, actor)) + .rejects.toMatchObject({ status: 403, details: { code: "connector_disabled" } }); + await request(app).get(`/api/companies/${company.id}/tools/apps/paperclip-notebook/preflight`).expect(403); + await expect(service.createConnection(company.id, { name: "Notebook bypass", transport: "mcp_remote", config: { + sourceTemplateKey: "paperclip-notebook", url: MCP_URL, + } }, actor)).rejects.toMatchObject({ status: 403, details: { code: "connector_disabled" } }); + expect(await db.select().from(toolConnections).where(eq(toolConnections.companyId, company.id))).toEqual([]); + expect(await db.select().from(companySecrets).where(eq(companySecrets.companyId, company.id))).toEqual([]); + await settings.updateExperimental({ enablePaperclipNotebook: true }); + useNotebookFixtureEndpoint(); + const fixture = installMcpOAuthFixture({ auth: "oauth", notebookAccount: { id: "cloud-owner", email: "owner@example.test", emailVerified: true } }); + const enabledGallery = await request(app).get(`/api/companies/${company.id}/tools/gallery`).expect(200); + expect(enabledGallery.body.apps.some((item: { slug: string }) => item.slug === "paperclip-notebook")).toBe(true); + const connected = await service.connectGalleryApp(company.id, { galleryKey: "paperclip-notebook", grantKind: "user" }, actor); + const start = await service.startOAuth(company.id, connected.connectionId, { redirectUri: REDIRECT_URI, actor }); + const saved = await service.getConnection(connected.connectionId, company.id); + const config = { ...saved.config }; + delete config.sourceTemplateKey; + if (marker === "relabelled") config.sourceTemplateKey = "custom-mcp"; + await service.updateConnection(connected.connectionId, { config }); + await settings.updateExperimental({ enablePaperclipNotebook: false }); + await expect(service.completeOAuthCallback({ state: new URL(start.authorizationUrl).searchParams.get("state")!, + code: fixture.issueAuthorizationCode(start.authorizationUrl), iss: ISSUER, redirectUri: REDIRECT_URI, actor })) + .rejects.toMatchObject({ status: 403, details: { code: "connector_disabled" } }); + expect(fixture.requestsTo("/token")).toHaveLength(0); + await expect(service.startOAuth(company.id, connected.connectionId, { redirectUri: REDIRECT_URI, actor })) + .rejects.toMatchObject({ status: 403, details: { code: "connector_disabled" } }); + await expect(service.reconnectGalleryApp(connected.connectionId, company.id, { credentialValues: {} }, actor)) + .rejects.toMatchObject({ status: 403, details: { code: "connector_disabled" } }); + await expect(service.connectGalleryApp(company.id, { resumeConnectionId: connected.connectionId, grantKind: "user" }, actor)) + .rejects.toMatchObject({ status: 403, details: { code: "connector_disabled" } }); + await expect(service.finishGalleryAppConnection(company.id, connected.connectionId, { enabledCatalogEntryIds: [], askFirstCatalogEntryIds: [], access: { agentIds: [] } }, actor)) + .rejects.toMatchObject({ status: 403, details: { code: "connector_disabled" } }); + await expect(service.archiveConnection(connected.connectionId, company.id, actor)).resolves.toMatchObject({ connection: { status: "archived" } }); + }); + + it("connects Notebook with personal OAuth and preserves policies across ordinary rotation, reconnect, and local removal", async () => { + await instanceSettingsService(db).updateExperimental({ enablePaperclipNotebook: true }); + useNotebookFixtureEndpoint(); + const scopes = ["notebook:read", "notebook:write", "notebook:share", "notebook:delete"]; + const fixture = installMcpOAuthFixture({ auth: "oauth", scopes, rotateRefreshTokens: true, + notebookAccount: { id: "cloud-owner", email: "owner@example.test", emailVerified: true }, + tools: [ + { name: "notebook_get_account", annotations: { readOnlyHint: true } }, + { name: "notebook_list_pages", annotations: { readOnlyHint: true } }, + { name: "notebook_publish", annotations: { readOnlyHint: false } }, + { name: "notebook_set_sharing", annotations: { readOnlyHint: false } }, + { name: "notebook_delete_page", annotations: { readOnlyHint: false, destructiveHint: true } }, + ] }); + const company = await createCompany(db); + const serviceDb = createDb(externalTestDatabaseUrl ?? tempDb!.connectionString, { maxConnections: 1 }); + const service = toolAccessService(serviceDb, { paperclipCloudConnector: null }); + const actor = { actorType: "user" as const, actorId: "board-user" }; + const connected = await service.connectGalleryApp(company.id, { galleryKey: "paperclip-notebook" }, actor); + const authorize = async () => { + const start = await service.startOAuth(company.id, connected.connectionId, { redirectUri: REDIRECT_URI, actor }); + expect(start.registrationSource).toBe("dcr"); + const authorization = new URL(start.authorizationUrl); + expect(authorization.searchParams.get("resource")).toBe(MCP_URL); + expect(authorization.searchParams.get("code_challenge_method")).toBe("S256"); + expect(authorization.searchParams.get("scope")?.split(" ")).toEqual(scopes); + return service.completeOAuthCallback({ state: authorization.searchParams.get("state")!, code: fixture.issueAuthorizationCode(start.authorizationUrl), + iss: ISSUER, redirectUri: REDIRECT_URI, actor }); + }; + const completed = await authorize(); + expect(completed.connection).toMatchObject({ name: "Paperclip Notebook", credentialPolicy: "per_user" }); + expect(completed.catalog).toHaveLength(5); + expect(completed.catalog.every(entry => entry.status === "active")).toBe(true); + const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, connected.connectionId)); + expect(grant).toMatchObject({ kind: "user", subjectUserId: "board-user" }); + expect(grant!.providerTenant).not.toHaveProperty("name"); + expect(grant!.providerTenant).not.toHaveProperty("externalId"); + expect(fixture.requestsTo("/mcp").filter(call => (call.body as Record)?.method === "tools/call")).toEqual([]); + const stored = await db.select().from(companySecrets).where(inArray(companySecrets.id, grant!.credentialSecretRefs.map(ref => ref.secretId))); + expect(stored).toHaveLength(2); + expect(stored.every(secret => secret.scope === "user" && secret.ownerUserId === "board-user")).toBe(true); + expect(JSON.stringify(completed)).not.toContain("fixture-access-"); + for (let rotation = 0; rotation < 2; rotation += 1) { + await service.refreshOAuthGrantCredentials({ companyId: company.id, connectionId: connected.connectionId, grantId: grant!.id, forceRefresh: true, actor }); + } + const refreshRequests = fixture.requestsTo("/token").filter(call => (call.body as URLSearchParams).get("grant_type") === "refresh_token"); + expect(refreshRequests).toHaveLength(2); + expect((refreshRequests[0]!.body as URLSearchParams).get("refresh_token")).toBe(fixture.tokenResponses[0]!.refresh_token); + expect((refreshRequests[1]!.body as URLSearchParams).get("refresh_token")).toBe(fixture.tokenResponses[1]!.refresh_token); + expect(fixture.tokenResponses[0]!.refresh_token).not.toBe(fixture.tokenResponses[1]!.refresh_token); + expect(fixture.requests.every(call => call.headers["idempotency-key"] === undefined)).toBe(true); + const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Notebook author", role: "engineer", status: "active", adapterType: "process", adapterConfig: {}, runtimeConfig: {} }).returning(); + const [unassigned] = await db.insert(agents).values({ companyId: company.id, name: "Unassigned", role: "engineer", status: "active", adapterType: "process", adapterConfig: {}, runtimeConfig: {} }).returning(); + await service.finishGalleryAppConnection(company.id, connected.connectionId, { + enabledCatalogEntryIds: completed.catalog.filter(entry => entry.toolName !== "notebook_delete_page").map(entry => entry.id), + askFirstCatalogEntryIds: completed.catalog.filter(entry => entry.toolName === "notebook_set_sharing").map(entry => entry.id), access: { agentIds: [agent!.id] }, + }, actor); + await authorize(); + expect(await db.select({ id: connectionGrants.id }).from(connectionGrants).where(eq(connectionGrants.connectionId, connected.connectionId))).toEqual([{ id: grant!.id }]); + const policy = toolAccessPolicyService(db); + const decision = (name: string, agentId = agent!.id) => { + const entry = completed.catalog.find(entry => entry.toolName === name)!; + return policy.decide({ companyId: company.id, actor: { actorType: "agent", actorId: agentId, agentId }, + request: { connectionId: connected.connectionId, catalogEntryId: entry.id, toolName: name } }); + }; + await expect(decision("notebook_publish")).resolves.toMatchObject({ allowed: true }); + await expect(decision("notebook_set_sharing")).resolves.toMatchObject({ allowed: false, decision: "require_approval" }); + await expect(decision("notebook_delete_page")).resolves.toMatchObject({ allowed: false, decision: "deny" }); + await expect(decision("notebook_publish", unassigned!.id)).resolves.toMatchObject({ allowed: false }); + const [issue] = await db.insert(issues).values({ companyId: company.id, title: "Notebook MCP publication", status: "in_progress", assigneeAgentId: agent!.id }).returning(); + const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent!.id, invocationSource: "assignment", status: "running", + responsibleUserId: "board-user", contextSnapshot: { issueId: issue!.id } }).returning(); + const [profile] = await db.select().from(toolProfiles).where(eq(toolProfiles.profileKey, `app:${connected.connectionId}`)); + const gateway = createToolGatewayService(db, { toolActionSigningSecret: "notebook-mcp-fixture" }); + const named = await gateway.createNamedGateway({ companyId: company.id, body: { name: "Notebook MCP", profileId: profile!.id, agentId: agent!.id, issueId: issue!.id } }); + const token = await gateway.createNamedGatewayToken({ companyId: company.id, gatewayId: named.id, body: { name: "Notebook run", subjectType: "heartbeat_run", subjectId: run!.id } }); + const descriptors = await gateway.listToolsForNamedGateway({ gatewayPublicId: named.gatewayPublicId, bearerToken: token.token }); + const publish = descriptors.find(tool => tool.upstreamToolName === "notebook_publish")!; + const share = descriptors.find(tool => tool.upstreamToolName === "notebook_set_sharing")!; + expect(descriptors.some(tool => tool.upstreamToolName === "notebook_delete_page")).toBe(false); + await expect(gateway.executeTool({ gatewayPublicId: named.gatewayPublicId, sessionToken: token.token, tool: publish.name, parameters: {} })).resolves.toMatchObject({ status: "completed" }); + const callsBeforeApproval = fixture.requestsTo("/mcp").length; + await expect(gateway.executeTool({ gatewayPublicId: named.gatewayPublicId, sessionToken: token.token, tool: share.name, parameters: {} })).rejects.toMatchObject({ reasonCode: "approval_required" }); + expect(fixture.requestsTo("/mcp")).toHaveLength(callsBeforeApproval); + const [action] = await db.select().from(toolActionRequests).where(and(eq(toolActionRequests.companyId, company.id), eq(toolActionRequests.status, "pending"))); + expect(action).toBeDefined(); + const saved = await service.getConnection(connected.connectionId, company.id); + const config = { ...saved.config }; + delete config.sourceTemplateKey; + await service.updateConnection(connected.connectionId, { config }); + const [currentGrant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, grant!.id)); + await db.update(connectionGrants).set({ providerTenant: { ...currentGrant!.providerTenant, oauth: { + ...currentGrant!.providerTenant?.oauth, accessTokenExpiresAt: new Date(0).toISOString(), + } } }).where(eq(connectionGrants.id, grant!.id)); + await instanceSettingsService(db).updateExperimental({ enablePaperclipNotebook: false }); + const callsBeforeDisabledDispatch = fixture.requestsTo("/mcp").length; + expect(await gateway.listToolsForNamedGateway({ gatewayPublicId: named.gatewayPublicId, bearerToken: token.token })).toEqual([]); + await expect(gateway.executeTool({ gatewayPublicId: named.gatewayPublicId, sessionToken: token.token, tool: publish.name, parameters: {} })).rejects.toMatchObject({ reasonCode: "tool_not_found" }); + await expect(gateway.approveActionRequest({ companyId: company.id, actionRequestId: action!.id, actor: { userId: "board-user" } })).rejects.toMatchObject({ reasonCode: "tool_not_found" }); + const intents = connectionIntentService(db); + const claims = { sub: agent!.id, company_id: company.id, run_id: run!.id, responsible_user_id: "board-user" }; + expect((await intents.search(claims, "Paperclip Notebook")).results.some(item => item.service === "paperclip-notebook")).toBe(false); + await expect(intents.request(claims, "paperclip-notebook")).rejects.toMatchObject({ status: 404 }); + expect(fixture.requestsTo("/mcp")).toHaveLength(callsBeforeDisabledDispatch); + const tokenRequestsWhileOff = fixture.requestsTo("/token").length; + const mcpRequestsWhileOff = fixture.requestsTo("/mcp").length; + const secretAccessBeforeOff = await db.select({ id: secretAccessEvents.id }).from(secretAccessEvents); + await expect(service.checkHealth(connected.connectionId, actor)).rejects.toMatchObject({ status: 403, details: { code: "connector_disabled" } }); + await expect(service.refreshCatalog(connected.connectionId, actor)).rejects.toMatchObject({ status: 403, details: { code: "connector_disabled" } }); + expect(fixture.requestsTo("/token")).toHaveLength(tokenRequestsWhileOff); + expect(fixture.requestsTo("/mcp")).toHaveLength(mcpRequestsWhileOff); + expect(await db.select({ id: secretAccessEvents.id }).from(secretAccessEvents)).toEqual(secretAccessBeforeOff); + const removed = await service.archiveConnection(connected.connectionId, company.id, actor); + expect(removed.connection.status).toBe("archived"); + expect(removed.removal).not.toHaveProperty("oauthRevocationPending"); + await expect(decision("notebook_publish")).resolves.toMatchObject({ allowed: false }); + const [removedGrant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, grant!.id)); + expect(removedGrant).toMatchObject({ status: "revoked", credentialSecretRefs: [] }); + expect(await db.select().from(companySecrets).where(inArray(companySecrets.id, currentGrant!.credentialSecretRefs.map(ref => ref.secretId)))).toEqual([]); + expect(fixture.requestsTo("/oauth/revoke")).toEqual([]); + await instanceSettingsService(db).updateExperimental({ enablePaperclipNotebook: true }); + await service.connectGalleryApp(company.id, { galleryKey: "paperclip-notebook", applicationId: connected.application.id }, actor); + await authorize(); + expect(fixture.requestsTo("/oauth/revoke")).toEqual([]); + expect(fixture.requests.every(call => call.headers["idempotency-key"] === undefined)).toBe(true); + }); + + it("reconnects Notebook after a lost rotating refresh response requires new authorization", async () => { + await instanceSettingsService(db).updateExperimental({ enablePaperclipNotebook: true }); + useNotebookFixtureEndpoint(); + const fixture = installMcpOAuthFixture({ auth: "oauth", rotateRefreshTokens: true, dropFirstRefreshResponse: true, + notebookAccount: { id: "cloud-owner", email: "owner@example.test", emailVerified: true } }); + const company = await createCompany(db), service = toolAccessService(db, { paperclipCloudConnector: null }); + const actor = { actorType: "user" as const, actorId: "board-user" }; + const connected = await service.connectGalleryApp(company.id, { galleryKey: "paperclip-notebook" }, actor); + const authorize = async () => { + const start = await service.startOAuth(company.id, connected.connectionId, { redirectUri: REDIRECT_URI, actor }); + return service.completeOAuthCallback({ state: new URL(start.authorizationUrl).searchParams.get("state")!, code: fixture.issueAuthorizationCode(start.authorizationUrl), + iss: ISSUER, redirectUri: REDIRECT_URI, actor }); + }; + await authorize(); + const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, connected.connectionId)); + const refresh = () => service.refreshOAuthGrantCredentials({ companyId: company.id, connectionId: connected.connectionId, grantId: grant!.id, forceRefresh: true, actor }); + await expect(refresh()).rejects.toThrow("Fixture lost the committed rotating refresh response"); + expect(fixture.requestsTo("/token")).toHaveLength(2); + await expect(refresh()).rejects.toMatchObject({ status: 422, details: { code: "oauth_reauthorization_required" } }); + const [expired] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, grant!.id)); + expect(expired!.status).toBe("needs_reauthorization"); + await authorize(); + const [renewed] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, grant!.id)); + expect(renewed).toMatchObject({ status: "active", credentialSecretRefs: expect.any(Array) }); + expect(renewed!.credentialSecretRefs).toHaveLength(2); + await expect(refresh()).resolves.toMatchObject({ status: "active" }); + expect(fixture.requestsTo("/oauth/revoke")).toEqual([]); + expect(fixture.requests.every(call => call.headers["idempotency-key"] === undefined)).toBe(true); + }); + it.each(["mcp-oauth", "mcp-api-key"])("connects Fireflies through %s with vaulted credentials and its stable meeting tools", async (methodKey) => { // Keep the real catalog method and governance identity; redirect only its // transport URL to the deterministic protocol fixture. @@ -1332,7 +1673,7 @@ describeEmbeddedPostgres("generic remote MCP connections", () => { it("completes organization OAuth with a single database connection", async () => { const fixture = installMcpOAuthFixture({ auth: "oauth" }); const company = await createCompany(db); - const callbackDb = createDb(tempDb!.connectionString, { maxConnections: 1 }); + const callbackDb = createDb(externalTestDatabaseUrl ?? tempDb!.connectionString, { maxConnections: 1 }); const service = toolAccessService(callbackDb); let deadline: ReturnType | null = null; @@ -1416,8 +1757,8 @@ describeEmbeddedPostgres("generic remote MCP connections", () => { it("serializes organization OAuth completion behind membership revocation", async () => { const fixture = installMcpOAuthFixture({ auth: "oauth" }); const company = await createCompany(db); - const callbackDb = createDb(tempDb!.connectionString, { maxConnections: 1 }); - const removalDb = createDb(tempDb!.connectionString, { maxConnections: 1 }); + const callbackDb = createDb(externalTestDatabaseUrl ?? tempDb!.connectionString, { maxConnections: 1 }); + const removalDb = createDb(externalTestDatabaseUrl ?? tempDb!.connectionString, { maxConnections: 1 }); const service = toolAccessService(callbackDb); const actor = { actorType: "user" as const, actorId: "board-user" }; let releaseRemoval!: () => void; @@ -2187,7 +2528,7 @@ describeEmbeddedPostgres("generic remote MCP connections", () => { // serialize the two callbacks and prove nothing. With two connections both // callbacks can read a live state row before either deletes it, so the // atomic `DELETE … RETURNING` is what picks the winner. - const otherDb = createDb(tempDb!.connectionString); + const otherDb = createDb(externalTestDatabaseUrl ?? tempDb!.connectionString); const otherService = toolAccessService(otherDb); let settled: PromiseSettledResult[]; try { diff --git a/server/src/__tests__/instance-settings-managed-overlay.test.ts b/server/src/__tests__/instance-settings-managed-overlay.test.ts index 5285f5b4694..60f9964fb64 100644 --- a/server/src/__tests__/instance-settings-managed-overlay.test.ts +++ b/server/src/__tests__/instance-settings-managed-overlay.test.ts @@ -85,6 +85,43 @@ describe("applyManagedExperimentalOverlay", () => { }); describe("instanceSettingsService managed overlay", () => { + it.each(["team.staging.paperclip.app", "team.paperclip.app"])( + "enables private Notebook through managed config without changing stored opt-out on %s", + async (primaryHost) => { + const row = settingsRow({ enablePaperclipNotebook: false }); + const { db, persistedSets } = stubDb(row); + const service = instanceSettingsService(db, { runtimeEnv: { + PAPERCLIP_PRIMARY_HOST: primaryHost, + ...managedEnv(JSON.stringify({ + v: 1, mode: "cloud", catalogVersion: "test", + features: { enablePaperclipNotebook: true }, plugins: { autoInstall: [] }, + })), + } }); + expect(await service.getExperimental()).toMatchObject({ + enablePaperclipNotebook: true, + managedKeys: { enablePaperclipNotebook: { managed: true, managedBy: "paperclip-cloud" } }, + }); + expect((await service.get()).experimental.enablePaperclipNotebook).toBe(true); + expect(persistedSets).toHaveLength(0); + expect(row.experimental).toEqual({ enablePaperclipNotebook: false }); + expect((await instanceSettingsService(db, { runtimeEnv: {} }).getExperimental()).enablePaperclipNotebook).toBe(false); + }, + ); + + it("persists an explicit self-hosted Notebook opt-in and disable across service recreation", async () => { + const row = settingsRow({}); + const { db, persistedSets } = stubDb(row); + expect((await instanceSettingsService(db, { runtimeEnv: {} }).getExperimental()).enablePaperclipNotebook).toBe(false); + for (const enabled of [true, false]) { + const service = instanceSettingsService(db, { runtimeEnv: {} }); + const updated = await service.updateExperimental({ enablePaperclipNotebook: enabled }); + Object.assign(row, persistedSets.at(-1)); + expect(updated.experimental.enablePaperclipNotebook).toBe(enabled); + expect((row.experimental as Record).enablePaperclipNotebook).toBe(enabled); + expect((await instanceSettingsService(db, { runtimeEnv: {} }).getExperimental()).enablePaperclipNotebook).toBe(enabled); + } + }); + it.each(["team.staging.paperclip.app", "team.paperclip.app", undefined])( "enforces every Cloud-enabled flag over old tenant values on %s", async (primaryHost) => { diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 4078d9ad6f8..2ddd8f31552 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -912,6 +912,38 @@ describe("instance settings routes", () => { }); describe("Cloud staging experiments", () => { + it.each([ + ["team.staging.paperclip.app", 200], + ["team.paperclip.app", 403], + ] as const)( + "keeps private Notebook operator-only under the real hidden wildcard on %s", + async (primaryHost, visibleExperimentStatus) => { + vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "tenant-token"); + vi.stubEnv("PAPERCLIP_PRIMARY_HOST", primaryHost); + vi.stubEnv("PAPERCLIP_HIDDEN_SETTINGS", "instance.experimental.*"); + mockInstanceSettingsService.getExperimental.mockResolvedValue({ + enablePaperclipNotebook: false, enableChatConnectors: false, + }); + const app = createApp({ + type: "board", userId: "stack-admin-1", source: "cloud_tenant", + companyIds: ["company-1"], isInstanceAdmin: false, canManageExperimentalSettings: true, + }); + const denied = await request(app).patch("/api/instance/settings/experimental") + .send({ enablePaperclipNotebook: true }).expect(403); + expect(denied.body.details).toMatchObject({ code: "settings_operator_managed" }); + expect(mockInstanceSettingsService.updateExperimental).not.toHaveBeenCalled(); + expect(mockLogActivity).not.toHaveBeenCalled(); + + await request(app).patch("/api/instance/settings/experimental") + .send({ enableChatConnectors: true }).expect(visibleExperimentStatus); + if (visibleExperimentStatus === 200) { + expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledExactlyOnceWith({ enableChatConnectors: true }); + } else { + expect(mockInstanceSettingsService.updateExperimental).not.toHaveBeenCalled(); + } + }, + ); + it.each(["team.staging.paperclip.app", "team.paperclip.app"])( "reads Cloud-enabled isolation and rejects disabling it through the real service on %s", async (primaryHost) => { diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index 2502015867c..631c1389844 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -7,6 +7,18 @@ import { } from "../services/instance-settings.js"; describe("instance settings service", () => { + it("keeps Notebook off for legacy or invalid settings and round-trips explicit operator choices", () => { + for (const stored of [undefined, null, {}, { enableApps: true }, + { enablePaperclipNotebook: "true" }, { enablePaperclipNotebook: 1 }, + { enablePaperclipNotebook: true, enableCases: "invalid" }]) { + expect(normalizeExperimentalSettings(stored).enablePaperclipNotebook).toBe(false); + } + const enabled = applyExperimentalSettingsPatch({}, { enablePaperclipNotebook: true }); + const restored = normalizeExperimentalSettings(JSON.parse(JSON.stringify(enabled))); + expect(restored).toMatchObject({ enablePaperclipNotebook: true, enableMemoryConnectors: false }); + const disabled = applyExperimentalSettingsPatch(restored, { enablePaperclipNotebook: false }); + expect(normalizeExperimentalSettings(JSON.parse(JSON.stringify(disabled))).enablePaperclipNotebook).toBe(false); + }); it("keeps OpenAI Dot opt-in and persists its setting independently of its prerequisites", () => { expect(normalizeExperimentalSettings({}).enableOpenAiDot).toBe(false); const enabled = applyExperimentalSettingsPatch({}, { enableOpenAiDot: true }); @@ -67,6 +79,7 @@ describe("instance settings service", () => { enableChatConnectors: false, enableGitHubReviewBots: false, enableMemoryConnectors: false, + enablePaperclipNotebook: false, enableConferenceRoomChat: false, enableClassicTaskInterface: false, enableExternalObjects: false, diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index f2213242972..20df4507b80 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -94,6 +94,7 @@ import { HttpError } from "../errors.js"; import type { VercelConnectClient } from "../services/vercel-connect.js"; import { invalidatePaperclipCloudConnectorCapabilities, type PaperclipCloudConnector } from "../services/paperclip-cloud-connector.js"; +const externalDatabaseUrl = process.env.PAPERCLIP_TOOL_ACCESS_TEST_DATABASE_URL?.trim(); const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe @@ -909,8 +910,6 @@ describeEmbeddedPostgres("tool access service", () => { > | null = null; beforeAll(async () => { - const externalDatabaseUrl = - process.env.PAPERCLIP_TOOL_ACCESS_TEST_DATABASE_URL?.trim(); if (externalDatabaseUrl) { db = createDb(externalDatabaseUrl); await instanceSettingsService(db).updateExperimental({ enableMemoryConnectors: true }); @@ -1104,8 +1103,8 @@ describeEmbeddedPostgres("tool access service", () => { const { run } = await createIssueAndRun(db, company.id, agent.id); const { connection } = await createBrokerConnection(db, company.id); await allowConnectionForAgent(db, company.id, agent.id, connection.id); - const mintDb = createDb(tempDb!.connectionString, { maxConnections: 1 }); - const revocationDb = createDb(tempDb!.connectionString, { + const mintDb = createDb(externalDatabaseUrl ?? tempDb!.connectionString, { maxConnections: 1 }); + const revocationDb = createDb(externalDatabaseUrl ?? tempDb!.connectionString, { maxConnections: 1, }); const service = createTestToolAccessService(mintDb); @@ -1691,9 +1690,9 @@ describeEmbeddedPostgres("tool access service", () => { .values(personalGrantRow(company, connection)) .returning() .then((rows) => rows[0]!); - const serviceDb = createDb(tempDb!.connectionString, { maxConnections: 1 }); + const serviceDb = createDb(externalDatabaseUrl ?? tempDb!.connectionString, { maxConnections: 1 }); const service = createTestToolAccessService(serviceDb); - const removalDb = createDb(tempDb!.connectionString, { maxConnections: 1 }); + const removalDb = createDb(externalDatabaseUrl ?? tempDb!.connectionString, { maxConnections: 1 }); let releaseRemoval!: () => void; const removalMayCommit = new Promise((resolve) => { releaseRemoval = resolve; @@ -1973,13 +1972,13 @@ describeEmbeddedPostgres("tool access service", () => { subjectId: "previous-user", }); - const firstReplacementDb = createDb(tempDb!.connectionString, { + const firstReplacementDb = createDb(externalDatabaseUrl ?? tempDb!.connectionString, { maxConnections: 1, }); - const replacementDb = createDb(tempDb!.connectionString, { + const replacementDb = createDb(externalDatabaseUrl ?? tempDb!.connectionString, { maxConnections: 1, }); - const cleanupDb = createDb(tempDb!.connectionString, { + const cleanupDb = createDb(externalDatabaseUrl ?? tempDb!.connectionString, { maxConnections: 1, }); const replacementService = createTestToolAccessService(replacementDb); @@ -7579,7 +7578,7 @@ describeEmbeddedPostgres("tool access service", () => { const company = await createCompany(db); const userId = `gmail-member-${randomUUID()}`; await grantBoardUser(db, company.id, userId, []); - const callbackDb = createDb(tempDb!.connectionString, { + const callbackDb = createDb(externalDatabaseUrl ?? tempDb!.connectionString, { maxConnections: 1, }); const connector = fakeGmailConnector(company.id, userId); @@ -9383,7 +9382,7 @@ describeEmbeddedPostgres("tool access service", () => { await grantBoardUser(db, company.id, userId, [], "owner"); const actor = { actorType: "user" as const, actorId: userId }; const connector = fakeGmailConnector(company.id, userId); - const refreshDb = createDb(tempDb!.connectionString, { maxConnections: 1 }); + const refreshDb = createDb(externalDatabaseUrl ?? tempDb!.connectionString, { maxConnections: 1 }); const service = createTestToolAccessService(db, { paperclipCloudConnector: connector }); const definition = getConnectableAppDefinition("gmail")!; const availability = definition.ownershipAvailability; @@ -9523,10 +9522,10 @@ describeEmbeddedPostgres("tool access service", () => { const company = await createCompany(db); const userId = `gmail-member-${randomUUID()}`; await grantBoardUser(db, company.id, userId, []); - const callbackDb = createDb(tempDb!.connectionString, { + const callbackDb = createDb(externalDatabaseUrl ?? tempDb!.connectionString, { maxConnections: 1, }); - const removalDb = createDb(tempDb!.connectionString, { maxConnections: 1 }); + const removalDb = createDb(externalDatabaseUrl ?? tempDb!.connectionString, { maxConnections: 1 }); const service = createTestToolAccessService(callbackDb, { paperclipCloudConnector: fakeGmailConnector(company.id, userId), }); diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index 48a590a5bc1..9ffebb7b9ed 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -9,6 +9,7 @@ import { agents, companies, connectionGrants, issueThreadInteractions, toolConne import { and, eq, or } from "drizzle-orm"; import { APP_STORE_DEFINITIONS, + isConnectorFeatureEnabled, isMemoryConnectorId, GITHUB_CONNECTOR_PROFILES, GOOGLE_WORKSPACE_CONNECTOR_PROFILES, @@ -819,7 +820,7 @@ function connectorEnrollmentPrincipal(req: Request): string { ? await options.paperclipCloudConnector.getCapabilities() : []; const vercelConnect = vercelConnectIntegrationStatus(); - const { enableMemoryConnectors } = await instanceSettingsService(db).getExperimental(); + const settings = await instanceSettingsService(db).getExperimental(); let oauthCallbackUrl: string | undefined; try { // Setup must display the callback used by its initiating POST. Provider @@ -850,7 +851,8 @@ function connectorEnrollmentPrincipal(req: Request): string { : "Vercel Connect setup is disabled on this Paperclip instance.", }, }, - apps: [...APP_STORE_DEFINITIONS.filter((app) => (enableMemoryConnectors || !isMemoryConnectorId(app.slug))).map((app) => + apps: [...APP_STORE_DEFINITIONS.filter((app) => (settings.enableMemoryConnectors || !isMemoryConnectorId(app.slug)) + && isConnectorFeatureEnabled(app, settings)).map((app) => appWithPaperclipCloudConnectorAvailability(app, advertisedProfiles) ), ...await aiConnectionRouterService(db).catalog()], }); diff --git a/server/src/services/connection-intents.ts b/server/src/services/connection-intents.ts index 90381467751..41cf08ceb90 100644 --- a/server/src/services/connection-intents.ts +++ b/server/src/services/connection-intents.ts @@ -29,6 +29,8 @@ import { } from "@paperclipai/db"; import { APP_STORE_DEFINITIONS, + isConnectorFeatureEnabled, + getAppDefinitionForApplicationKey, AGGREGATOR_PRIORITY, AGGREGATOR_NAMES, AGGREGATOR_CATALOG_SOURCES, findAggregatorService, searchAggregatorServices, prepareConnectionSearch, scoreConnectionSearch, explicitAggregatorQuery, normalizeConnectionQuery, parseAggregatorRoute, aggregatorProviderQuestion, aggregatorProviderQuestionSet, aggregatorContinuationInstruction, isRemoteMcpConnectorId, askUserQuestionsPayloadSchema, askUserQuestionsResultSchema, @@ -364,6 +366,9 @@ export function connectionIntentService(db: Db) { if (!service.startsWith("connection:")) { const app = getAppStoreDefinition(service); if (!app) throw notFound("Connection service was not found"); + if (app.experimentalFeatureKey && !isConnectorFeatureEnabled(app, await instanceSettingsService(db).getExperimental())) { + throw notFound("Connection service was not found"); + } if (purpose === "channel" && service !== "agentmail") { throw unprocessable("This email connection is not available"); } @@ -389,6 +394,14 @@ export function connectionIntentService(db: Db) { || (grant.kind === "agent" && grant.subjectAgentId === agentId) ))) throw notFound("Configured connection was not found"); const application = await access.getApplication(connection.applicationId, companyId); + const definition = application.applicationKey != null + ? getAppDefinitionForApplicationKey(application.applicationKey) + : typeof connection.config?.sourceTemplateKey === "string" + ? getAppStoreDefinition(connection.config?.sourceTemplateKey) : null; + if (definition?.experimentalFeatureKey && !isConnectorFeatureEnabled(definition, + await instanceSettingsService(db).getExperimental())) { + throw notFound("Configured connection was not found"); + } return { slug: service, name: connection.name, description: application.description, searchCapabilities: "", branding: { logoUrl: undefined, darkLogoUrl: undefined }, @@ -413,7 +426,8 @@ export function connectionIntentService(db: Db) { const discoveryMethods = (app: (typeof APP_STORE_DEFINITIONS)[number]) => getAvailableConnectionMethods(app) .filter(method => method.purpose !== "channel" || app.slug === "agentmail" || (method.provider === "github" ? settings.enableGitHubReviewBots : settings.enableChatConnectors)); - const services = [...APP_STORE_DEFINITIONS.filter(app => discoveryMethods(app).length).map((app) => app.slug), + const services = [...APP_STORE_DEFINITIONS.filter(app => isConnectorFeatureEnabled(app, settings) + && discoveryMethods(app).length).map((app) => app.slug), ...inventory.connections.filter((connection) => sourceSlugForConnection(connection, inventory.applicationsById)?.startsWith("connection:") && connection.status !== "archived").map((connection) => `connection:${connection.id}`)]; diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index c3c5b01b505..23c66dd35ac 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -243,6 +243,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableChatConnectors: parsed.data.enableChatConnectors ?? false, enableGitHubReviewBots: parsed.data.enableGitHubReviewBots ?? false, enableMemoryConnectors: parsed.data.enableMemoryConnectors ?? false, + enablePaperclipNotebook: parsed.data.enablePaperclipNotebook ?? false, enableCases: parsed.data.enableCases ?? false, enableAgentChat: parsed.data.enableAgentChat ?? false, enableCombinedInboxTasks: parsed.data.enableCombinedInboxTasks ?? false, @@ -293,6 +294,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableChatConnectors: false, enableGitHubReviewBots: false, enableMemoryConnectors: false, + enablePaperclipNotebook: false, enableCases: false, enableAgentChat: false, enableCombinedInboxTasks: false, diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 11de5463f08..c3c2c6b0b55 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -1,3 +1,4 @@ +import { getAppDefinitionForApplicationKey, isConnectorFeatureEnabled } from "@paperclipai/shared"; import { stableHash, descriptorHash } from "./tool-catalog-hash.js"; import { assertProfileEntryInputs } from "./tool-profile-entry-validation.js"; import { DAYTONA_TOOLS, daytonaClient, daytonaRisk, isDaytonaConnection, isDaytonaToolName } from "./daytona.js"; @@ -7182,6 +7183,7 @@ export function toolAccessService( unavailableServices: string[] = [], ): Promise { assertSupportedConnection(connection); + await assertConnectorConnectionSetupEnabled(connection); let headers = credentialHeaders ?? { ...projectedConnectionHeaders(connection), ...(await resolveCredentialHeaders(connection, actor)), @@ -7561,6 +7563,7 @@ export function toolAccessService( unavailableServices: string[] = [], ): Promise { assertSupportedConnection(connection); + await assertConnectorConnectionSetupEnabled(connection); if (connection.connectionPurpose === "ai") throw unprocessable("AI connections provide runtime authentication, not tool actions"); if (isDaytonaConnection(connection)) { const headers = credentialHeaders ?? await resolveCredentialHeaders(connection, actor); @@ -7681,6 +7684,7 @@ export function toolAccessService( ): Promise { const connection = await getConnectionRow(connectionId); if (connection.connectionPurpose === "ai") return { connection: toConnection(connection), runtimeSlot: null }; + await assertConnectorConnectionSetupEnabled(connection); const unavailableServices: string[] = []; try { assertSupportedConnection(connection); @@ -7828,6 +7832,7 @@ export function toolAccessService( ): Promise { const connection = await getConnectionRow(connectionId); if (connection.connectionPurpose === "ai") throw unprocessable("AI connections do not have a tool catalog"); + await assertConnectorConnectionSetupEnabled(connection); const refreshedAt = now(); const unavailableServices: string[] = []; let descriptors: McpToolDescriptor[]; @@ -12440,12 +12445,31 @@ export function toolAccessService( } async function assertExperimentalConnectorSetupEnabled(provider: unknown, existing = false) { - if (!existing && isMemoryConnectorId(provider) - && !(await instanceSettingsService(db).getExperimental()).enableMemoryConnectors) { + const app = typeof provider === "string" ? getConnectableAppDefinition(provider) : null; + if (!app?.experimentalFeatureKey && (existing || !isMemoryConnectorId(provider))) return; + const settings = await instanceSettingsService(db).getExperimental(); + if (app && !isConnectorFeatureEnabled(app, settings)) { + throw forbidden(`${app.name} is unavailable on this instance`, { code: "connector_disabled" }); + } + if (!existing && isMemoryConnectorId(provider) && !settings.enableMemoryConnectors) { throw forbidden("Enable memory connectors in Settings → Experimental to set up this connection", { code: "memory_connectors_disabled" }); } } + async function assertConnectorConnectionSetupEnabled(connection: { config?: Record; transportConfig?: Record; applicationId?: string; companyId?: string; status?: string }, applicationKey?: unknown) { + let resolvedApplicationKey = applicationKey; + if (resolvedApplicationKey == null && connection.applicationId && connection.companyId) { + const [application] = await db.select({ applicationKey: toolApplications.applicationKey }) + .from(toolApplications).where(and(eq(toolApplications.id, connection.applicationId), + eq(toolApplications.companyId, connection.companyId))).limit(1); + resolvedApplicationKey = application?.applicationKey; + } + const marker = connection.config?.sourceTemplateKey ?? connection.transportConfig?.sourceTemplateKey; + const app = resolvedApplicationKey != null ? getAppDefinitionForApplicationKey(resolvedApplicationKey) + : typeof marker === "string" ? getConnectableAppDefinition(marker) : null; + if (app) await assertExperimentalConnectorSetupEnabled(app.slug, connection.status !== "draft"); + } + async function connectGalleryApp( companyId: string, input: ConnectToolApp, @@ -12565,6 +12589,7 @@ export function toolAccessService( } } + await assertConnectorConnectionSetupEnabled(requestedResumeConnection ?? {}, existingApplication?.applicationKey); const requestedName = input.name ?? existingApplication?.name ?? @@ -12769,7 +12794,7 @@ export function toolAccessService( } } const requestedGrantKind = - retainedGrantKind ?? input.grantKind ?? "organization"; + retainedGrantKind ?? input.grantKind ?? (method?.grantKinds?.length === 1 ? method.grantKinds[0]! : "organization"); if (method?.grantKinds && !method.grantKinds.includes(requestedGrantKind)) { throw badRequest( `${galleryEntry?.name ?? "This app"} supports only ${method.grantKinds.join(" or ")} credentials`, @@ -14066,6 +14091,7 @@ export function toolAccessService( actor?: ActorInfo, ): Promise { const connection = await getConnectionRow(connectionId, companyId); + await assertConnectorConnectionSetupEnabled(connection); if (connection.status === "archived") throw conflict("Archived app connections cannot be finished"); if (connection.config.mcpPreserveAccess === true && isRemoteMcpConnectorMethod(connection.config.sourceTemplateKey, connection.config.connectionMethodKey)) { @@ -14561,6 +14587,7 @@ export function toolAccessService( ): Promise { const connection = await getConnectionRow(connectionId, companyId); assertSupportedConnection(connection); + await assertConnectorConnectionSetupEnabled(connection); await assertExperimentalConnectorSetupEnabled(connection.config.sourceTemplateKey, connection.status !== "draft"); if (connection.status === "archived") throw conflict("Archived app connections cannot be reconnected"); @@ -14745,6 +14772,7 @@ export function toolAccessService( ): Promise { let connection = await getConnectionRow(connectionId, companyId); assertSupportedConnection(connection); + await assertConnectorConnectionSetupEnabled(connection); await assertExperimentalConnectorSetupEnabled(connection.config.sourceTemplateKey, connection.status !== "draft"); if (connection.status === "archived") throw conflict("Archived app connections cannot start sign in"); @@ -16371,6 +16399,7 @@ export function toolAccessService( status: connection.status, enabled: connection.enabled, }; + await assertConnectorConnectionSetupEnabled(connection); const sourceTemplateKey = typeof connection.config.sourceTemplateKey === "string" ? connection.config.sourceTemplateKey @@ -18474,6 +18503,7 @@ export function toolAccessService( const config = normalizeGoogleSheetsConnectionConfig( input.config ?? input.transportConfig ?? {}, ); + await assertConnectorConnectionSetupEnabled({ config, applicationId, companyId }); // Validate company-scoped references before touching a caller-supplied // network endpoint. Besides failing fast, this keeps cross-company // authorization errors from being masked by DNS or SSRF validation. @@ -18489,6 +18519,7 @@ export function toolAccessService( if (applicationId) { const app = await assertApplication(companyId, applicationId); applicationNamespace = app.applicationKey ?? app.name; + await assertConnectorConnectionSetupEnabled({ config }, applicationNamespace); if ( (transport === "mcp_remote" && app.type !== "mcp_http") || (transport === "local_stdio" && app.type !== "mcp_stdio") diff --git a/server/src/services/tool-gateway.ts b/server/src/services/tool-gateway.ts index 73b9245366a..3e97a9fd7cc 100644 --- a/server/src/services/tool-gateway.ts +++ b/server/src/services/tool-gateway.ts @@ -29,6 +29,8 @@ import { emitConnectionInvoked } from "./connector-telemetry.js"; import { resolveManagedGitHubIdentitySelection } from "./git-credentials.js"; import { extractRemoteMcpPending } from "./remote-mcp-pending.js"; import { logger } from "../middleware/logger.js"; +import { getAppDefinitionForApplicationKey, isConnectorFeatureEnabled } from "@paperclipai/shared"; +import { instanceSettingsService } from "./instance-settings.js"; import { spawn } from "node:child_process"; import { createHash, randomBytes, randomUUID } from "node:crypto"; import { isDeepStrictEqual } from "node:util"; @@ -1437,8 +1439,11 @@ export function createToolGatewayService( return connectionRow ? [{ catalogEntry, ...connectionRow }] : []; }); + const featureSettings = connectionRows.some(({ application }) => getAppDefinitionForApplicationKey(application.applicationKey)?.experimentalFeatureKey) + ? await instanceSettingsService(db).getExperimental() : undefined; const eligibleRows = rows.filter( ({ catalogEntry, connection, application }) => + (!featureSettings || isConnectorFeatureEnabled(getAppDefinitionForApplicationKey(application.applicationKey), featureSettings)) && !isRetiredComposioConnection(connection) && !(isDaytonaConnection(connection) && !isCurrentDaytonaCatalogEntry(catalogEntry)) && !(isRailwayEndpoint(connection.config.url) && (isRailwayToolBlocked(catalogEntry.toolName) || (normalizeRailwayToolName(catalogEntry.toolName).startsWith(RAILWAY_TOOL_PREFIX) && connection.config.railwayApiStatus !== "available"))) && @@ -5340,15 +5345,23 @@ export function createToolGatewayService( } } + async function assertApplicationFeatureAvailable(applicationKey: unknown) { + const app = getAppDefinitionForApplicationKey(applicationKey); + if (app?.experimentalFeatureKey && !isConnectorFeatureEnabled(app, await instanceSettingsService(db).getExperimental())) { + throw new ToolGatewayHttpError(403, "This connector is unavailable on this instance.", "connector_unavailable"); + } + } + async function fullyAssignedMcpConnections(session: ToolGatewaySession) { if (!session.gatewayProfileId) return []; - return db - .select({ connection: toolConnections }) + const rows = await db + .select({ connection: toolConnections, applicationKey: toolApplications.applicationKey }) .from(toolProfileEntries) .innerJoin( toolConnections, eq(toolProfileEntries.connectionId, toolConnections.id), ) + .innerJoin(toolApplications, eq(toolConnections.applicationId, toolApplications.id)) .where( and( eq(toolProfileEntries.companyId, session.companyId), @@ -5356,12 +5369,16 @@ export function createToolGatewayService( eq(toolProfileEntries.selectorType, "connection"), eq(toolProfileEntries.effect, "include"), eq(toolConnections.companyId, session.companyId), + eq(toolApplications.companyId, session.companyId), eq(toolConnections.enabled, true), eq(toolConnections.status, "active"), inArray(toolConnections.transport, ["mcp_remote", "local_stdio"]), ), - ) - .then((rows) => rows.map((row) => row.connection)); + ); + const settings = rows.some(row => getAppDefinitionForApplicationKey(row.applicationKey)?.experimentalFeatureKey) + ? await instanceSettingsService(db).getExperimental() : undefined; + return rows.filter(row => !settings || isConnectorFeatureEnabled(getAppDefinitionForApplicationKey(row.applicationKey), settings)) + .map(row => row.connection); } async function callRemoteConnectionProtocol(input: { @@ -5472,6 +5489,10 @@ export function createToolGatewayService( params?: Record; callerHeaders?: Record; }): Promise { + const [application] = await db.select({ applicationKey: toolApplications.applicationKey }).from(toolApplications) + .where(and(eq(toolApplications.id, input.connection.applicationId), eq(toolApplications.companyId, input.session.companyId))).limit(1); + if (!application) throw new ToolGatewayHttpError(404, "MCP connection not found", "connection_not_found"); + await assertApplicationFeatureAvailable(application.applicationKey); if (input.connection.transport === "mcp_remote") { return callRemoteConnectionProtocol({ ...input, @@ -6039,6 +6060,7 @@ export function createToolGatewayService( session, tool, ); + await assertApplicationFeatureAvailable(tool.applicationKey); // Recheck immediately before dispatch, including previously approved calls // and connections whose stored grant/catalog predates scope reduction. assertGoogleChatToolArgumentsSupported(connection, entry.toolName, parameters); diff --git a/ui/public/brands/apps/manifest.json b/ui/public/brands/apps/manifest.json index b5ee462fd90..1710fb254a4 100644 --- a/ui/public/brands/apps/manifest.json +++ b/ui/public/brands/apps/manifest.json @@ -234,7 +234,10 @@ "provider": "Google Workspace", "catalogVisible": true, "localAsset": "/brands/apps/google-people.svg", - "aliases": ["google suite", "g suite"] + "aliases": [ + "google suite", + "g suite" + ] }, { "slug": "hugging-face", @@ -679,6 +682,13 @@ "catalogVisible": true, "localAsset": "/brands/apps/daytona.svg", "darkAsset": "/brands/apps/daytona-dark.svg" + }, + { + "slug": "paperclip-notebook", + "provider": "Paperclip Notebook", + "catalogVisible": true, + "localAsset": "/brands/apps/paperclip-notebook.svg", + "darkAsset": "/brands/apps/paperclip-notebook-dark.svg" } ] } diff --git a/ui/public/brands/apps/paperclip-notebook-dark.svg b/ui/public/brands/apps/paperclip-notebook-dark.svg new file mode 100644 index 00000000000..ce2f6baecd9 --- /dev/null +++ b/ui/public/brands/apps/paperclip-notebook-dark.svg @@ -0,0 +1 @@ + diff --git a/ui/public/brands/apps/paperclip-notebook.svg b/ui/public/brands/apps/paperclip-notebook.svg new file mode 100644 index 00000000000..565ee465849 --- /dev/null +++ b/ui/public/brands/apps/paperclip-notebook.svg @@ -0,0 +1 @@ + diff --git a/ui/src/features/connections/ConnectionSetupFlow.tsx b/ui/src/features/connections/ConnectionSetupFlow.tsx index 606fa741975..aafba8a76c8 100644 --- a/ui/src/features/connections/ConnectionSetupFlow.tsx +++ b/ui/src/features/connections/ConnectionSetupFlow.tsx @@ -7,6 +7,7 @@ import { connectionInstructionsConfig, defaultConnectionAgentInstructions, type import { RemoteMcpProductionSetup } from "./remote-mcp/RemoteMcpProductionSetup"; import { findAggregatorApp } from "@paperclipai/shared/aggregator-app-catalog"; import { useMemoryConnectorsEnabled } from "@/hooks/useMemoryConnectorsEnabled"; +import { useConnectorAvailability } from "@/hooks/useConnectorAvailability"; import { AiConnectionCredentialStep } from "@/components/ai-connections/AiConnectionCredentialStep"; import { ConnectionChoiceList } from "./ConnectionChoiceList"; import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode, type Ref } from "react"; @@ -51,6 +52,7 @@ import { connectionMethodSupportsAutomaticOAuth, credentialConfigPath, getAppDefinitionForUrl, + getAppDefinitionForApplicationKey, getConnectableAppDefinition, getAvailableConnectionMethods, getRecommendedConnectionMethod, @@ -584,8 +586,9 @@ export function ConnectionSetupFlow(props: ConnectionSetupFlowProps = {}) { const params = useParams<{ appKey?: string }>(); const { selectedCompanyId } = useCompany(); const memory = useMemoryConnectorsEnabled(); + const availability = useConnectorAvailability(); const interactionId = props.interactionId || searchParams.get("intent") || undefined; - const source = props.serviceSlug || searchParams.get("source") || params.appKey || searchParams.get("appKey"); + const source = (props.serviceSlug || searchParams.get("source") || params.appKey || searchParams.get("appKey"))?.trim() || undefined; const draftId = useMemo(() => { if (interactionId && isRemoteMcpConnectorId(source)) { try { return sessionStorage.getItem(`paperclip:mcp-intent-draft:${selectedCompanyId}:${interactionId}`); } catch { /* Storage may be disabled. */ } @@ -593,12 +596,29 @@ export function ConnectionSetupFlow(props: ConnectionSetupFlowProps = {}) { return null; }, [interactionId, selectedCompanyId, source]); const existingId = props.configuredConnection?.id || searchParams.get("resume") || searchParams.get("reconnect") || draftId; - const lookup = Boolean(existingId && (!source || isRemoteMcpConnectorId(source) || isMemoryConnectorId(source))); + const requestedDefinition = getConnectableAppDefinition(source ?? ""); + const lookup = Boolean(existingId && (!source || isRemoteMcpConnectorId(source) || isMemoryConnectorId(source) || requestedDefinition?.experimentalFeatureKey)); const existing = useQuery({ queryKey: ["tools", "connection", existingId], queryFn: () => toolsApi.getConnection(existingId!), enabled: lookup }); + const applications = useQuery({ queryKey: queryKeys.tools.applications(selectedCompanyId ?? "__none__"), + queryFn: () => toolsApi.listApplications(selectedCompanyId!), enabled: Boolean(existingId && selectedCompanyId) }); + const connections = useQuery({ queryKey: queryKeys.tools.connections(selectedCompanyId ?? "__none__"), + queryFn: () => toolsApi.listConnections(selectedCompanyId!), enabled: Boolean(existingId && selectedCompanyId) }); + const retained = existing.data ?? connections.data?.connections.find(item => item.id === existingId); + const application = applications.data?.applications.find(item => item.id === (retained?.applicationId ?? props.configuredConnection?.applicationId)); + const persistedDefinition = application?.applicationKey != null + ? getAppDefinitionForApplicationKey(application.applicationKey) + : getConnectableAppDefinition(String(retained?.config?.sourceTemplateKey ?? "")); + const gatedDefinitions = [persistedDefinition, requestedDefinition].filter(app => app?.experimentalFeatureKey); const provider = source || existing.data?.config?.sourceTemplateKey; const method = searchParams.get("method") || existing.data?.config?.connectionMethodKey; if (lookup && existing.isPending) return

Loading connection…

; if (lookup && existing.isError) return

Could not load this connection. Your saved access and credentials have not changed.

; + if (gatedDefinitions.length) { + if (!availability.loaded) return

Loading connection settings…

; + if (availability.failed) return

Could not load connector availability. Refresh to try again.

; + const disabled = gatedDefinitions.find(app => !availability.enabled(app)); + if (disabled) return

{disabled.name} is not available on this instance.

; + } if (isMemoryConnectorId(provider) && !(existing.data && existing.data.status !== "draft" && existing.data.config?.sourceTemplateKey === provider)) { if (!memory.loaded) return

Loading connection settings…

; if (!memory.enabled) return

Enable memory connectors in Settings → Experimental to set up this connection.

; @@ -648,6 +668,7 @@ function StandardConnectionSetupFlow({ const { selectedCompany, selectedCompanyId } = useCompany(); const { enabled: chatConnectorsEnabled, githubEnabled } = useChatConnectorsEnabled(); const { enabled: memoryConnectorsEnabled } = useMemoryConnectorsEnabled(); + const connectorAvailability = useConnectorAvailability(); const { setBreadcrumbs } = useBreadcrumbs(); const { pushToast } = useToast(); const [searchParams] = useSearchParams(); @@ -1038,12 +1059,13 @@ function StandardConnectionSetupFlow({ // Use the same visible catalog for cards and every branded URL shortcut. // Generic custom URLs remain usable without selecting a hidden provider. const visibleGalleryApps = useMemo( - () => (galleryQuery.data?.apps ?? []).filter((app) => memoryConnectorsEnabled || !isMemoryConnectorId(app.slug)).filter((app) => + () => (galleryQuery.data?.apps ?? []).filter((app) => (memoryConnectorsEnabled || !isMemoryConnectorId(app.slug)) + && connectorAvailability.enabled(app)).filter((app) => chatProviderVisible(app.slug, chatConnectorsEnabled, githubEnabled) || !app.methods.some((method) => method.purpose === "channel") || appSupportsToolCatalogSetup(app), ), - [galleryQuery.data, chatConnectorsEnabled, githubEnabled, memoryConnectorsEnabled], + [galleryQuery.data, chatConnectorsEnabled, githubEnabled, memoryConnectorsEnabled, connectorAvailability.enabled], ); const fullRequestedDefinition = requestedAppKey ? getConnectableAppDefinition(requestedAppKey) diff --git a/ui/src/hooks/useConnectorAvailability.ts b/ui/src/hooks/useConnectorAvailability.ts new file mode 100644 index 00000000000..5aff4f535ce --- /dev/null +++ b/ui/src/hooks/useConnectorAvailability.ts @@ -0,0 +1,15 @@ +import { useCallback } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { isConnectorFeatureEnabled, type AppDefinition } from "@paperclipai/shared"; +import { instanceSettingsApi } from "@/api/instanceSettings"; +import { queryKeys } from "@/lib/queryKeys"; + +export function useConnectorAvailability() { + const query = useQuery({ + queryKey: queryKeys.instance.experimentalSettings, + queryFn: () => instanceSettingsApi.getExperimental(), + }); + const enabled = useCallback((app: AppDefinition | null | undefined) => + isConnectorFeatureEnabled(app, query.isError ? undefined : query.data), [query.data, query.isError]); + return { enabled, loaded: query.isFetched, failed: query.isError }; +} diff --git a/ui/src/pages/InstanceExperimentalSettings.test.tsx b/ui/src/pages/InstanceExperimentalSettings.test.tsx index 0145120b416..073d1a85b47 100644 --- a/ui/src/pages/InstanceExperimentalSettings.test.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.test.tsx @@ -92,6 +92,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload { enableChatConnectors: false, enableGitHubReviewBots: false, enableMemoryConnectors: false, + enablePaperclipNotebook: false, enableCases: false, enableAgentChat: false, enableCombinedInboxTasks: false, @@ -1148,6 +1149,20 @@ describe("InstanceExperimentalSettings — operator-hidden cards", () => { expect(mockInstanceSettingsApi.updateExperimental).not.toHaveBeenCalled(); }); + it.each([ + { enabled: false, developerMode: false }, { enabled: true, developerMode: false }, + { enabled: false, developerMode: true }, { enabled: true, developerMode: true }, + ])("never offers a Notebook control with enabled=$enabled and developerMode=$developerMode", async ({ enabled, developerMode }) => { + await renderPage(undefined, { ...defaultExperimentalSettings(), enablePaperclipNotebook: enabled, + enablePaperclipDeveloperMode: developerMode, + managedKeys: { enablePaperclipNotebook: { managed: true, managedBy: "paperclip-cloud" } }, + }); + expect(container.textContent).toContain("Experimental features"); + expect(container.textContent).not.toContain("Paperclip Notebook"); + expect(container.querySelector('button[aria-label="Toggle Paperclip Notebook experimental setting"]')).toBeNull(); + expect(mockInstanceSettingsApi.updateExperimental).not.toHaveBeenCalled(); + }); + it("renders nothing for an operator-hidden toggle and keeps the rest", async () => { await renderPage(["instance.experimental.enableEnvironments"]); diff --git a/ui/src/pages/apps/AppsConnect.test.tsx b/ui/src/pages/apps/AppsConnect.test.tsx index e321f9881f1..551c025251d 100644 --- a/ui/src/pages/apps/AppsConnect.test.tsx +++ b/ui/src/pages/apps/AppsConnect.test.tsx @@ -665,6 +665,64 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(finishAppMock).not.toHaveBeenCalled(); }); + it.each([ + "source=paperclip-notebook", "source=%20paperclip-notebook%20", "appKey=paperclip-notebook", "source=paperclip-notebook&resume=existing-notebook", + "source=paperclip-notebook&reconnect=existing-notebook", "reconnect=existing-notebook", + ])("blocks Notebook setup and existing authorization shortcuts while disabled: %s", async query => { + mockSearch.value = query; + getConnectionMock.mockResolvedValue({ id: "existing-notebook", status: "active", config: { sourceTemplateKey: "paperclip-notebook" } }); + listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("paperclip-notebook")] }); + await render(); + expect(container.textContent).toContain("Paperclip Notebook is not available on this instance"); + expect(connectAppMock).not.toHaveBeenCalled(); + expect(startOAuthMock).not.toHaveBeenCalled(); + expect(finishAppMock).not.toHaveBeenCalled(); + }); + + it.each(["path", "inline"])("blocks the Notebook %s setup route while disabled", async route => { + getConnectionMock.mockResolvedValue({ id: "existing-notebook", status: "active", config: { sourceTemplateKey: "paperclip-notebook" } }); + mockParams.appKey = route === "path" ? "paperclip-notebook" : undefined; + await render(undefined, false, route === "inline" ? : undefined); + expect(container.textContent).toContain("Paperclip Notebook is not available on this instance"); + expect(connectAppMock).not.toHaveBeenCalled(); + expect(startOAuthMock).not.toHaveBeenCalled(); + }); + + it("uses the saved application identity when its mutable provider marker has changed", async () => { + mockSearch.value = "source=notion&reconnect=existing-notebook"; + getConnectionMock.mockResolvedValue({ id: "existing-notebook", applicationId: "notebook-app", status: "active", config: { sourceTemplateKey: "notion" } }); + listConnectionsMock.mockResolvedValue({ connections: [{ id: "existing-notebook", applicationId: "notebook-app", status: "active", config: { sourceTemplateKey: "notion" } }] }); + listApplicationsMock.mockResolvedValue({ applications: [{ id: "notebook-app", applicationKey: "app-gallery:paperclip-notebook:11111111-1111-4111-8111-111111111111", metadata: { sourceTemplateKey: "notion" } }] }); + await render(); + expect(container.textContent).toContain("Paperclip Notebook is not available on this instance"); + expect(connectAppMock).not.toHaveBeenCalled(); + expect(startOAuthMock).not.toHaveBeenCalled(); + }); + + it("waits for Notebook settings and fails closed when they cannot be loaded", async () => { + mockSearch.value = "source=paperclip-notebook"; + let reject!: (error: Error) => void; + experimentalMock.mockImplementation(() => new Promise((_resolve, fail) => { reject = fail; })); + await render(); + expect(container.textContent).toContain("Loading connection settings"); + await act(async () => { reject(new Error("Settings unavailable")); }); + await flushReact(); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("Could not load connector availability"); + expect(connectAppMock).not.toHaveBeenCalled(); + expect(startOAuthMock).not.toHaveBeenCalled(); + }); + + it("opens Notebook setup only with its explicit rollout opt-in", async () => { + mockSearch.value = "source=paperclip-notebook"; + experimentalMock.mockResolvedValue({ enablePaperclipNotebook: true }); + listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("paperclip-notebook")] }); + await render(); + expect(container.textContent).toContain("Paperclip Notebook"); + expect(container.textContent).not.toContain("not available on this instance"); + expect(listGalleryMock).toHaveBeenCalledWith("company-1"); + expect([...container.querySelectorAll("button")].some(button => /Continue to|Continue to sign in|Connect/.test(button.textContent ?? ""))).toBe(true); + }); + it.each(["mem0", "zep", "supermemory", "cognee", "honcho"])("blocks direct %s setup while memory connectors are off", async (provider) => { mockSearch.value = `source=${provider}`; await render(); diff --git a/ui/src/pages/apps/Browse.test.tsx b/ui/src/pages/apps/Browse.test.tsx index fb691451ca9..50f98f95dff 100644 --- a/ui/src/pages/apps/Browse.test.tsx +++ b/ui/src/pages/apps/Browse.test.tsx @@ -935,6 +935,68 @@ describe("Connectors landing page", () => { }, ); + it("uses availability metadata for a non-Notebook connector in cached gallery results", async () => { + const privateResearch = { ...getAppStoreDefinition("notion")!, slug: "private-research", name: "Private Research", experimentalFeatureKey: "enableCases" as const }; + listGalleryMock.mockResolvedValue({ apps: [privateResearch, getAppStoreDefinition("context7")] }); + const client = await renderBrowse(); + expect(container.querySelector('[data-app-slug="private-research"]')).toBeNull(); + expect(container.querySelector('[data-app-slug="context7"]')).not.toBeNull(); + await act(() => { client.setQueryData(queryKeys.instance.experimentalSettings, { enableCases: true }); }); + await flushReact(); + expect(container.querySelector('[data-app-slug="private-research"]')).not.toBeNull(); + }); + + it("hides cached Notebook catalog entries until explicitly enabled", async () => { + listGalleryMock.mockResolvedValue({ apps: ["paperclip-notebook", "notion"].map(getAppStoreDefinition) }); + const client = await renderBrowse(); + expect(container.querySelector('[data-app-slug="paperclip-notebook"]')).toBeNull(); + expect(container.querySelector('[data-app-slug="notion"]')).not.toBeNull(); + await act(() => { client.setQueryData(queryKeys.instance.experimentalSettings, { enablePaperclipNotebook: true }); }); + await flushReact(); + expect(container.querySelector('[data-app-slug="paperclip-notebook"]')).not.toBeNull(); + await act(() => { client.setQueryData(queryKeys.instance.experimentalSettings, { enablePaperclipNotebook: false }); }); + await flushReact(); + expect(container.querySelector('[data-app-slug="paperclip-notebook"]')).toBeNull(); + }); + + it("hides Notebook while settings load", async () => { + experimentalMock.mockImplementation(() => new Promise(() => {})); + listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("paperclip-notebook")] }); + await renderBrowse(); + expect(container.querySelector('[data-app-slug="paperclip-notebook"]')).toBeNull(); + }); + + it("hides Notebook when a settings refresh fails with a cached opt-in", async () => { + experimentalMock.mockResolvedValue({ enablePaperclipNotebook: true }); + listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("paperclip-notebook")] }); + const client = await renderBrowse(); + expect(container.querySelector('[data-app-slug="paperclip-notebook"]')).not.toBeNull(); + experimentalMock.mockRejectedValue(new Error("Settings unavailable")); + await act(async () => { await client.invalidateQueries({ queryKey: queryKeys.instance.experimentalSettings }); }); + await flushReact(); + expect(client.getQueryData(queryKeys.instance.experimentalSettings)).toEqual({ enablePaperclipNotebook: true }); + expect(container.querySelector('[data-app-slug="paperclip-notebook"]')).toBeNull(); + }); + + it("keeps Notebook removal available while hiding reconnect and new-account controls", async () => { + listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("paperclip-notebook")] }); + listApplicationsMock.mockResolvedValue({ applications: [application({ id: "notebook-app", name: "Paperclip Notebook", + applicationKey: "app-gallery:paperclip-notebook:one", metadata: { sourceTemplateKey: "paperclip-notebook" } })] }); + listConnectionsMock.mockResolvedValue({ connections: [connection({ id: "saved-notebook", applicationId: "notebook-app", + name: "Notebook account", config: { sourceTemplateKey: "paperclip-notebook" }, healthStatus: "error", requiresReauthorization: true })] }); + await renderBrowse(); + const row = container.querySelector('[data-app-slug="paperclip-notebook"]')!; + expect(row).not.toBeNull(); + expect([...row.querySelectorAll("button")].some(button => /^(Connect|Add connection|Reconnect|Finish setup)$/.test(button.textContent?.trim() ?? ""))).toBe(false); + await act(() => { row.querySelector('button[aria-label^="Manage "]')!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); }); + await flushReact(); + await act(() => { [...document.querySelectorAll('[role="menuitem"]')].find(item => item.textContent?.trim() === "Remove connection")!.click(); }); + await flushReact(); + await act(() => { [...document.querySelectorAll("button")].find(button => button.textContent?.trim() === "Remove connection")!.click(); }); + await flushReact(); + expect(archiveConnectionMock).toHaveBeenCalledWith("saved-notebook"); + }); + it("hides cached memory connectors until enabled and preserves saved MCP connections", async () => { const providers = ["mem0", "zep", "supermemory", "cognee", "honcho"]; listGalleryMock.mockResolvedValue({ apps: [...providers, "notion"].map(getAppStoreDefinition) }); diff --git a/ui/src/pages/apps/Browse.tsx b/ui/src/pages/apps/Browse.tsx index 84318d0d233..843a8ec52ac 100644 --- a/ui/src/pages/apps/Browse.tsx +++ b/ui/src/pages/apps/Browse.tsx @@ -32,6 +32,7 @@ import { import type { ComposioAppSnapshot, ToolApplication, ToolConnection } from "@paperclipai/shared"; import { getAppDefinitionForUrl, + getAppDefinitionForApplicationKey, isMemoryConnectorId, getAppStoreDefinition, isToolConnectionAttentionHealth, @@ -43,6 +44,7 @@ import { import { useNavigate } from "@/lib/router"; import { useChatConnectorsEnabled, chatProviderVisible } from "@/hooks/useChatConnectorsEnabled"; import { useMemoryConnectorsEnabled } from "@/hooks/useMemoryConnectorsEnabled"; +import { useConnectorAvailability } from "@/hooks/useConnectorAvailability"; import { appCopyFor } from "@/lib/app-gallery-copy"; import { useCompany } from "@/context/CompanyContext"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; @@ -366,6 +368,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne const { userId: viewingUserId, settled: identitySettled } = useAccountIdentity(); const { enabled: chatConnectorsEnabled, githubEnabled } = useChatConnectorsEnabled(); const { enabled: memoryConnectorsEnabled } = useMemoryConnectorsEnabled(); + const connectorAvailability = useConnectorAvailability(); const { setBreadcrumbs } = useBreadcrumbs(); const [query, setQuery] = useState(""); const [page, setPage] = useState(1); @@ -522,6 +525,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne (galleryQuery.data?.apps ?? []) as AppGalleryDisplayEntry[] ).filter((entry) => { if (!memoryConnectorsEnabled && isMemoryConnectorId(appDefinitionSlug(entry))) return false; + if (!connectorAvailability.enabled(entry)) return false; const definition = getAppStoreDefinition(appDefinitionSlug(entry)); return ( chatProviderVisible(appDefinitionSlug(entry), chatConnectorsEnabled, githubEnabled) || @@ -954,6 +958,8 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne onRequestRemove={target => void requestConnectionRemoval(target)} preselectedAgentId={preselectedChatAgentId} chatConnectorsEnabled={chatConnectorsEnabled} + setupEnabled={connectorAvailability.enabled(row.entry ?? getConnectableAppDefinition(row.slug)) + && row.applications.every(application => connectorAvailability.enabled(getAppDefinitionForApplicationKey(application.applicationKey)))} githubReviewBotsEnabled={githubEnabled} onConnectAggregator={connectAggregator} onManageAggregator={(app, connectionId) => setAggregatorToManage({ app, connectionId })} @@ -1054,6 +1060,7 @@ export function ConnectorCard({ onRequestRemove, preselectedAgentId, chatConnectorsEnabled, + setupEnabled = true, githubReviewBotsEnabled = false, onConnectAggregator, onManageAggregator, @@ -1076,6 +1083,7 @@ export function ConnectorCard({ onRequestRemove: (target: ConnectionRemovalTarget) => void; preselectedAgentId?: string | null; chatConnectorsEnabled: boolean; + setupEnabled?: boolean; githubReviewBotsEnabled?: boolean; onConnectAggregator?: (app: AggregatorAppCatalogEntry) => void; onManageAggregator?: (app: AggregatorAppCatalogEntry, connectionId?: string) => void; @@ -1164,7 +1172,7 @@ export function ConnectorCard({ > Connect Copilot } - + } setExpandedOverride({ open, defaultExpanded })}> @@ -1198,6 +1206,7 @@ export function ConnectorCard({ details={renderAccountDetails?.(connection)} key={connection.id} row={row} + setupEnabled={setupEnabled} connection={connection} owner={connectionOwnerProfile(connection, userProfileById)} onNavigate={onNavigate} @@ -1347,6 +1356,7 @@ function ConnectionAccountRow({ discovery, onConfigureArcade, onConfigureExecutor, + setupEnabled = true, }: { details?: ReactNode; row: ConnectorRowModel; @@ -1359,6 +1369,7 @@ function ConnectionAccountRow({ discovery?: AggregatorAppsResponse; onConfigureArcade?: () => void; onConfigureExecutor?: () => void; + setupEnabled?: boolean; }) { const state = connectionState(connection); const actionHref = accountActionHref(row, connection); @@ -1371,7 +1382,7 @@ function ConnectionAccountRow({ return ( {details}{discovery?.discovery.message || discovery?.sync.status === "error" ?

{discovery.discovery.message ?? discovery.sync.error}

: null}} openLabel={`Open ${accountName} permissions`} onOpen={() => onNavigate(`/apps/${connection.id}/permissions`)}> - {state.kind === "attention" || state.kind === "draft" ? ( + {setupEnabled && (state.kind === "attention" || state.kind === "draft") ? (