diff --git a/packages/shared/src/app-definitions/slack.json b/packages/shared/src/app-definitions/slack.json index 208c7aaccb8..733de01cdee 100644 --- a/packages/shared/src/app-definitions/slack.json +++ b/packages/shared/src/app-definitions/slack.json @@ -22,7 +22,10 @@ "ownershipModes": [ "customer" ], - "whenToUse": "Use the provider-hosted connection for the quickest setup.", + "whenToUse": "Give agents Slack tools through Slack's hosted MCP server. Slack requires an OAuth app you create in Slack app settings.", + "consoleLinks": { + "register": "https://api.slack.com/apps" + }, "defaults": { "serverUrl": "https://mcp.slack.com/mcp", "authorizationEndpoint": "https://slack.com/oauth/v2/authorize", diff --git a/packages/shared/src/connector-availability.test.ts b/packages/shared/src/connector-availability.test.ts index 7b7f6cbfac5..eea485b72e5 100644 --- a/packages/shared/src/connector-availability.test.ts +++ b/packages/shared/src/connector-availability.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { getConnectableAppDefinition } from "./app-definitions.js"; -import { isConnectorFeatureEnabled } from "./connector-availability.js"; +import { isConnectorFeatureEnabled, nativeConnectorRequiresCustomerOAuthApp } from "./connector-availability.js"; import { appDefinitionSchema } from "./validators/app-definition.js"; import { instanceExperimentalSettingsSchema } from "./validators/instance.js"; @@ -37,3 +37,35 @@ describe("connector availability", () => { } }); }); + +describe("Slack tool connection guidance", () => { + // BUG-06: Slack's MCP server has no dynamic client registration, so this method needs a + // customer-created Slack app. Its guidance must say so and link to Slack's app settings. + it("describes the customer-created Slack app honestly and links to Slack app settings", () => { + const method = getConnectableAppDefinition("slack")!.methods.find((candidate) => candidate.key === "mcp-oauth")!; + expect(method.whenToUse).not.toMatch(/quickest/i); + expect(method.consoleLinks?.register).toBe("https://api.slack.com/apps"); + }); +}); + +describe("native connectors that need a customer-created OAuth app", () => { + it("flags Slack only while its chat setup is unavailable", () => { + const slack = getConnectableAppDefinition("slack")!; + expect(nativeConnectorRequiresCustomerOAuthApp(slack, {})).toBe(true); + expect(nativeConnectorRequiresCustomerOAuthApp(slack, { enableChatConnectors: true })).toBe(false); + }); + + it("does not flag connectors with a managed, dynamic-registration, or credential path", () => { + // Asana declares a Paperclip Cloud managed method, so it is never flagged, + // even while that method's broker profile is unavailable. + const asana = getConnectableAppDefinition("asana")!; + expect(nativeConnectorRequiresCustomerOAuthApp(asana, {})).toBe(false); + expect(nativeConnectorRequiresCustomerOAuthApp({ ...asana, ownershipAvailability: { + platform_shared: true, platform_provisioned: false, customer: true, dcr: true, + } }, {})).toBe(false); + for (const slug of ["notion", "linear", "github"]) { + expect(nativeConnectorRequiresCustomerOAuthApp(getConnectableAppDefinition(slug)!, {}), slug).toBe(false); + } + expect(nativeConnectorRequiresCustomerOAuthApp(null, {})).toBe(false); + }); +}); diff --git a/packages/shared/src/connector-availability.ts b/packages/shared/src/connector-availability.ts index 1e1b5c0a340..82aec0dfb2e 100644 --- a/packages/shared/src/connector-availability.ts +++ b/packages/shared/src/connector-availability.ts @@ -1,4 +1,9 @@ -import type { AppDefinition } from "./types/app-definition.js"; +import { + connectionMethodAcceptsCustomerOAuthClient, + connectionMethodSupportsAutomaticOAuth, + getAvailableConnectionMethods, +} from "./app-definitions.js"; +import type { AppDefinition, ConnectionMethodDef } from "./types/app-definition.js"; import type { InstanceExperimentalSettings } from "./types/instance.js"; export function isConnectorFeatureEnabled( @@ -7,3 +12,35 @@ export function isConnectorFeatureEnabled( ): boolean { return !app?.experimentalFeatureKey || settings?.[app.experimentalFeatureKey] === true; } + +function channelMethodEnabled( + method: ConnectionMethodDef, + settings: Partial | null | undefined, +): boolean { + if (method.provider === "agentmail") return true; + if (method.provider === "github") return settings?.enableGitHubReviewBots === true; + return settings?.enableChatConnectors === true; +} + +/** + * True when every setup path of a native connector first needs the customer to + * create an OAuth app with the provider (no dynamic registration and no enabled + * chat setup). Catalog and agent discovery then offer equivalent MCP aggregator + * sign-in for new setup. Callers keep the native connector for saved connections + * and when no aggregator serves the app. A connector that declares a Paperclip + * Cloud managed method is never flagged: whether its broker profile is live is a + * runtime fact, and a failed or slow capability read must not change setup routes. + */ +export function nativeConnectorRequiresCustomerOAuthApp( + app: AppDefinition | null | undefined, + settings: Partial | null | undefined, +): boolean { + if (!app || !isConnectorFeatureEnabled(app, settings)) return false; + if (app.methods.some((method) => method.oauthStrategy === "paperclip_cloud_connector" + || method.oauthStrategy === "paperclip_id_connector")) return false; + const methods = getAvailableConnectionMethods(app).filter((method) => method.purpose !== "ai" + && (method.purpose !== "channel" || channelMethodEnabled(method, settings))); + return methods.length > 0 && methods.every((method) => method.purpose !== "channel" + && connectionMethodAcceptsCustomerOAuthClient(method) + && !connectionMethodSupportsAutomaticOAuth(method)); +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d80124a0136..03fa88d7132 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -295,7 +295,7 @@ export { recommendedDefaultsForApp, resolveConnectionMethodServerUrl, } from "./app-definitions.js"; -export { isConnectorFeatureEnabled } from "./connector-availability.js"; +export { isConnectorFeatureEnabled, nativeConnectorRequiresCustomerOAuthApp } from "./connector-availability.js"; export { getAppDefinitionForApplicationKey } from "./connector-identity.js"; export { connectionSetupStateForApp, diff --git a/server/src/__tests__/connection-aggregator-fallback.test.ts b/server/src/__tests__/connection-aggregator-fallback.test.ts index 5dc08bae48a..1e98d0bce51 100644 --- a/server/src/__tests__/connection-aggregator-fallback.test.ts +++ b/server/src/__tests__/connection-aggregator-fallback.test.ts @@ -3,7 +3,7 @@ import { requestHumanInputAction } from "../../../packages/paperclip-runner/src/ import { questionSetToAskUserQuestionsPayload } from "@paperclipai/shared"; import { randomUUID } from "node:crypto"; import { eq } from "drizzle-orm"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { agents, authUsers, @@ -401,6 +401,70 @@ const support = await getEmbeddedPostgresTestSupport(); expect(result.providerQuestion).toBeUndefined(); expect(result.instruction).toContain("connection_request"); }); + // BUG-06: on Paperclip Cloud, the native Slack tool path requires a customer-created + // Slack OAuth app and managed Slack chat setup is behind the Chat connectors opt-in. + // Agents must offer the one-click aggregator routes instead of that card. + describe("Slack on Paperclip Cloud", () => { + const slackQuery = "Slack (team updates / notifications)"; + beforeAll(() => { vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "cloud-test-token"); }); + afterAll(() => { vi.unstubAllEnvs(); }); + it("offers aggregator Slack routes while native Slack only supports a customer-created OAuth app", async () => { + await resetQuestions(); + const result = await connectionIntentService(db).search(claims, slackQuery); + expect(result.results.map((item) => item.service)).toEqual([ + "via:composio:slack", + "via:arcade:slack", + "via:zapier:slack", + ]); + expect(result.providerQuestion?.prompt).toContain("Slack"); + }); + it("keeps native Slack when Chat connectors provide its own setup path", async () => { + await resetQuestions(); + await instanceSettingsService(db).updateExperimental({ enableChatConnectors: true }); + try { + const result = await connectionIntentService(db).search(claims, slackQuery); + expect(result.results[0]).toMatchObject({ service: "slack", state: "available" }); + expect(result.providerQuestion).toBeUndefined(); + } finally { + await instanceSettingsService(db).updateExperimental({ enableChatConnectors: false }); + } + }); + it("does not offer native Slack setup beside another app's native match", async () => { + await resetQuestions(); + const result = await connectionIntentService(db).search(claims, "Find Notion and Slack tools"); + const services = result.results.map((item) => item.service); + expect(services).toEqual(expect.arrayContaining(["notion", "via:composio:slack"])); + expect(services).not.toContain("slack"); + }); + it("keeps Slack's aggregator routes in capability-only discovery", async () => { + await resetQuestions(); + const result = await connectionIntentService(db).search(claims, "start and continue Paperclip work"); + const services = result.results.map((item) => item.service); + expect(services).toContain("via:composio:slack"); + expect(services).not.toContain("slack"); + // A route needs the provider-choice flow before connection_request. + expect(result.instruction).toContain("search its aggregator.targetService"); + }); + it("keeps native connectors that declare a Paperclip Cloud managed method", async () => { + await resetQuestions(); + const result = await connectionIntentService(db).search(claims, "asana"); + expect(result.results[0]).toMatchObject({ service: "asana" }); + expect(result.providerQuestion).toBeUndefined(); + }); + it("keeps native Slack for a company that already saved a Slack connection", async () => { + await resetQuestions(); + await seedProvider("slack", "slack_send_message"); + const result = await connectionIntentService(db).search(claims, slackQuery); + expect(result.results[0]).toMatchObject({ service: "slack" }); + expect(result.providerQuestion).toBeUndefined(); + }); + }); + it("keeps native Slack on self-hosted instances, where operators register their own Slack app", async () => { + await resetQuestions(); + const result = await connectionIntentService(db).search(claims, "Slack (team updates / notifications)"); + expect(result.results[0]).toMatchObject({ service: "slack", state: "available" }); + expect(result.providerQuestion).toBeUndefined(); + }); it("returns ranked verified routes and does not create questions or connections during search", async () => { await resetQuestions(); const result = await connectionIntentService(db).search( diff --git a/server/src/services/connection-intents.ts b/server/src/services/connection-intents.ts index 9544e73b1c8..c6450199fef 100644 --- a/server/src/services/connection-intents.ts +++ b/server/src/services/connection-intents.ts @@ -55,6 +55,7 @@ import { type AiConnectionBinding, } from "@paperclipai/shared"; import { instanceSettingsService } from "./instance-settings.js"; +import { createNativeConnectorYield } from "./connection-native-yield.js"; import { conflict, forbidden, notFound, unprocessable } from "../errors.js"; import type { RuntimeToolsTokenClaims } from "../runtime-tools-token.js"; import { issueThreadInteractionService, type InteractionResolutionMutationOptions } from "./issue-thread-interactions.js"; @@ -463,6 +464,9 @@ export function connectionIntentService(db: Db) { const inventory = await connectionInventory(run.companyId); const candidates: Array<{ item: ConnectionSearchResultItem; score: number; nameScore: number }> = []; const authorizedCatalogs = new Map>>(); + // New setup of these native services is offered through aggregators instead. + const yieldsToAggregators = createNativeConnectorYield(settings); + const yieldingServices = new Set(); const discoveryMethods = (app: (typeof APP_STORE_DEFINITIONS)[number]) => getAvailableConnectionMethods(app) .filter(method => method.purpose !== "channel" || app.slug === "agentmail" || (method.provider === "github" ? settings.enableGitHubReviewBots : settings.enableChatConnectors)); @@ -507,6 +511,7 @@ export function connectionIntentService(db: Db) { const { score, nameScore } = scoreConnectionSearch(preparedQuery, [app.slug, app.name], `${app.description ?? ""} ${app.searchCapabilities} ${catalog.map(tool => `${tool.toolName} ${tool.description ?? ""}`).join(" ")}`); if (!score) continue; + if (definition && !matching.length && yieldsToAggregators(definition)) yieldingServices.add(service); const ready = await usableConnectionForAgent({ companyId: run.companyId, agentId: agent.id, responsibleUserId: run.responsibleUserId!, serviceSlug: service, purpose: aiOnly ? "ai" : service === "agentmail" ? "channel" : undefined, inventory }); const denied = !aiOnly && !ready && matching.length > 0 && await administrativeDenial(run.companyId, agent.id, service, inventory); @@ -538,6 +543,18 @@ export function connectionIntentService(db: Db) { connectionId: ready?.id ?? null, }}); } + // A yielded native service is not offered for new setup when an aggregator + // route can serve it; its routes take its place in ranked results, so broad + // and capability-only searches still find the app. Without a route the + // native service remains the fallback. + const yieldedRoutes: typeof candidates = []; + for (const service of yieldingServices) { + const candidate = candidates.find(({ item }) => item.service === service); + const routes = candidate ? await aggregatorAlternatives(service, candidate.item.name, findAggregatorService(service)) : []; + if (!routes.length) continue; + candidates.splice(candidates.indexOf(candidate!), 1); + yieldedRoutes.push(...routes.map(item => ({ item, score: candidate!.score, nameScore: candidate!.nameScore }))); + } const publicMatches = searchAggregatorServices(preparedQuery); const bestMatches = publicMatches.filter(match => Math.floor(match.nameScore / 100) === Math.floor((publicMatches[0]?.nameScore ?? 0) / 100)); const namedPublicService = findAggregatorService(serviceQuery, publicMatches); @@ -566,8 +583,13 @@ export function connectionIntentService(db: Db) { // A typo match such as Notion must not hide the distinctly named app Motion. const exact = candidates.filter(({ item, nameScore }) => ((nameScore >= 200 && Math.floor(nameScore / 100) >= bestExternalTier) || item.service === publicService?.slug) && (!isRemoteMcpConnectorId(item.service) || (!publicService && !indexedMatches.length))); - const ranked = () => candidates.sort((a, b) => b.score - a.score || a.item.name.localeCompare(b.item.name)) + const ranked = () => [...candidates, ...yieldedRoutes].sort((a, b) => b.score - a.score || a.item.name.localeCompare(b.item.name)) .slice(0, 40).map(({ item }) => item); + // Aggregator routes need a recorded provider choice before connection_request. + const rankedResult = (suggestions = false) => { + const results = ranked(); + return results.some(item => item.source === "aggregator") ? discoverySuggestions(results) : directSearchResult(query, results, suggestions); + }; const targetService = publicService?.slug ?? (indexedMatches.length === 1 ? indexedMatches[0]!.slug : normalizeConnectionQuery(serviceQuery).replaceAll(" ", "-")); // Indexed-only app labels must be identical when requests re-search the slug. const targetName = publicService?.name ?? targetService.split("-").map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(" "); @@ -581,7 +603,7 @@ export function connectionIntentService(db: Db) { aggregatorAlternatives(service.slug, service.name, service)))).flat(); if (otherRoutes.length) return discoverySuggestions([...ranked(), ...otherRoutes]); } - return directSearchResult(query, ranked()); + return rankedResult(); } async function aggregatorAlternatives(targetService: string, targetName: string, publicService?: AggregatorServiceDefinition) { const alternatives: ConnectionSearchResultItem[] = []; @@ -647,7 +669,7 @@ export function connectionIntentService(db: Db) { aggregatorAlternatives(service.slug, service.name, service)))).flat(); if (matches.length) return discoverySuggestions([...ranked(), ...matches]); } - if (!alternatives.length) return directSearchResult(query, ranked(), true); + if (!alternatives.length) return rankedResult(true); const question = aggregatorProviderQuestion(targetService, targetName, alternatives); const latest = previous[0]; if (latest && (latest.status === "pending" || !options.retryProviderChoice)) { @@ -668,7 +690,8 @@ export function connectionIntentService(db: Db) { instruction: "No matching built-in Paperclip connection was found. Ask the responsible user with providerQuestion exactly as returned (including its id, full prompt, and options). With native request_human_input, use interactionKind questions, continuationPolicy wake_assignee, and payload {version:1, questionSet:providerQuestionSet} exactly as returned. Otherwise use ask_user_questions with payload {version:1, questions:[providerQuestion]}. These are external services. Wait for the saved answer; then call connection_request with the selected service identifier and selectionInteractionId set to the answered question interaction ID. None for now means do not connect. Do not claim app access yet." }; function discoverySuggestions(results: ConnectionSearchResultItem[]): ConnectionsSearchResult { - return { version: 1, query, results: results.slice(0, 40), + const seen = new Set(); + return { version: 1, query, results: results.filter(item => !seen.has(item.service) && Boolean(seen.add(item.service))).slice(0, 40), instruction: "These are possible connection matches. Choose the relevant service and method using descriptions and purposes. For available or needs_user_action results, tool methods and AgentMail use connection_request to show the inline setup card; other channel or AI methods use setupPath. Use ready tools as installed; ready AI authentication applies to the next execution without reconnection. For an aggregator result, search its aggregator.targetService to obtain the provider-choice question and follow that instruction before requesting a connection. Respect unavailable states. Do not treat a search match as provider consent or app authorization." }; } } diff --git a/server/src/services/connection-native-yield.ts b/server/src/services/connection-native-yield.ts new file mode 100644 index 00000000000..fd24adc9dc9 --- /dev/null +++ b/server/src/services/connection-native-yield.ts @@ -0,0 +1,13 @@ +import { nativeConnectorRequiresCustomerOAuthApp, type AppDefinition, type InstanceExperimentalSettings } from "@paperclipai/shared"; +import { isCloudManagedInstance } from "./cloud-instance.js"; + +/** + * Decides, per discovery request, whether a native connector should yield new + * setup to MCP aggregators: on Paperclip Cloud, when every setup path needs a + * customer-created OAuth app. This is the same rule the Connectors catalog uses; + * it is synchronous and never contacts the Cloud broker. + */ +export function createNativeConnectorYield(settings: Partial) { + const cloudManaged = isCloudManagedInstance(); + return (app: AppDefinition): boolean => cloudManaged && nativeConnectorRequiresCustomerOAuthApp(app, settings); +} diff --git a/ui/src/pages/apps/Browse.test.tsx b/ui/src/pages/apps/Browse.test.tsx index 3f73b43effc..95a5223cba9 100644 --- a/ui/src/pages/apps/Browse.test.tsx +++ b/ui/src/pages/apps/Browse.test.tsx @@ -233,10 +233,11 @@ describe("Connectors landing page", () => { vi.clearAllMocks(); }); - async function renderBrowse(allCatalog = true) { + async function renderBrowse(allCatalog = true, options: { cloud?: boolean } = {}) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); + if (options.cloud) client.setQueryData(queryKeys.health, { status: "ok", cloud: { managed: true } }); root = createRoot(container); await act(async () => { root.render( @@ -679,6 +680,64 @@ describe("Connectors landing page", () => { expect(container.querySelectorAll('[data-app-slug="hubspot"] button[aria-label*="third-party"]')).toHaveLength(2); }); + // BUG-06: on Paperclip Cloud, Slack's built-in tool method needs a customer-created OAuth + // app and, without the Chat connectors opt-in, there is no managed Slack path. Connect + // must offer the aggregators' one-click sign-in instead of the "needs its own OAuth app" form. + it("offers aggregator Slack sign-in on Cloud while the built-in Slack connector needs a customer OAuth app", async () => { + experimentalMock.mockResolvedValue({}); + aggregatorCatalogMock.push(indexedApp("Slack", ["composio", "arcade"])); + listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("slack"), getAppStoreDefinition("notion")] }); + await renderBrowse(true, { cloud: true }); + + const rows = container.querySelectorAll('[data-app-slug="slack"]'); + expect(rows).toHaveLength(1); + expect(rows[0]!.textContent).toContain("Connect through Composio or Arcade."); + await clickButton("Connect", rows[0]!); + expect(navigateMock).not.toHaveBeenCalledWith("/apps/connect?source=slack"); + expect(document.querySelector('[role="dialog"]')?.textContent).toContain("Which service would you like to use?"); + }); + + it.each([ + ["Chat connectors offer its setup path", { enableChatConnectors: true }, true], + ["the instance is self-hosted", {}, false], + ])("keeps the built-in Slack connector when %s", async (_label, experimental, cloud) => { + experimentalMock.mockResolvedValue(experimental); + aggregatorCatalogMock.push(indexedApp("Slack", ["composio", "arcade"])); + listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("slack")] }); + await renderBrowse(true, { cloud }); + + const rows = container.querySelectorAll('[data-app-slug="slack"]'); + expect(rows).toHaveLength(1); + expect(rows[0]!.textContent).not.toContain("Connect through Composio"); + }); + + it("keeps native Asana on Cloud when the gallery omits its unavailable managed method", async () => { + experimentalMock.mockResolvedValue({}); + aggregatorCatalogMock.push(indexedApp("Asana", ["composio", "arcade"])); + const asana = getAppStoreDefinition("asana")!; + // The gallery removes a Paperclip Cloud method whose broker profile is not advertised. + const galleryAsana = { ...asana, methods: asana.methods.filter((method) => !method.oauthStrategy) }; + listGalleryMock.mockResolvedValue({ apps: [galleryAsana] }); + await renderBrowse(true, { cloud: true }); + + const rows = container.querySelectorAll('[data-app-slug="asana"]'); + expect(rows).toHaveLength(1); + expect(rows[0]!.textContent).not.toContain("Connect through Composio"); + }); + + it("keeps a saved built-in Slack connection on its native row on Cloud", async () => { + experimentalMock.mockResolvedValue({}); + aggregatorCatalogMock.push(indexedApp("Slack", ["composio", "arcade"])); + listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("slack")] }); + listApplicationsMock.mockResolvedValue({ applications: [application({ id: "slack-app", name: "Slack", metadata: { sourceTemplateKey: "slack" } })] }); + listConnectionsMock.mockResolvedValue({ connections: [connection({ applicationId: "slack-app", name: "Team Slack", transport: "mcp_remote", config: { sourceTemplateKey: "slack" } })] }); + await renderBrowse(true, { cloud: true }); + + const rows = container.querySelectorAll('[data-app-slug="slack"]'); + expect(rows).toHaveLength(1); + expect(rows[0]!.textContent).toContain("Team Slack"); + }); + it("paginates the catalog while keeping installed connectors above every page and resets on search", async () => { aggregatorCatalogMock.push(...Array.from({ length: 60 }, (_, index) => indexedApp(`Indexed App ${String(index).padStart(2, "0")}`))); listApplicationsMock.mockResolvedValue({ applications: [application()] }); diff --git a/ui/src/pages/apps/Browse.tsx b/ui/src/pages/apps/Browse.tsx index f6fea3bc2a9..31ccbd6046c 100644 --- a/ui/src/pages/apps/Browse.tsx +++ b/ui/src/pages/apps/Browse.tsx @@ -40,11 +40,13 @@ import { GOOGLE_WORKSPACE_CONNECTOR_PROFILES, CONNECTABLE_APP_DEFINITIONS, normalizeConnectionQuery, + nativeConnectorRequiresCustomerOAuthApp, } from "@paperclipai/shared"; import { useNavigate } from "@/lib/router"; import { useChatConnectorsEnabled, chatProviderVisible } from "@/hooks/useChatConnectorsEnabled"; import { useMemoryConnectorsEnabled } from "@/hooks/useMemoryConnectorsEnabled"; import { useConnectorAvailability } from "@/hooks/useConnectorAvailability"; +import { useCloudInstance } from "@/hooks/useCloudInstance"; import { appCopyFor } from "@/lib/app-gallery-copy"; import { useCompany } from "@/context/CompanyContext"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; @@ -377,6 +379,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne const { enabled: memoryConnectorsEnabled } = useMemoryConnectorsEnabled(); const connectorAvailability = useConnectorAvailability(); const googleWorkspaceConnectorEnabled = connectorAvailability.enabled(getConnectableAppDefinition("google-workspace")); + const cloudManaged = useCloudInstance()?.managed === true; const { setBreadcrumbs } = useBreadcrumbs(); const { status: connectivityStatus } = useConnectivity(); const [query, setQuery] = useState(""); @@ -777,8 +780,25 @@ 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 yieldsToAggregators = (slug: string) => !googleWorkspaceConnectorEnabled - && BUILT_IN_GOOGLE_CONNECTOR_SLUGS.has(slug) && !(rowsBySlug.get(slug)?.connections.length); + // On Cloud, a native connector whose every setup path needs a customer-created + // OAuth app (such as Slack without Chat connectors) yields new setup to the + // aggregators' managed sign-in. Saved native accounts keep their native row. + const hasAggregatorRoute = (row: ConnectorRowModel) => managedCatalog.some((app) => + [app.name, app.slug, ...app.aliases].some((alias) => + [row.slug, row.name].some((name) => aggregatorAppIdentity(alias) === aggregatorAppIdentity(name)))); + const yieldsToAggregators = (slug: string) => { + const row = rowsBySlug.get(slug); + if (row?.connections.length || row?.chatEndpoints.length) return false; + if (!googleWorkspaceConnectorEnabled && BUILT_IN_GOOGLE_CONNECTOR_SLUGS.has(slug)) return true; + // Decide from the declared methods: the gallery drops a Cloud managed method + // whose broker profile is unavailable, and that must not change setup routes. + const declaredMethods = getAppStoreDefinition(slug)?.methods ?? row?.entry?.methods; + return cloudManaged && Boolean(row?.entry) && hasAggregatorRoute(row!) + && nativeConnectorRequiresCustomerOAuthApp({ ...row!.entry!, methods: declaredMethods ?? [] }, { + enableChatConnectors: chatConnectorsEnabled, + enableGitHubReviewBots: githubEnabled, + }); + }; const nativeIdentities = new Set([...CONNECTABLE_APP_DEFINITIONS, ...(galleryQuery.data?.apps ?? [])] .filter((app) => !yieldsToAggregators(appDefinitionSlug(app))) .flatMap((app) => [appDefinitionName(app), appDefinitionSlug(app)].map(aggregatorAppIdentity))); @@ -800,7 +820,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne })); return [...rowsBySlug.values(), ...customRows, ...aggregatorRows] - .filter((row) => row.aggregatorApp || !LEGACY_GOOGLE_CONNECTOR_SLUGS.has(row.slug) + .filter((row) => row.aggregatorApp || (!LEGACY_GOOGLE_CONNECTOR_SLUGS.has(row.slug) && !yieldsToAggregators(row.slug)) || row.connections.length > 0 || row.upstreamApps?.some(snapshot => snapshot.accounts.length > 0)) .map((row) => ({ @@ -831,6 +851,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne gallery, galleryQuery.data, googleWorkspaceConnectorEnabled, + cloudManaged, upstreamAppsBySlug, ]);