Skip to content

Commit 10fdfd4

Browse files
fix(copilot): gate service account tool on the same preview flag as the UI
The in-chat connect button hides itself when the provider's gating block is preview-hidden (a custom Slack bot needs slack_v2). The tool didn't check this, so it returned success for slack-custom-bot even when slack_v2 was preview-gated off — the agent said "here's the setup form" and the button silently rendered nothing, leaving the user with no form at all. Adds getServiceAccountGatingBlockType as the single source for the provider→gating-block mapping, consumed by both the tool (server-side, via getBlockVisibilityForCopilot) and the connect hook (client overlay). When the gating block is hidden the tool now fails with a fall-back-to-OAuth message instead of promising an invisible form. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Phx1MLjf8Ui3M3VpwisZds
1 parent 53b7e0f commit 10fdfd4

4 files changed

Lines changed: 153 additions & 4 deletions

File tree

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

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { type ComponentType, useMemo } from 'react'
44
import { getClientCredentialAccountDescriptor } from '@/lib/credentials/client-credential-accounts/descriptors'
5+
import { getServiceAccountGatingBlockType } from '@/lib/credentials/service-account-provider-ids'
56
import { getTokenServiceAccountDescriptor } from '@/lib/credentials/token-service-accounts/descriptors'
67
import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
78
import type { ServiceAccountProviderId } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal'
@@ -53,10 +54,14 @@ export function useServiceAccountConnectTarget({
5354
const isSlackBot = serviceAccountProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID
5455

5556
const hidden = useMemo(() => {
56-
if (!isSlackBot) return false
57-
const v2 = getBlock('slack_v2')
58-
return !v2 || isHiddenUnder(overlayVisibility(), v2)
59-
}, [isSlackBot, blockOverlayVersion])
57+
const gatingBlockType = serviceAccountProviderId
58+
? getServiceAccountGatingBlockType(serviceAccountProviderId)
59+
: null
60+
if (!gatingBlockType) return false
61+
const gatingBlock = getBlock(gatingBlockType)
62+
return !gatingBlock || isHiddenUnder(overlayVisibility(), gatingBlock)
63+
// blockOverlayVersion is read to re-evaluate when the overlay changes.
64+
}, [serviceAccountProviderId, blockOverlayVersion])
6065

