From 21640b6d514a5569cd76b2d3a3bb9e13e2a623d2 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:37:24 -0600 Subject: [PATCH] feat(connections): gate the built-in Google Workspace connector behind an experimental toggle (#19247) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The Apps catalog lets operators connect external tools, including Google services. > - Paperclip has a built-in Google Workspace connector, but Google has not yet verified Paperclip's Google app. > - The built-in connector also hides the Composio and Arcade Google apps, because native Google definitions claim those app names. > - Users therefore see an unverified connector and cannot find the working aggregator Google apps. > - This pull request puts the built-in connector behind a default-off experimental setting. When the setting is off, the aggregator Google apps appear. > - The benefit is that users get working Google connectors now, and the team can still test the built-in connector on demand. ## Linked Issues or Issue Description **Problem** Google has not verified Paperclip's Google app yet. The Apps page still offers the built-in Google Workspace connector. The built-in Google definitions also suppress the Composio and Arcade Google apps (Gmail, Google Calendar, Google Drive, Docs, Sheets, Slides, Chat), so users cannot find them. **Proposed solution** Add the `enableGoogleWorkspaceConnector` experimental setting. The default is off. When it is off, hide and block the built-in connector, and show the aggregator Google apps. When it is on, keep the current behavior. **Alternatives considered** Delete the built-in connector until verification is complete. We rejected this option because the team must continue to test the connector. **Additional context** Composio documents Gmail (63 tools), Google Calendar (50 tools), and Google Drive toolkits that use Composio-managed OAuth. These toolkits do not need Paperclip's Google app verification. ## What Changed - Added the default-off `enableGoogleWorkspaceConnector` experimental setting to the shared types, validator, feature catalog, server defaults, and the managed-key list for Paperclip Cloud staging. - Added `experimentalFeatureKey: "enableGoogleWorkspaceConnector"` to the `google-workspace` app definition. The existing generic connector gate then hides the connector in the gallery and blocks its setup, discovery, execution, and agent suggestions when the setting is off. - Added a "Google Workspace connector (Paperclip)" toggle card to **Settings → Experimental**. - Apps page (`Browse.tsx`): when the setting is off, built-in Google rows without saved accounts no longer claim aggregator app identities. The Composio and Arcade Google apps then show as aggregator rows. The legacy Google slug filter now applies only to native rows, so the Composio `gmail` row is not dropped. - Saved per-service Google connections (Gmail, Drive, and others) are not gated. They stay visible and usable. - Updated `doc/connections/GOOGLE-WORKSPACE.md`. - Tests: added tests for the hidden and visible Apps page states, the settings toggle, and the definition gate. Existing built-in Google Workspace tests now enable the setting. ## Verification - `cd packages/shared && npx vitest run` passes (1055 tests). - `cd server && npx vitest run src/__tests__/connection-management.integration.test.ts src/__tests__/tool-access-service.test.ts src/__tests__/tool-gateway-service.test.ts src/services/gmail-tool-governance.test.ts src/services/google-chat-tool-policy.test.ts src/__tests__/instance-settings-service.test.ts src/__tests__/health.test.ts src/__tests__/instance-settings-managed-overlay.test.ts` passes (864 tests). - `cd ui && npx vitest run src/pages/apps src/pages/InstanceExperimentalSettings.test.tsx src/features/connections` passes. - `tsc --noEmit` passes for `packages/shared`, `server`, and `ui`. The `ui` run shows errors only in the untouched `storybook/support/feature-map` files. - `pnpm check:token-gates` passes. - Manual check: open **Apps** with the setting off. Search for "Gmail". The Composio Gmail row appears, and the built-in Google Workspace card does not. Turn on the setting in **Settings → Experimental**. The built-in card appears again. ## Risks - Existing built-in `google-workspace` connections stop running while the setting is off, because the generic gate blocks execution. This is intentional, so that the unverified Google app is not used. Turn on the setting on instances that must keep these connections. - Saved per-service Google connections are not affected. - When the setting is on, the behavior does not change. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Claude Opus 5.5 (Anthropic), model ID `claude-opus-5-5`, running in Claude Code with tool use (shell, file edits, web fetch). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- doc/connections/GOOGLE-WORKSPACE.md | 8 +++ .../src/app-definitions/google-workspace.json | 1 + .../shared/src/connector-availability.test.ts | 12 +++++ packages/shared/src/feature-catalog.ts | 7 +++ packages/shared/src/types/instance.ts | 2 + packages/shared/src/validators/instance.ts | 1 + .../connection-management.integration.test.ts | 2 +- .../instance-settings-service.test.ts | 1 + .../src/__tests__/tool-access-service.test.ts | 4 +- server/src/services/cloud-instance.ts | 1 + server/src/services/instance-settings.ts | 2 + .../InstanceExperimentalSettings.test.tsx | 13 +++++ ui/src/pages/InstanceExperimentalSettings.tsx | 12 +++++ ui/src/pages/apps/AppsConnect.test.tsx | 9 +++- ui/src/pages/apps/Browse.test.tsx | 52 +++++++++++++++++++ ui/src/pages/apps/Browse.tsx | 17 ++++-- 16 files changed, 135 insertions(+), 9 deletions(-) diff --git a/doc/connections/GOOGLE-WORKSPACE.md b/doc/connections/GOOGLE-WORKSPACE.md index d810dcc2437..6106fc8270d 100644 --- a/doc/connections/GOOGLE-WORKSPACE.md +++ b/doc/connections/GOOGLE-WORKSPACE.md @@ -6,6 +6,14 @@ 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. +The built-in connector is behind the default-off `enableGoogleWorkspaceConnector` +experimental setting while Paperclip's Google app verification is pending. When +the setting is off, the Apps page hides the built-in connector, and the server +blocks its setup, discovery, and execution. Google apps from MCP aggregators +(for example Composio Gmail, Google Calendar, and Google Drive) then appear in +the catalog instead. Saved per-service Google connections stay visible and usable. +Turn the setting on in **Settings → Experimental** to test the built-in connector. + 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 diff --git a/packages/shared/src/app-definitions/google-workspace.json b/packages/shared/src/app-definitions/google-workspace.json index 68e401fa299..c3b55dc3ec5 100644 --- a/packages/shared/src/app-definitions/google-workspace.json +++ b/packages/shared/src/app-definitions/google-workspace.json @@ -1,4 +1,5 @@ { + "experimentalFeatureKey": "enableGoogleWorkspaceConnector", "schemaVersion": 1, "slug": "google-workspace", "name": "Google Workspace", diff --git a/packages/shared/src/connector-availability.test.ts b/packages/shared/src/connector-availability.test.ts index a1e17eb4072..7b7f6cbfac5 100644 --- a/packages/shared/src/connector-availability.test.ts +++ b/packages/shared/src/connector-availability.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { getConnectableAppDefinition } from "./app-definitions.js"; import { isConnectorFeatureEnabled } from "./connector-availability.js"; import { appDefinitionSchema } from "./validators/app-definition.js"; +import { instanceExperimentalSettingsSchema } from "./validators/instance.js"; describe("connector availability", () => { const ordinary = getConnectableAppDefinition("notion")!; @@ -24,4 +25,15 @@ describe("connector availability", () => { expect(isConnectorFeatureEnabled(notebook, {})).toBe(false); expect(isConnectorFeatureEnabled(notebook, { enablePaperclipNotebook: true })).toBe(true); }); + + it("gates only the built-in Google Workspace connector, default off, while verification is pending", () => { + const workspace = getConnectableAppDefinition("google-workspace")!; + expect(workspace.experimentalFeatureKey).toBe("enableGoogleWorkspaceConnector"); + expect(isConnectorFeatureEnabled(workspace, instanceExperimentalSettingsSchema.parse({}))).toBe(false); + expect(isConnectorFeatureEnabled(workspace, { enableGoogleWorkspaceConnector: true })).toBe(true); + // Saved per-service Google connections keep working; only the unified entry is gated. + for (const slug of ["gmail", "google-drive", "google-calendar", "google-docs", "google-sheets"]) { + expect(getConnectableAppDefinition(slug)?.experimentalFeatureKey).toBeUndefined(); + } + }); }); diff --git a/packages/shared/src/feature-catalog.ts b/packages/shared/src/feature-catalog.ts index 0d774f636c3..fedbeca4b76 100644 --- a/packages/shared/src/feature-catalog.ts +++ b/packages/shared/src/feature-catalog.ts @@ -175,6 +175,13 @@ export const INSTANCE_FEATURE_CATALOG: Record { server = await startRunnerApiTestServer({ authPublicBaseUrl: "https://paperclip.fixture.invalid", humanSessions: true, databaseUrl: process.env.PAPERCLIP_CONNECTION_MANAGEMENT_TEST_DATABASE_URL }); lifecycle = configureAgentLifecycle(server.db, { requiredPluginIds: async () => [], runPlugin: async () => "complete", runHost: async () => "complete" }); vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY_FILE", join(server.root, "master.key")); - await instanceSettingsService(server.db).updateExperimental({ enableChatConnectors: true, enableGitHubReviewBots: true }); + await instanceSettingsService(server.db).updateExperimental({ enableChatConnectors: true, enableGitHubReviewBots: true, enableGoogleWorkspaceConnector: true }); }, 90_000); afterAll(async () => { await lifecycle?.stop(); await server?.close(); providerFetch?.mockRestore(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); }); diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index 2574df39fa0..2399dd67724 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -115,6 +115,7 @@ describe("instance settings service", () => { enableGitHubReviewBots: false, enableMemoryConnectors: false, enablePaperclipNotebook: false, + enableGoogleWorkspaceConnector: false, enableConferenceRoomChat: false, enableClassicTaskInterface: false, enableExternalObjects: false, diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 166ec8f212a..a6b26a1a78a 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -954,14 +954,14 @@ describeEmbeddedPostgres("tool access service", () => { if (externalDatabaseUrl) { await applyPendingMigrations(externalDatabaseUrl); db = createDb(externalDatabaseUrl, { maxConnections: 4 }); - await instanceSettingsService(db).updateExperimental({ enableMemoryConnectors: true }); + await instanceSettingsService(db).updateExperimental({ enableMemoryConnectors: true, enableGoogleWorkspaceConnector: true }); return; } tempDb = await startEmbeddedPostgresTestDatabase( "paperclip-tool-access-service-", ); db = createDb(tempDb.connectionString, { maxConnections: 4 }); - await instanceSettingsService(db).updateExperimental({ enableMemoryConnectors: true }); + await instanceSettingsService(db).updateExperimental({ enableMemoryConnectors: true, enableGoogleWorkspaceConnector: true }); }, 20_000); afterEach(async () => { diff --git a/server/src/services/cloud-instance.ts b/server/src/services/cloud-instance.ts index afddae33956..3658834c22b 100644 --- a/server/src/services/cloud-instance.ts +++ b/server/src/services/cloud-instance.ts @@ -19,6 +19,7 @@ const CLOUD_STAGING_EXPERIMENTAL_KEYS: ReadonlySet = new Set([ "enableFastResponses", "enableFirstTaskPlanProposal", "enableGitHubReviewBots", + "enableGoogleWorkspaceConnector", "enableGoalsSidebarLink", "enableGrokBot", "enableIsolatedWorkspaces", diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 3080be641ac..fcde8b8fe61 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -265,6 +265,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableGitHubReviewBots: parsed.data.enableGitHubReviewBots ?? false, enableMemoryConnectors: parsed.data.enableMemoryConnectors ?? false, enablePaperclipNotebook: parsed.data.enablePaperclipNotebook ?? false, + enableGoogleWorkspaceConnector: parsed.data.enableGoogleWorkspaceConnector ?? false, enableCases: parsed.data.enableCases ?? false, enableAgentChat: parsed.data.enableAgentChat ?? false, enableCombinedInboxTasks: parsed.data.enableCombinedInboxTasks ?? false, @@ -322,6 +323,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableGitHubReviewBots: false, enableMemoryConnectors: false, enablePaperclipNotebook: false, + enableGoogleWorkspaceConnector: false, enableCases: false, enableAgentChat: false, enableCombinedInboxTasks: false, diff --git a/ui/src/pages/InstanceExperimentalSettings.test.tsx b/ui/src/pages/InstanceExperimentalSettings.test.tsx index 2297abed2fd..f255a769472 100644 --- a/ui/src/pages/InstanceExperimentalSettings.test.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.test.tsx @@ -96,6 +96,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload { enableChatConnectors: false, enableGitHubReviewBots: false, enableMemoryConnectors: false, + enableGoogleWorkspaceConnector: false, enablePaperclipNotebook: false, enableCases: false, enableAgentChat: false, @@ -245,6 +246,18 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233) } }); + it("defaults the Paperclip Google Workspace connector off and persists an explicit toggle in both directions", async () => { + await renderPage(); + const selector = 'button[aria-label="Toggle Paperclip Google Workspace connector experimental setting"]'; + expect(container.querySelector(selector)?.getAttribute("aria-checked")).toBe("false"); + for (const enabled of [true, false]) { + await act(() => container.querySelector(selector)!.click()); + await flushReact(); + expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenLastCalledWith({ enableGoogleWorkspaceConnector: enabled }); + expect(container.querySelector(selector)?.getAttribute("aria-checked")).toBe(String(enabled)); + } + }); + it("defaults Combined Inbox + Task List off and persists an explicit toggle in both directions", async () => { await renderPage(); const selector = 'button[aria-label="Toggle combined inbox and task list experimental setting"]'; diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx index 0a724e84337..7737df4d688 100644 --- a/ui/src/pages/InstanceExperimentalSettings.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.tsx @@ -494,6 +494,18 @@ export function InstanceExperimentalSettings() { ariaLabel="Toggle GitHub review bots experimental setting" /> + toggleMutation.mutate({ enableGoogleWorkspaceConnector: checked })} + disabled={toggleMutation.isPending} + settingKey="enableGoogleWorkspaceConnector" + managed={managedKeys.enableGoogleWorkspaceConnector} + ariaLabel="Toggle Paperclip Google Workspace connector experimental setting" + /> + { "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)!; + experimentalMock.mockResolvedValue({ enableGoogleWorkspaceConnector: true }); const readMethod = definition.methods.find((method) => method.key === "paperclip-read" || method.key === "paperclip-workspace")!; mockSearch.value = enrollmentReturn ? `source=${slug}&stage=setup&cloud_connector=enrolled` @@ -2090,6 +2091,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { }); it("selects Workspace service groups before creating a single connection", async () => { + experimentalMock.mockResolvedValue({ enableGoogleWorkspaceConnector: true }); mockSearch.value = "source=google-workspace&stage=setup"; const workspace = getAppStoreDefinition("google-workspace")!; listGalleryMock.mockResolvedValue({ apps: [{ @@ -3074,18 +3076,21 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { }); it("keeps Gmail read-only selected when Finish setup resumes a declined Workspace draft", async () => { + experimentalMock.mockResolvedValue({ enableGoogleWorkspaceConnector: true }); const connectionId = "22222222-2222-4222-8222-222222222222"; const workspace = getAppStoreDefinition("google-workspace")!; mockSearch.value = `source=google-workspace&resume=${connectionId}&oauth=denied`; listGalleryMock.mockResolvedValue({ apps: [{ ...workspace, ownershipAvailability: { ...workspace.ownershipAvailability, platform_shared: true } }] }); listApplicationsMock.mockResolvedValue({ applications: [{ id: "app-workspace", companyId: "company-1", status: "draft", metadata: { sourceTemplateKey: "google-workspace" } }] }); - listConnectionsMock.mockResolvedValue({ connections: [{ + const draft = { ...notionConnection(connectionId, "per_user", "draft"), companyId: "company-1", applicationId: "app-workspace", config: { sourceTemplateKey: "google-workspace", connectionMethodKey: "paperclip-workspace", workspaceSetup: { version: 1, companyId: "company-1", connectionId, actorType: "user", actorId: "me", grantKind: "user", subjectId: "me", requestedScopes: ["https://www.googleapis.com/auth/gmail.readonly"], } }, - }] }); + }; + listConnectionsMock.mockResolvedValue({ connections: [draft] }); + getConnectionMock.mockResolvedValue(draft); listConnectionGrantsMock.mockResolvedValue({ currentUserId: "me", grants: [] }); await render(); await vi.waitFor(() => expect(container.textContent).toContain("Try again")); diff --git a/ui/src/pages/apps/Browse.test.tsx b/ui/src/pages/apps/Browse.test.tsx index ba0cf2c9343..3f73b43effc 100644 --- a/ui/src/pages/apps/Browse.test.tsx +++ b/ui/src/pages/apps/Browse.test.tsx @@ -667,6 +667,7 @@ describe("Connectors landing page", () => { }); it("suppresses aggregator routes for native apps, including hidden native connectors", async () => { + experimentalMock.mockResolvedValue({ enableChatConnectors: true, enableGitHubReviewBots: true, enableGoogleWorkspaceConnector: true }); aggregatorCatalogMock.push(indexedApp("Notion", ["composio", "arcade"]), indexedApp("Google Sheets"), indexedApp("GitHub API"), indexedApp("Context7"), indexedApp("HubSpot", ["composio", "arcade"])); await renderBrowse(); expect(container.querySelectorAll('[data-app-slug="notion"]')).toHaveLength(1); @@ -850,6 +851,7 @@ describe("Connectors landing page", () => { ]; it("offers one Google Workspace connector while retaining the individual service definitions", async () => { + experimentalMock.mockResolvedValue({ enableGoogleWorkspaceConnector: true }); const definitions = googleSlugs.map((slug) => getAppStoreDefinition(slug)!); listGalleryMock.mockResolvedValue({ apps: [...definitions, getAppStoreDefinition("notion")] }); const client = await renderBrowse(); @@ -872,6 +874,56 @@ describe("Connectors landing page", () => { expect(navigateMock).toHaveBeenCalledWith("/apps/connect?source=google-workspace"); }); + function googleAggregatorApp(name: string, toolkit: string): AggregatorAppCatalogEntry { + return { name, slug: toolkit, aliases: [toolkit], routes: [{ provider: "composio", toolkit, + logoUrl: `https://logos.composio.dev/api/${toolkit}`, docsUrl: `https://docs.composio.dev/toolkits/${toolkit}` }] }; + } + + it("hides the built-in Google Workspace connector by default and offers aggregator Google apps", async () => { + experimentalMock.mockResolvedValue({}); + aggregatorCatalogMock.push(googleAggregatorApp("Gmail", "gmail"), googleAggregatorApp("Google Calendar", "googlecalendar"), + googleAggregatorApp("Google Drive", "googledrive")); + listGalleryMock.mockResolvedValue({ apps: [...googleSlugs.map(getAppStoreDefinition), getAppStoreDefinition("notion")] }); + await renderBrowse(); + + expect(container.querySelector('[data-app-slug="google-workspace"]')).toBeNull(); + for (const slug of ["gmail", "googlecalendar", "googledrive"]) { + const row = container.querySelector(`[data-app-slug="${slug}"]`); + expect(row, slug).not.toBeNull(); + expect(row?.textContent).toContain("Composio"); + } + expect(container.querySelector('[data-app-slug="google-calendar"]')).toBeNull(); + expect(container.querySelector('[data-app-slug="notion"]')).not.toBeNull(); + }); + + it("shows an existing aggregator Google account once, on the aggregator row, while the built-in connector is off", async () => { + experimentalMock.mockResolvedValue({}); + aggregatorCatalogMock.push(googleAggregatorApp("Google Calendar", "googlecalendar")); + listGalleryMock.mockResolvedValue({ apps: [...googleSlugs.map(getAppStoreDefinition), getAppStoreDefinition("composio")] }); + listApplicationsMock.mockResolvedValue({ applications: [application({ id: "gateway-app", name: "Composio", metadata: { sourceTemplateKey: "composio" } })] }); + listConnectionsMock.mockResolvedValue({ connections: [connection({ applicationId: "gateway-app", name: "Composio account", transport: "mcp_remote", config: { sourceTemplateKey: "composio" } })] }); + const snapshot = { connectionId: "conn-notion", toolkit: "googlecalendar", status: "connected", checkedAt: new Date().toISOString(), + accounts: [{ id: "ca-calendar", alias: "Work calendar", status: "ACTIVE", isDefault: true }] }; + listComposioAppsMock.mockResolvedValue({ apps: [snapshot] }); + refreshComposioAppsMock.mockResolvedValue({ apps: [snapshot] }); + await renderBrowse(); + + expect(container.querySelector('[data-app-slug="google-calendar"]')).toBeNull(); + expect(container.querySelector('[data-app-slug="googlecalendar"]')?.textContent).toContain("Work calendar"); + expect(container.textContent?.split("Work calendar").length).toBe(2); + }); + + it("keeps aggregator Google apps folded into the built-in connector when it is enabled", async () => { + experimentalMock.mockResolvedValue({ enableGoogleWorkspaceConnector: true }); + aggregatorCatalogMock.push(googleAggregatorApp("Gmail", "gmail"), googleAggregatorApp("Google Calendar", "googlecalendar")); + listGalleryMock.mockResolvedValue({ apps: googleSlugs.map(getAppStoreDefinition) }); + await renderBrowse(); + + expect(container.querySelector('[data-app-slug="google-workspace"]')).not.toBeNull(); + expect(container.querySelector('[data-app-slug="gmail"]')).toBeNull(); + expect(container.querySelector('[data-app-slug="googlecalendar"]')).toBeNull(); + }); + it.each(["active", "draft", "disabled"])( "shows saved Google %s accounts without changing their access or status", async (status) => { diff --git a/ui/src/pages/apps/Browse.tsx b/ui/src/pages/apps/Browse.tsx index 71eb7041f70..f6fea3bc2a9 100644 --- a/ui/src/pages/apps/Browse.tsx +++ b/ui/src/pages/apps/Browse.tsx @@ -154,6 +154,9 @@ const LEGACY_GOOGLE_CONNECTOR_SLUGS = new Set( .map((profile) => profile.appSlug) .filter((slug) => slug !== "google-workspace"), ); +// While the built-in connector is experimental, these native Google rows do not +// claim the Google apps offered by MCP aggregators unless they hold an account. +const BUILT_IN_GOOGLE_CONNECTOR_SLUGS = new Set([...LEGACY_GOOGLE_CONNECTOR_SLUGS, "google-workspace"]); function chatProviderForSlug(slug: string): ChatProvider | null { const method = getAppStoreDefinition(slug)?.methods.find( @@ -373,6 +376,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne const { enabled: chatConnectorsEnabled, githubEnabled } = useChatConnectorsEnabled(); const { enabled: memoryConnectorsEnabled } = useMemoryConnectorsEnabled(); const connectorAvailability = useConnectorAvailability(); + const googleWorkspaceConnectorEnabled = connectorAvailability.enabled(getConnectableAppDefinition("google-workspace")); const { setBreadcrumbs } = useBreadcrumbs(); const { status: connectivityStatus } = useConnectivity(); const [query, setQuery] = useState(""); @@ -773,12 +777,16 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne // Native definitions take precedence even when temporarily hidden or unavailable. // Public metadata never creates an application, account, or installed status. - const nativeIdentities = new Set([...CONNECTABLE_APP_DEFINITIONS, ...(galleryQuery.data?.apps ?? [])].flatMap((app) => - [appDefinitionName(app), appDefinitionSlug(app)].map(aggregatorAppIdentity))); + const yieldsToAggregators = (slug: string) => !googleWorkspaceConnectorEnabled + && BUILT_IN_GOOGLE_CONNECTOR_SLUGS.has(slug) && !(rowsBySlug.get(slug)?.connections.length); + const nativeIdentities = new Set([...CONNECTABLE_APP_DEFINITIONS, ...(galleryQuery.data?.apps ?? [])] + .filter((app) => !yieldsToAggregators(appDefinitionSlug(app))) + .flatMap((app) => [appDefinitionName(app), appDefinitionSlug(app)].map(aggregatorAppIdentity))); for (const app of managedCatalog) { const snapshots = upstreamAppsBySlug.get(app.slug) ?? []; const aliases = new Set([app.name, app.slug, ...app.aliases].map(aggregatorAppIdentity)); - const nativeRow = [...rowsBySlug.values()].find(row => aliases.has(aggregatorAppIdentity(row.slug)) || aliases.has(aggregatorAppIdentity(row.name))); + const nativeRow = [...rowsBySlug.values()].find(row => !yieldsToAggregators(row.slug) + && (aliases.has(aggregatorAppIdentity(row.slug)) || aliases.has(aggregatorAppIdentity(row.name)))); if (nativeRow) { nativeRow.upstreamApps = [...(nativeRow.upstreamApps ?? []), ...snapshots]; nativeRow.upstreamCatalogApp = app; } } const aggregatorRows: ConnectorRowModel[] = managedCatalog.filter((app) => @@ -792,7 +800,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne })); return [...rowsBySlug.values(), ...customRows, ...aggregatorRows] - .filter((row) => !LEGACY_GOOGLE_CONNECTOR_SLUGS.has(row.slug) + .filter((row) => row.aggregatorApp || !LEGACY_GOOGLE_CONNECTOR_SLUGS.has(row.slug) || row.connections.length > 0 || row.upstreamApps?.some(snapshot => snapshot.accounts.length > 0)) .map((row) => ({ @@ -822,6 +830,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne connectionsQuery.data, gallery, galleryQuery.data, + googleWorkspaceConnectorEnabled, upstreamAppsBySlug, ]);