Skip to content

Commit ed3ffad

Browse files
feat(copilot): make the tool own service-account discovery
Removes the VFS auth-metadata exposure and returns connectNoun from the service_account_get_setup_link result instead. The VFS aggregate was a second, viewer-independent source of truth that couldn't agree with the per-viewer preview gate (it always hid slack-custom-bot, even for viewers with slack_v2 revealed, while the tool accepts it for them). The tool now resolves the provider, applies the per-viewer gate, and returns either the in-chat button + connectNoun or a fall-back-to-oauth error — one source of truth. connectNoun stays DRY via getServiceAccountConnectNoun, shared with the connect-button label.
1 parent 10fdfd4 commit ed3ffad

5 files changed

Lines changed: 110 additions & 15 deletions

File tree

apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect.ts

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
'use client'
22

33
import { type ComponentType, useMemo } from 'react'
4-
import { getClientCredentialAccountDescriptor } from '@/lib/credentials/client-credential-accounts/descriptors'
5-
import { getServiceAccountGatingBlockType } from '@/lib/credentials/service-account-provider-ids'
6-
import { getTokenServiceAccountDescriptor } from '@/lib/credentials/token-service-accounts/descriptors'
4+
import {
5+
getServiceAccountConnectNoun,
6+
getServiceAccountGatingBlockType,
7+
} from '@/lib/credentials/service-account-provider-ids'
78
import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
89
import type { ServiceAccountProviderId } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal'
910
import { getBlock } from '@/blocks'
@@ -66,15 +67,9 @@ export function useServiceAccountConnectTarget({
6667
return useMemo(() => {
6768
if (!serviceAccountProviderId || !serviceName || !serviceIcon) return null
6869

69-
const nounDescriptor =
70-
getTokenServiceAccountDescriptor(serviceAccountProviderId) ??
71-
getClientCredentialAccountDescriptor(serviceAccountProviderId)
72-
7370
const label = isSlackBot
7471
? 'Set up a custom bot'
75-
: nounDescriptor
76-
? `Add ${nounDescriptor.connectNoun}`
77-
: 'Add service account'
72+
: `Add ${getServiceAccountConnectNoun(serviceAccountProviderId)}`
7873

7974
return { serviceAccountProviderId, serviceName, serviceIcon, label, hidden }
8075
}, [serviceAccountProviderId, serviceName, serviceIcon, isSlackBot, hidden])

apps/sim/lib/copilot/tools/handlers/service-account.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ interface Output {
4545
provider?: string
4646
providerId?: string
4747
serviceAccountProviderId?: string
48+
connectNoun?: string
4849
instructions?: string
4950
}
5051

@@ -69,6 +70,10 @@ describe('executeServiceAccountGetSetupLink', () => {
6970
expect(output.setup_url).toContain('/integrations/notion?connect=service-account')
7071
// The agent is steered to emit the tag, not surface the URL as a link.
7172
expect(output.instructions).toContain('service_account')
73+
// The tool carries the secret's noun so the agent can tell the user what to
74+
// prepare — this is the discovery surface, replacing the reverted VFS field.
75+
expect(output.connectNoun).toBe('integration secret')
76+
expect(output.instructions).toContain('integration secret')
7277
// An ungated provider never consults block visibility.
7378
expect(mockGetBlockVisibility).not.toHaveBeenCalled()
7479
})

apps/sim/lib/copilot/tools/handlers/service-account.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility'
33
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
44
import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access'
55
import { getBaseUrl } from '@/lib/core/utils/urls'
6-
import { getServiceAccountGatingBlockType } from '@/lib/credentials/service-account-provider-ids'
6+
import {
7+
getServiceAccountConnectNoun,
8+
getServiceAccountGatingBlockType,
9+
} from '@/lib/credentials/service-account-provider-ids'
710
import {
811
listServiceAccountIntegrationNames,
912
resolveServiceAccountIntegration,
@@ -65,22 +68,28 @@ export async function executeServiceAccountGetSetupLink(
6568
const url = new URL(`${baseUrl}/workspace/${context.workspaceId}/integrations/${match.slug}`)
6669
url.searchParams.set(CONNECT_QUERY_PARAM, CONNECT_MODE.serviceAccount)
6770

71+
const connectNoun = getServiceAccountConnectNoun(match.serviceAccountProviderId)
72+
6873
return {
6974
success: true,
7075
output: {
7176
message: `Service account setup available for ${match.serviceName}.`,
7277
instructions:
7378
`Emit <credential>{"type":"service_account","provider":"${match.providerId}"}</credential> ` +
7479
`to open the ${match.serviceName} setup form directly in this chat. Only fall back to ` +
75-
`setup_url when you cannot render a tag (headless/MCP). The form collects the ` +
76-
`credential — never ask the user to paste key material into chat.`,
80+
`setup_url when you cannot render a tag (headless/MCP). Before or alongside the tag, tell ` +
81+
`the user they'll need a ${connectNoun} — the form collects it, so never ask them to paste ` +
82+
`key material into chat.`,
7783
provider: match.serviceName,
7884
// The OAuth provider value, NOT the service-account provider id — both
7985
// the tag renderer and the link renderer resolve display metadata from
8086
// this, and a service-account id resolves to whichever family member is
8187
// registered first: `google-service-account` labels Google Sheets "Gmail".
8288
providerId: match.providerId,
8389
serviceAccountProviderId: match.serviceAccountProviderId,
90+
// The vendor noun for the secret the form collects ("private app token",
91+
// "server-to-server app"), so the agent can tell the user what to prepare.
92+
connectNoun,
8493
/** Headless fallback only; interactive chat should emit the tag. */
8594
setup_url: url.toString(),
8695
},
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import {
6+
getServiceAccountConnectNoun,
7+
getServiceAccountGatingBlockType,
8+
isServiceAccountProviderId,
9+
} from '@/lib/credentials/service-account-provider-ids'
10+
11+
describe('isServiceAccountProviderId', () => {
12+
it('recognizes every family of service-account id', () => {
13+
expect(isServiceAccountProviderId('google-service-account')).toBe(true)
14+
expect(isServiceAccountProviderId('atlassian-service-account')).toBe(true)
15+
expect(isServiceAccountProviderId('slack-custom-bot')).toBe(true)
16+
expect(isServiceAccountProviderId('notion-service-account')).toBe(true)
17+
expect(isServiceAccountProviderId('salesforce-service-account')).toBe(true)
18+
})
19+
20+
it('is case- and whitespace-insensitive', () => {
21+
expect(isServiceAccountProviderId(' SLACK-CUSTOM-BOT ')).toBe(true)
22+
})
23+
24+
it('rejects OAuth provider values and unknowns', () => {
25+
// The distinction the oauth_get_auth_link guard depends on: `slack` is an
26+
// OAuth provider value, not a service-account id, even though Slack offers a
27+
// custom bot.
28+
expect(isServiceAccountProviderId('slack')).toBe(false)
29+
expect(isServiceAccountProviderId('google-email')).toBe(false)
30+
expect(isServiceAccountProviderId('github')).toBe(false)
31+
expect(isServiceAccountProviderId('')).toBe(false)
32+
})
33+
})
34+
35+
describe('getServiceAccountGatingBlockType', () => {
36+
it('maps the custom Slack bot to slack_v2 and leaves everything else ungated', () => {
37+
expect(getServiceAccountGatingBlockType('slack-custom-bot')).toBe('slack_v2')
38+
expect(getServiceAccountGatingBlockType('notion-service-account')).toBeNull()
39+
expect(getServiceAccountGatingBlockType('google-service-account')).toBeNull()
40+
expect(getServiceAccountGatingBlockType('salesforce-service-account')).toBeNull()
41+
})
42+
})
43+
44+
describe('getServiceAccountConnectNoun', () => {
45+
it('names the token-paste secret each provider actually collects', () => {
46+
expect(getServiceAccountConnectNoun('notion-service-account')).toBe('integration secret')
47+
expect(getServiceAccountConnectNoun('hubspot-service-account')).toBe('private app token')
48+
expect(getServiceAccountConnectNoun('linear-service-account')).toBe('API key')
49+
})
50+
51+
it('names the client-credential secret', () => {
52+
expect(getServiceAccountConnectNoun('zoom-service-account')).toBe('server-to-server app')
53+
})
54+
55+
it('calls a custom Slack bot a custom bot', () => {
56+
expect(getServiceAccountConnectNoun('slack-custom-bot')).toBe('custom bot')
57+
})
58+
59+
it('falls back to the generic noun for bespoke providers with no descriptor', () => {
60+
// Google (paste a JSON key) and Atlassian (token + domain) have no
61+
// token/client descriptor, so they read as a plain "service account".
62+
expect(getServiceAccountConnectNoun('google-service-account')).toBe('service account')
63+
expect(getServiceAccountConnectNoun('atlassian-service-account')).toBe('service account')
64+
})
65+
})

apps/sim/lib/credentials/service-account-provider-ids.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1-
import { isClientCredentialAccountProviderId } from '@/lib/credentials/client-credential-accounts/descriptors'
2-
import { isTokenServiceAccountProviderId } from '@/lib/credentials/token-service-accounts/descriptors'
1+
import {
2+
getClientCredentialAccountDescriptor,
3+
isClientCredentialAccountProviderId,
4+
} from '@/lib/credentials/client-credential-accounts/descriptors'
5+
import {
6+
getTokenServiceAccountDescriptor,
7+
isTokenServiceAccountProviderId,
8+
} from '@/lib/credentials/token-service-accounts/descriptors'
39
import {
410
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
511
GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID,
@@ -54,3 +60,18 @@ export function isServiceAccountProviderId(value: string): boolean {
5460
export function getServiceAccountGatingBlockType(providerId: string): string | null {
5561
return providerId === SLACK_CUSTOM_BOT_PROVIDER_ID ? 'slack_v2' : null
5662
}
63+
64+
/**
65+
* Vendor-accurate noun for the credential a service-account provider collects
66+
* ("private app token", "server-to-server app", …), for connect-control labels
67+
* and agent-facing discovery. Token-paste and client-credential providers name
68+
* their own; bespoke providers (Google JSON key, Atlassian token) fall back to
69+
* the generic "service account". Single source shared by the connect hook and
70+
* the VFS catalog so the wording can't drift.
71+
*/
72+
export function getServiceAccountConnectNoun(providerId: string): string {
73+
if (providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) return 'custom bot'
74+
const descriptor =
75+
getTokenServiceAccountDescriptor(providerId) ?? getClientCredentialAccountDescriptor(providerId)
76+
return descriptor?.connectNoun ?? 'service account'
77+
}

0 commit comments

Comments
 (0)