6166
return useMemo(() => {
6267
if (!serviceAccountProviderId || !serviceName || !serviceIcon) return null
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const { mockEnsureWorkspaceAccess, mockGetBlockVisibility, mockGetBlock } = vi.hoisted(() => ({
7+
mockEnsureWorkspaceAccess: vi.fn(),
8+
mockGetBlockVisibility: vi.fn(),
9+
mockGetBlock: vi.fn(),
10+
}))
11+
12+
vi.mock('@/lib/copilot/tools/handlers/access', () => ({
13+
ensureWorkspaceAccess: mockEnsureWorkspaceAccess,
14+
}))
15+
16+
vi.mock('@/lib/copilot/block-visibility', () => ({
17+
getBlockVisibilityForCopilot: mockGetBlockVisibility,
18+
}))
19+
20+
vi.mock('@/blocks', () => ({
21+
getBlock: mockGetBlock,
22+
}))
23+
24+
import type { ExecutionContext } from '@/lib/copilot/request/types'
25+
import { executeServiceAccountGetSetupLink } from '@/lib/copilot/tools/handlers/service-account'
26+
27+
const BASE_URL = 'https://sim.test'
28+
const context = {
29+
workspaceId: 'ws-1',
30+
userId: 'user-1',
31+
chatId: 'chat-1',
32+
} as unknown as ExecutionContext
33+
34+
/** slack_v2 revealed → visible. Empty → preview-hidden (fail-closed). */
35+
function visibility(revealed: string[] = []) {
36+
return {
37+
revealed: new Set(revealed),
38+
disabled: new Set<string>(),
39+
previewTagged: new Set<string>(),
40+
}
41+
}
42+
43+
interface Output {
44+
setup_url?: string
45+
provider?: string
46+
providerId?: string
47+
serviceAccountProviderId?: string
48+
instructions?: string
49+
}
50+
51+
describe('executeServiceAccountGetSetupLink', () => {
52+
beforeEach(() => {
53+
vi.clearAllMocks()
54+
process.env.NEXT_PUBLIC_APP_URL = BASE_URL
55+
mockEnsureWorkspaceAccess.mockResolvedValue({ canWrite: true })
56+
mockGetBlockVisibility.mockResolvedValue(visibility())
57+
mockGetBlock.mockReturnValue({ type: 'slack_v2', preview: true })
58+
})
59+
60+
it('resolves an ungated provider to an in-chat setup tag, not a bare link', async () => {
61+
const result = await executeServiceAccountGetSetupLink({ providerName: 'notion' }, context)
62+
63+
expect(result.success).toBe(true)
64+
const output = result.output as Output
65+
// The provider on the tag is the OAuth provider value so the button label
66+
// resolves; the service-account id rides alongside.
67+
expect(output.providerId).toBe('notion')
68+
expect(output.serviceAccountProviderId).toBe('notion-service-account')
69+
expect(output.setup_url).toContain('/integrations/notion?connect=service-account')
70+
// The agent is steered to emit the tag, not surface the URL as a link.
71+
expect(output.instructions).toContain('service_account')
72+
// An ungated provider never consults block visibility.
73+
expect(mockGetBlockVisibility).not.toHaveBeenCalled()
74+
})
75+
76+
it('rejects an unsupported provider', async () => {
77+
const result = await executeServiceAccountGetSetupLink({ providerName: 'github' }, context)
78+
expect(result.success).toBe(false)
79+
expect(result.error).toContain('no service account flow')
80+
})
81+
82+
describe('preview gating (slack custom bot ↔ slack_v2)', () => {
83+
it('rejects slack-custom-bot when slack_v2 is preview-hidden, so the agent falls back to OAuth', async () => {
84+
// This is the exact production symptom: the tool used to return success,
85+
// the agent said "here's the setup form", and the in-chat button hid
86+
// itself because slack_v2 is preview-gated — leaving no form at all.
87+
mockGetBlockVisibility.mockResolvedValue(visibility([]))
88+
89+
const result = await executeServiceAccountGetSetupLink({ providerName: 'slack' }, context)
90+
91+
expect(result.success).toBe(false)
92+
expect(result.error).toContain('OAuth')
93+
expect((result.output as Output).setup_url).toContain('/integrations')
94+
// Crucially no service-account setup surface is offered.
95+
expect((result.output as Output).instructions).toBeUndefined()
96+
})
97+
98+
it('offers slack-custom-bot once slack_v2 is revealed for the viewer', async () => {
99+
mockGetBlockVisibility.mockResolvedValue(visibility(['slack_v2']))
100+
101+
const result = await executeServiceAccountGetSetupLink({ providerName: 'slack' }, context)
102+
103+
expect(result.success).toBe(true)
104+
expect((result.output as Output).serviceAccountProviderId).toBe('slack-custom-bot')
105+
})
106+
107+
it('does not consult visibility for a non-slack provider', async () => {
108+
await executeServiceAccountGetSetupLink({ providerName: 'google-sheets' }, context)
109+
expect(mockGetBlockVisibility).not.toHaveBeenCalled()
110+
})
111+
})
112+
})

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { toError } from '@sim/utils/errors'
2+
import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility'
23
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
34
import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access'
45
import { getBaseUrl } from '@/lib/core/utils/urls'
6+
import { getServiceAccountGatingBlockType } from '@/lib/credentials/service-account-provider-ids'
57
import {
68
listServiceAccountIntegrationNames,
79
resolveServiceAccountIntegration,
@@ -10,6 +12,8 @@ import {
1012
CONNECT_MODE,
1113
CONNECT_QUERY_PARAM,
1214
} from '@/app/workspace/[workspaceId]/integrations/connect-route'
15+
import { getBlock } from '@/blocks'
16+
import { isHiddenUnder } from '@/blocks/visibility/context'
1317

1418
/**
1519
* Returns a link that opens the integration detail page with the
@@ -41,6 +45,23 @@ export async function executeServiceAccountGetSetupLink(
4145
)
4246
}
4347

48+
// The in-chat button hides itself when the provider's gating block is
49+
// preview-hidden for the viewer (a custom Slack bot needs slack_v2). Reject
50+
// here on the same predicate so the tool never reports success for a form
51+
// the UI will silently drop — the agent would promise a form that never
52+
// appears. Fall the agent back to OAuth instead.
53+
const gatingBlockType = getServiceAccountGatingBlockType(match.serviceAccountProviderId)
54+
if (gatingBlockType) {
55+
const visibility = await getBlockVisibilityForCopilot(context.userId, context.workspaceId)
56+
const gatingBlock = getBlock(gatingBlockType)
57+
if (!gatingBlock || isHiddenUnder(visibility, gatingBlock)) {
58+
throw new Error(
59+
`${match.serviceName} service account setup isn't available in this workspace yet. ` +
60+
`Use oauth_get_auth_link to connect ${match.serviceName} via OAuth instead.`
61+
)
62+
}
63+
}
64+
4465
const url = new URL(`${baseUrl}/workspace/${context.workspaceId}/integrations/${match.slug}`)
4566
url.searchParams.set(CONNECT_QUERY_PARAM, CONNECT_MODE.serviceAccount)
4667

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,14 @@ export function asServiceAccountProviderId(
4343
export function isServiceAccountProviderId(value: string): boolean {
4444
return asServiceAccountProviderId(value.toLowerCase().trim()) !== undefined
4545
}
46+
47+
/**
48+
* The block type whose preview gate governs a service-account provider's setup
49+
* surface, or `null` when the provider is ungated. A custom Slack bot is only
50+
* usable through `slack_v2`, so its setup form must stay hidden wherever that
51+
* block is preview-hidden — both the in-chat connect button and the tool that
52+
* offers it read this so they can't disagree on availability.
53+
*/
54+
export function getServiceAccountGatingBlockType(providerId: string): string | null {
55+
return providerId === SLACK_CUSTOM_BOT_PROVIDER_ID ? 'slack_v2' : null
56+
}

0 commit comments

Comments
 (0)