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, ]);