diff --git a/doc/connections/GOOGLE-WORKSPACE-SERVICE-CONSENT.md b/doc/connections/GOOGLE-WORKSPACE-SERVICE-CONSENT.md new file mode 100644 index 00000000000..143fbc273cb --- /dev/null +++ b/doc/connections/GOOGLE-WORKSPACE-SERVICE-CONSENT.md @@ -0,0 +1,43 @@ +# Google Workspace service consent + +New Workspace setup uses one account connection and three service groups: +Gmail, Drive (including Docs, Sheets, and Slides), and Calendar. Every group +starts enabled with read and write access. The picker can disable groups or +choose read-only access. Chat, Contacts/directory, and cross-service search are +not offered. Existing individual-service connections remain compatible. + +`GoogleWorkspaceServicesDialog` is shared by Apps and task-hosted setup. +It appears before creating an OAuth draft or starting reconnect. Cancel does +not create a connection. Reconnect reads the current subject's grant and +restores its saved request; failure to load never substitutes broader defaults. +Account-page reconnect calls without a new selection also restore the exact +subject's saved request on the server. Missing request/grant evidence blocks +reconnect until a service choice is supplied; new-account defaults never replace it. +The same service fields appear in Storybook's **Google Workspace → Service +consent preview → Production picker**. Storybook callbacks do not grant access. + +The `workspaceServices` input accepts only nonempty combinations of `gmail`, +`drive`, and `calendar`, each `read` or `write`. The server derives exact scopes +from the shared closed registry. Default write access requests nine scopes. +Drive file creation is limited to app-opened/app-created files; Docs, Sheets, +and Slides have their respective editor scopes. Gmail uses `gmail.compose` +for draft creation; Google permits sending under that scope, but Paperclip's +closed tool allowlist still excludes sending. + +Both customer OAuth and managed Cloud OAuth bind the request to durable OAuth +state. The broker must support nonempty `workspace.all` scope subsets, preserve +them in the provider URL and encrypted claim binding, and reject unknown scopes. +Other managed profiles retain their exact-set contract. Ship that broker change +before updating tenant apps; an older broker rejects narrowed requests safely. + +Only provider-reported consent enables tools. Callback and refresh cap effective +permissions to the saved request, including read-only implications of broader +Google grants. Accumulated grants cannot enable unselected services or writes. +Credential ownership, agent installations, action policies, company boundaries, +and single-use state remain under their existing enforcement paths. + +Production readiness additionally requires the intended OAuth client and +`workspace.all` profile to be enabled on the target broker, a deployed app and +broker with matching contracts, and live reviewer-path verification. Passing +Storybook or fixture-backed OAuth tests does not establish Google verification +approval, production deployment, or a successful live grant. diff --git a/doc/connections/GOOGLE-WORKSPACE.md b/doc/connections/GOOGLE-WORKSPACE.md index ff76addd514..993a8cf31b7 100644 --- a/doc/connections/GOOGLE-WORKSPACE.md +++ b/doc/connections/GOOGLE-WORKSPACE.md @@ -1,40 +1,92 @@ # Google Workspace connections -Paperclip presents Google Workspace as nine independent Apps entries, not as -one combined Google connection: +New Google Workspace setup uses one `google-workspace` connection and the +`workspace.all` OAuth profile. Paperclip first offers Gmail, Drive (including +Docs, Sheets, and Slides), and Calendar. All three start enabled with read and +write access. Users can disable a group or choose read-only access. New setup +does not offer Chat, Contacts/directory, or cross-service Search. -1. Gmail -2. Google Drive -3. Google Docs -4. Google Sheets -5. Google Slides -6. Google Calendar -7. Google Chat -8. Google People -9. Google Workspace Search +The combined connection has one credential grant, capability catalog, policy +profile, audit trail and reconnect lifecycle. It requests only the selected +groups' scopes (nine scopes for the default selection), not the legacy full +union. Google's consent screen also lets users decline +permissions; discovery skips services without grants and excludes unsupported +actions. The gateway checks actual grant scopes again before each call, including +after refresh. Requested scopes alone never authorize an action. -Each entry creates its own connection, consent grant, capability catalog, -policy, audit trail, and reconnect/revoke lifecycle. Connecting Drive does not -create a Docs or Sheets connection, and connecting Gmail does not enable -Workspace Search. +Discovery isolates service outages. Healthy services remain usable while the +connection's health message identifies unavailable services and recommends a +catalog refresh. Failed services' cached actions are disabled until rediscovered; +an outage affecting every requested service still fails the refresh. Loss of the +shared OAuth grant never degrades into a successful partial discovery. Because +the catalog is shared, actions skipped because of narrower consent are preserved +when another active grant supports them. Failed services and tools removed from a +successful discovery are still disabled. Each caller's listing and dispatch use only their +selected grant, without combining permissions from different identities. +Individual call failures are reported and audited without marking unrelated +Google services unhealthy; they do not trigger automatic write retries. + +Existing individual product connections and their direct setup routes remain +supported. They are not automatically merged, upgraded or reauthorized. The Sheets +robot-account method remains separate because it uses explicitly shared files, +not the user's Google OAuth identity. Google's hosted Workspace MCP servers are Developer Preview services. The app -cards remain independent even when several services use the same customer-owned -Google OAuth client or the same Paperclip Cloud broker deployment. +endpoints remain separate. Paperclip namespaces actions by service and routes them +to a closed endpoint registry using the combined grant. It does not create child +connections or send provider tokens through Paperclip ID. -## Temporary Connections page visibility hold +The catalog reuses the existing reviewed Google mark (`google-people.svg`), +registered under its own `google-workspace` brand identity. No new artwork is +downloaded or substituted for a product logo. -While Google OAuth verification is pending, the Connections landing page -(`ui/src/pages/apps/Browse.tsx`) hides all nine Google Workspace entries, -including their saved accounts. This is a display-only filter. App definitions, -direct setup and management routes, OAuth profiles, saved credentials, and -runtime tools remain unchanged. This is not an access-control restriction. +## Combined profile deployment and verification -Keep verification instances pinned to their pre-hold app release so reviewers -can still find and test the integrations. After approval, remove the page's -`GOOGLE_CONNECTOR_SLUGS` filter and update its visibility tests before upgrading -those instances. Do not disable the shared definitions or broker profiles to -control this page's visibility. +Deploy the companion Cloud broker registry before enabling `workspace.all` in +`CLOUD_HARNESS_CONNECTOR_GOOGLE_ENABLED_PROFILES`. It uses the existing WORKSPACE +client pair; that client's Cloud project must have every applicable MCP API and +preview enrollment. Do not remove legacy enabled profiles while their grants are +in use. This code change does not enable the broker profile or deploy any stack. + +The signed request and envelope authentication bind the exact selected request; +the legacy 21-scope profile remains supported for old clients. The encrypted +credential payload preserves Google's actual nonempty +subset. Missing scope evidence and scopes outside that reviewed union fail closed +on authorization and refresh. Individual legacy profiles retain exact matching. +For the new `google/workspace.all` profile only, HKDF info is SHA-256(AAD) to +remain below Node's 1024-byte limit. AES-GCM still authenticates the complete AAD; +legacy envelope key derivation is unchanged. + +Managed OAuth: browser → Cloud `/v1/connector/sessions` → Google +`https://accounts.google.com/o/oauth2/v2/auth` → Cloud +`/v1/connector/oauth/google/callback` → instance +`/api/tools/oauth/cloud-connector/callback`. Cloud exchanges codes and refreshes at +`https://oauth2.googleapis.com/token`; provider tool calls go directly from the +instance to the fixed endpoints below. Customer OAuth uses the instance's existing +`/api/tools/oauth/callback` instead. Register that exact HTTPS or loopback callback +on the customer-owned client. + +Removal is local for managed grants: Google's project-wide revocation can break +other connections. Before rollout, verify full and partial consent with an enrolled +test account, allowed reads/writes, reconnect, refresh and removal. Deterministic +tests are not real-provider proof. Combining profiles simplifies onboarding, not +Google's per-scope demonstration, justification or security-assessment obligations. + +Sources: [granular consent](https://developers.google.com/identity/protocols/oauth2/resources/granular-permissions), +[Workspace MCP setup](https://developers.google.com/workspace/guides/configure-mcp-servers). + +## Connections catalog and rollout + +The Connections page offers one Google Workspace card for new accounts. Saved +individual-service accounts stay visible and retain their management routes. +This replaces the earlier display-only Google visibility hold. Showing the card +does not enable managed OAuth: signed instance-specific broker availability +still controls it. A customer-owned OAuth app remains a separate setup method. + +Do not upgrade or unpin existing verification instances as an implementation +step. Qualify the selected-scope flow on an isolated reviewer staging app first. +See [service consent](GOOGLE-WORKSPACE-SERVICE-CONSENT.md) for the app/broker +deployment order and live verification gates. ## Developer Preview enrollment @@ -60,7 +112,7 @@ registered internal testers during preview. Other companies must enroll their own Workspace testers and Cloud project and use a customer-owned OAuth app until Google makes Workspace MCP generally available. -## App matrix +## Service endpoints and legacy capability choices | App card | MCP endpoint | Capability choices | | --- | --- | --- | @@ -74,7 +126,11 @@ Google makes Workspace MCP generally available. | Google People | `https://people.googleapis.com/mcp/v1` | Read contacts | | Google Workspace Search | `https://workspacemcp.googleapis.com/mcp/v1` | Search Workspace | -The setup flow asks for the capability first. When the managed method is +Legacy product setup exposes capability choices under Change. The combined +Workspace setup has one capability and asks for service groups before Google +consent. The endpoint table also documents legacy connections; it is not the +new-account service list. +When the managed method is available, it uses Paperclip by default. A small **Use your own Google OAuth app** link reveals the custom client fields; **Use Paperclip instead** returns to the managed method. The available authentication methods are: @@ -89,8 +145,8 @@ managed method. The available authentication methods are: - **Use the Paperclip robot account** remains an additional Google Sheets-only option for explicitly shared spreadsheets. -Before Google consent, the setup flow asks whether the credential is for just -the connecting user or for any human in the company. A personal choice stores +Before Google consent, the setup flow shows the credential's human and agent +access and lets the user change it. A personal choice stores the tokens only on that user's grant. A company choice stores them on the default organization grant, while still recording which signed-in Google principal completed consent so refresh and reconnect stay bound to that @@ -104,7 +160,7 @@ callback, refresh, and revoke, while the broker enforces current profile access. Switching capability or authentication methods preserves the selected credential owner when the new method supports that owner. -## Broker profiles +## Legacy product-specific broker profiles The Paperclip-managed method signs every broker request with one explicit profile. The broker binds that profile into sessions, one-time claims, sealed diff --git a/doc/connections/tool-method-permission-reviews.json b/doc/connections/tool-method-permission-reviews.json index eaa8114a565..b22e6554de4 100644 --- a/doc/connections/tool-method-permission-reviews.json +++ b/doc/connections/tool-method-permission-reviews.json @@ -2,6 +2,66 @@ "reviewedAt": "2026-09-30", "evidenceLevel": "Official documentation and public MCP metadata; no account-bound read/write proof. Runtime discovery never expands this allowlist.", "methods": [ + { + "app": "google-workspace", + "method": "paperclip-workspace", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.compose", + "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/drive.file", + "https://www.googleapis.com/auth/documents.readonly", + "https://www.googleapis.com/auth/documents", + "https://www.googleapis.com/auth/spreadsheets.readonly", + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/presentations.readonly", + "https://www.googleapis.com/auth/presentations", + "https://www.googleapis.com/auth/calendar.calendarlist.readonly", + "https://www.googleapis.com/auth/calendar.events.freebusy", + "https://www.googleapis.com/auth/calendar.events.readonly", + "https://www.googleapis.com/auth/calendar.events" + ], + "capability": "write", + "supportedActions": "One grant for selected Gmail, Drive (Docs, Sheets, Slides), and Calendar services. The list is the read/write selection allowlist, not the default request; default write selections request nine scopes. Chat, People, and Search are excluded. Gmail sending remains unsupported.", + "evidence": [ + "https://developers.google.com/workspace/guides/configure-mcp-servers", + "https://developers.google.com/identity/protocols/oauth2/resources/granular-permissions" + ], + "reviewedAt": "2026-10-19", + "liveProof": "not-run" + }, + { + "app": "google-workspace", + "method": "customer-workspace-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.compose", + "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/drive.file", + "https://www.googleapis.com/auth/documents.readonly", + "https://www.googleapis.com/auth/documents", + "https://www.googleapis.com/auth/spreadsheets.readonly", + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/presentations.readonly", + "https://www.googleapis.com/auth/presentations", + "https://www.googleapis.com/auth/calendar.calendarlist.readonly", + "https://www.googleapis.com/auth/calendar.events.freebusy", + "https://www.googleapis.com/auth/calendar.events.readonly", + "https://www.googleapis.com/auth/calendar.events" + ], + "capability": "write", + "supportedActions": "One grant for selected Gmail, Drive (Docs, Sheets, Slides), and Calendar services. The list is the read/write selection allowlist, not the default request; default write selections request nine scopes. Chat, People, and Search are excluded. Gmail sending remains unsupported.", + "evidence": [ + "https://developers.google.com/workspace/guides/configure-mcp-servers", + "https://developers.google.com/identity/protocols/oauth2/resources/granular-permissions" + ], + "reviewedAt": "2026-10-19", + "liveProof": "not-run" + }, { "app": "airtable", "method": "mcp-oauth", diff --git a/doc/plans/2026-10-08-unified-google-workspace-connector.md b/doc/plans/2026-10-08-unified-google-workspace-connector.md new file mode 100644 index 00000000000..dd951ea77c0 --- /dev/null +++ b/doc/plans/2026-10-08-unified-google-workspace-connector.md @@ -0,0 +1,39 @@ +# Unified Google Workspace connector + +## Finish line + +One connection, OAuth consent, credential grant and action profile for the nine +existing Google services. Google controls granular consent; only actually granted +actions are discoverable and executable. No new scopes, Gmail sends, Chat +membership or unread-state access. Existing product connections remain compatible. + +## Scope and rollout + +- App branch merged with `origin/master` at `6dc32b57f`. +- App delivery branch: `codex/unified-google-workspace`. +- Companion Cloud broker branch: `codex/google-workspace-connector`. +- Reuse normal setup, vault, policies, refresh and audit paths. +- Keep the temporary Connections-page hold. No production deployment, Console + changes, reviewer-stack upgrade or unpinning as part of this implementation. +- Sign-in tokens are never reused as resource tokens; id.paperclip.ing never + stores resource tokens; no connections hub on the ID service. + +## Acceptance + +- Full and partial consent, exact requested scope union, one saved connection. +- Namespaced tools route to fixed service endpoints; unknown tools fail closed. +- Declined/revoked scopes block cached agent and board calls. +- Reconnect preserves narrower local action policies and credential ownership. +- Existing scopes, individual profiles and the Sheets robot method survive. +- Deterministic checks plus local browser setup inspection. Real Google OAuth + remains a separate release gate until an enrolled test client is available. + +## Current state + +Implementation complete in the app and broker. Focused app setup, vault, gateway, +all-service routing and consent tests pass. Broker integration tests (32), its +full suite (2,871 passed; 77 skipped), app build, workspace typecheck, brand asset +validation and token gates pass. +The production setup components were checked in a background browser using +simulated Storybook data: one profile, personal access and OAuth method switching. +Full regression checks and PR review are in progress. No live Google proof yet. diff --git a/packages/shared/src/app-definitions-url.test.ts b/packages/shared/src/app-definitions-url.test.ts index 1d9711a5d8b..7a8e327ceae 100644 --- a/packages/shared/src/app-definitions-url.test.ts +++ b/packages/shared/src/app-definitions-url.test.ts @@ -11,6 +11,7 @@ describe("tool app gallery URL matching", () => { expect(getAppDefinitionForUrl("https://github.com/paperclipai/paperclip/pull/1")?.slug).toBe("github"); expect(getAppDefinitionForUrl("https://docs.google.com/spreadsheets/d/sheet_123/edit")?.slug).toBe("google-sheets"); expect(getAppDefinitionForUrl("https://gmailmcp.googleapis.com/mcp/v1")?.slug).toBe("gmail"); + expect(getAppDefinitionForUrl("https://workspace.google.com/")?.slug).toBe("google-workspace"); expect(getAppDefinitionForUrl("https://mcp.neon.tech/mcp")?.slug).toBe("neon"); expect(getAppDefinitionForUrl("https://app.withgauge.com/mcp")?.slug).toBe("gauge"); expect(getAppDefinitionForUrl("https://www.superagent.sh/mcp")?.slug).toBe("superagent"); diff --git a/packages/shared/src/app-definitions.generated.ts b/packages/shared/src/app-definitions.generated.ts index 1666fb5c280..467412e6ea1 100644 --- a/packages/shared/src/app-definitions.generated.ts +++ b/packages/shared/src/app-definitions.generated.ts @@ -81,26 +81,27 @@ import a79 from "./app-definitions/windsor-ai.json" with { type: "json" }; import a80 from "./app-definitions/gsc-wizard.json" with { type: "json" }; import a81 from "./app-definitions/clickup.json" with { type: "json" }; import a82 from "./app-definitions/klaviyo.json" with { type: "json" }; -import a83 from "./app-definitions/gmail.json" with { type: "json" }; -import a84 from "./app-definitions/google-drive.json" with { type: "json" }; -import a85 from "./app-definitions/google-docs.json" with { type: "json" }; -import a86 from "./app-definitions/google-sheets.json" with { type: "json" }; -import a87 from "./app-definitions/google-slides.json" with { type: "json" }; -import a88 from "./app-definitions/google-calendar.json" with { type: "json" }; -import a89 from "./app-definitions/google-chat.json" with { type: "json" }; -import a90 from "./app-definitions/google-people.json" with { type: "json" }; -import a91 from "./app-definitions/google-workspace-search.json" with { type: "json" }; -import a92 from "./app-definitions/openai.json" with { type: "json" }; -import a93 from "./app-definitions/openrouter.json" with { type: "json" }; -import a94 from "./app-definitions/xai.json" with { type: "json" }; -import a95 from "./app-definitions/google.json" with { type: "json" }; -import a96 from "./app-definitions/bedrock.json" with { type: "json" }; -import a97 from "./app-definitions/responses-api.json" with { type: "json" }; -import a98 from "./app-definitions/messages-api.json" with { type: "json" }; -import a99 from "./app-definitions/chat-completions-api.json" with { type: "json" }; -import a100 from "./app-definitions/local.json" with { type: "json" }; -import a101 from "./app-definitions/daytona.json" with { type: "json" }; -import a102 from "./app-definitions/monday.json" with { type: "json" }; -import a103 from "./app-definitions/speko.json" with { type: "json" }; +import a83 from "./app-definitions/google-workspace.json" with { type: "json" }; +import a84 from "./app-definitions/gmail.json" with { type: "json" }; +import a85 from "./app-definitions/google-drive.json" with { type: "json" }; +import a86 from "./app-definitions/google-docs.json" with { type: "json" }; +import a87 from "./app-definitions/google-sheets.json" with { type: "json" }; +import a88 from "./app-definitions/google-slides.json" with { type: "json" }; +import a89 from "./app-definitions/google-calendar.json" with { type: "json" }; +import a90 from "./app-definitions/google-chat.json" with { type: "json" }; +import a91 from "./app-definitions/google-people.json" with { type: "json" }; +import a92 from "./app-definitions/google-workspace-search.json" with { type: "json" }; +import a93 from "./app-definitions/openai.json" with { type: "json" }; +import a94 from "./app-definitions/openrouter.json" with { type: "json" }; +import a95 from "./app-definitions/xai.json" with { type: "json" }; +import a96 from "./app-definitions/google.json" with { type: "json" }; +import a97 from "./app-definitions/bedrock.json" with { type: "json" }; +import a98 from "./app-definitions/responses-api.json" with { type: "json" }; +import a99 from "./app-definitions/messages-api.json" with { type: "json" }; +import a100 from "./app-definitions/chat-completions-api.json" with { type: "json" }; +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 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] 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] as AppDefinition[]; diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index 6cc868cf6ce..50cf0b0e572 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -884,7 +884,7 @@ describe("AppDefinition catalog", () => { "ticktick", "xero", ]); - expect(APP_STORE_DEFINITIONS).toHaveLength(81); + expect(APP_STORE_DEFINITIONS).toHaveLength(82); const connectableSlugs = new Set( CONNECTABLE_APP_DEFINITIONS.map((entry) => entry.slug), ); @@ -956,7 +956,7 @@ describe("AppDefinition catalog", () => { }); it("keeps all Google Workspace profiles aligned with their app, endpoint, scopes, ownership, risk, and write policy", () => { expect(GOOGLE_WORKSPACE_CONNECTOR_PROFILE_IDS).toEqual( - GOOGLE_WORKSPACE_PROFILE_EXPECTATIONS.map((entry) => entry.profile), + [...GOOGLE_WORKSPACE_PROFILE_EXPECTATIONS.map((entry) => entry.profile), "workspace.all"], ); expect(Object.keys(GOOGLE_WORKSPACE_CONNECTOR_PROFILES)).toEqual([ ...GOOGLE_WORKSPACE_CONNECTOR_PROFILE_IDS, diff --git a/packages/shared/src/app-definitions.ts b/packages/shared/src/app-definitions.ts index 40d56a9453a..d88f8509157 100644 --- a/packages/shared/src/app-definitions.ts +++ b/packages/shared/src/app-definitions.ts @@ -33,6 +33,7 @@ export const CONNECTABLE_APP_SLUGS = new Set([ "google-chat", "google-people", "google-workspace-search", + "google-workspace", "github", "github-code-review-bot", "discord", diff --git a/packages/shared/src/app-definitions/google-workspace.json b/packages/shared/src/app-definitions/google-workspace.json new file mode 100644 index 00000000000..68e401fa299 --- /dev/null +++ b/packages/shared/src/app-definitions/google-workspace.json @@ -0,0 +1,121 @@ +{ + "schemaVersion": 1, + "slug": "google-workspace", + "name": "Google Workspace", + "description": "Connect Gmail, Drive, Docs, Sheets, Slides, and Calendar with one Google account.", + "categories": [ + "productivity", + "communication", + "content" + ], + "featured": true, + "branding": { + "logoUrl": "/brands/apps/google-people.svg" + }, + "urlPatterns": [ + "https://workspace.google.com/*" + ], + "docsUrl": "https://developers.google.com/workspace/guides/configure-mcp-servers", + "setupPrerequisite": { + "title": "Google Developer Preview access required", + "description": "Google must register the Workspace email and the Cloud project that owns the OAuth client before these services can be used.", + "actionLabel": "Check preview enrollment", + "actionUrl": "https://developers.google.com/workspace/preview" + }, + "redirectConstraints": "https-or-loopback-http", + "methods": [ + { + "key": "paperclip-workspace", + "label": "Connect with Paperclip", + "transport": "mcp_remote", + "auth": "oauth", + "oauthStrategy": "paperclip_cloud_connector", + "connectorProfile": "workspace.all", + "capabilityProfile": { + "key": "workspace", + "label": "Google Workspace", + "description": "Choose services and read or write access before signing in." + }, + "grantKinds": [ + "user", + "organization" + ], + "ownershipModes": [ + "platform_shared" + ], + "whenToUse": "Use one Paperclip-managed sign-in for your selected Google services.", + "defaults": { + "serverUrl": "https://workspacemcp.googleapis.com/mcp/v1", + "scopesHint": [ + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.compose", + "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/drive.file", + "https://www.googleapis.com/auth/documents.readonly", + "https://www.googleapis.com/auth/documents", + "https://www.googleapis.com/auth/spreadsheets.readonly", + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/presentations.readonly", + "https://www.googleapis.com/auth/presentations", + "https://www.googleapis.com/auth/calendar.calendarlist.readonly", + "https://www.googleapis.com/auth/calendar.events.freebusy", + "https://www.googleapis.com/auth/calendar.events.readonly", + "https://www.googleapis.com/auth/calendar.events" + ] + }, + "guidanceMd": "Choose Gmail, Drive, or Calendar and their access level before signing in. Only selected and granted actions are available.", + "riskTier": "S4" + }, + { + "key": "customer-workspace-oauth", + "label": "Use your own Google OAuth app", + "transport": "mcp_remote", + "auth": "oauth", + "capabilityProfile": { + "key": "workspace", + "label": "Google Workspace", + "description": "Choose services and read or write access before signing in." + }, + "grantKinds": [ + "user", + "organization" + ], + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use one customer-owned Google OAuth client for your selected Google services.", + "defaults": { + "serverUrl": "https://workspacemcp.googleapis.com/mcp/v1", + "authorizationEndpoint": "https://accounts.google.com/o/oauth2/v2/auth", + "tokenEndpoint": "https://oauth2.googleapis.com/token", + "metadataUrl": "https://accounts.google.com/.well-known/openid-configuration", + "scopesHint": [ + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.compose", + "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/drive.file", + "https://www.googleapis.com/auth/documents.readonly", + "https://www.googleapis.com/auth/documents", + "https://www.googleapis.com/auth/spreadsheets.readonly", + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/presentations.readonly", + "https://www.googleapis.com/auth/presentations", + "https://www.googleapis.com/auth/calendar.calendarlist.readonly", + "https://www.googleapis.com/auth/calendar.events.freebusy", + "https://www.googleapis.com/auth/calendar.events.readonly", + "https://www.googleapis.com/auth/calendar.events" + ], + "oauthAuthorizationParams": { + "access_type": "offline", + "prompt": "consent" + } + }, + "guidanceMd": "Enable the Google Workspace MCP APIs and register Paperclip's callback URI. Choose which permissions to grant on Google's consent screen; declined permissions disable only the corresponding actions.", + "consoleLinks": { + "register": "https://console.cloud.google.com/auth/clients", + "docs": "https://developers.google.com/workspace/guides/configure-mcp-servers" + }, + "riskTier": "S4" + } + ] +} diff --git a/packages/shared/src/google-workspace-connectors.test.ts b/packages/shared/src/google-workspace-connectors.test.ts new file mode 100644 index 00000000000..2a3eae7217d --- /dev/null +++ b/packages/shared/src/google-workspace-connectors.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { getConnectableAppDefinition } from "./app-definitions.js"; +import { + GOOGLE_WORKSPACE_CONNECTOR_PROFILES as profiles, GOOGLE_WORKSPACE_SERVICES, + googleWorkspaceGrantedScopes, googleWorkspaceToolTarget, isGoogleWorkspaceToolGranted, + googleWorkspaceServiceGranted, isGoogleWorkspaceScopeGrant, + googleWorkspaceSelectedScopes, googleWorkspaceEffectiveScopes, GOOGLE_WORKSPACE_DEFAULT_SELECTION, +} from "./google-workspace-connectors.js"; + +const scope = (value: string) => `https://www.googleapis.com/auth/${value}`; + +describe("combined Google Workspace consent", () => { + it("defaults to Gmail, combined Drive, and Calendar write scopes without Chat or People", () => { + const selected = googleWorkspaceSelectedScopes(GOOGLE_WORKSPACE_DEFAULT_SELECTION); + expect(selected).toHaveLength(9); + expect(selected).toContain(scope("gmail.compose")); + expect(selected.some(value => /chat|contacts|directory|userinfo/.test(value))).toBe(false); + expect(googleWorkspaceSelectedScopes({})).toEqual([]); + expect(googleWorkspaceSelectedScopes({ drive: "read" })).toEqual([ + scope("documents.readonly"), scope("drive.readonly"), scope("presentations.readonly"), scope("spreadsheets.readonly"), + ]); + }); + + it("caps cumulative Google grants to the selected access on callback and refresh", () => { + const effective = googleWorkspaceEffectiveScopes([scope("documents.readonly")], [scope("documents"), scope("gmail.compose")]); + expect(effective).toEqual([scope("documents.readonly")]); + expect(isGoogleWorkspaceToolGranted("docs__update_doc", effective)).toBe(false); + expect(isGoogleWorkspaceToolGranted("docs__read_doc", effective)).toBe(true); + expect(googleWorkspaceEffectiveScopes([scope("documents")], [scope("documents.readonly")])).toEqual([scope("documents.readonly")]); + }); + it("requests the existing union with one profile and preserves legacy definitions", () => { + const expected = [...new Set(Object.entries(profiles).filter(([key]) => key !== "workspace.all" && key !== "gmail.archive").flatMap(([, value]) => value.scopes))]; + expect(profiles["workspace.all"].scopes).toEqual(expected); + expect(expected).toHaveLength(21); + expect(expected).not.toContain(scope("gmail.modify")); + const app = getConnectableAppDefinition("google-workspace")!; + expect(app.methods).toHaveLength(2); + expect(app.methods.filter((method) => method.connectorProfile)).toHaveLength(1); + for (const method of app.methods) expect(method.defaults?.scopesHint).toEqual(expected.filter(scope => !/chat|contacts|directory|userinfo|\/calendar.readonly$/.test(scope))); + for (const profile of Object.values(GOOGLE_WORKSPACE_SERVICES)) expect(getConnectableAppDefinition(profiles[profile].appSlug)).not.toBeNull(); + }); + + it("accepts a nonempty subset but not missing or unrequested permissions", () => { + expect(isGoogleWorkspaceScopeGrant([scope("documents")])).toBe(true); + expect(isGoogleWorkspaceScopeGrant([])).toBe(false); + expect(isGoogleWorkspaceScopeGrant([scope("chat.memberships.readonly")])).toBe(false); + expect(isGoogleWorkspaceScopeGrant([scope("gmail.send")])).toBe(false); + }); + + it("declining Gmail leaves Drive available without enabling Gmail or Docs", () => { + const granted = [scope("drive.readonly")]; + expect(isGoogleWorkspaceToolGranted("drive__search_files", granted)).toBe(true); + expect(isGoogleWorkspaceToolGranted("drive__create_file", granted)).toBe(false); + expect(isGoogleWorkspaceToolGranted("gmail__get_message", granted)).toBe(false); + expect(isGoogleWorkspaceToolGranted("docs__read_doc", granted)).toBe(false); + expect(googleWorkspaceServiceGranted("gmail", granted)).toBe(false); + expect(googleWorkspaceServiceGranted("drive", granted)).toBe(true); + expect(isGoogleWorkspaceToolGranted("search__search_corpus", granted)).toBe(false); + }); + + it("allows read-only grants and write grants for the corresponding editors", () => { + for (const [service, permission, read, write] of [ + ["docs", "documents", "read_doc", "update_doc"], + ["sheets", "spreadsheets", "get_values", "update_values"], + ["slides", "presentations", "read_presentation", "update_presentation"], + ]) { + expect(isGoogleWorkspaceToolGranted(`${service}__${read}`, [scope(`${permission}.readonly`)])).toBe(true); + expect(isGoogleWorkspaceToolGranted(`${service}__${write}`, [scope(`${permission}.readonly`)])).toBe(false); + expect(isGoogleWorkspaceToolGranted(`${service}__${read}`, [scope(permission!)])).toBe(true); + expect(isGoogleWorkspaceToolGranted(`${service}__${write}`, [scope(permission!)])).toBe(true); + } + }); + + it("routes colliding tool names to closed endpoints and rejects unknown actions", () => { + expect(googleWorkspaceToolTarget("gmail__search_messages")?.serverUrl).toContain("gmailmcp.googleapis.com"); + expect(googleWorkspaceToolTarget("chat__search_messages")?.serverUrl).toContain("chatmcp.googleapis.com"); + for (const name of ["gmail__send_message", "chat__list_members", "docs__delete_doc", "evil__get_message", "__proto____get_message", "gmail__constructor"]) + expect(googleWorkspaceToolTarget(name)).toBeNull(); + expect(isGoogleWorkspaceToolGranted("gmail__send_message", profiles["workspace.all"].scopes)).toBe(false); + }); + + it("never substitutes requested scopes for actual consent", () => { + expect(googleWorkspaceGrantedScopes(null)).toEqual([]); + expect(googleWorkspaceGrantedScopes({ providerTenant: { oauth: { scopes: [scope("documents")], scopeSource: "requested_fallback" } } })).toEqual([]); + expect(googleWorkspaceGrantedScopes({ providerTenant: { oauth: { scopes: [scope("documents")], scopeSource: "provider" } } })).toEqual([scope("documents")]); + }); +}); diff --git a/packages/shared/src/google-workspace-connectors.ts b/packages/shared/src/google-workspace-connectors.ts index 601f10314c4..f2890ae7d6c 100644 --- a/packages/shared/src/google-workspace-connectors.ts +++ b/packages/shared/src/google-workspace-connectors.ts @@ -2,13 +2,55 @@ export const GOOGLE_WORKSPACE_CONNECTOR_PROFILE_IDS = [ "gmail.read", "gmail.draft", "gmail.archive", "drive.read", "drive.write", "docs.read", "docs.write", "sheets.read", "sheets.write", "slides.read", "slides.write", "calendar.read", "calendar.write", "chat.read", "chat.write", "people.read", "workspace-search.read", + "workspace.all", ] as const; export type GoogleWorkspaceConnectorProfileId = (typeof GOOGLE_WORKSPACE_CONNECTOR_PROFILE_IDS)[number]; +export type GoogleWorkspaceServiceSelection = Partial>; +export const GOOGLE_WORKSPACE_DEFAULT_SELECTION: GoogleWorkspaceServiceSelection = { gmail: "write", drive: "write", calendar: "write" }; +export const GOOGLE_WORKSPACE_SERVICE_GROUPS = [ + { id: "gmail", name: "Gmail", brandKey: "gmail", read: ["gmail.read"], write: ["gmail.draft"] }, + { id: "drive", name: "Drive", brandKey: "google-drive", description: "Docs, Sheets, and Slides", read: ["drive.read", "docs.read", "sheets.read", "slides.read"], write: ["drive.write", "docs.write", "sheets.write", "slides.write"] }, + { id: "calendar", name: "Calendar", brandKey: "google-calendar", read: ["calendar.read"], write: ["calendar.write"] }, +] as const; + +/** Derive resource permissions from closed service groups, never client-supplied scope strings. */ +export function googleWorkspaceSelectedScopes(selection: GoogleWorkspaceServiceSelection): string[] { + return [...new Set(GOOGLE_WORKSPACE_SERVICE_GROUPS.flatMap(group => { + const access = selection[group.id]; + return access ? group[access].flatMap(profile => GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].scopes) : []; + }))].sort(); +} + +/** Keep provider consent inside this request, including a provider's read-only downgrade. */ +export function googleWorkspaceEffectiveScopes(requested: readonly string[], granted: readonly string[]): string[] { + const implies: Record = { + documents: ["documents.readonly"], spreadsheets: ["spreadsheets.readonly"], + presentations: ["presentations.readonly"], + "calendar.events": ["calendar.events.readonly", "calendar.events.freebusy"], + "calendar.readonly": ["calendar.calendarlist.readonly", "calendar.events.readonly", "calendar.events.freebusy"], + }; + const expand = (scopes: readonly string[]) => new Set(scopes.flatMap(scope => [scope, ...(implies[scope.replace("https://www.googleapis.com/auth/", "")] ?? []).map(value => `https://www.googleapis.com/auth/${value}`)])); + const allowed = expand(requested); + const effective = [...expand(granted)].filter(scope => allowed.has(scope)); + return effective.filter(scope => !effective.some(broader => broader !== scope && (implies[broader.replace("https://www.googleapis.com/auth/", "")] ?? []).some(value => scope === `https://www.googleapis.com/auth/${value}`))).sort(); +} + +export function googleWorkspaceSelectionForScopes(scopes: readonly string[]): GoogleWorkspaceServiceSelection { + const selection: GoogleWorkspaceServiceSelection = {}; + for (const group of GOOGLE_WORKSPACE_SERVICE_GROUPS) { + const writes = group.write.flatMap(profile => GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].scopes); + const reads = group.read.flatMap(profile => GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].scopes); + if (writes.every(scope => scopes.includes(scope))) selection[group.id] = "write"; + else if (reads.some(scope => scopes.includes(scope))) selection[group.id] = "read"; + } + return selection; +} + const auth = (scope: string) => `https://www.googleapis.com/auth/${scope}`; -export const GOOGLE_WORKSPACE_CONNECTOR_PROFILES: Readonly, { appSlug: string; serverUrl: string; scopes: readonly string[]; @@ -33,6 +75,99 @@ export const GOOGLE_WORKSPACE_CONNECTOR_PROFILES: Readonly id !== "gmail.archive").flatMap(([, profile]) => profile.scopes))]), +}; + +export const GOOGLE_WORKSPACE_SERVICES = { + gmail: "gmail.draft", drive: "drive.write", docs: "docs.write", sheets: "sheets.write", + slides: "slides.write", calendar: "calendar.write", chat: "chat.write", + people: "people.read", search: "workspace-search.read", +} as const; +export type GoogleWorkspaceService = keyof typeof GOOGLE_WORKSPACE_SERVICES; + +// Each inner group is a set of authorization alternatives; every group is required. +// Keep the reviewed tool allowlist closed, even when upstream adds tools. +const one = (...scopes: string[]) => [scopes.map(auth)]; +const toolScopes: Record> = { + gmail: Object.fromEntries([ + ...["get_message", "get_thread", "get_draft", "list_drafts", "list_labels", "search_threads", "list_threads", "search_messages"] + .map((name) => [name, one("gmail.readonly")]), + ["create_draft", one("gmail.compose")], + ]), + drive: Object.fromEntries([ + ...["download_file_content", "get_file_metadata", "get_file_permissions", "list_recent_files", "read_file_content", "search_files"] + .map((name) => [name, one("drive.readonly")]), + ...["copy_file", "create_file"].map((name) => [name, one("drive.file")]), + ]), + docs: { read_doc: one("documents.readonly", "documents"), update_doc: one("documents") }, + sheets: Object.fromEntries([ + ...["get_values", "get_spreadsheet"].map((name) => [name, one("spreadsheets.readonly", "spreadsheets")]), + ...["update_spreadsheet", "update_values", "update_formulas", "insert_dimension"].map((name) => [name, one("spreadsheets")]), + ]), + slides: { read_presentation: one("presentations.readonly", "presentations"), update_presentation: one("presentations") }, + calendar: Object.fromEntries([ + ["list_calendars", one("calendar.calendarlist.readonly", "calendar.readonly")], + ...["get_event", "list_events", "search_events"].map((name) => [name, one("calendar.events.readonly", "calendar.events", "calendar.readonly")]), + ["suggest_time", one("calendar.events.freebusy", "calendar.events", "calendar.readonly")], + ...["create_event", "update_event", "delete_event", "respond_to_event"].map((name) => [name, one("calendar.events")]), + ]), + chat: { + search_conversations: one("chat.spaces.readonly"), + list_messages: one("chat.messages.readonly"), search_messages: one("chat.messages.readonly"), + send_message: one("chat.messages.create"), + }, + people: { + search_directory_people: one("directory.readonly"), search_contacts: one("contacts.readonly"), + get_user_profile: one("userinfo.profile"), + }, + search: { search_corpus: SERVICE_PROFILES["workspace-search.read"].scopes.map((scope) => [scope]) }, +}; + +export function googleWorkspaceToolName(service: GoogleWorkspaceService, upstreamName: string): string { + return `${service}__${upstreamName}`; +} + +/** The endpoint is derived from a closed registry, never provider annotations or caller input. */ +export function googleWorkspaceToolTarget(name: string) { + const separator = name.indexOf("__"); + const service = name.slice(0, separator) as GoogleWorkspaceService; + if (separator < 1 || !Object.hasOwn(GOOGLE_WORKSPACE_SERVICES, service)) return null; + const upstreamName = name.slice(separator + 2); + const leaf = (upstreamName.split(/[.:/]/).pop() ?? upstreamName) + .replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/-/g, "_").toLowerCase(); + if (!Object.hasOwn(toolScopes[service], leaf)) return null; + const profile = SERVICE_PROFILES[GOOGLE_WORKSPACE_SERVICES[service]]; + return { service, upstreamName, leaf, serverUrl: profile.serverUrl, appSlug: profile.appSlug, + requiredScopes: toolScopes[service][leaf]!, write: profile.writeTools.some((tool) => tool === leaf) }; +} + +export function isGoogleWorkspaceToolGranted(name: string, scopes: readonly string[]): boolean { + const target = googleWorkspaceToolTarget(name); + return Boolean(target && target.requiredScopes.every((alternatives) => alternatives.some((scope) => scopes.includes(scope)))); +} + +export function isGoogleWorkspaceScopeGrant(scopes: readonly string[]): boolean { + const allowed = GOOGLE_WORKSPACE_CONNECTOR_PROFILES["workspace.all"].scopes; + return scopes.length > 0 && scopes.every((scope) => allowed.includes(scope)); +} + +export function googleWorkspaceServiceGranted(service: GoogleWorkspaceService, scopes: readonly string[]): boolean { + return Object.keys(toolScopes[service]).some((name) => isGoogleWorkspaceToolGranted(googleWorkspaceToolName(service, name), scopes)); +} + +/** Requested scopes and connection-wide defaults are never evidence of consent. */ +export function googleWorkspaceGrantedScopes(grant: { providerTenant?: unknown } | null): string[] { + const tenant = grant?.providerTenant as { oauth?: { scopes?: unknown; scopeSource?: unknown } } | undefined; + const oauth = tenant?.oauth; + if (oauth?.scopeSource === "requested_fallback" || !Array.isArray(oauth?.scopes)) return []; + return oauth.scopes.filter((scope): scope is string => typeof scope === "string"); +} + export function isGoogleWorkspaceConnectorProfileId(value: string): value is GoogleWorkspaceConnectorProfileId { return Object.prototype.hasOwnProperty.call(GOOGLE_WORKSPACE_CONNECTOR_PROFILES, value); } diff --git a/packages/shared/src/validators/tool-access.test.ts b/packages/shared/src/validators/tool-access.test.ts index 3e3553cbcf6..b5838320115 100644 --- a/packages/shared/src/validators/tool-access.test.ts +++ b/packages/shared/src/validators/tool-access.test.ts @@ -5,12 +5,20 @@ import { createToolMcpGatewayTokenSchema, createToolConnectionSchema, startConnectionAuthorizationSchema, + startToolOAuthSchema, toolCredentialSecretRefSchema, toolRedactedValueSummarySchema, toolTransportConfigSchema, } from "./tool-access.js"; describe("tool access validators", () => { + it("accepts only nonempty reviewed Workspace service groups", () => { + expect(startToolOAuthSchema.parse({ workspaceServices: { gmail: "read", drive: "write" } }).workspaceServices).toEqual({ gmail: "read", drive: "write" }); + for (const workspaceServices of [{}, { chat: "read" }, { people: "read" }, { gmail: "admin" }]) { + expect(startToolOAuthSchema.safeParse({ workspaceServices }).success).toBe(false); + expect(connectToolAppSchema.safeParse({ galleryKey: "google-workspace", workspaceServices }).success).toBe(false); + } + }); it("treats a gateway token owner note as optional", () => { const parsed = createToolMcpGatewayTokenSchema.parse({ name: "cursor-client", diff --git a/packages/shared/src/validators/tool-access.ts b/packages/shared/src/validators/tool-access.ts index 243ad9d1dcc..bafe22e80e1 100644 --- a/packages/shared/src/validators/tool-access.ts +++ b/packages/shared/src/validators/tool-access.ts @@ -411,7 +411,14 @@ function rejectUnsafeHeaderCredentials( } } +export const googleWorkspaceServiceSelectionSchema = z.object({ + gmail: z.enum(["read", "write"]).optional(), + drive: z.enum(["read", "write"]).optional(), + calendar: z.enum(["read", "write"]).optional(), +}).strict().refine(value => Object.keys(value).length > 0, "Select at least one Google service"); + export const connectToolAppSchema = z.object({ + workspaceServices: googleWorkspaceServiceSelectionSchema.optional(), agentInstructions: connectionAgentInstructionsSchema.nullable().optional(), galleryKey: z.string().trim().min(1).max(120).optional(), connectionMethodKey: z.string().trim().min(1).max(120).optional(), @@ -441,6 +448,9 @@ export const connectToolAppSchema = z.object({ /** Same-company agent that owns a dedicated provider identity. */ subjectAgentId: z.string().guid().optional(), }).superRefine((value, ctx) => { + if (value.workspaceServices && value.galleryKey !== "google-workspace") { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["workspaceServices"], message: "Service selection requires Google Workspace" }); + } if (value.configValues) rejectSensitiveConfigKeys(value.configValues, ctx, ["configValues"]); if (value.credentialValues) rejectUnsafeHeaderCredentials(value.credentialValues, ctx, ["credentialValues"]); if ((value.grantKind === "agent") !== Boolean(value.subjectAgentId)) { @@ -521,6 +531,7 @@ export const finalizeOAuthAccessSchema = z.object({ export type FinalizeOAuthAccess = z.infer; export const startToolOAuthSchema = z.object({ + workspaceServices: googleWorkspaceServiceSelectionSchema.optional(), asCurrentUser: z.boolean().optional(), asAgentId: z.string().uuid().optional(), interactionId: z.string().uuid().optional(), diff --git a/scripts/ingest-app-definitions.mjs b/scripts/ingest-app-definitions.mjs index f62069347c4..9d6cb4b60fe 100644 --- a/scripts/ingest-app-definitions.mjs +++ b/scripts/ingest-app-definitions.mjs @@ -1807,10 +1807,10 @@ for (const entry of researchManifest.entries) { }); } // Google Workspace definitions are reviewed, first-class app entries rather -// than rows synthesized from the generic connection corpus. Keep each product -// independent in the generated manifest while sharing only backend OAuth -// infrastructure. +// than rows synthesized from the generic connection corpus. Keep legacy product +// definitions for saved connections alongside the combined Workspace entry. const reviewedGoogleSlugs = [ + "google-workspace", "gmail", "google-drive", "google-docs", diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index f4cfcaf4cb4..7ed7118cc13 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -57,6 +57,8 @@ import { GITHUB_CONNECTOR_PROFILES, GOOGLE_WORKSPACE_CONNECTOR_PROFILE_IDS, GOOGLE_WORKSPACE_CONNECTOR_PROFILES, + googleWorkspaceSelectedScopes, + GOOGLE_WORKSPACE_DEFAULT_SELECTION, getAvailableConnectionMethod, getConnectableAppDefinition, type GoogleWorkspaceConnectorProfileId, @@ -2527,6 +2529,143 @@ describeEmbeddedPostgres("tool access service", () => { expect(reconnected.connection.agentInstructions).toEqual(settings); }); + it.each(["user", "organization"] as const)("preserves the %s Workspace request when account-page reconnect omits a selection", async (kind) => { + const company = await createCompany(db); + const service = createTestToolAccessService(db); + const actor = { actorType: "user" as const, actorId: "board" }; + const connected = await service.connectGalleryApp(company.id, { + galleryKey: "google-workspace", connectionMethodKey: "customer-workspace-oauth", grantKind: kind, + oauthClient: { clientId: "fixture-client", clientSecret: "fixture-secret" }, + }, actor); + const requestedScopes = ["https://www.googleapis.com/auth/gmail.readonly"]; + await db.update(toolConnections).set({ status: "active" }).where(eq(toolConnections.id, connected.connectionId)); + await db.update(connectionGrants).set({ + providerTenant: { oauth: { requestedScopes, scopes: [...requestedScopes, "https://www.googleapis.com/auth/documents"] } }, + status: "needs_reauthorization", + }).where(and(eq(connectionGrants.connectionId, connected.connectionId), eq(connectionGrants.kind, kind))); + const started = await service.startOAuth(company.id, connected.connectionId, { + redirectUri: "https://paperclip.example.test/api/tools/oauth/callback", actor, + }); + const url = new URL(started.authorizationUrl); + expect(url.searchParams.get("scope")?.split(" ")).toEqual(requestedScopes); + const [state] = await db.select().from(toolOauthStates).where(eq(toolOauthStates.state, url.searchParams.get("state")!)); + expect(state.requestedScopes).toEqual(requestedScopes); + await db.update(connectionGrants).set({ providerTenant: {} }) + .where(eq(connectionGrants.connectionId, connected.connectionId)); + await expect(service.startOAuth(company.id, connected.connectionId, { + redirectUri: "https://paperclip.example.test/api/tools/oauth/callback", actor, + })).rejects.toMatchObject({ status: 400 }); + const selected = await service.startOAuth(company.id, connected.connectionId, { + redirectUri: "https://paperclip.example.test/api/tools/oauth/callback", actor, + workspaceServices: { gmail: "read" }, + }); + expect(new URL(selected.authorizationUrl).searchParams.get("scope")?.split(" ")).toEqual(requestedScopes); + }); + + it("binds selected Workspace service scopes to OAuth state and rejects other providers", async () => { + const company = await createCompany(db); + const service = createTestToolAccessService(db); + const actor = { actorType: "user" as const, actorId: "board" }; + const connected = await service.connectGalleryApp(company.id, { + galleryKey: "google-workspace", connectionMethodKey: "customer-workspace-oauth", grantKind: "user", + oauthClient: { clientId: "fixture-client", clientSecret: "fixture-secret" }, + }, actor); + const started = await service.startOAuth(company.id, connected.connectionId, { + redirectUri: "https://paperclip.example.test/api/tools/oauth/callback", actor, workspaceServices: { drive: "read" }, + }); + const url = new URL(started.authorizationUrl); + const expected = ["documents.readonly", "drive.readonly", "presentations.readonly", "spreadsheets.readonly"].map(value => `https://www.googleapis.com/auth/${value}`); + expect(url.searchParams.get("scope")?.split(" ")).toEqual(expected); + const [state] = await db.select().from(toolOauthStates).where(eq(toolOauthStates.state, url.searchParams.get("state")!)); + expect(state.requestedScopes).toEqual(expected); + await expect(service.startOAuth(company.id, connected.connectionId, { + redirectUri: "https://paperclip.example.test/api/tools/oauth/callback", actor, workspaceServices: {}, + })).rejects.toMatchObject({ status: 400 }); + }); + + it.each(["https://www.googleapis.com/auth/documents.readonly", undefined, "", "https://www.googleapis.com/auth/gmail.send"])( + "requires explicit reviewed partial consent for customer Workspace OAuth (%s)", async (scope) => { + const company = await createCompany(db); + const userId = `workspace-customer-${randomUUID()}`; + await grantBoardUser(db, company.id, userId, [], "owner"); + const actor = { actorType: "user" as const, actorId: userId }; + const service = createTestToolAccessService(db); + const connected = await service.connectGalleryApp(company.id, { + galleryKey: "google-workspace", connectionMethodKey: "customer-workspace-oauth", grantKind: "user", + oauthClient: { clientId: "test-workspace-client", clientSecret: "test-workspace-secret" }, + }, actor); + const redirectUri = "https://paperclip.example.test/api/tools/oauth/callback"; + const started = await service.startOAuth(company.id, connected.connectionId, { redirectUri, actor }); + const authorization = new URL(started.authorizationUrl); + expect(authorization.searchParams.get("scope")?.split(" ")).toEqual(googleWorkspaceSelectedScopes(GOOGLE_WORKSPACE_DEFAULT_SELECTION)); + expect(authorization.searchParams.get("enable_granular_consent")).toBe("true"); + const provider = vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => { + if (String(url) === "https://oauth2.googleapis.com/token") return Response.json({ + access_token: "fixture-workspace-access", refresh_token: "fixture-workspace-refresh", expires_in: 3600, token_type: "Bearer", scope, + }); + expect(String(url)).toBe("https://docsmcp.googleapis.com/mcp/v1"); + return mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: [ + { name: "read_doc", annotations: { readOnlyHint: true } }, { name: "update_doc" }, + ] } }); + }); + const completing = service.completeOAuthCallback({ state: authorization.searchParams.get("state")!, code: "fixture-code", redirectUri, actor }); + if (scope?.endsWith("documents.readonly")) { + const completed = await completing; + expect(completed.connection.status).toBe("active"); + expect(completed.catalog.map((entry) => entry.toolName)).toEqual(["docs__read_doc"]); + } else { + await expect(completing).rejects.toMatchObject({ details: { code: "oauth_scope_grant_invalid" } }); + expect(provider).toHaveBeenCalledTimes(1); + } + }, + ); + + it.each(["customer", "managed"] as const)( + "preserves an explicit empty agent selection through %s Workspace OAuth and reconnect", async (mode) => { + const company = await createCompany(db); + const userId = `workspace-no-agents-${randomUUID()}`; + await grantBoardUser(db, company.id, userId, [], "owner"); + const actor = { actorType: "user" as const, actorId: userId }; + const connector = fakeGoogleWorkspaceConnector(company.id, userId, "workspace.all"); + const claim = connector.claim; + const scope = "https://www.googleapis.com/auth/documents.readonly"; + connector.claim = vi.fn(async (input) => ({ ...await claim(input), scopes: [scope] })); + const service = createTestToolAccessService(db, { paperclipCloudConnector: connector }); + const input = { + galleryKey: "google-workspace", grantKind: "user" as const, + connectionMethodKey: mode === "customer" ? "customer-workspace-oauth" : "paperclip-workspace", + ...(mode === "customer" ? { oauthClient: { clientId: "workspace-client", clientSecret: "workspace-secret" } } : {}), + }; + vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => { + if (String(url) === "https://oauth2.googleapis.com/token") return Response.json({ + access_token: "fixture-workspace-access", refresh_token: "fixture-workspace-refresh", + expires_in: 3600, token_type: "Bearer", scope, + }); + expect(String(url)).toBe("https://docsmcp.googleapis.com/mcp/v1"); + return mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { + tools: [{ name: "read_doc", annotations: { readOnlyHint: true } }], + } }); + }); + const connected = await service.connectGalleryApp(company.id, input, actor); + await service.putConnectionInstalls(connected.connectionId, { installs: [] }, actor); + const redirectUri = `https://paperclip.example/api/tools/oauth/${mode === "managed" ? "cloud-connector/" : ""}callback`; + for (const reconnect of [false, true]) { + if (reconnect) await service.connectGalleryApp(company.id, { ...input, reconnectConnectionId: connected.connectionId }, actor); + const started = await service.startOAuth(company.id, connected.connectionId, { redirectUri, actor }); + const state = new URL(started.authorizationUrl).searchParams.get("state")!; + const completed = mode === "managed" + ? await service.completePaperclipCloudConnectorCallback({ state, claimId: "workspace-no-agents", actor }) + : await service.completeOAuthCallback({ state, code: "workspace-no-agents", redirectUri, actor }); + expect(completed.connection.status).toBe("active"); + expect(completed.catalog.map((entry) => entry.toolName)).toEqual(["docs__read_doc"]); + expect(await service.listConnectionInstalls(connected.connectionId)).toEqual([]); + const [profile] = await db.select().from(toolProfiles).where(eq(toolProfiles.profileKey, `app:${connected.connectionId}`)); + expect(profile).toBeDefined(); + expect(await db.select().from(toolProfileBindings).where(eq(toolProfileBindings.profileId, profile.id))).toEqual([]); + } + }, + ); + it.each(["read", "write"])("requests only reduced Chat scopes for customer-owned %s OAuth, including reconnect", async (capability) => { const company = await createCompany(db); const service = createTestToolAccessService(db); @@ -7994,6 +8133,244 @@ describeEmbeddedPostgres("tool access service", () => { } }); + it.each(["user", "organization"] as const)("builds one Workspace catalog from actual partial consent for a %s grant", async (grantKind) => { + const company = await createCompany(db); + const userId = `workspace-member-${randomUUID()}`; + await grantBoardUser(db, company.id, userId, [], "owner"); + const connector = fakeGoogleWorkspaceConnector(company.id, userId, "workspace.all"); + const fullClaim = connector.claim; + connector.claim = vi.fn(async (input) => ({ ...await fullClaim(input), scopes: ["https://www.googleapis.com/auth/documents"] })); + const service = createTestToolAccessService(db, { paperclipCloudConnector: connector }); + const actor = { actorType: "user" as const, actorId: userId }; + const provider = mockToolsList([ + { name: "read_doc", annotations: { readOnlyHint: true } }, + { name: "update_doc", annotations: { readOnlyHint: true } }, + { name: "delete_doc" }, + ]); + const connected = await service.connectGalleryApp(company.id, { + galleryKey: "google-workspace", connectionMethodKey: "paperclip-workspace", grantKind, + }, actor); + const started = await service.startOAuth(company.id, connected.connectionId, { + redirectUri: "https://paperclip.example/api/tools/oauth/cloud-connector/callback", actor, + }); + const completed = await service.completePaperclipCloudConnectorCallback({ + state: new URL(started.authorizationUrl).searchParams.get("state")!, claimId: "workspace-claim", actor, + }); + expect(completed.connection.status).toBe("active"); + expect(completed.catalog.map((tool) => tool.toolName).sort()).toEqual(["docs__read_doc", "docs__update_doc"]); + expect(completed.catalog.find((tool) => tool.toolName === "docs__update_doc")?.riskLevel).toBe("write"); + expect(provider.mock.calls.length).toBeGreaterThan(0); + expect(new Set(provider.mock.calls.map(([url]) => String(url)))).toEqual(new Set(["https://docsmcp.googleapis.com/mcp/v1"])); + const saved = await db.select().from(toolConnections).where(eq(toolConnections.companyId, company.id)); + expect(saved).toHaveLength(1); + const grants = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, connected.connectionId)); + expect(grants.find((grant) => grant.status === "active")?.providerTenant?.oauth?.scopes).toEqual(["https://www.googleapis.com/auth/documents"]); + const profiles = await db.select().from(toolProfiles).where(eq(toolProfiles.profileKey, `app:${connected.connectionId}`)); + expect(profiles).toHaveLength(1); + expect(JSON.stringify(completed)).not.toContain("workspace-access-token"); + }); + + describe("combined Workspace service isolation and refresh consent", () => { + async function connectWorkspace(mode: "managed" | "customer", permissions = ["documents.readonly", "gmail.readonly"]) { + const company = await createCompany(db); + const userId = `workspace-refresh-${randomUUID()}`; + await grantBoardUser(db, company.id, userId, [], "owner"); + const actor = { actorType: "user" as const, actorId: userId }; + const initialScopes = permissions.map((scope) => `https://www.googleapis.com/auth/${scope}`); + let authorizationScopes = initialScopes; + let refreshScopes: string[] | undefined = initialScopes; + const connector = fakeGoogleWorkspaceConnector(company.id, userId, "workspace.all"); + const originalClaim = connector.claim; + connector.claim = vi.fn(async (input) => ({ ...await originalClaim(input), scopes: initialScopes, + accessTokenExpiresAt: new Date(Date.now() + 86_400_000).toISOString() })); + connector.refresh = vi.fn(async () => ({ ...await connector.claim({ subject: userId, companyId: company.id, + profile: "workspace.all", claimId: "fixture", redemptionId: "fixture" }), scopes: refreshScopes ?? [] })); + const service = createTestToolAccessService(db, { paperclipCloudConnector: connector }); + const calls: string[] = []; + const unavailable = new Set(); + const omitted = new Set(); + let unauthorizedNextCall = false; + const tokenRefreshes: string[] = []; + vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { + const href = String(url); + if (href === "https://oauth2.googleapis.com/token") { + const refreshing = new URLSearchParams(String(init?.body)).get("grant_type") === "refresh_token"; + if (refreshing) tokenRefreshes.push(href); + return Response.json({ access_token: "workspace-refresh-access", refresh_token: "workspace-refresh-token", + token_type: "Bearer", expires_in: 86_400, scope: (refreshing ? refreshScopes : authorizationScopes)?.join(" ") }); + } + const host = new URL(href).hostname; + if (unavailable.has(host)) return new Response("unavailable", { status: 503 }); + const body = JSON.parse(String(init?.body)); + if (body.method === "tools/call") { + calls.push(body.params.name); + if (unauthorizedNextCall) { unauthorizedNextCall = false; return new Response("expired", { status: 401 }); } + return Response.json({ jsonrpc: "2.0", id: body.id, result: { content: [{ type: "text", text: "ok" }] } }); + } + const names = host === "docsmcp.googleapis.com" ? ["read_doc", "update_doc"] : ["get_message"]; + return mcpHttpResponse({ jsonrpc: "2.0", id: body.id, result: { tools: omitted.has(host) ? [] : names.map((name) => ({ + name, annotations: { readOnlyHint: name !== "update_doc" }, + inputSchema: { type: "object", properties: {} }, + })) } }); + }); + const connected = await service.connectGalleryApp(company.id, { + galleryKey: "google-workspace", grantKind: "organization", + connectionMethodKey: mode === "managed" ? "paperclip-workspace" : "customer-workspace-oauth", + ...(mode === "customer" ? { oauthClient: { clientId: "workspace-client", clientSecret: "workspace-client-secret" } } : {}), + }, actor); + const redirectUri = mode === "managed" ? "https://paperclip.example/api/tools/oauth/cloud-connector/callback" : "https://paperclip.example/api/tools/oauth/callback"; + const started = await service.startOAuth(company.id, connected.connectionId, { redirectUri, actor }); + const state = new URL(started.authorizationUrl).searchParams.get("state")!; + const completed = mode === "managed" + ? await service.completePaperclipCloudConnectorCallback({ state, claimId: "fixture", actor }) + : await service.completeOAuthCallback({ state, code: "fixture", redirectUri, actor }); + const agent = await createAgent(db, company.id); + await service.finishGalleryAppConnection(company.id, connected.connectionId, { + enabledCatalogEntryIds: completed.catalog.map((entry) => entry.id), askFirstCatalogEntryIds: [], access: { agentIds: [agent.id] }, + }, actor); + const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id, + invocationSource: "on_demand", status: "running", contextSnapshot: {} }).returning(); + const gateway = createToolGatewayService(db, { toolActionSigningSecret: "test-secret", + oauthGrantRefresher: service.refreshOAuthGrantCredentials }); + const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + return { company, actor, agent, service, connected, gateway, session, calls, unavailable, omitted, connector, tokenRefreshes, + setAuthorizationScopes: (scopes: string[]) => { authorizationScopes = scopes; }, + expireNextCall: (scopes: string[] | undefined) => { refreshScopes = scopes; unauthorizedNextCall = true; } }; + } + + it("preserves organization actions when a personal OAuth callback grants fewer permissions", async () => { + const fixture = await connectWorkspace("customer", ["documents", "gmail.readonly"]); + // This supported policy selects personal consent when present and the + // organization identity for callers without a personal grant. + await db.update(toolConnections).set({ credentialPolicy: "per_user_with_fallback" }) + .where(eq(toolConnections.id, fixture.connected.connectionId)); + const userId = `workspace-personal-${randomUUID()}`; + await grantBoardUser(db, fixture.company.id, userId, [], "owner"); + const actor = { actorType: "user" as const, actorId: userId }; + const redirectUri = "https://paperclip.example/api/tools/oauth/callback"; + fixture.setAuthorizationScopes(["https://www.googleapis.com/auth/documents.readonly"]); + const started = await fixture.service.startOAuth(fixture.company.id, fixture.connected.connectionId, { + redirectUri, actor, subjectUserId: userId, + }); + await fixture.service.completeOAuthCallback({ state: new URL(started.authorizationUrl).searchParams.get("state")!, + code: "personal-docs-only", redirectUri, actor }); + const [gmailEntry] = await db.select().from(toolCatalogEntries).where(and( + eq(toolCatalogEntries.connectionId, fixture.connected.connectionId), eq(toolCatalogEntries.toolName, "gmail__get_message"), + )); + expect(gmailEntry.status).toBe("active"); + const [docsWrite] = await db.select().from(toolCatalogEntries).where(and( + eq(toolCatalogEntries.connectionId, fixture.connected.connectionId), eq(toolCatalogEntries.toolName, "docs__update_doc"), + )); + // Docs was probed, but its write action was filtered by personal consent, + // not removed by Google. The organization still retains that action. + expect(docsWrite.status).toBe("active"); + const gmail = (await fixture.gateway.listToolsForSession(fixture.session.token)).find((tool) => tool.upstreamToolName === "gmail__get_message")!; + expect(gmail).toBeTruthy(); + await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: gmail.name, parameters: {} })).resolves.toMatchObject({ status: "completed" }); + await expect(fixture.gateway.executeTestCall({ companyId: fixture.company.id, connectionId: fixture.connected.connectionId, + agentId: fixture.agent.id, userId, toolName: "gmail__get_message", parameters: {} })).resolves.toMatchObject({ error: { reasonCode: "oauth_insufficient_scope" } }); + expect(fixture.calls).toEqual(["get_message"]); + // Once the other authority is revoked, a personal refresh can remove the + // unsupported shared entry; revoked grants never preserve capabilities. + await db.update(connectionGrants).set({ status: "revoked" }).where(and( + eq(connectionGrants.connectionId, fixture.connected.connectionId), eq(connectionGrants.kind, "organization"), + )); + await fixture.service.refreshCatalog(fixture.connected.connectionId, actor); + const [removed] = await db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.id, gmailEntry.id)); + expect(removed.status).toBe("disabled"); + }); + + it.each(["outage", "removed"] as const)("does not preserve a probed Gmail service's missing tools across grants (%s)", async (failure) => { + const fixture = await connectWorkspace("customer"); + const userId = `workspace-both-${randomUUID()}`; + await grantBoardUser(db, fixture.company.id, userId, [], "owner"); + const actor = { actorType: "user" as const, actorId: userId }; + const redirectUri = "https://paperclip.example/api/tools/oauth/callback"; + const started = await fixture.service.startOAuth(fixture.company.id, fixture.connected.connectionId, { redirectUri, actor, subjectUserId: userId }); + await fixture.service.completeOAuthCallback({ state: new URL(started.authorizationUrl).searchParams.get("state")!, code: "personal-both", redirectUri, actor }); + (failure === "outage" ? fixture.unavailable : fixture.omitted).add("gmailmcp.googleapis.com"); + const refreshed = await fixture.service.refreshCatalog(fixture.connected.connectionId, actor); + expect(refreshed.connection.healthStatus).toBe("ok"); + const tools = await fixture.gateway.listToolsForSession(fixture.session.token); + expect(tools.some((tool) => tool.upstreamToolName === "gmail__get_message")).toBe(false); + const doc = tools.find((tool) => tool.upstreamToolName === "docs__read_doc")!; + expect(doc).toBeTruthy(); + await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: doc.name, parameters: {} })).resolves.toMatchObject({ status: "completed" }); + expect(fixture.calls).toEqual(["read_doc"]); + const [gmail] = await db.select().from(toolCatalogEntries).where(and(eq(toolCatalogEntries.connectionId, fixture.connected.connectionId), eq(toolCatalogEntries.toolName, "gmail__get_message"))); + expect(gmail.status).toBe("disabled"); + }); + + it("keeps healthy service tools visible during a partial outage and restores recovered services", async () => { + const fixture = await connectWorkspace("managed"); + fixture.unavailable.add("gmailmcp.googleapis.com"); + const refreshed = await fixture.service.refreshCatalog(fixture.connected.connectionId, fixture.actor); + expect(refreshed.connection).toMatchObject({ healthStatus: "ok", healthMessage: expect.stringContaining("gmail") }); + expect(refreshed.catalog.map((entry) => entry.toolName)).toEqual(["docs__read_doc"]); + const tools = await fixture.gateway.listToolsForSession(fixture.session.token); + const doc = tools.find((tool) => tool.upstreamToolName === "docs__read_doc")!; + expect(doc).toBeTruthy(); + expect(tools.some((tool) => tool.upstreamToolName === "gmail__get_message")).toBe(false); + await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: doc.name, parameters: {} })).resolves.toMatchObject({ status: "completed" }); + const health = await fixture.service.checkHealth(fixture.connected.connectionId, fixture.actor); + expect(health.connection).toMatchObject({ healthStatus: "ok", healthMessage: expect.stringContaining("gmail") }); + const events = await db.select().from(toolAccessAuditEvents).where(eq(toolAccessAuditEvents.connectionId, fixture.connected.connectionId)); + expect(events).toContainEqual(expect.objectContaining({ reasonCode: "google_workspace_service_unavailable", details: { service: "gmail" } })); + fixture.unavailable.clear(); + const recovered = await fixture.service.refreshCatalog(fixture.connected.connectionId, fixture.actor); + expect(recovered.catalog.map((entry) => entry.toolName).sort()).toEqual(["docs__read_doc", "gmail__get_message"]); + expect(recovered.connection.healthMessage).not.toContain("unavailable"); + fixture.unavailable.add("gmailmcp.googleapis.com"); + fixture.unavailable.add("docsmcp.googleapis.com"); + await expect(fixture.service.refreshCatalog(fixture.connected.connectionId, fixture.actor)).rejects.toMatchObject({ details: { code: "google_workspace_services_unavailable" } }); + }); + + it("does not let a cached Gmail call failure hide healthy Docs actions", async () => { + const fixture = await connectWorkspace("customer"); + const gmail = (await fixture.gateway.listToolsForSession(fixture.session.token)).find((tool) => tool.upstreamToolName === "gmail__get_message")!; + fixture.unavailable.add("gmailmcp.googleapis.com"); + await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: gmail.name, parameters: {} })).rejects.toMatchObject({ reasonCode: "mcp_remote_status" }); + expect((await fixture.service.getConnection(fixture.connected.connectionId)).healthStatus).toBe("ok"); + const doc = (await fixture.gateway.listToolsForSession(fixture.session.token)).find((tool) => tool.upstreamToolName === "docs__read_doc")!; + expect(doc).toBeTruthy(); + await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: doc.name, parameters: {} })).resolves.toMatchObject({ status: "completed" }); + expect(fixture.calls).toEqual(["read_doc"]); + }); + + it.each(["managed", "customer"] as const)("blocks explicit retry and updates listing after %s refresh narrows consent", async (mode) => { + const fixture = await connectWorkspace(mode); + const tool = (await fixture.gateway.listToolsForSession(fixture.session.token)).find((item) => item.upstreamToolName === "docs__read_doc")!; + expect(tool).toBeTruthy(); + const scopes = ["https://www.googleapis.com/auth/gmail.readonly"]; + fixture.expireNextCall(scopes); + await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: tool.name, parameters: {} })).rejects.toMatchObject({ reasonCode: "oauth_refreshed_retry_required" }); + await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: tool.name, parameters: {} })).rejects.toMatchObject({ reasonCode: "oauth_insufficient_scope" }); + expect(fixture.calls).toEqual(["read_doc"]); // No second tools/call after the 401 refresh removed Docs permission. + const [grant] = await db.select().from(connectionGrants).where(and(eq(connectionGrants.connectionId, fixture.connected.connectionId), eq(connectionGrants.status, "active"))); + expect(grant.providerTenant?.oauth?.scopes).toEqual(scopes); + if (mode === "managed") expect(fixture.connector.refresh).toHaveBeenCalled(); + else expect(fixture.tokenRefreshes).toHaveLength(1); + const tools = await fixture.gateway.listToolsForSession(fixture.session.token); + expect(tools.some((item) => item.upstreamToolName === "docs__read_doc")).toBe(false); + const gmail = tools.find((item) => item.upstreamToolName === "gmail__get_message")!; + expect(gmail).toBeTruthy(); + await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: gmail.name, parameters: {} })).resolves.toMatchObject({ status: "completed" }); + }); + + it.each((["managed", "customer"] as const).flatMap((mode) => [undefined, [], ["https://www.googleapis.com/auth/gmail.send"]].map((scopes) => ({ mode, scopes }))))( + "fails closed on invalid refreshed permissions ($mode, $scopes)", async ({ mode, scopes }) => { + const fixture = await connectWorkspace(mode); + const tool = (await fixture.gateway.listToolsForSession(fixture.session.token)).find((item) => item.upstreamToolName === "docs__read_doc")!; + fixture.expireNextCall(scopes); + await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: tool.name, parameters: {} })).rejects.toMatchObject({ reasonCode: "oauth_reauthorization_required" }); + expect(fixture.calls).toEqual(["read_doc"]); + const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, fixture.connected.connectionId)); + expect(grant.status).toBe("needs_reauthorization"); + expect((await fixture.gateway.listToolsForSession(fixture.session.token)).some((item) => item.connectionId === fixture.connected.connectionId)).toBe(false); + }, + ); + }); + it("routes a managed Drive callback into the personal vault, filtered catalog, and provider-specific activity", async () => { const company = await createCompany(db); const userId = `drive-member-${randomUUID()}`; diff --git a/server/src/__tests__/tool-gateway-service.test.ts b/server/src/__tests__/tool-gateway-service.test.ts index 34d96ea95e6..5faedf14519 100644 --- a/server/src/__tests__/tool-gateway-service.test.ts +++ b/server/src/__tests__/tool-gateway-service.test.ts @@ -227,6 +227,7 @@ describeEmbeddedPostgres("tool gateway service", () => { await db.delete(issues); await db.delete(projects); await db.delete(agents); + await db.delete(companyMemberships); await db.delete(companies); }); @@ -1273,6 +1274,47 @@ describeEmbeddedPostgres("tool gateway service", () => { ]); }); + it.each([ + ["docs__read_doc", "documents", "docsmcp"], + ["gmail__get_message", "gmail.readonly", "gmailmcp"], + ["drive__search_files", "drive.readonly", "drivemcp"], + ["sheets__get_values", "spreadsheets", "sheetsmcp"], + ["slides__read_presentation", "presentations", "slidesmcp"], + ["calendar__list_events", "calendar.events", "calendarmcp"], + ["chat__list_messages", "chat.messages.readonly", "chatmcp"], + ["people__get_user_profile", "userinfo.profile", "people"], + ["search__search_corpus", null, "workspacemcp"], + ] as const)("routes %s and denies cached actions after consent changes", async (toolName, permission, host) => { + const { company, agent, run } = await createRunFixture(db); + await db.insert(companyMemberships).values({ companyId: company.id, principalType: "user", principalId: "board", status: "active", membershipRole: "owner" }); + const { connection, catalogEntry } = await createRemoteMcpToolFixture(db, company.id); + await db.update(toolConnections).set({ config: { sourceTemplateKey: "google-workspace", url: "https://workspacemcp.googleapis.com/mcp/v1" } }) + .where(eq(toolConnections.id, connection.id)); + const scopes = permission ? [`https://www.googleapis.com/auth/${permission}`] : ["gmail.readonly", "drive.readonly", "calendar.readonly", "chat.messages.readonly"].map((value) => `https://www.googleapis.com/auth/${value}`); + await db.update(connectionGrants).set({ providerTenant: { oauth: { scopes, scopeSource: "provider" } } }) + .where(eq(connectionGrants.connectionId, connection.id)); + await db.update(toolCatalogEntries).set({ name: toolName, toolName }).where(eq(toolCatalogEntries.id, catalogEntry.id)); + await db.insert(toolPolicies).values({ companyId: company.id, name: "Allow reads", policyType: "allow", selectors: { riskLevel: "read" } }); + const provider = vi.fn(async (_url, init) => { + const payload = JSON.parse(String(init.body)); + return Response.json({ jsonrpc: "2.0", id: payload.id, result: { content: [{ type: "text", text: "Test document." }] } }); + }); + const gateway = createTestToolGatewayService(db, { remoteHttpRequest: provider }); + const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const tool = (await gateway.listToolsForSession(session.token)).find((item) => item.upstreamToolName === toolName)!; + expect(tool).toBeTruthy(); + await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} })).resolves.toMatchObject({ status: "completed" }); + expect(provider.mock.calls[0][0]).toBe(`https://${host}.googleapis.com/mcp/v1`); + expect(JSON.parse(String(provider.mock.calls[0][1].body)).params.name).toBe(toolName.split("__")[1]); + await db.update(connectionGrants).set({ providerTenant: { oauth: { scopes: [], scopeSource: "provider" } } }) + .where(eq(connectionGrants.connectionId, connection.id)); + expect((await gateway.listToolsForSession(session.token)).some((item) => item.upstreamToolName === toolName)).toBe(false); + await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} })) + .rejects.toMatchObject({ reasonCode: "oauth_insufficient_scope" }); + await expect(gateway.executeTestCall({ companyId: company.id, connectionId: connection.id, agentId: agent.id, + userId: "board", toolName, parameters: {} })).resolves.toMatchObject({ error: { reasonCode: "oauth_insufficient_scope" } }); + expect(provider).toHaveBeenCalledTimes(1); + }); it("keeps direct tools/call for generic connections without the curated session preference", async () => { const { company, agent, run } = await createRunFixture(db); const { connection } = await createRemoteMcpToolFixture(db, company.id); diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index 51b4425e60e..48a590a5bc1 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -922,6 +922,7 @@ function connectorEnrollmentPrincipal(req: Request): string { const personalSubjectUserId = req.body.grantKind === "user" ? req.actor.userId ?? null : null; const dedicatedSubjectAgentId = req.body.grantKind === "agent" ? req.body.subjectAgentId ?? null : null; const start = await svc.startOAuth(companyId, result.connectionId, { + workspaceServices: req.body.workspaceServices, redirectUri: oauthRedirectUri(req), actor: getActorInfo(req), ...(personalSubjectUserId ? { subjectUserId: personalSubjectUserId } : {}), @@ -1008,6 +1009,7 @@ function connectorEnrollmentPrincipal(req: Request): string { await assertToolConnectionConfigureAccess(req, existing); } const result = await svc.startOAuth(existing.companyId, existing.id, { + workspaceServices: req.body.workspaceServices, redirectUri: oauthRedirectUri(req), actor: getActorInfo(req), returnTo: oauthBrowserOrigin(req) ?? undefined, diff --git a/server/src/services/gmail-tool-governance.test.ts b/server/src/services/gmail-tool-governance.test.ts index 9d0b0b27396..64060c072bb 100644 --- a/server/src/services/gmail-tool-governance.test.ts +++ b/server/src/services/gmail-tool-governance.test.ts @@ -23,6 +23,16 @@ describe("Gmail tool governance", () => { }); describe("Google Workspace tool governance", () => { + it("keeps combined-profile writes and destructive annotations behind their existing risk gates", () => { + expect(classifyRisk({ name: "docs__update_doc", annotations: { readOnlyHint: true } }, "google-workspace")).toBe("write"); + expect(classifyRisk({ name: "docs__update_doc", annotations: { destructiveHint: true } }, "google-workspace")).toBe("destructive"); + expect(classifyRisk({ name: "docs__read_doc" }, "google-workspace")).toBe("read"); + expect(classifyRisk({ name: "calendar__delete_event" }, "google-workspace")).toBe("destructive"); + expect(classifyRisk({ name: "chat__send_message" }, "google-workspace")).toBe("destructive"); + expect(isGoogleWorkspaceToolAllowed("workspace.all", { name: "gmail__send_message" })).toBe(false); + expect(isGoogleWorkspaceToolAllowed("workspace.all", { name: "chat__list_memberships" })).toBe(false); + }); + it("limits each capability profile to its reviewed reads and writes", () => { expect(isGoogleWorkspaceToolAllowed("drive.read", { name: "search_files" })).toBe(true); expect(isGoogleWorkspaceToolAllowed("drive.read", { name: "create_file" })).toBe(false); diff --git a/server/src/services/google-chat-tool-policy.test.ts b/server/src/services/google-chat-tool-policy.test.ts index 827c1eff12a..8daa872474b 100644 --- a/server/src/services/google-chat-tool-policy.test.ts +++ b/server/src/services/google-chat-tool-policy.test.ts @@ -8,6 +8,14 @@ import { const chat = { config: { sourceTemplateKey: "google-chat" } }; describe("Google Chat search without read-state permission", () => { + it("applies the same unread restriction to the combined connector without affecting Gmail", () => { + const workspace = { config: { sourceTemplateKey: "google-workspace" } }; + expect(() => assertGoogleChatToolArgumentsSupported(workspace, "chat__search_messages", { isUnread: false })) + .toThrow("read/unread filtering is not supported"); + expect(() => assertGoogleChatToolArgumentsSupported(workspace, "gmail__search_messages", { isUnread: false })).not.toThrow(); + expect(googleChatToolInputSchema(workspace, "chat__search_messages", { type: "object", properties: { isUnread: { type: "boolean" } } })) + .toEqual({ type: "object", properties: {} }); + }); it.each([true, false, null, "true"])("rejects explicit unread filters (%s)", (isUnread) => { for (const tool of ["search_messages", "chat.searchMessages", "google/chat/search-messages"]) { for (const args of [ diff --git a/server/src/services/google-chat-tool-policy.ts b/server/src/services/google-chat-tool-policy.ts index 4811e33bcd3..a0e40b41f60 100644 --- a/server/src/services/google-chat-tool-policy.ts +++ b/server/src/services/google-chat-tool-policy.ts @@ -1,4 +1,5 @@ import { badRequest } from "../errors.js"; +import { googleWorkspaceToolTarget } from "@paperclipai/shared"; type Connection = { config: Record }; @@ -9,6 +10,10 @@ function record(value: unknown): Record { } function isChatSearch(connection: Connection, toolName: string): boolean { + if (connection.config.sourceTemplateKey === "google-workspace") { + const target = googleWorkspaceToolTarget(toolName); + return target?.service === "chat" && target.leaf === "search_messages"; + } const leaf = (toolName.split(/[.:/]/).pop() ?? toolName) .replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/_/g, "-").toLowerCase(); if (leaf !== "search-messages") return false; diff --git a/server/src/services/paperclip-cloud-connector.test.ts b/server/src/services/paperclip-cloud-connector.test.ts index 999236c6c54..80ce27343e3 100644 --- a/server/src/services/paperclip-cloud-connector.test.ts +++ b/server/src/services/paperclip-cloud-connector.test.ts @@ -55,6 +55,28 @@ function config() { } describe("Paperclip Cloud connector", () => { + it("accepts actual partial Workspace consent inside an envelope bound to the full requested profile", async () => { + const keys = config(); + const scopes = ["https://www.googleapis.com/auth/documents"]; + const credentials = { v: 1, accessToken: "test-access", refreshToken: "test-refresh", tokenType: "Bearer", + accessTokenExpiresAt: null, refreshTokenExpiresAt: null, scopes, subject, companyId, instanceId, + environment: keys.config.environment, provider: "google", profile: "workspace.all" }; + let sealed = seal(credentials, keys.sealPublicKey, "initial", keys.config, "workspace.all"); + const connector = createPaperclipCloudConnector({ config: keys.config, + request: vi.fn(async () => Response.json({ sealed })) as typeof fetch }); + await expect(connector.claim({ subject, companyId, profile: "workspace.all", claimId: "test-claim", redemptionId: "test-state" })).resolves.toEqual(credentials); + sealed = seal(credentials, keys.sealPublicKey, "access", keys.config, "workspace.all"); + await expect(connector.refresh({ subject, companyId, profile: "workspace.all", refreshToken: "test-refresh" })).resolves.toEqual(credentials); + for (const invalid of [[], [...scopes, "https://www.googleapis.com/auth/gmail.send"]]) { + credentials.scopes = invalid; + sealed = seal(credentials, keys.sealPublicKey, "initial", keys.config, "workspace.all"); + await expect(connector.claim({ subject, companyId, profile: "workspace.all", claimId: "test-claim", redemptionId: "test-state" })).rejects.toMatchObject({ code: "REAUTHORIZATION_REQUIRED" }); + sealed = seal(credentials, keys.sealPublicKey, "access", keys.config, "workspace.all"); + await expect(connector.refresh({ subject, companyId, profile: "workspace.all", refreshToken: "test-refresh" })).rejects.toMatchObject({ code: "REAUTHORIZATION_REQUIRED" }); + } + sealed = seal({ ...credentials, scopes: undefined }, keys.sealPublicKey, "access", keys.config, "workspace.all"); + await expect(connector.refresh({ subject, companyId, profile: "workspace.all", refreshToken: "test-refresh" })).rejects.toBeInstanceOf(PaperclipCloudConnectorError); + }); it("accepts a managed test identity and rejects other environments at its broker", () => { const identity = config().config; const env = { @@ -211,6 +233,21 @@ describe("Paperclip Cloud connector", () => { expect(request).toHaveBeenCalledOnce(); }); + it("signs selected Workspace scopes without substituting the full profile", async () => { + const keys = config(); + const scopes = ["https://www.googleapis.com/auth/gmail.readonly"]; + const request = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)); + const claims = JSON.parse(Buffer.from(body.request.split(".")[1], "base64url").toString("utf8")); + expect(claims).toMatchObject({ prf: "workspace.all", scp: scopes }); + return Response.json({ confirmationUrl: "https://my.example.test/connections/confirm?session=workspace", expiresAt: "2099-01-01T00:00:00Z" }); + }); + const connector = createPaperclipCloudConnector({ config: keys.config, request: request as typeof fetch }); + await connector.startAuthorization({ subject, companyId, profile: "workspace.all", scopes, + returnUri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback", returnState: "workspace-state" }); + expect(request).toHaveBeenCalledOnce(); + }); + it("starts a signed session with exact endpoint audience and scope contract", async () => { const keys = config(); const request = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { @@ -664,7 +701,7 @@ function seal( "sha256", diffieHellman({ privateKey: ephemeral.privateKey, publicKey: recipientPublicKey }), Buffer.concat([ephemeralRaw, recipientRaw]), - aad, + profile === "workspace.all" ? createHash("sha256").update(aad).digest() : aad, 32, )); const iv = randomBytes(12); diff --git a/server/src/services/paperclip-cloud-connector.ts b/server/src/services/paperclip-cloud-connector.ts index 3f330292dab..774dd1f3bc8 100644 --- a/server/src/services/paperclip-cloud-connector.ts +++ b/server/src/services/paperclip-cloud-connector.ts @@ -17,6 +17,7 @@ import { GOOGLE_WORKSPACE_CONNECTOR_PROFILES, isGitHubConnectorProfileId, isGoogleWorkspaceConnectorProfileId, + isGoogleWorkspaceScopeGrant, type GitHubConnectorProfileId, type GoogleWorkspaceConnectorProfileId, } from "@paperclipai/shared"; @@ -274,7 +275,7 @@ export function createPaperclipCloudConnector(input: { async function call( operation: PaperclipCloudConnectorOperation, - claims: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; returnUri?: string; returnState?: string; claimId?: string; redemptionId?: string }, + claims: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; scopes?: string[]; returnUri?: string; returnState?: string; claimId?: string; redemptionId?: string }, secret?: { field: "refreshToken" | "token" | "binding" | "acknowledgement"; value: string }, ): Promise { const endpoint = new URL(ENDPOINTS[operation], `${config.baseUrl}/`).toString(); @@ -298,7 +299,10 @@ export function createPaperclipCloudConnector(input: { const definition = connectorProfileDefinition(claims.profile); payload.prv = definition.provider; payload.prf = claims.profile; - payload.scp = [...definition.scopes]; + if (claims.scopes && (claims.profile !== "workspace.all" || !isGoogleWorkspaceScopeGrant(claims.scopes))) { + throw new PaperclipCloudConnectorError("Invalid requested service permissions", "CONNECTOR_CONFIG_INVALID"); + } + payload.scp = [...(claims.scopes ?? definition.scopes)]; } if (secret) payload.sh = await sha256Base64Url(secret.value); const body = { @@ -338,6 +342,7 @@ export function createPaperclipCloudConnector(input: { subject: string, companyId: string, profile: PaperclipCloudConnectorProfileId, + requestedScopes?: string[], ): SealedConnectorCredentials { const definition = connectorProfileDefinition(profile); const envelope = parseEnvelope(response.sealed, purpose, definition.provider, profile); @@ -348,7 +353,7 @@ export function createPaperclipCloudConnector(input: { config.environment, definition.provider, profile, - definition.scopes, + requestedScopes ?? definition.scopes, ); if ( credentials.instanceId !== config.instanceId @@ -362,7 +367,7 @@ export function createPaperclipCloudConnector(input: { if (credentials.profile !== profile) { throw new PaperclipCloudConnectorError("Paperclip Cloud connector profile binding did not match", "CONNECTOR_BINDING_MISMATCH"); } - if (!sameStringSet(credentials.scopes, definition.scopes)) { + if (!(profile === "workspace.all" ? isGoogleWorkspaceScopeGrant(credentials.scopes) : sameStringSet(credentials.scopes, definition.scopes))) { throw new PaperclipCloudConnectorError("Paperclip Cloud scope grant did not match", "REAUTHORIZATION_REQUIRED"); } return credentials; @@ -423,7 +428,7 @@ export function createPaperclipCloudConnector(input: { || !manager || typeof manager !== "object" || !("appId" in manager) || typeof manager.appId !== "string" || !/^A[A-Z0-9]+$/.test(manager.appId)) return null; return manager.appId; }, - async startAuthorization(values: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; returnUri: string; returnState: string }) { + async startAuthorization(values: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; scopes?: string[]; returnUri: string; returnState: string }) { const profile = values.profile ?? "gmail.draft"; const response = await call("session", { ...values, profile }); if (typeof response.confirmationUrl !== "string" || typeof response.expiresAt !== "string") { @@ -472,9 +477,9 @@ export function createPaperclipCloudConnector(input: { ...(handoff ? { handoff } : {}), }; }, - async claim(values: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; claimId: string; redemptionId: string }) { + async claim(values: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; scopes?: string[]; claimId: string; redemptionId: string }) { const profile = values.profile ?? "gmail.draft"; - return openCredentials(await call("claim", { ...values, profile }), sealPurpose("initial", profile), values.subject, values.companyId, profile); + return openCredentials(await call("claim", { ...values, profile }), sealPurpose("initial", profile), values.subject, values.companyId, profile, values.scopes); }, async refresh(values: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; refreshToken: string }) { const profile = values.profile ?? "gmail.draft"; @@ -761,7 +766,10 @@ function decryptEnvelope( "sha256", diffieHellman({ privateKey: recipientPrivateKey, publicKey: ephemeralKey }), Buffer.concat([ephemeralRaw, recipientRaw]), - aad, + // The combined profile's scope binding exceeds Node's 1024-byte HKDF-info + // limit. Its new wire contract hashes that binding; legacy profiles retain + // their original derivation. GCM still authenticates the complete AAD. + provider === "google" && profile === "workspace.all" ? createHash("sha256").update(aad).digest() : aad, 32, )); const iv = Buffer.from(envelope.iv, "base64url"); diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 4643c43d832..ea8dd9d8751 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -13,6 +13,7 @@ import type { ComposioAppAccount, ComposioAppAccountInput, ComposioAppSetupInput import { composioAppAccounts, composioAppSetupResult } from "./composio-app-setup.js"; import { honchoManagedArguments } from "./honcho-connection.js"; import { defaultConnectionAgentInstructions } from "@paperclipai/shared"; +import { googleWorkspaceEffectiveScopes, GOOGLE_WORKSPACE_DEFAULT_SELECTION, googleWorkspaceSelectedScopes, type GoogleWorkspaceServiceSelection, GOOGLE_WORKSPACE_SERVICES, googleWorkspaceServiceGranted, googleWorkspaceToolName, googleWorkspaceToolTarget, googleWorkspaceGrantedScopes, isGoogleWorkspaceToolGranted, isGoogleWorkspaceScopeGrant, type GoogleWorkspaceService } from "@paperclipai/shared"; import { isInsufficientConnectionScope, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE } from "./connection-permission-errors.js"; import { mcpDiscoveryHttpFailure, retainMcpConnectionFailure, withMcpConnectionFailure } from "./mcp-connection-failure.js"; import { ASANA_CONNECTOR_SCOPES, isAsanaConnectorProfileId, type AsanaConnectorProfileId } from "@paperclipai/shared"; @@ -2384,6 +2385,14 @@ export function classifyRisk( tool: McpToolDescriptor, sourceTemplateKey?: string | null, ): ToolRiskLevel { + if (sourceTemplateKey === "google-workspace") { + const target = googleWorkspaceToolTarget(tool.name); + if (!target) return "destructive"; + const risk = classifyRisk({ ...tool, name: target.upstreamName }, target.appSlug); + if (risk === "destructive" || target.leaf === "delete_event" || target.leaf === "send_message") return "destructive"; + if (target.write) return "write"; + return risk; + } const annotations = tool.annotations ?? {}; if (annotations.destructiveHint === true || annotations.destructive === true) return "destructive"; @@ -2541,6 +2550,7 @@ export function isGoogleWorkspaceToolAllowed( profileId: GoogleWorkspaceConnectorProfileId, tool: McpToolDescriptor, ): boolean { + if (profileId === "workspace.all") return googleWorkspaceToolTarget(tool.name) !== null; const profile = GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profileId]; const toolName = googleWorkspaceToolLeafName(tool.name); const readTools = @@ -2931,7 +2941,7 @@ function sanitizeHttpFailure(error: unknown): { if (code === "grant_credential_invalid" || code === "oauth_insufficient_scope") { return { status: code === "grant_credential_invalid" ? "missing_secret" : "degraded", message: error.message, code }; } - if (code === "slack_mcp_access_disabled") { + if (code === "slack_mcp_access_disabled" || code === "google_workspace_services_unavailable") { return { status: "error", message: error.message, code }; } if (code === "user_authorization_required") { @@ -7166,13 +7176,47 @@ export function toolAccessService( connection: typeof toolConnections.$inferSelect, credentialHeaders?: Record, actor?: ActorInfo, + workspaceEndpoint?: string, + unavailableServices: string[] = [], ): Promise { assertSupportedConnection(connection); let headers = credentialHeaders ?? { ...projectedConnectionHeaders(connection), ...(await resolveCredentialHeaders(connection, actor)), }; - const endpoint = await resolvedRemoteEndpoint(connection, actor); + if (connection.config.sourceTemplateKey === "google-workspace" && !workspaceEndpoint) { + const grant = await vaultGrantForConnection(connection, actor); + const scopes = googleWorkspaceGrantedScopes(grant); + const services = (Object.keys(GOOGLE_WORKSPACE_SERVICES) as GoogleWorkspaceService[]) + .filter((service) => googleWorkspaceServiceGranted(service, scopes)); + // One credential authority, no child connections or implied service grants. + const catalogs = await Promise.allSettled(services.map(async (service) => { + const profile = GOOGLE_WORKSPACE_CONNECTOR_PROFILES[GOOGLE_WORKSPACE_SERVICES[service]]; + const tools = await remoteTools(connection, headers, actor, profile.serverUrl); + return tools.map((tool) => ({ ...tool, name: googleWorkspaceToolName(service, tool.name) })) + .filter((tool) => isGoogleWorkspaceToolGranted(tool.name, scopes)); + })); + for (const [index, catalog] of catalogs.entries()) { + if (catalog.status === "fulfilled") continue; + const service = services[index]!; + unavailableServices.push(service); + await audit({ companyId: connection.companyId, connectionId: connection.id, + action: "tool_connection.catalog_refresh", outcome: "failure", actor, + reasonCode: "google_workspace_service_unavailable", details: { service } }); + } + const currentGrant = await vaultGrantForConnection(connection, actor); + if (!currentGrant) throw unprocessable("OAuth authorization must be reconnected", { code: "oauth_reauthorization_required" }); + if (catalogs.length > 0 && catalogs.every((catalog) => catalog.status === "rejected")) { + throw new HttpError(502, `Google services unavailable: ${unavailableServices.join(", ")}. Retry refreshing actions.`, + { code: "google_workspace_services_unavailable" }); + } + // A service's authentication retry may have refreshed into a narrower + // grant. Never publish actions using the pre-refresh permission snapshot. + const currentScopes = googleWorkspaceGrantedScopes(currentGrant); + return catalogs.flatMap((catalog) => catalog.status === "fulfilled" ? catalog.value : []) + .filter((tool) => isGoogleWorkspaceToolGranted(tool.name, currentScopes)); + } + const endpoint = workspaceEndpoint ?? await resolvedRemoteEndpoint(connection, actor); // Pinned to the address the guard approved: `config.url` is operator-supplied, // so a second DNS resolution here would reopen the rebinding window that // PAP-17098 closed for the OAuth endpoints. @@ -7512,6 +7556,7 @@ export function toolAccessService( connection: typeof toolConnections.$inferSelect, credentialHeaders?: Record, actor?: ActorInfo, + unavailableServices: string[] = [], ): Promise { assertSupportedConnection(connection); if (connection.connectionPurpose === "ai") throw unprocessable("AI connections provide runtime authentication, not tool actions"); @@ -7530,7 +7575,7 @@ export function toolAccessService( return []; } if (connection.transport === "mcp_remote") - return remoteTools(connection, credentialHeaders, actor); + return remoteTools(connection, credentialHeaders, actor, undefined, unavailableServices); if (connection.transport !== "local_stdio") { throw unsupportedToolConnectionTransport(); } @@ -7634,6 +7679,7 @@ export function toolAccessService( ): Promise { const connection = await getConnectionRow(connectionId); if (connection.connectionPurpose === "ai") return { connection: toConnection(connection), runtimeSlot: null }; + const unavailableServices: string[] = []; try { assertSupportedConnection(connection); const config = asRecord(connection.config); @@ -7695,7 +7741,7 @@ export function toolAccessService( : canProbeWithoutAuthorization ? {} : undefined; - await remoteTools(connection, credentialHeaders, actor); + await remoteTools(connection, credentialHeaders, actor, undefined, unavailableServices); } else if (connection.transport === "local_stdio") { await resolveCredentialHeaders(connection); await stdioTemplateId(connection.companyId, connection.config); @@ -7706,7 +7752,9 @@ export function toolAccessService( const updated = await updateConnectionHealth( connection, "ok", - config.sourceTemplateKey === "github" && + unavailableServices.length > 0 + ? `Connected. Google services unavailable: ${unavailableServices.join(", ")}. Other services remain available; retry refreshing actions.` + : config.sourceTemplateKey === "github" && oauth.connectorProfile === "github.code" ? "GitHub account, installation, and repository access are available." : isDaytonaConnection(connection) @@ -7779,12 +7827,14 @@ export function toolAccessService( const connection = await getConnectionRow(connectionId); if (connection.connectionPurpose === "ai") throw unprocessable("AI connections do not have a tool catalog"); const refreshedAt = now(); + const unavailableServices: string[] = []; let descriptors: McpToolDescriptor[]; try { descriptors = await discoverTools( connection, refreshOptions.credentialHeaders, actor, + unavailableServices, ); } catch (error) { if ( @@ -7832,10 +7882,30 @@ export function toolAccessService( existingRows.map((entry) => [entry.toolName, entry]), ); const nativeDaytona = isDaytonaConnection(connection); - if (nativeDaytona || isRemoteMcpConnectorMethod(connection.config.sourceTemplateKey, connection.config.connectionMethodKey)) { + if (connection.config.sourceTemplateKey === "google-workspace" + || nativeDaytona || isRemoteMcpConnectorMethod(connection.config.sourceTemplateKey, connection.config.connectionMethodKey)) { const discoveredNames = new Set(descriptors.map((descriptor) => descriptor.name)); + // The catalog is shared, but discovery uses only the caller's grant. A + // narrower personal consent must not remove actions another active grant + // still supports. Never combine scopes from different grants into authority. + let otherWorkspaceGrants: string[][] = []; + let selectedWorkspaceScopes: string[] = []; + if (connection.config.sourceTemplateKey === "google-workspace") { + const selected = await vaultGrantForConnection(connection, actor); + selectedWorkspaceScopes = googleWorkspaceGrantedScopes(selected); + const grants = await db.select().from(connectionGrants).where(and( + eq(connectionGrants.companyId, connection.companyId), eq(connectionGrants.connectionId, connection.id), + eq(connectionGrants.status, "active"), + )); + otherWorkspaceGrants = grants.filter((grant) => grant.id !== selected?.id).map(googleWorkspaceGrantedScopes); + } + // Preserve only consent-filtered actions, not failed-service catalogs or + // tools a successful, sufficiently authorized discovery actually removed. const removedStatus = nativeDaytona ? "removed" : "disabled"; - const removedIds = existingRows.filter((entry) => !discoveredNames.has(entry.toolName) && entry.status !== removedStatus).map((entry) => entry.id); + const removedIds = existingRows.filter((entry) => !discoveredNames.has(entry.toolName) && entry.status !== removedStatus + && !(otherWorkspaceGrants.some((scopes) => isGoogleWorkspaceToolGranted(entry.toolName, scopes)) + && !isGoogleWorkspaceToolGranted(entry.toolName, selectedWorkspaceScopes) + && !unavailableServices.includes(googleWorkspaceToolTarget(entry.toolName)?.service ?? ""))).map((entry) => entry.id); if (removedIds.length) await publish(tx => tx.update(toolCatalogEntries) .set({ status: removedStatus, quarantineReason: nativeDaytona ? "provider_tool_removed" : "mcp_tool_removed", updatedAt: refreshedAt }) .where(and(eq(toolCatalogEntries.companyId, connection.companyId), eq(toolCatalogEntries.connectionId, connection.id), inArray(toolCatalogEntries.id, removedIds)))); @@ -7971,7 +8041,9 @@ export function toolAccessService( config: normalizedConfig, transportConfig: normalizedTransportConfig, healthStatus: "ok", - healthMessage: isAgentMailConnection(connection) + healthMessage: unavailableServices.length > 0 + ? `Actions refreshed. Google services unavailable: ${unavailableServices.join(", ")}. Other services remain available; retry refreshing actions.` + : isAgentMailConnection(connection) ? "AgentMail API key is connected." : "Tool catalog refreshed.", healthCheckedAt: refreshedAt, @@ -11516,6 +11588,13 @@ export function toolAccessService( profile: profile.id, refreshToken: refreshSecret.value, }); + if (profile.id === "workspace.all") { + const requested = normalizeOauthScopes(oauthGrantConfig(grant).requestedScopes ?? oauthGrantConfig(grant).scopes); + credentials.scopes = googleWorkspaceEffectiveScopes(requested, credentials.scopes); + } + if (profile.id === "workspace.all" && !isGoogleWorkspaceScopeGrant(credentials.scopes)) { + throw new PaperclipCloudConnectorError("Google did not return verifiable permissions", "REAUTHORIZATION_REQUIRED"); + } } catch (error) { if ( error instanceof PaperclipCloudConnectorError && @@ -11811,6 +11890,12 @@ export function toolAccessService( throw error; } + if (connection.config.sourceTemplateKey === "google-workspace" + && !isGoogleWorkspaceScopeGrant(normalizeOauthScopes(token.scope))) { + await db.update(connectionGrants).set({ status: "needs_reauthorization", updatedAt: now() }) + .where(and(eq(connectionGrants.id, grant.id), eq(connectionGrants.companyId, connection.companyId))); + throw unprocessable("Google did not return verifiable permissions. Reconnect Google Workspace.", { code: "oauth_reauthorization_required" }); + } let nextRefreshRef: ToolCredentialSecretRef | null = null; if (token.refreshToken) { nextRefreshRef = await createOrRotateOAuthSecret({ @@ -11873,6 +11958,9 @@ export function toolAccessService( unrequestedScopes: grantOauth.unrequestedScopes, }, }); + if (connection.config.sourceTemplateKey === "google-workspace") { + refreshed.scopes = googleWorkspaceEffectiveScopes(normalizeOauthScopes(grantOauth.requestedScopes ?? grantOauth.scopes), refreshed.scopes); + } const providerTenant = { ...(grant.providerTenant ?? {}), oauth: { @@ -14624,6 +14712,7 @@ export function toolAccessService( subjectUserId?: string; subjectAgentId?: string; scopes?: string[]; + workspaceServices?: GoogleWorkspaceServiceSelection; returnTo?: string; issueId?: string; interactionId?: string; @@ -14645,7 +14734,36 @@ export function toolAccessService( const galleryMethod = galleryEntry ? connectionMethodForConnection(galleryEntry, connection) : null; - const requestedScopes = (() => { + const requestedScopes = await (async () => { + if (input.workspaceServices && sourceTemplateKey !== "google-workspace") throw badRequest("Service selection requires Google Workspace"); + if (sourceTemplateKey === "google-workspace") { + if (input.workspaceServices && input.scopes) throw badRequest("Choose services or scopes, not both"); + const allowed = normalizeOauthScopes(galleryMethod?.defaults?.scopesHint); + let scopes = input.scopes ?? (input.workspaceServices ? googleWorkspaceSelectedScopes(input.workspaceServices) : undefined); + if (!scopes && connection.status !== "draft") { + // Account-page reconnect has no new service choice. Never replace its + // subject's saved request with the new-account write defaults. + const binding = actorBinding(input.actor); + const subjectUserId = input.subjectUserId ?? (binding.actorType === "user" ? binding.actorId : null); + const kind = connection.credentialPolicy === "per_user" ? "user" + : connection.credentialPolicy === "per_agent" ? "agent" : "organization"; + const [grant] = await db.select().from(connectionGrants).where(and( + eq(connectionGrants.companyId, companyId), + eq(connectionGrants.connectionId, connection.id), + eq(connectionGrants.kind, kind), + kind === "user" ? eq(connectionGrants.subjectUserId, subjectUserId ?? "") + : kind === "agent" ? eq(connectionGrants.subjectAgentId, input.subjectAgentId ?? "") + : eq(connectionGrants.isDefault, true), + )).limit(1); + const saved = grant?.providerTenant?.oauth; + scopes = normalizeOauthScopes(saved?.requestedScopes ?? saved?.scopes) + .filter(scope => allowed.includes(scope)); + if (scopes.length === 0) throw badRequest("Choose your Google Workspace services before reconnecting"); + } + scopes ??= googleWorkspaceSelectedScopes(GOOGLE_WORKSPACE_DEFAULT_SELECTION); + if (!isGoogleWorkspaceScopeGrant(scopes) || scopes.some(scope => !allowed.includes(scope))) throw badRequest("Select at least one supported Google service"); + return scopes; + } if (!galleryMethod) return input.scopes ?? null; const allowed = normalizeOauthScopes(galleryMethod.defaults?.scopesHint); if (!input.scopes) return allowed; @@ -14866,6 +14984,7 @@ export function toolAccessService( : subjectUserId!, companyId, profile: managedProfile.id, + ...(managedProfile.id === "workspace.all" ? { scopes: requestedScopes! } : {}), returnUri: returnUri.toString(), returnState: state, }); @@ -14887,7 +15006,7 @@ export function toolAccessService( createdBySessionId: binding.sessionId, subjectUserId, subjectAgentId: authorizationSubjectAgentId, - requestedScopes: [...managedProfile.scopes], + requestedScopes: managedProfile.id === "workspace.all" ? requestedScopes! : [...managedProfile.scopes], returnTo: input.returnTo, issueId: intentLink?.issueId ?? input.issueId, interactionId: intentLink?.id, @@ -15005,6 +15124,8 @@ export function toolAccessService( // against which it could safely judge the caller's requested scope. if (authorizationScopes.length > 0) authorizationUrl.searchParams.set("scope", authorizationScopes.join(" ")); + if (galleryMethod && connection.config.sourceTemplateKey === "google-workspace") + authorizationUrl.searchParams.set("enable_granular_consent", "true"); const reviewedAuthorizationParams = galleryMethod?.defaults?.oauthAuthorizationParams; if (reviewedAuthorizationParams?.access_type) @@ -15385,9 +15506,11 @@ export function toolAccessService( : suggestedAgentIds.length > 0 ? { agentIds: suggestedAgentIds } : "all_agents"; - // An explicitly saved empty Access selection means no agents. A missing - // selection must still use managed OAuth's subject/recommended defaults. - const remoteMcpAccess = isRemoteMcpConnectorMethod( + // Workspace setup saves the operator's install selection before OAuth too. + // An empty selection means no agents, not permission to apply all-agent + // defaults after the user returns from Google. + const preserveEmptyInstalls = input.connection.config.sourceTemplateKey === "google-workspace" + || isRemoteMcpConnectorMethod( input.connection.config.sourceTemplateKey, input.connection.config.connectionMethodKey, ) || (input.connection.transport === "mcp_remote" @@ -15395,7 +15518,7 @@ export function toolAccessService( const access: FinishToolApp["access"] = deferTaskAccess ? { agentIds: [] } : installs.length === 0 - ? remoteMcpAccess ? { agentIds: [] } : normalizedSuggestedAccess + ? preserveEmptyInstalls ? { agentIds: [] } : normalizedSuggestedAccess : companyInstall ? "all_agents" : { agentIds }; @@ -15430,7 +15553,7 @@ export function toolAccessService( }, input.actor, ); - if (!deferTaskAccess && !remoteMcpAccess && installs.length === 0) { + if (!deferTaskAccess && !preserveEmptyInstalls && installs.length === 0) { const installTargets = access === "all_agents" ? [ @@ -15553,7 +15676,13 @@ export function toolAccessService( profile: profile.id, claimId: input.claimId, redemptionId: input.state, + ...(profile.id === "workspace.all" && stateRow.requestedScopes ? { scopes: stateRow.requestedScopes } : {}), }); + if (profile.id === "workspace.all") { + const requested = normalizeOauthScopes(stateRow.requestedScopes); + credentials.scopes = googleWorkspaceEffectiveScopes(requested, credentials.scopes); + if (!isGoogleWorkspaceScopeGrant(credentials.scopes)) throw unprocessable("Google did not grant any selected service permissions", { code: "oauth_scope_grant_invalid" }); + } const refreshToken = credentials.refreshToken; if ((profile.provider === "google" || profile.provider === "asana") && !refreshToken) { throw unprocessable( @@ -15731,6 +15860,7 @@ export function toolAccessService( strategy: "paperclip_cloud_connector", accessTokenExpiresAt: credentials.accessTokenExpiresAt, scopes: credentials.scopes, + requestedScopes: stateRow.requestedScopes ?? undefined, tokenType: credentials.tokenType, ...(credentials.refreshTokenExpiresAt ? { refreshTokenExpiresAt: credentials.refreshTokenExpiresAt } @@ -16257,6 +16387,13 @@ export function toolAccessService( code: input.code, resource: endpoints.resource, }); + if (sourceTemplateKey === "google-workspace" && !isGoogleWorkspaceScopeGrant(normalizeOauthScopes(token.scope))) { + throw unprocessable("Google did not return a supported permission grant. Reconnect and choose the Google services you want to use.", { code: "oauth_scope_grant_invalid" }); + } + if (sourceTemplateKey === "google-workspace") { + token.scope = googleWorkspaceEffectiveScopes(authorizedScopes, normalizeOauthScopes(token.scope)).join(" "); + if (!token.scope) throw unprocessable("Google did not grant any selected service permissions", { code: "oauth_scope_grant_invalid" }); + } const connectedAt = now(); const expiresAt = token.expiresIn ? new Date(connectedAt.getTime() + token.expiresIn * 1000).toISOString() diff --git a/server/src/services/tool-gateway.ts b/server/src/services/tool-gateway.ts index 95de0f8c244..c08b5f72be9 100644 --- a/server/src/services/tool-gateway.ts +++ b/server/src/services/tool-gateway.ts @@ -2,6 +2,7 @@ import { notifyDeliveryWork, DELIVERY_QUEUES } from "./delivery-work-notificatio import { measureToolDiscovery, measureRunPhase } from "./execution-preparation-timing.js"; import { executeDaytonaTool, isDaytonaConnection, isCurrentDaytonaCatalogEntry } from "./daytona.js"; import { composeConnectionInstructions } from "./connection-instructions.js"; +import { googleWorkspaceToolTarget, googleWorkspaceGrantedScopes, isGoogleWorkspaceToolGranted } from "@paperclipai/shared"; import { isInsufficientConnectionScope, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE } from "./connection-permission-errors.js"; import { boundedMcpToolName } from "./mcp-tool-names.js"; import { browserUseService } from "./browser-use.js"; @@ -2974,7 +2975,7 @@ export function createToolGatewayService( async function searchableOnDemandTools( session: ToolGatewaySession, ): Promise { - const tools = (await connectedMcpToolsForCompany(session.companyId)).filter( + const tools = (await connectionToolsForContext(session)).filter( isOnDemandRemoteTool, ); const decisions = await decideToolsForListing(tools, (tool) => @@ -3045,8 +3046,21 @@ export function createToolGatewayService( /** Shared connection descriptors and task restrictions; no provider calls. */ async function connectionToolsForContext(session: ToolGatewaySession): Promise { const guestBotConnection = await measureRunPhase("connectors.discovery.github", () => githubGuestBotConnectionForSession(db, session)); - const connectedTools = (await measureRunPhase("connectors.discovery.connections", () => connectedMcpToolsForCompany(session.companyId))) + let connectedTools = (await measureRunPhase("connectors.discovery.connections", () => connectedMcpToolsForCompany(session.companyId))) .filter(tool => !guestBotConnection || !tool.connectionId || (tool.connectionId === guestBotConnection && tool.providerType === "paperclip_github_chat")); + const connectionIds = [...new Set(connectedTools.flatMap((tool) => tool.connectionId ? [tool.connectionId] : []))]; + if (connectionIds.length) { + const connections = await db.select().from(toolConnections).where(and( + eq(toolConnections.companyId, session.companyId), inArray(toolConnections.id, connectionIds), + )); + for (const connection of connections.filter((item) => item.config.sourceTemplateKey === "google-workspace")) { + let scopes: string[] = []; + try { scopes = googleWorkspaceGrantedScopes(await resolveConnectionGrant(session, connection, false)); } + catch { /* Missing identity or consent never contributes tools. */ } + connectedTools = connectedTools.filter((tool) => tool.connectionId !== connection.id + || isGoogleWorkspaceToolGranted(tool.upstreamToolName ?? "", scopes)); + } + } return [ ...await measureRunPhase("connectors.discovery.github", () => githubBotToolsForSession(db, session)), ...await measureRunPhase("connectors.discovery.slack", () => slackToolsForSession(db, session)), @@ -6007,7 +6021,19 @@ export function createToolGatewayService( const data = await browserUseService(db, options.remoteHttpRequest).execute(session, grant, invocationId!, entry.toolName, parameters); return { result: { content: JSON.stringify(data), data: { structuredContent: data, transport: "rest_api" } } }; } - const endpoint = await resolvedRemoteEndpoint(session, connection, grant); + const workspaceTarget = connection.config.sourceTemplateKey === "google-workspace" + ? googleWorkspaceToolTarget(entry.toolName) : null; + // One service's call failure is not evidence that every Google endpoint is + // unhealthy. The invocation still records/returns its error; catalog refresh + // handles service availability, and grant checks remain authoritative. + const markCallHealth = (status: Parameters[1], message: string) => + workspaceTarget && status !== "ok" ? Promise.resolve() : markRemoteConnectionHealth(connection, status, message); + if (connection.config.sourceTemplateKey === "google-workspace" + && (!workspaceTarget || !isGoogleWorkspaceToolGranted(entry.toolName, googleWorkspaceGrantedScopes(grant)))) { + throw new ToolGatewayHttpError(403, "This Google action needs a permission you have not granted. Reconnect Google Workspace to change access.", "oauth_insufficient_scope"); + } + const endpoint = workspaceTarget?.serverUrl ?? await resolvedRemoteEndpoint(session, connection, grant); + const upstreamToolName = workspaceTarget?.upstreamName ?? entry.toolName; // Method-defined headers are trusted catalog configuration. Treat them as // managed headers so callers cannot override the scope that was reviewed // during tools/list. Credentials remain authoritative on collisions. @@ -6032,7 +6058,7 @@ export function createToolGatewayService( endpoint: auditSafeEndpoint(endpoint), mcpMethod: "tools/call", requestId, - upstreamToolName: entry.toolName, + upstreamToolName, dispatched: true, }, }; @@ -6043,8 +6069,19 @@ export function createToolGatewayService( // identity's session and leaves other agents on the connection alone. let mcpSession: { scope: string; headers: Record; sessionId: string } | undefined; try { - const dispatchRemote = (target: string, init: RequestInit) => - options.remoteHttpRequest + const dispatchRemote = async (target: string, init: RequestInit) => { + // Refresh can reduce consent. Recheck the selected grant after refreshing + // credentials and before every dispatch (including an authorization retry). + if (workspaceTarget) { + const [currentGrant] = await db.select().from(connectionGrants).where(and( + eq(connectionGrants.id, grant.id), eq(connectionGrants.companyId, connection.companyId), + eq(connectionGrants.connectionId, connection.id), eq(connectionGrants.status, "active"), + )); + if (!currentGrant || !isGoogleWorkspaceToolGranted(entry.toolName, googleWorkspaceGrantedScopes(currentGrant))) { + throw new ToolGatewayHttpError(403, "This Google action is not covered by the current permission grant.", "oauth_insufficient_scope"); + } + } + return options.remoteHttpRequest ? options.remoteHttpRequest(target, init) : guardedRemoteHttpFetch(target, init, { ...remoteHttpFetchOptions(), @@ -6053,6 +6090,7 @@ export function createToolGatewayService( // letting the tighter default cut a legitimately slow tool short. responseTimeoutMs: ms, }); + }; const requireExplicitRetryAfterOAuthRefresh = async (refreshedResponse: Response): Promise => { // The provider rejected this dispatch, but automatic replay is unsafe: // a server can apply a request before returning 401. A caller retry @@ -6136,7 +6174,7 @@ export function createToolGatewayService( id: requestId, method: "tools/call", params: { - name: entry.toolName, + name: upstreamToolName, arguments: parameters, }, }), @@ -6144,7 +6182,7 @@ export function createToolGatewayService( let response = await dispatchRemote(endpoint, requestInit); if (isInsufficientConnectionScope(response)) { await response.body?.cancel().catch(() => undefined); - await markRemoteConnectionHealth(connection, "degraded", INSUFFICIENT_CONNECTION_SCOPE_MESSAGE); + await markCallHealth("degraded", INSUFFICIENT_CONNECTION_SCOPE_MESSAGE); throw new ToolGatewayHttpError(403, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE, "oauth_insufficient_scope", { connectionId: connection.id }); } const oauth = asRecord(asRecord(connection.config)?.oauth); @@ -6266,7 +6304,7 @@ export function createToolGatewayService( response.headers.get("traceparent"), }; if (isInsufficientConnectionScope(response, body)) { - await markRemoteConnectionHealth(connection, "degraded", INSUFFICIENT_CONNECTION_SCOPE_MESSAGE); + await markCallHealth("degraded", INSUFFICIENT_CONNECTION_SCOPE_MESSAGE); throw new ToolGatewayHttpError(403, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE, "oauth_insufficient_scope", { connectionId: connection.id, catalogEntryId: entry.id, }); @@ -6275,7 +6313,7 @@ export function createToolGatewayService( // Session expiration is recoverable on an explicit retry. Marking the // connection unhealthy here would hide every tool and prevent it. if (!sessionExpired) { - await markRemoteConnectionHealth(connection, "error", "Remote MCP server returned an HTTP error."); + await markCallHealth("error", "Remote MCP server returned an HTTP error."); } throw new ToolGatewayHttpError( 502, @@ -6323,8 +6361,7 @@ export function createToolGatewayService( } if (payloadRecord.error !== undefined) { const errorRecord = asRecord(payloadRecord.error); - await markRemoteConnectionHealth( - connection, + await markCallHealth( "error", "Remote MCP server returned a JSON-RPC error.", ); @@ -6365,8 +6402,7 @@ export function createToolGatewayService( false, sourceTemplateKey, ); - await markRemoteConnectionHealth( - connection, + await markCallHealth( "ok", "Remote MCP server responded to tools/call.", ); @@ -6401,8 +6437,7 @@ export function createToolGatewayService( ); } if (error instanceof Error && error.name === "AbortError") { - await markRemoteConnectionHealth( - connection, + await markCallHealth( "error", "Remote MCP tool call timed out.", ); @@ -6417,8 +6452,7 @@ export function createToolGatewayService( }, ); } - await markRemoteConnectionHealth( - connection, + await markCallHealth( "error", "Remote MCP tool call failed.", ); diff --git a/ui/public/brands/apps/manifest.json b/ui/public/brands/apps/manifest.json index 597371920bc..b5ee462fd90 100644 --- a/ui/public/brands/apps/manifest.json +++ b/ui/public/brands/apps/manifest.json @@ -229,6 +229,13 @@ "workspace search" ] }, + { + "slug": "google-workspace", + "provider": "Google Workspace", + "catalogVisible": true, + "localAsset": "/brands/apps/google-people.svg", + "aliases": ["google suite", "g suite"] + }, { "slug": "hugging-face", "provider": "Hugging Face", diff --git a/ui/src/api/tools.ts b/ui/src/api/tools.ts index 1702478e54d..f8a726d4583 100644 --- a/ui/src/api/tools.ts +++ b/ui/src/api/tools.ts @@ -309,6 +309,7 @@ export const toolsApi = { asCurrentUser?: boolean; asAgentId?: string; interactionId?: string; + workspaceServices?: import("@paperclipai/shared").GoogleWorkspaceServiceSelection; } = {}, ) => api.post(`/tools/oauth/${connectionId}/start`, input), diff --git a/ui/src/features/connections/ConnectionSetupFlow.tsx b/ui/src/features/connections/ConnectionSetupFlow.tsx index e7622eec999..b45f73007c0 100644 --- a/ui/src/features/connections/ConnectionSetupFlow.tsx +++ b/ui/src/features/connections/ConnectionSetupFlow.tsx @@ -1,4 +1,6 @@ import { StepHeader } from "./ConnectionSetupHeader"; +import { GoogleWorkspaceServicesDialog } from "./GoogleWorkspaceServices"; +import type { GoogleWorkspaceServiceSelection } from "@paperclipai/shared"; export { StepHeader } from "./ConnectionSetupHeader"; import { ConnectionInstructionsEditor } from "./ConnectionInstructions"; import { connectionInstructionsConfig, defaultConnectionAgentInstructions, type ConnectionAgentInstructions } from "@paperclipai/shared"; @@ -624,6 +626,10 @@ function StandardConnectionSetupFlow({ onCancel, renderCredentialStep, }: ConnectionSetupFlowProps = {}) { + const workspaceSelectionRef = useRef(null); + const [workspaceConsent, setWorkspaceConsent] = useState<(() => void) | null>(null); + const [workspaceConnection, setWorkspaceConnection] = useState(); + const [workspaceDraft, setWorkspaceDraft] = useState(); const queryClient = useQueryClient(); const routeNavigate = useNavigate(); const navigate = useCallback((to: string, options?: { replace?: boolean }) => { @@ -1227,6 +1233,12 @@ function StandardConnectionSetupFlow({ }) : false; const identityConnection = resumeConnection ?? reconnectConnection; + useEffect(() => { + workspaceSelectionRef.current = null; + setWorkspaceConsent(null); + setWorkspaceConnection(undefined); + setWorkspaceDraft(undefined); + }, [selectedCompanyId, requestedAppKey, identityConnection?.id]); const reconnectGrantKind: ConnectionGrantKind | null = identityConnection ? identityConnection.credentialPolicy === "per_user" ? "user" @@ -1302,6 +1314,7 @@ function StandardConnectionSetupFlow({ await toolsApi.updateConnection(connection.id, { agentInstructions: instructionValue }); } return toolsApi.startOAuth(connection.id, { + ...(connection.config?.sourceTemplateKey === "google-workspace" && workspaceSelectionRef.current ? { workspaceServices: workspaceSelectionRef.current } : {}), asCurrentUser: connection.credentialPolicy === "per_user", ...(connection.credentialPolicy === "per_agent" ? { asAgentId: configuredAgentIdentity(connection) ?? [...installAgentIds][0] } @@ -1315,6 +1328,7 @@ function StandardConnectionSetupFlow({ ? (error.body as { details?: { code?: unknown } }).details : null; setOAuthPhase("error"); + workspaceSelectionRef.current = null; onPhaseChange?.("needs_retry"); setOAuthError( details?.code === "invalid_grant" @@ -1327,6 +1341,16 @@ function StandardConnectionSetupFlow({ }); const mutateOAuthStart = oauthStartMutation.mutate; const startOAuth = useCallback((connection: ToolConnection) => { + if (connection.config?.sourceTemplateKey === "google-workspace" && !workspaceSelectionRef.current) { + setWorkspaceConnection(connection); + setWorkspaceConsent(() => () => { + onPhaseChange?.("authorizing"); + reserveOAuthPopup(); + if (host === "dialog" && !connectionIntentId) setDialogOAuthConnectionId(connection.id); + mutateOAuthStart(connection); + }); + return; + } onPhaseChange?.("authorizing"); reserveOAuthPopup(); if (host === "dialog" && !connectionIntentId) setDialogOAuthConnectionId(connection.id); @@ -1369,6 +1393,7 @@ function StandardConnectionSetupFlow({ requestedGrantKind, ); result = await toolsApi.connectApp(selectedCompanyId!, { + ...(connectEntry.slug === "google-workspace" && workspaceSelectionRef.current ? { workspaceServices: workspaceSelectionRef.current } : {}), galleryKey: connectEntry.slug, ...(instructionValue ? { agentInstructions: instructionValue } : {}), ...(connectionMethodKey ? { connectionMethodKey } : {}), @@ -1489,6 +1514,7 @@ function StandardConnectionSetupFlow({ const details = error instanceof ApiError && error.body && typeof error.body === "object" ? (error.body as { details?: { code?: unknown } }).details : null; + if (entry?.slug === "google-workspace") workspaceSelectionRef.current = null; if (automaticOAuthEntry) { setOAuthPhase("error"); onPhaseChange?.("needs_retry"); @@ -1530,11 +1556,19 @@ function StandardConnectionSetupFlow({ const method = connectEntry ? availableToolConnectionMethod(connectEntry, connectionMethodKey || null) : null; + if (connectEntry?.slug === "google-workspace" && !workspaceSelectionRef.current) { + setWorkspaceConnection(identityConnection ?? undefined); + setWorkspaceConsent(() => () => { + reserveOAuthPopup(); + mutateConnect(entryOverride); + }); + return; + } if (method?.auth === "oauth") { reserveOAuthPopup(); } mutateConnect(entryOverride); - }, [connectionMethodKey, entry, mutateConnect, reserveOAuthPopup]); + }, [connectionMethodKey, entry, identityConnection, mutateConnect, reserveOAuthPopup]); useEffect(() => { if (!requestedAppKey || galleryQuery.isLoading || !galleryQuery.data) return; @@ -2172,6 +2206,19 @@ function StandardConnectionSetupFlow({ ) ); + if (workspaceConsent) return { if (selection) setWorkspaceDraft(selection); setWorkspaceConsent(null); setOAuthPhase("entry"); onPhaseChange?.("requested"); }} + onContinue={selection => { + workspaceSelectionRef.current = selection; + setWorkspaceDraft(selection); + const proceed = workspaceConsent; + setWorkspaceConsent(null); + proceed(); + }} + />; + if (showCuratedOAuthState && automaticOAuthEntry) { return ( vi.fn()); +vi.mock("@/api/tools", () => ({ toolsApi: { listConnectionGrants: listGrants } })); +vi.mock("@/pages/apps/AppLogo", () => ({ AppLogo: () => null })); + +describe("Workspace service consent", () => { + let root: Root; + let container: HTMLDivElement; + let client: QueryClient; + const onContinue = vi.fn(); + const onCancel = vi.fn(); + const existing = { id: "workspace-account", status: "active", credentialPolicy: "per_user", config: {} } as ToolConnection; + const flush = async () => { await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); }); }; + const click = async (name: string) => { + const button = [...document.querySelectorAll("button")].find(item => item.textContent === name)!; + await act(async () => { button.click(); }); + await flush(); + }; + async function render(connection?: ToolConnection) { + await act(async () => { root.render( + + ); }); + await flush(); + await flush(); + } + beforeEach(() => { + vi.clearAllMocks(); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + }); + afterEach(async () => { + await act(async () => { root.unmount(); }); + client.clear(); + container.remove(); + }); + + it("defaults all three groups to write and submits only their selections", async () => { + await render(); + expect([...document.querySelectorAll('[role="switch"]')].map(item => item.getAttribute("aria-checked"))).toEqual(["true", "true", "true"]); + expect([...document.querySelectorAll("select")].map(item => item.value)).toEqual(["write", "write", "write"]); + expect(listGrants).not.toHaveBeenCalled(); + await click("Continue"); + expect(onContinue).toHaveBeenCalledWith({ gmail: "write", drive: "write", calendar: "write" }); + }); + + it("restores the current user's saved request, not an organization or another user's broader grant", async () => { + listGrants.mockResolvedValue({ currentUserId: "me", grants: [ + { kind: "organization", providerTenant: { oauth: { requestedScopes: ["https://www.googleapis.com/auth/documents"] } } }, + { kind: "user", subjectUserId: "other", providerTenant: { oauth: { requestedScopes: ["https://www.googleapis.com/auth/calendar.events"] } } }, + { kind: "user", subjectUserId: "me", providerTenant: { oauth: { + requestedScopes: ["https://www.googleapis.com/auth/gmail.readonly"], + scopes: ["https://www.googleapis.com/auth/gmail.readonly", "https://www.googleapis.com/auth/documents"], + } } }, + ] }); + await render(existing); + expect([...document.querySelectorAll('[role="switch"]')].map(item => item.getAttribute("aria-checked"))).toEqual(["true", "false", "false"]); + expect(document.querySelector("select")?.value).toBe("read"); + await click("Continue"); + expect(onContinue).toHaveBeenCalledWith({ gmail: "read" }); + }); + + it("blocks on grant-load failure and retries without broadening the saved request", async () => { + listGrants.mockRejectedValueOnce(new Error("unavailable")); + await render(existing); + expect(document.body.textContent).toContain("Could not load your saved service access"); + expect(document.querySelector('[role="switch"]')).toBeNull(); + expect(onContinue).not.toHaveBeenCalled(); + listGrants.mockResolvedValue({ currentUserId: "me", grants: [] }); + await click("Try again"); + await flush(); + const continueButton = [...document.querySelectorAll("button")].find(item => item.textContent === "Continue")!; + expect(continueButton.disabled).toBe(true); + await click("Cancel"); + expect(onCancel).toHaveBeenCalledWith({}); + expect(onContinue).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/features/connections/GoogleWorkspaceServices.tsx b/ui/src/features/connections/GoogleWorkspaceServices.tsx new file mode 100644 index 00000000000..70d1eb16931 --- /dev/null +++ b/ui/src/features/connections/GoogleWorkspaceServices.tsx @@ -0,0 +1,78 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { GOOGLE_WORKSPACE_DEFAULT_SELECTION, GOOGLE_WORKSPACE_SERVICE_GROUPS, googleWorkspaceSelectionForScopes, type GoogleWorkspaceServiceSelection, type ToolConnection } from "@paperclipai/shared"; +import { toolsApi } from "@/api/tools"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { NativeSelect } from "@/components/ui/select"; +import { ToggleSwitch } from "@/components/ui/toggle-switch"; +import { AppLogo } from "@/pages/apps/AppLogo"; + +export function GoogleWorkspaceServiceFields({ value, onChange }: { + value: GoogleWorkspaceServiceSelection; + onChange: (value: GoogleWorkspaceServiceSelection) => void; +}) { + return
+ {GOOGLE_WORKSPACE_SERVICE_GROUPS.map(group =>
+ +
+ + {"description" in group &&

{group.description}

} +
+ onChange({ ...value, [group.id]: event.target.value as "read" | "write" })}> + + + { + const next = { ...value }; + if (checked) next[group.id] = "write"; + else delete next[group.id]; + onChange(next); + }} /> +
)} +
; +} + +type DialogProps = { + onContinue: (value: GoogleWorkspaceServiceSelection) => void; + onCancel: (selection?: GoogleWorkspaceServiceSelection) => void; + initial?: GoogleWorkspaceServiceSelection; + connection?: ToolConnection; +}; + +export function GoogleWorkspaceServicesDialog(props: DialogProps) { + const existing = props.connection && props.connection.status !== "draft" ? props.connection : null; + const grants = useQuery({ queryKey: ["workspace-service-consent", existing?.id], queryFn: () => toolsApi.listConnectionGrants(existing!.id), enabled: Boolean(existing) }); + if (existing && !grants.data) return { if (!open) props.onCancel(); }}> + Connect Google Workspace{grants.isError ? "Could not load your saved service access. Your connection has not changed." : "Loading your saved service access…"} +
{grants.isError && }
+
; + const grant = grants.data?.grants.find(grant => existing?.credentialPolicy === "per_user" + ? grant.kind === "user" && grant.subjectUserId === grants.data?.currentUserId + : existing?.credentialPolicy === "per_agent" + ? grant.kind === "agent" && grant.subjectAgentId === (existing.config?.oauth as { connectorSubjectAgentId?: string } | undefined)?.connectorSubjectAgentId + : grant.kind === "organization"); + const savedScopes = grant?.providerTenant?.oauth?.requestedScopes ?? grant?.providerTenant?.oauth?.scopes ?? []; + return ; +} + +function ServicePicker({ onContinue, onCancel, initial = GOOGLE_WORKSPACE_DEFAULT_SELECTION }: DialogProps) { + const [selection, setSelection] = useState(initial); + const empty = Object.keys(selection).length === 0; + return { if (!open) onCancel(selection); }}> + + +
Connect Google Workspace
+ Choose the Google services to connect. +
+ + {empty &&

Select at least one service to continue.

} +
+ + +
+
+
; +} diff --git a/ui/src/pages/apps/AppsConnect.test.tsx b/ui/src/pages/apps/AppsConnect.test.tsx index 779dfe5c6ab..352d4148d88 100644 --- a/ui/src/pages/apps/AppsConnect.test.tsx +++ b/ui/src/pages/apps/AppsConnect.test.tsx @@ -1793,7 +1793,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { "preserves personal Workspace access for $slug when changing method (enrollment return: $enrollmentReturn)", async ({ slug, enrollmentReturn }) => { const definition = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === slug)!; - const readMethod = definition.methods.find((method) => method.key === "paperclip-read")!; + const readMethod = definition.methods.find((method) => method.key === "paperclip-read" || method.key === "paperclip-workspace")!; mockSearch.value = enrollmentReturn ? `source=${slug}&stage=setup&cloud_connector=enrolled` : `source=${slug}`; @@ -1814,7 +1814,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { await flushReact(); } - if (definition.methods.some((method) => method.capabilityProfile?.key !== "read")) { + if (definition.methods.some((method) => method.capabilityProfile?.key !== readMethod.capabilityProfile?.key)) { const readChoice = radioContaining(readMethod.capabilityProfile!.label); expect(readChoice).not.toBeNull(); await act(async () => { @@ -1851,8 +1851,12 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { buttonByText("Continue to sign in")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); + if (slug === "google-workspace") { + await act(async () => buttonByText("Continue")!.click()); + await flushReact(); + } expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ - galleryKey: slug, connectionMethodKey: "paperclip-read", grantKind: "user", + galleryKey: slug, connectionMethodKey: readMethod.key, grantKind: "user", })); }); @@ -1891,6 +1895,45 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(container.textContent).toContain("needs its own OAuth app"); }); + it("selects Workspace service groups before creating a single connection", async () => { + mockSearch.value = "source=google-workspace&stage=setup"; + const workspace = getAppStoreDefinition("google-workspace")!; + listGalleryMock.mockResolvedValue({ apps: [{ + ...workspace, ownershipAvailability: { ...workspace.ownershipAvailability, platform_shared: true }, + }] }); + await render(); + + expect(container.textContent).toContain("Google Workspace"); + expect(container.textContent).not.toContain("What should Paperclip be able to do?"); + await openAccessAdvanced(); + expect(buttonByText("Use your own Google OAuth app")).toBeDefined(); + expect(buttonByText("Continue to sign in")?.disabled).toBe(false); + await act(async () => { + buttonByText("Continue to sign in")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + expect(connectAppMock).not.toHaveBeenCalled(); + const dialog = document.querySelector('[role="dialog"]')!; + expect(dialog.textContent).toContain("Docs, Sheets, and Slides"); + expect(dialog.querySelectorAll('[role="switch"]')).toHaveLength(3); + expect(dialog.textContent).not.toContain("Contacts"); + expect(dialog.textContent).not.toContain("compose permission"); + const gmail = dialog.querySelector('select[aria-label="Gmail access"]') as HTMLSelectElement; + await act(async () => { + gmail.value = "read"; + gmail.dispatchEvent(new Event("change", { bubbles: true })); + }); + await act(async () => { + Array.from(dialog.querySelectorAll("button")).find(button => button.textContent === "Continue")!.click(); + }); + await flushReact(); + expect(connectAppMock).toHaveBeenCalledTimes(1); + expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ + galleryKey: "google-workspace", connectionMethodKey: "paperclip-workspace", + workspaceServices: { gmail: "read", drive: "write", calendar: "write" }, + })); + }); + it("renders Google Calendar as one minimal, unboxed setup screen", async () => { mockParams.appKey = "google-calendar"; listGalleryMock.mockResolvedValue({ apps: [GOOGLE_CALENDAR] }); diff --git a/ui/src/pages/apps/Browse.test.tsx b/ui/src/pages/apps/Browse.test.tsx index 3a1b30589aa..693fc8a9626 100644 --- a/ui/src/pages/apps/Browse.test.tsx +++ b/ui/src/pages/apps/Browse.test.tsx @@ -810,11 +810,12 @@ describe("Connectors landing page", () => { }); const googleSlugs = [ + "google-workspace", "gmail", "google-drive", "google-docs", "google-sheets", "google-slides", "google-calendar", "google-chat", "google-people", "google-workspace-search", ]; - it("temporarily hides all Google Workspace catalog rows without changing their definitions", async () => { + it("offers one Google Workspace connector while retaining the individual service definitions", async () => { const definitions = googleSlugs.map((slug) => getAppStoreDefinition(slug)!); listGalleryMock.mockResolvedValue({ apps: [...definitions, getAppStoreDefinition("notion")] }); const client = await renderBrowse(); @@ -822,17 +823,23 @@ describe("Connectors landing page", () => { for (const definition of definitions) { expect(definition.methods.length).toBeGreaterThan(0); expect(getAppStoreDefinition(definition.slug)).toBe(definition); - expect(container.querySelector(`[data-app-slug="${definition.slug}"]`)).toBeNull(); + if (definition.slug === "google-workspace") { + expect(container.querySelectorAll('[data-app-slug="google-workspace"]')).toHaveLength(1); + } else { + expect(container.querySelector(`[data-app-slug="${definition.slug}"]`)).toBeNull(); + } } expect(container.querySelector('[data-app-slug="notion"]')).not.toBeNull(); expect(client.getQueryData(queryKeys.apps.gallery("company-1"))).toEqual({ apps: [...definitions, getAppStoreDefinition("notion")], }); expect(archiveConnectionMock).not.toHaveBeenCalled(); + await clickButton("Connect", container.querySelector('[data-app-slug="google-workspace"]')!); + expect(navigateMock).toHaveBeenCalledWith("/apps/connect?source=google-workspace"); }); it.each(["active", "draft", "disabled"])( - "hides saved Google %s accounts without disabling or removing them", + "shows saved Google %s accounts without changing their access or status", async (status) => { const applications = googleSlugs.map((slug) => application({ id: `app-${slug}`, name: `Saved ${slug}`, applicationKey: `app-gallery:${slug}:one`, @@ -848,8 +855,8 @@ describe("Connectors landing page", () => { const client = await renderBrowse(); for (const slug of googleSlugs) { - expect(container.querySelector(`[data-app-slug="${slug}"]`)).toBeNull(); - expect(container.textContent).not.toContain(`Account for ${slug}`); + expect(container.querySelector(`[data-app-slug="${slug}"]`)).not.toBeNull(); + expect(container.textContent).toContain(`Account for ${slug}`); } expect(client.getQueryData(queryKeys.tools.connections("company-1"))).toEqual({ connections }); expect(client.getQueryData(queryKeys.tools.applications("company-1"))).toEqual({ applications }); @@ -858,7 +865,7 @@ describe("Connectors landing page", () => { ); it.each(["config", "transportConfig"])( - "hides a Google account identified by %s even when its gallery entry is absent", + "shows a Google account identified by %s even when its gallery entry is absent", async (sourceField) => { listGalleryMock.mockResolvedValue({ apps: [] }); listApplicationsMock.mockResolvedValue({ applications: [application({ @@ -870,14 +877,14 @@ describe("Connectors landing page", () => { }), connection()] }); await renderBrowse(); - expect(container.textContent).not.toContain("Saved Google account"); + expect(container.textContent).toContain("Saved Google account"); expect(container.textContent).toContain("Notion"); expect(archiveConnectionMock).not.toHaveBeenCalled(); }, ); it.each(["catalog", "custom"])( - "preserves non-Google accounts in a mixed-provider %s row", + "preserves both Google and non-Google accounts in a mixed-provider %s row", async (rowKind) => { const notion = getAppStoreDefinition("notion")!; listGalleryMock.mockResolvedValue({ apps: rowKind === "catalog" ? [notion] : [] }); @@ -885,7 +892,7 @@ describe("Connectors landing page", () => { ? { name: "My tools", applicationKey: null, metadata: null } : {})] }); const connections = [connection({ - id: "google-account", name: "Hidden Google account", + id: "google-account", name: "Saved Google account", config: { sourceTemplateKey: "google-docs" }, }), connection({ id: "notion-account", name: "Visible Notion account", @@ -894,8 +901,9 @@ describe("Connectors landing page", () => { listConnectionsMock.mockResolvedValue({ connections }); const client = await renderBrowse(); + await clickButton("2 connections"); expect(container.textContent).toContain("Visible Notion account"); - expect(container.textContent).not.toContain("Hidden Google account"); + expect(container.textContent).toContain("Saved Google account"); expect(container.textContent).toContain(rowKind === "catalog" ? "Notion" : "My tools"); expect(client.getQueryData(queryKeys.tools.connections("company-1"))).toEqual({ connections }); expect(archiveConnectionMock).not.toHaveBeenCalled(); @@ -903,7 +911,7 @@ describe("Connectors landing page", () => { ); it.each(["metadata", "applicationKey"])( - "keeps a non-Google custom connector identified by %s when only its Google accounts are hidden", + "keeps a non-Google custom connector identified by %s and its saved Google account", async (sourceField) => { listGalleryMock.mockResolvedValue({ apps: [] }); const savedApplication = application({ @@ -913,14 +921,14 @@ describe("Connectors landing page", () => { }); listApplicationsMock.mockResolvedValue({ applications: [savedApplication] }); const connections = [connection({ - name: "Hidden Google account", config: { sourceTemplateKey: "google-docs" }, + name: "Saved Google account", config: { sourceTemplateKey: "google-docs" }, })]; listConnectionsMock.mockResolvedValue({ connections }); const client = await renderBrowse(); expect(container.querySelector('[data-app-slug="custom-provider"]')).not.toBeNull(); expect(container.textContent).toContain("My custom connector"); - expect(container.textContent).not.toContain("Hidden Google account"); + expect(container.textContent).toContain("Saved Google account"); expect(client.getQueryData(queryKeys.tools.applications("company-1"))).toEqual({ applications: [savedApplication] }); expect(client.getQueryData(queryKeys.tools.connections("company-1"))).toEqual({ connections }); expect(archiveConnectionMock).not.toHaveBeenCalled(); diff --git a/ui/src/pages/apps/Browse.tsx b/ui/src/pages/apps/Browse.tsx index 37385598800..eb1096b1e85 100644 --- a/ui/src/pages/apps/Browse.tsx +++ b/ui/src/pages/apps/Browse.tsx @@ -142,11 +142,12 @@ type ConnectionRemovalTarget = { poolRevision?: number; } & ({ kind: "chat"; provider: ChatProvider } | { kind?: undefined }); -// Temporary, page-only hold until Google OAuth verification is approved. -// Keep definitions, direct setup/management routes, and runtime access intact. -// Remove this filter after approval; reviewer instances stay on their pinned build. -const GOOGLE_CONNECTOR_SLUGS = new Set( - Object.values(GOOGLE_WORKSPACE_CONNECTOR_PROFILES).map((profile) => profile.appSlug), +// Offer the unified connector for new Google accounts. Existing per-service +// connections remain visible and keep their definitions and management routes. +const LEGACY_GOOGLE_CONNECTOR_SLUGS = new Set( + Object.values(GOOGLE_WORKSPACE_CONNECTOR_PROFILES) + .map((profile) => profile.appSlug) + .filter((slug) => slug !== "google-workspace"), ); function chatProviderForSlug(slug: string): ChatProvider | null { @@ -641,15 +642,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne const savedAppConnections = connectionsByApplicationId.get(application.id) ?? []; if (applicationSlug === "gateway" && savedAppConnections.length === 0) continue; - let appConnections = savedAppConnections.filter( - (connection) => !GOOGLE_CONNECTOR_SLUGS.has(appConnectionSourceSlug(connection) ?? ""), - ); - // Hide source-only Google rows, but keep independently identified connectors. - if ( - (!applicationSlug || applicationSlug === "link") && - savedAppConnections.length > 0 && - appConnections.length === 0 - ) continue; + let appConnections = savedAppConnections; // One legacy gateway application may contain several API formats. Group // each saved AI account by its actual routing, not the old application slug. const ungroupedCount = appConnections.length; @@ -770,7 +763,9 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne })); return [...rowsBySlug.values(), ...customRows, ...aggregatorRows] - .filter((row) => !GOOGLE_CONNECTOR_SLUGS.has(row.slug) || row.upstreamApps?.some(snapshot => snapshot.accounts.length > 0)) + .filter((row) => !LEGACY_GOOGLE_CONNECTOR_SLUGS.has(row.slug) + || row.connections.length > 0 + || row.upstreamApps?.some(snapshot => snapshot.accounts.length > 0)) .map((row) => ({ ...row, connections: [...row.connections].sort( diff --git a/ui/storybook/prototypes/google-workspace/ServiceConsent.test.ts b/ui/storybook/prototypes/google-workspace/ServiceConsent.test.ts new file mode 100644 index 00000000000..0a9213805b0 --- /dev/null +++ b/ui/storybook/prototypes/google-workspace/ServiceConsent.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { defaultSelection, selectedScopes, services } from "./ServiceConsent"; + +const scope = (name: string) => `https://www.googleapis.com/auth/${name}`; +describe("Workspace service consent preview", () => { + it("requests nothing before selection", () => { + expect(selectedScopes({})).toEqual([]); + }); + it("keeps a Gmail-only reviewer request read-only", () => { + expect(selectedScopes({ gmail: "read" })).toEqual([scope("gmail.readonly")]); + }); + it("adds compose only for the explicit draft selection", () => { + expect(selectedScopes({ gmail: "write" })).toEqual([scope("gmail.compose"), scope("gmail.readonly")]); + }); + it("checks every supported service with write access by default", () => { + expect(Object.keys(defaultSelection)).toEqual(services.map(service => service.id)); + expect(Object.values(defaultSelection)).toEqual(Array(3).fill("write")); + const scopes = selectedScopes(defaultSelection); + expect(scopes.some(value => value.includes("/auth/chat."))).toBe(false); + expect(scopes).toContain(scope("gmail.compose")); + expect(scopes).not.toContain(scope("contacts.readonly")); + expect(scopes).not.toContain(scope("directory.readonly")); + }); + it("ignores removed service keys in old preview selections", () => { + expect(selectedScopes({ chat: "write", search: "read", people: "read", docs: "write", sheets: "write", slides: "write" })).toEqual([]); + }); + it("combines Drive, Docs, Sheets, and Slides into one deduplicated permission group", () => { + expect(selectedScopes({ drive: "write" })).toEqual([ + scope("documents"), scope("drive.file"), scope("drive.readonly"), scope("presentations"), scope("spreadsheets"), + ]); + expect(selectedScopes({ drive: "read" })).toEqual([ + scope("documents.readonly"), scope("drive.readonly"), scope("presentations.readonly"), scope("spreadsheets.readonly"), + ]); + }); +}); diff --git a/ui/storybook/prototypes/google-workspace/ServiceConsent.tsx b/ui/storybook/prototypes/google-workspace/ServiceConsent.tsx new file mode 100644 index 00000000000..fca3ebb03d1 --- /dev/null +++ b/ui/storybook/prototypes/google-workspace/ServiceConsent.tsx @@ -0,0 +1,71 @@ +import { useState } from "react"; +import { Check, ChevronLeft } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { AppLogo } from "@/pages/apps/AppLogo"; +import { GoogleWorkspaceServiceFields } from "@/features/connections/GoogleWorkspaceServices"; +import { GOOGLE_WORKSPACE_DEFAULT_SELECTION, GOOGLE_WORKSPACE_SERVICE_GROUPS, googleWorkspaceSelectedScopes } from "@paperclipai/shared"; + +const permissionDetails = { + gmail: "Google’s compose permission also permits sending email. Paperclip currently exposes draft creation, not sending.", + drive: "Read files across Drive; create files opened or created with this app, and edit documents, spreadsheets, and presentations.", + calendar: "Managing events includes creating, changing, deleting, and responding to events.", +}; +export const services = GOOGLE_WORKSPACE_SERVICE_GROUPS.map(group => ({ ...group, slug: group.brandKey, warning: permissionDetails[group.id] })); +export type Selection = Partial>; +export const defaultSelection: Selection = GOOGLE_WORKSPACE_DEFAULT_SELECTION; +export function selectedScopes(selection: Selection) { + return googleWorkspaceSelectedScopes(selection); +} + +export function ServiceConsent({ initial = defaultSelection, existing = false, error = false, start = "services" }: { + initial?: Selection; existing?: boolean; error?: boolean; start?: "catalog" | "services" | "review"; +}) { + const [selection, setSelection] = useState(initial); + const [open, setOpen] = useState(start !== "catalog"); + const [step, setStep] = useState<"services" | "review">(start === "review" ? "review" : "services"); + const [handoff, setHandoff] = useState(false); + const [failed, setFailed] = useState(false); + const scopes = selectedScopes(selection); + const previousScopes = selectedScopes(existing ? initial : {}); + const added = scopes.filter(scope => !previousScopes.includes(scope)); + const chosen = services.filter(service => selection[service.id]); + const warnings = chosen.filter(service => service.warning && selection[service.id] === "write"); + return
+
+

Apps

Paperclip
+

Connectors

+
+

Google Workspace

Gmail, Drive, and Calendar

+

One Google account. Choose which services Paperclip can access.

+ {existing &&

reviewer@example.com · Gmail

} + +
+
+ + + +
+ {existing ? "Manage Google Workspace services" : "Connect Google Workspace"} + {existing ? "reviewer@example.com" : "Choose the Google services to connect."} +
+
+ {step === "services" ? <>
+ + {!chosen.length &&

Select at least one service to continue.

} +
+ : <> +
+

{existing ? "Review your updated access" : "Google will ask for access to"}

+ {chosen.map(service =>
{service.name}{selection[service.id] === "write" ? "Read & write" : "Read only"}
)} +
Exact Google permissions ({scopes.length})
{warnings.map(service =>

{service.warning}

)}
    {scopes.map(scope =>
  • {scope}{existing && !previousScopes.includes(scope) && New}
  • )}
+ {existing &&

{added.length ? `${added.length} additional permissions need your consent. Your existing connection stays unchanged until Google confirms access.` : "No additional Google permissions requested. Turning a service off in Paperclip does not revoke permissions already granted to Google."}

} + {failed &&

Google sign-in could not start. Your selections are saved in this preview. Try again.

} + {handoff &&

{existing && !added.length ? "Service changes previewed" : "Permission request prepared"}

{existing && !added.length ? "Storybook preview only. No saved connection or Google permissions were changed." : "Storybook preview only. In the real flow, Google opens here to confirm this exact permission set. No account was connected."}

} +
+
+ } +
+
+
; +} diff --git a/ui/storybook/stories/connection-setup/google-workspace-services.stories.tsx b/ui/storybook/stories/connection-setup/google-workspace-services.stories.tsx new file mode 100644 index 00000000000..8a1f3e44221 --- /dev/null +++ b/ui/storybook/stories/connection-setup/google-workspace-services.stories.tsx @@ -0,0 +1,65 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, userEvent, within } from "storybook/test"; +import { GoogleWorkspaceServicesDialog } from "@/features/connections/GoogleWorkspaceServices"; +import { ServiceConsent } from "../../prototypes/google-workspace/ServiceConsent"; + +const meta = { + id: "apps-connections-google-workspace-service-consent-preview", + title: "Apps, channels, and extensions/Connection setup/Google Workspace service consent", + component: ServiceConsent, + parameters: { layout: "fullscreen", paperclip: { + capabilities: ["connection-setup/catalog", "app-permissions/account-maintenance"], + status: "current", implementation: "mixed", boundary: "fixtures", + notes: "Production service fields and picker with simulated journey callbacks. No provider authorization, persistence, or Google verification is established by these stories.", + }, docs: { description: { component: "The production service picker and fixture-backed journey previews. Google authorization is simulated without provider calls. Paperclip audiences and action policies remain separate." } } }, +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const ProductionPicker: Story = { + render: () => , + parameters: { docs: { description: { story: "The production picker used by both Apps and task-hosted OAuth setup. Callbacks are mocked in Storybook; no OAuth grant is created." } } }, +}; +export const ChooseServices: Story = { args: {} }; +export const FromApps: Story = { args: { start: "catalog" } }; +export const GmailOnlyReviewer: Story = { args: { initial: { gmail: "read" }, start: "review" } }; +export const SelectedServices: Story = { args: { initial: { gmail: "read", drive: "read", calendar: "write" } } }; +export const DraftsPermissionDisclosure: Story = { args: { initial: { gmail: "write" }, start: "review" } }; +export const ExistingAccount: Story = { args: { initial: { gmail: "read" }, existing: true } }; +export const SignInRecovery: Story = { args: { initial: { gmail: "read" }, start: "review", error: true } }; +export const Mobile: Story = { globals: { viewport: { value: "mobile", isRotated: false } } }; +export const Light: Story = { globals: { theme: "light" } }; +export const AllServicesCheckedByDefault: Story = { + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + const toggles = body.getAllByRole("switch"); + await expect(toggles).toHaveLength(3); + for (const toggle of toggles) await expect(toggle).toBeChecked(); + for (const dropdown of body.getAllByRole("combobox")) await expect(dropdown).toHaveValue("write"); + await expect(body.queryByText(/Google’s compose permission/)).not.toBeInTheDocument(); + await expect(body.queryByRole("switch", { name: /^Chat$/ })).not.toBeInTheDocument(); + await expect(body.queryByRole("switch", { name: /Contacts|directory/ })).not.toBeInTheDocument(); + await userEvent.click(body.getByRole("button", { name: /^Continue$/ })); + await userEvent.click(body.getByText(/^Exact Google permissions/)); + await expect(body.queryByText(/googleapis.com\/auth\/chat\./)).not.toBeInTheDocument(); + }, +}; +export const SelectionChangesPermissions: Story = { + args: { initial: {} }, + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + await expect(body.getByRole("button", { name: /^Continue$/ })).toBeDisabled(); + await userEvent.click(body.getByRole("switch", { name: /^Gmail$/ })); + await userEvent.selectOptions(body.getByRole("combobox", { name: "Gmail access" }), "read"); + await userEvent.click(body.getByRole("button", { name: /^Continue$/ })); + await userEvent.click(body.getByText("Exact Google permissions (1)")); + await expect(body.getByText("https://www.googleapis.com/auth/gmail.readonly", { exact: true })).toBeVisible(); + await expect(body.queryByText("https://www.googleapis.com/auth/drive.readonly", { exact: true })).not.toBeInTheDocument(); + await userEvent.click(body.getByRole("button", { name: /^Back$/ })); + await userEvent.selectOptions(body.getByRole("combobox", { name: "Gmail access" }), "write"); + await userEvent.click(body.getByRole("button", { name: /^Continue$/ })); + await userEvent.click(body.getByText("Exact Google permissions (2)")); + await expect(body.getByText("https://www.googleapis.com/auth/gmail.compose", { exact: true })).toBeVisible(); + await userEvent.click(body.getByRole("button", { name: "Continue with Google" })); + await expect(body.getByRole("status")).toHaveTextContent("No account was connected."); + }, +}; diff --git a/ui/storybook/stories/connection-setup/google-workspace.stories.tsx b/ui/storybook/stories/connection-setup/google-workspace.stories.tsx new file mode 100644 index 00000000000..21780a224f2 --- /dev/null +++ b/ui/storybook/stories/connection-setup/google-workspace.stories.tsx @@ -0,0 +1,53 @@ +import { useEffect, useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { getAppStoreDefinition } from "@paperclipai/shared"; +import { ConnectionSetupFlow } from "@/features/connections/ConnectionSetupFlow"; +import { useNavigate } from "@/lib/router"; + +const workspace = getAppStoreDefinition("google-workspace")!; +const noop = () => {}; + +const meta = { + id: "apps-connections-google-workspace", + title: "Apps, channels, and extensions/Connection setup/Google Workspace", + parameters: { layout: "fullscreen", paperclip: { + capabilities: ["connection-setup/catalog", "app-permissions/account-maintenance"], + status: "current", implementation: "production", boundary: "fixtures", + notes: "Production connection setup with simulated gallery metadata. Mutations are rejected; no successful Google grant or persistence is simulated.", + } }, + beforeEach: () => { + const original = window.fetch; + window.fetch = async (input, init) => { + const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, window.location.origin); + if (url.pathname.endsWith("/tools/gallery")) return Response.json({ + apps: [{ ...workspace, ownershipAvailability: { ...workspace.ownershipAvailability, platform_shared: true } }], + capabilities: { canCreateOrganizationGrant: true, canSetCompanyInstall: true }, + }); + const method = init?.method ?? (input instanceof Request ? input.method : "GET"); + if (url.pathname.startsWith("/api/") && !["GET", "HEAD"].includes(method.toUpperCase())) { + return Response.json({ error: "Preview only: no Google authorization was started." }, { status: 422 }); + } + return original(input, init); + }; + return () => { window.fetch = original; }; + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function Setup() { + const navigate = useNavigate(); + const [ready, setReady] = useState(false); + useEffect(() => { + navigate("/apps/connect?source=google-workspace", { replace: true }); + setReady(true); + }, [navigate]); + return
+

Production setup UI with simulated account data. Google consent and provider calls are not simulated as successful.

+ {ready && } +
; +} + +export const Connect: Story = { render: () => }; +export const Light: Story = { ...Connect, globals: { theme: "light" } }; +export const Mobile: Story = { ...Connect, globals: { viewport: { value: "mobile", isRotated: false } } };