feat(copilot): service account setup & reconnect in chat (#5786)

* feat(copilot): add service_account_get_setup_link handler

Resolves a loosely-specified integration name to the catalog slug whose
detail page mounts ConnectServiceAccountModal, and returns
`/integrations/{slug}?connect=service-account`. The agent surfaces it via
the existing <credential type="link"> tag, so the user gets a Connect
button and supplies the key material in Sim's own form — the agent never
handles the secret.

Exact matches beat fuzzy ones so a caller naming a specific service lands
on it (gmail stays Gmail rather than collapsing to Drive), and family
names resolve through an explicit canonical map rather than to whichever
member sorts first.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Phx1MLjf8Ui3M3VpwisZds

* fix(copilot): reject service account ids in oauth_get_auth_link

The fuzzy provider match falls back to substring containment, so
`slack-custom-bot` contains `slack` and resolved to the Slack OAuth
service. The tool then returned a personal-OAuth authorize URL and
reported success — a user who asked for a shared custom bot got a
Connect button that linked their own account instead. Every service
account id degraded this way (notion-, salesforce-, zoom-, linear-),
always silently.

Guard runs before the fuzzy pass and points at
service_account_get_setup_link. Keys off the id being a service-account
id, not off the integration offering one, so `slack` and `notion` still
resolve for OAuth.

Moves the narrowing predicate out of the integration catalog module so
callers that need only the predicate skip the integrations.json load and
the OAUTH_PROVIDERS walk.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Phx1MLjf8Ui3M3VpwisZds

* feat(copilot): open the service account form in-chat instead of linking out

The tool handed back a /integrations/{slug}?connect=service-account URL,
so accepting the agent's offer navigated away from the conversation that
asked for the credential. Adds a `service_account` credential tag that
mounts ConnectServiceAccountModal over the chat; setup_url stays as the
headless/MCP fallback.

The tag carries a provider and no value — the secret is typed into Sim's
own form and never enters the transcript — so the validator gets a branch
alongside secret_input/sim_key rather than falling through to the
value-required check.

Extracts useServiceAccountConnectTarget so the chat and the integrations
page share one source of truth for the connect label and the preview
gate. Custom Slack bots ride the slack_v2 flag; without the shared gate
the chat would have surfaced a setup form the integrations page hides.

Modal is lazy-loaded off the deep path (not the barrel) to keep three
provider-specific setup forms out of the chat's initial chunk.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Phx1MLjf8Ui3M3VpwisZds

* 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

* 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.

* feat(copilot): make service-account setup a direct tag, no tool

The agent now emits the service_account credential tag directly from
intent — like secret_input — instead of round-tripping through a tool.
Removes service_account_get_setup_link (handler, registration, display
title, Go tool def) and restores auth.serviceAccount as the VFS discovery
field so the agent knows which providers support a service account.

The link-vs-tag distinction was the wrong axis: only oauth needs a tool,
because its button carries a minted URL that can't be reconstructed. The
service_account tag carries just a provider name the agent already knows,
so it needs no tool — discovery lives in the VFS (auth.serviceAccount,
GA-only, so slack's preview-gated custom bot is never proactively
offered), and the per-viewer gate lives in the renderer, which renders
nothing when a provider isn't available for the viewer (no OAuth
fallback — a shared credential and a personal one are different intents).

oauth_get_auth_link's service-account-id guard now points at the tag.

* feat(copilot): support service-account reconnect from chat

Reconnect had no service-account path — it required oauth_get_auth_link
and a link tag for every repair, so rotating a workspace service account
either errored or pushed the user through OAuth.

The service_account tag now takes an optional credentialId; when present
the renderer opens the modal in reconnect mode (rotates the secret on that
credential in place, id preserved) and labels the button "Reconnect X".
credentials.json now carries each credential's type (oauth vs
service_account) so the agent can branch: service accounts reconnect via
the tag + credentialId, oauth via oauth_get_auth_link as before.

* fix(copilot): coherent service-account rejection in oauth_get_auth_link

Review round on #5786:
- The service-account-id guard threw into the generic catch, which
  overwrote its recovery hint with a "connect manually" message and a
  workspace oauth_url — contradictory signals. It now returns a coherent
  failure directly, before the try, with no oauth_url.
- Normalize spaces/underscores before the check so a readable form
  ("slack custom bot", "google service account") is caught too, not
  passed to the fuzzy OAuth resolver.
- Remove listServiceAccountIntegrationNames — dead after the tool was
  removed (its only caller was the deleted handler's error copy).

* fix(copilot): service-account discovery must un-gate after the block GAs

Review round on #5786: describeServiceAccountForOAuthProvider used
`getBlock(...)?.preview ?? true`, which treats a GA'd gating block — one
that dropped its `preview` flag, exactly slack_v2's documented migration —
as still gated, so the custom bot would stay omitted from VFS discovery
forever after GA even though the UI shows it. Reuse the canonical
isHiddenUnder(null, block) predicate instead, so a non-preview block is
visible. Adds service-account-gate.test.ts covering preview → omit, GA →
include, and missing → fail-closed with a mocked getBlock (the block
registry is globally stubbed, so the real slack_v2 preview flag isn't
observable through serializeIntegrationSchema).

* fix(copilot): align SA resolver normalization and reject blank credentialId

Review round on #5786:
- resolveServiceAccountIntegration only lowercased/trimmed, but the
  oauth_get_auth_link guard normalizes spaces/underscores to hyphens
  before rejecting a service-account id and steering the agent to a
  service_account tag. The chat renderer then couldn't resolve those same
  readable forms ("slack custom bot", "notion_service_account") and
  rendered nothing. Apply the same normalization to the id lookups (raw
  query still used for display-name matches).
- service_account tag validation rejected a blank provider but allowed a
  whitespace-only credentialId, which is truthy — the renderer took the
  reconnect path and tried to rotate a non-existent credential. Reject a
  blank/whitespace credentialId.

* refactor(credentials): route the editor SA picker through the canonical connect hook

The workflow-editor credential selector (from #5800's merged picker) resolved
its service-account setup surface inline and mounted the modal with NO preview
gate — so a `credentialKind: 'service-account'` picker would offer a custom-bot
setup even when slack_v2 is preview-gated off, the leak the integrations page
and chat already guard against.

Route it through the shared useServiceAccountConnectTarget hook (the same
resolver chat and the integrations page use): suppress the setup action when
`hidden`, and use the hook's vendor-accurate label ("Add private app token",
"Set up a custom bot") as the default connect-row copy. Existing service
accounts stay selectable; the per-block `credentialLabels.serviceAccountConnect`
override still wins. One resolver now backs all three SA connect surfaces.

* docs(add-block): document credentialKind and the service-account picker

The add-block skill had no mention of credentialKind — the mechanism (#5800)
that controls whether an oauth-input offers OAuth, service-account, or a merged
picker — and its example was a plain oauth-input mislabeled "Service Account".
Documents the three credentialKind modes, that a default oauth-input already
lets users select an existing service account (they fold in), and the
credentialLabels / allowServiceAccounts companions. Regenerates the .claude and
.cursor projections.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Theodore Li
2026-07-22 14:06:29 -07:00
committed by GitHub
parent 6f33a9485b
commit ae7b5eff6c
20 changed files with 975 additions and 76 deletions
+19
View File
@@ -144,6 +144,25 @@ export const {ServiceName}Block: BlockConfig = {
**Scope descriptions:** When adding a new OAuth provider, also add human-readable descriptions for all scopes in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`.
**Service accounts (shared, app-level credentials):** A plain `oauth-input` already lets users *select* an existing service account — those credentials fold into the picker automatically (a Google service account created for any Google service appears in every Google block's picker). You only set `credentialKind` when you want to change the *connect* action:
```typescript
{
id: 'credential',
title: 'Account',
type: 'oauth-input',
serviceId: '{service}',
requiredScopes: getScopesForService('{service}'),
credentialKind: 'any', // omit | 'service-account' | 'any'
}
```
- **omit (default):** lists OAuth accounts + any existing service accounts; the only connect action is "Connect account" (OAuth). Use this for the common "let users pick a service account someone set up elsewhere, but don't offer inline setup" case — no config needed.
- **`'service-account'`:** service-account credentials *only*, plus an inline setup action that opens the provider's connect modal. Use when a block accepts *only* an app credential.
- **`'any'`:** merged picker — OAuth accounts *and* service accounts in one grouped dropdown, with a connect action for each. Use when a block supports both (e.g. Slack: a personal account *or* a custom bot).
Optional companions: `credentialLabels` (override the picker's section/connect-row copy) and `allowServiceAccounts: true` (trigger-mode only — list service accounts, which triggers otherwise exclude; set only when the trigger's polling path can resolve a service-account token). The connect modal, provider families (Google JSON key, Atlassian token, token-paste, client-credential, Slack bot), and the preview gate are all resolved from `serviceAccountProviderId` — you don't wire them per block.
### Selectors (with dynamic options)
```typescript
// Channel selector (Slack, Discord, etc.)
+19
View File
@@ -143,6 +143,25 @@ export const {ServiceName}Block: BlockConfig = {
**Scope descriptions:** When adding a new OAuth provider, also add human-readable descriptions for all scopes in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`.
**Service accounts (shared, app-level credentials):** A plain `oauth-input` already lets users *select* an existing service account — those credentials fold into the picker automatically (a Google service account created for any Google service appears in every Google block's picker). You only set `credentialKind` when you want to change the *connect* action:
```typescript
{
id: 'credential',
title: 'Account',
type: 'oauth-input',
serviceId: '{service}',
requiredScopes: getScopesForService('{service}'),
credentialKind: 'any', // omit | 'service-account' | 'any'
}
```
- **omit (default):** lists OAuth accounts + any existing service accounts; the only connect action is "Connect account" (OAuth). Use this for the common "let users pick a service account someone set up elsewhere, but don't offer inline setup" case — no config needed.
- **`'service-account'`:** service-account credentials *only*, plus an inline setup action that opens the provider's connect modal. Use when a block accepts *only* an app credential.
- **`'any'`:** merged picker — OAuth accounts *and* service accounts in one grouped dropdown, with a connect action for each. Use when a block supports both (e.g. Slack: a personal account *or* a custom bot).
Optional companions: `credentialLabels` (override the picker's section/connect-row copy) and `allowServiceAccounts: true` (trigger-mode only — list service accounts, which triggers otherwise exclude; set only when the trigger's polling path can resolve a service-account token). The connect modal, provider families (Google JSON key, Atlassian token, token-paste, client-credential, Slack bot), and the preview gate are all resolved from `serviceAccountProviderId` — you don't wire them per block.
### Selectors (with dynamic options)
```typescript
// Channel selector (Slack, Discord, etc.)
+19
View File
@@ -138,6 +138,25 @@ export const {ServiceName}Block: BlockConfig = {
**Scope descriptions:** When adding a new OAuth provider, also add human-readable descriptions for all scopes in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`.
**Service accounts (shared, app-level credentials):** A plain `oauth-input` already lets users *select* an existing service account — those credentials fold into the picker automatically (a Google service account created for any Google service appears in every Google block's picker). You only set `credentialKind` when you want to change the *connect* action:
```typescript
{
id: 'credential',
title: 'Account',
type: 'oauth-input',
serviceId: '{service}',
requiredScopes: getScopesForService('{service}'),
credentialKind: 'any', // omit | 'service-account' | 'any'
}
```
- **omit (default):** lists OAuth accounts + any existing service accounts; the only connect action is "Connect account" (OAuth). Use this for the common "let users pick a service account someone set up elsewhere, but don't offer inline setup" case — no config needed.
- **`'service-account'`:** service-account credentials *only*, plus an inline setup action that opens the provider's connect modal. Use when a block accepts *only* an app credential.
- **`'any'`:** merged picker — OAuth accounts *and* service accounts in one grouped dropdown, with a connect action for each. Use when a block supports both (e.g. Slack: a personal account *or* a custom bot).
Optional companions: `credentialLabels` (override the picker's section/connect-row copy) and `allowServiceAccounts: true` (trigger-mode only — list service accounts, which triggers otherwise exclude; set only when the trigger's polling path can resolve a service-account token). The connect modal, provider families (Google JSON key, Atlassian token, token-paste, client-credential, Slack bot), and the preview gate are all resolved from `serviceAccountProviderId` — you don't wire them per block.
### Selectors (with dynamic options)
```typescript
// Channel selector (Slack, Discord, etc.)
@@ -167,3 +167,87 @@ describe('parseSpecialTags with <question>', () => {
])
})
})
describe('service_account credential tag', () => {
it('parses a service_account tag into a credential segment', () => {
const body = JSON.stringify({ type: 'service_account', provider: 'slack' })
const { segments } = parseSpecialTags(`Set this up: <credential>${body}</credential>`, false)
const credential = segments.find((segment) => segment.type === 'credential')
expect(credential).toBeDefined()
expect(credential).toMatchObject({
type: 'credential',
data: { type: 'service_account', provider: 'slack' },
})
})
it('carries no value — the secret is typed into Sims own form, never the transcript', () => {
const body = JSON.stringify({ type: 'service_account', provider: 'google-sheets' })
const { segments } = parseSpecialTags(`<credential>${body}</credential>`, false)
const credential = segments.find((segment) => segment.type === 'credential')
expect((credential as { data: { value?: string } }).data.value).toBeUndefined()
})
it('suppresses the tag while it is still streaming', () => {
// A half-streamed tag must not flash raw JSON into the message body.
const { segments, hasPendingTag } = parseSpecialTags(
'Set this up: <credential>{"type": "service_a',
true
)
expect(hasPendingTag).toBe(true)
expect(segments.some((segment) => segment.type === 'credential')).toBe(false)
const text = segments
.filter((segment): segment is { type: 'text'; content: string } => segment.type === 'text')
.map((segment) => segment.content)
.join('')
expect(text).not.toContain('service_a')
})
})
describe('service_account tag validation', () => {
it('rejects a provider-less tag, which would render an unresolvable control', () => {
const { segments } = parseSpecialTags(
`<credential>${JSON.stringify({ type: 'service_account' })}</credential>`,
false
)
expect(segments.some((segment) => segment.type === 'credential')).toBe(false)
})
it('rejects a blank provider', () => {
const { segments } = parseSpecialTags(
`<credential>${JSON.stringify({ type: 'service_account', provider: ' ' })}</credential>`,
false
)
expect(segments.some((segment) => segment.type === 'credential')).toBe(false)
})
it('accepts an optional credentialId for reconnect and carries it through', () => {
const body = JSON.stringify({
type: 'service_account',
provider: 'notion',
credentialId: 'cred_abc123',
})
const { segments } = parseSpecialTags(`<credential>${body}</credential>`, false)
const credential = segments.find((segment) => segment.type === 'credential')
expect(credential).toMatchObject({
type: 'credential',
data: { type: 'service_account', provider: 'notion', credentialId: 'cred_abc123' },
})
})
it('rejects a non-string credentialId', () => {
const body = JSON.stringify({ type: 'service_account', provider: 'notion', credentialId: 42 })
const { segments } = parseSpecialTags(`<credential>${body}</credential>`, false)
expect(segments.some((segment) => segment.type === 'credential')).toBe(false)
})
it.each(['', ' '])(
'rejects a blank credentialId (%j) so reconnect cannot target a missing credential',
(credentialId) => {
const body = JSON.stringify({ type: 'service_account', provider: 'notion', credentialId })
const { segments } = parseSpecialTags(`<credential>${body}</credential>`, false)
expect(segments.some((segment) => segment.type === 'credential')).toBe(false)
}
)
})
@@ -1,6 +1,6 @@
'use client'
import { createElement, useMemo, useState } from 'react'
import { createElement, lazy, Suspense, useMemo, useState } from 'react'
import {
ArrowRight,
Button,
@@ -19,6 +19,10 @@ import { useSession } from '@/lib/auth/auth-client'
import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions'
import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
import { isSafeHttpUrl } from '@/lib/core/utils/urls'
import {
resolveOAuthServiceForSlug,
resolveServiceAccountIntegration,
} from '@/lib/integrations/oauth-service'
import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth'
import { getServiceConfigByProviderId } from '@/lib/oauth/utils'
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
@@ -27,6 +31,10 @@ import type {
ChatMessageContext,
MothershipResource,
} from '@/app/workspace/[workspaceId]/home/types'
// Deep import, not the barrel: the barrel also re-exports
// ConnectServiceAccountModal, and that edge would pull the modal into this
// chunk and defeat the lazy() split below.
import { useServiceAccountConnectTarget } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect'
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useWorkspaceCredential } from '@/hooks/queries/credentials'
@@ -62,6 +70,17 @@ export interface UsageUpgradeTagData {
message: string
}
/**
* Kept out of the chat's initial chunk — it pulls in three provider-specific
* setup forms and is only mounted once a message actually offers a service
* account.
*/
const ConnectServiceAccountModal = lazy(() =>
import(
'@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal'
).then((m) => ({ default: m.ConnectServiceAccountModal }))
)
export const CREDENTIAL_TAG_TYPES = [
'env_key',
'oauth_key',
@@ -69,6 +88,7 @@ export const CREDENTIAL_TAG_TYPES = [
'credential_id',
'link',
'secret_input',
'service_account',
] as const
export type CredentialTagType = (typeof CREDENTIAL_TAG_TYPES)[number]
@@ -88,6 +108,11 @@ export interface CredentialTagData {
name?: string
/** Where a secret_input value is persisted. Defaults to "workspace". */
scope?: SecretInputScope
/**
* Existing credential to reconnect in place (service_account only). Present =
* rotate the secret on this credential; absent = create a new one.
*/
credentialId?: string
}
export interface MothershipErrorTagData {
@@ -225,6 +250,20 @@ function isCredentialTagData(value: unknown): value is CredentialTagData {
}
return typeof value.name === 'string' && value.name.trim().length > 0
}
// A service_account tag is a control, not a value: it names the provider
// whose setup form to open, and the user types the secret into that form —
// so it never carries a `value`, but it is useless without a provider. An
// optional `credentialId` reconnects an existing service account in place;
// reject a blank one, since the renderer treats a truthy id as "reconnect"
// and would try to rotate a non-existent credential.
if (value.type === 'service_account') {
if (value.credentialId !== undefined) {
if (typeof value.credentialId !== 'string' || value.credentialId.trim().length === 0) {
return false
}
}
return typeof value.provider === 'string' && value.provider.trim().length > 0
}
// A sim_key chip is platform-filled: the model only marks where the workspace
// API key belongs (it never holds the value) and Sim injects it from the tool
// result, so the tag is valid with or without a `value`. Every other rendered
@@ -870,6 +909,74 @@ function SecretInputDisplay({ data }: { data: CredentialTagData }) {
)
}
/**
* Inline "set up a service account" control rendered for
* `<credential>{"type":"service_account","provider":"slack"}</credential>`.
*
* Opens `ConnectServiceAccountModal` over the chat rather than linking out to
* the integrations page — the user stays in the conversation that asked for
* the credential, and comes back to it with the credential in hand.
*/
function ServiceAccountConnectDisplay({ data }: { data: CredentialTagData }) {
const { workspaceId } = useParams<{ workspaceId: string }>()
const { canEdit } = useUserPermissionsContext()
const [open, setOpen] = useState(false)
const match = useMemo(
() => (data.provider ? resolveServiceAccountIntegration(data.provider) : null),
[data.provider]
)
const service = useMemo(() => (match ? resolveOAuthServiceForSlug(match.slug) : null), [match])
const target = useServiceAccountConnectTarget({
serviceAccountProviderId: match?.serviceAccountProviderId,
serviceName: match?.serviceName,
serviceIcon: service?.serviceIcon,
})
// A credentialId reconnects (rotates the secret on) that existing service
// account in place rather than creating a new one — the modal keeps its id.
const reconnectCredentialId = data.credentialId
const { data: reconnectCredential } = useWorkspaceCredential(reconnectCredentialId)
// Creating a credential mutates the workspace — hide it from read-only
// members, and honour the provider's own preview gate (custom Slack bots
// ride the slack_v2 flag) so chat can't surface what the integrations page
// deliberately hides.
if (!target || target.hidden || !canEdit || !workspaceId) return null
const label = reconnectCredentialId
? `Reconnect ${reconnectCredential?.displayName ?? target.serviceName}`
: `${target.label} for ${target.serviceName}`
return (
<>
<button
type='button'
onClick={() => setOpen(true)}
className='flex w-full items-center gap-2 rounded-2xl border border-[var(--border-1)] px-3 py-2.5 text-left transition-colors hover-hover:bg-[var(--surface-5)]'
>
{createElement(target.serviceIcon, { className: 'size-[16px] shrink-0' })}
<span className='flex-1 text-[var(--text-body)] text-sm'>{label}</span>
<ArrowRight className='size-[16px] shrink-0 text-[var(--text-icon)]' />
</button>
{open && (
<Suspense fallback={null}>
<ConnectServiceAccountModal
open={open}
onOpenChange={setOpen}
workspaceId={workspaceId}
serviceAccountProviderId={target.serviceAccountProviderId}
serviceName={target.serviceName}
serviceIcon={target.serviceIcon}
credentialId={reconnectCredentialId}
credentialDisplayName={reconnectCredential?.displayName ?? undefined}
/>
</Suspense>
)}
</>
)
}
function CredentialLinkDisplay({ data }: { data: CredentialTagData }) {
const { canEdit } = useUserPermissionsContext()
@@ -921,6 +1028,10 @@ function CredentialDisplay({ data }: { data: CredentialTagData }) {
return <CredentialLinkDisplay data={data} />
}
if (data.type === 'service_account') {
return <ServiceAccountConnectDisplay data={data} />
}
if (data.type === 'sim_key') {
// SecretReveal masks itself when there's no value, so a value-less tag (the
// model's placeholder / persisted form) renders masked and a Sim-filled tag
@@ -6,25 +6,23 @@ import { ArrowLeft, ArrowRight, Plus } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useQueryState } from 'nuqs'
import { getClientCredentialAccountDescriptor } from '@/lib/credentials/client-credential-accounts/descriptors'
import { getTokenServiceAccountDescriptor } from '@/lib/credentials/token-service-accounts/descriptors'
import {
blockTypeToIconMap,
type Integration,
resolveOAuthServiceForIntegration,
} from '@/lib/integrations'
import { getServiceConfigByProviderId } from '@/lib/oauth'
import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
import { IntegrationSkillsSection } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section'
import { connectParam } from '@/app/workspace/[workspaceId]/integrations/[block]/search-params'
import { ConnectServiceAccountModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal'
import {
ConnectServiceAccountModal,
useServiceAccountConnectTarget,
} from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal'
import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/components/integration-section'
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route'
import { useScrollRestoration } from '@/app/workspace/[workspaceId]/integrations/hooks/use-scroll-restoration'
import { getBlock } from '@/blocks'
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
import { getTileIconColorClass } from '@/blocks/icon-color'
import { storeCuratedPrompt } from '@/blocks/integration-matcher'
import {
@@ -32,7 +30,6 @@ import {
getTemplatesForBlock,
type ScopedBlockTemplate,
} from '@/blocks/registry'
import { isHiddenUnder, overlayVisibility } from '@/blocks/visibility/context'
import { useWorkspaceCredentials } from '@/hooks/queries/credentials'
import { useOAuthReturnRouter } from '@/hooks/use-oauth-return'
@@ -78,29 +75,13 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
)
}, [credentials, oauthService])
const [serviceAccountOpen, setServiceAccountOpen] = useState(false)
const isSlackBot = oauthService?.serviceAccountProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID
const blockOverlayVersion = useCustomBlockOverlayVersion()
// Custom Slack bots ride the slack_v2 preview flag: the setup surface stays
// hidden until that block is revealed for this viewer.
const slackBotPreviewHidden = useMemo(() => {
if (!isSlackBot) return false
const v2 = getBlock('slack_v2')
return !v2 || isHiddenUnder(overlayVisibility(), v2)
}, [isSlackBot, blockOverlayVersion])
const hasServiceAccount =
Boolean(oauthService?.serviceAccountProviderId) && !slackBotPreviewHidden
// Vendor-accurate connect label: token-paste and client-credential
// providers use their own noun ("Add API key", "Add server-to-server app");
// only true service-account providers (Google, Atlassian) say
// "Add service account".
const nounDescriptor =
getTokenServiceAccountDescriptor(oauthService?.serviceAccountProviderId) ??
getClientCredentialAccountDescriptor(oauthService?.serviceAccountProviderId)
const serviceAccountConnectLabel = isSlackBot
? 'Set up a custom bot'
: nounDescriptor
? `Add ${nounDescriptor.connectNoun}`
: 'Add service account'
const serviceAccountTarget = useServiceAccountConnectTarget({
serviceAccountProviderId: oauthService?.serviceAccountProviderId,
serviceName: oauthService?.serviceName,
serviceIcon: oauthService?.serviceIcon,
})
const hasServiceAccount = Boolean(serviceAccountTarget) && !serviceAccountTarget?.hidden
const serviceAccountConnectLabel = serviceAccountTarget?.label ?? 'Add service account'
const hasHandledConnectQueryRef = useRef(false)
useEffect(() => {
@@ -2,3 +2,7 @@ export {
ConnectServiceAccountModal,
type ServiceAccountProviderId,
} from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal'
export {
type ServiceAccountConnectTarget,
useServiceAccountConnectTarget,
} from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect'
@@ -0,0 +1,76 @@
'use client'
import { type ComponentType, useMemo } from 'react'
import {
getServiceAccountConnectNoun,
getServiceAccountGatingBlockType,
} from '@/lib/credentials/service-account-provider-ids'
import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
import type { ServiceAccountProviderId } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal'
import { getBlock } from '@/blocks'
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
import { isHiddenUnder, overlayVisibility } from '@/blocks/visibility/context'
/**
* Everything a caller needs to render a service-account connect control:
* whether to show it at all, what to call it, and the props the modal takes.
*/
export interface ServiceAccountConnectTarget {
serviceAccountProviderId: ServiceAccountProviderId
serviceName: string
serviceIcon: ComponentType<{ className?: string }>
/**
* Vendor-accurate control label — token-paste and client-credential
* providers use their own noun ("Add API key", "Add server-to-server app");
* only true service-account providers say "Add service account".
*/
label: string
/**
* True when the provider's setup surface must stay hidden for this viewer.
* Custom Slack bots ride the `slack_v2` preview flag, so any surface that
* offers one — the integrations page or the chat — has to honour it or the
* flag is trivially bypassed.
*/
hidden: boolean
}
interface UseServiceAccountConnectTargetArgs {
serviceAccountProviderId: ServiceAccountProviderId | undefined
serviceName: string | undefined
serviceIcon: ComponentType<{ className?: string }> | undefined
}
/**
* Derives the connect-control label and preview gating for a service-account
* provider. Shared by the integrations detail page and the chat's inline
* connect button so the two can't drift on either the wording or the gate.
*/
export function useServiceAccountConnectTarget({
serviceAccountProviderId,
serviceName,
serviceIcon,
}: UseServiceAccountConnectTargetArgs): ServiceAccountConnectTarget | null {
const blockOverlayVersion = useCustomBlockOverlayVersion()
const isSlackBot = serviceAccountProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID
const hidden = useMemo(() => {
const gatingBlockType = serviceAccountProviderId
? getServiceAccountGatingBlockType(serviceAccountProviderId)
: null
if (!gatingBlockType) return false
const gatingBlock = getBlock(gatingBlockType)
return !gatingBlock || isHiddenUnder(overlayVisibility(), gatingBlock)
// blockOverlayVersion is read to re-evaluate when the overlay changes.
}, [serviceAccountProviderId, blockOverlayVersion])
return useMemo(() => {
if (!serviceAccountProviderId || !serviceName || !serviceIcon) return null
const label = isSlackBot
? 'Set up a custom bot'
: `Add ${getServiceAccountConnectNoun(serviceAccountProviderId)}`
return { serviceAccountProviderId, serviceName, serviceIcon, label, hidden }
}, [serviceAccountProviderId, serviceName, serviceIcon, isSlackBot, hidden])
}
@@ -17,6 +17,7 @@ import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/conn
import {
ConnectServiceAccountModal,
type ServiceAccountProviderId,
useServiceAccountConnectTarget,
} from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight'
@@ -130,6 +131,20 @@ export function CredentialSelector({
[credentialKind, isMergedKinds, serviceId]
)
// Canonical resolver for the service-account connect control: the vendor-
// accurate label and — critically — the per-viewer preview gate (a custom
// Slack bot rides `slack_v2`). Shared with the integrations page and chat so
// the gate can't be bypassed here. When `hidden`, the setup action is
// suppressed; existing service-account credentials stay selectable.
const serviceAccountTarget = useServiceAccountConnectTarget({
serviceAccountProviderId: serviceAccountService?.serviceAccountProviderId as
| ServiceAccountProviderId
| undefined,
serviceName: serviceAccountService?.name,
serviceIcon: serviceAccountService?.icon,
})
const serviceAccountConnectHidden = Boolean(serviceAccountTarget?.hidden)
const selectedCredential = useMemo(
() => credentials.find((cred) => cred.id === selectedId),
[credentials, selectedId]
@@ -249,19 +264,23 @@ export function CredentialSelector({
iconElement: getProviderIcon((cred.provider ?? provider) as OAuthProvider),
}))
options.push({
label:
credentialKind === 'service-account'
? (subBlock.credentialLabels?.serviceAccountConnect ??
(credentials.length > 0
? `Add another ${getProviderName(provider)} key`
: `Add ${getProviderName(provider)} key`))
: credentials.length > 0
? `Connect another ${getProviderName(provider)} account`
: `Connect ${getProviderName(provider)} account`,
value: '__connect_account__',
iconElement: <ExternalLink className='size-3' />,
})
// Suppress the setup action when the service-account flow is preview-gated
// for this viewer (a custom Slack bot needs slack_v2) — existing accounts
// above stay selectable.
if (credentialKind !== 'service-account' || !serviceAccountConnectHidden) {
options.push({
label:
credentialKind === 'service-account'
? (subBlock.credentialLabels?.serviceAccountConnect ??
serviceAccountTarget?.label ??
`Add ${getProviderName(provider)} key`)
: credentials.length > 0
? `Connect another ${getProviderName(provider)} account`
: `Connect ${getProviderName(provider)} account`,
value: '__connect_account__',
iconElement: <ExternalLink className='size-3' />,
})
}
return options
}, [
@@ -271,6 +290,8 @@ export function CredentialSelector({
credentials,
credentialKind,
subBlock.credentialLabels,
serviceAccountConnectHidden,
serviceAccountTarget,
provider,
getProviderIcon,
getProviderName,
@@ -302,11 +323,19 @@ export function CredentialSelector({
section: labels?.serviceAccountGroup ?? 'Service accounts',
items: [
...credentials.filter((c) => c.type === 'service_account').map(toOption),
{
label: labels?.serviceAccountConnect ?? `Add ${getProviderName(provider)} key`,
value: '__connect_service_account__',
iconElement: <ExternalLink className='size-3' />,
},
// Drop the setup action when the flow is preview-gated for this viewer.
...(serviceAccountConnectHidden
? []
: [
{
label:
labels?.serviceAccountConnect ??
serviceAccountTarget?.label ??
`Add ${getProviderName(provider)} key`,
value: '__connect_service_account__',
iconElement: <ExternalLink className='size-3' />,
},
]),
],
},
]
@@ -314,6 +343,8 @@ export function CredentialSelector({
isMergedKinds,
subBlock.credentialLabels,
credentials,
serviceAccountConnectHidden,
serviceAccountTarget,
provider,
getProviderIcon,
getProviderName,
@@ -207,3 +207,65 @@ describe('executeOAuthGetAuthLink', () => {
})
})
})
describe('executeOAuthGetAuthLink service account rejection', () => {
beforeEach(() => {
vi.clearAllMocks()
process.env.NEXT_PUBLIC_APP_URL = BASE_URL
mockEnsureWorkspaceAccess.mockResolvedValue(WORKSPACE_ACCESS)
})
/**
* Regression: a user asked for a "new custom bot", the agent correctly
* resolved that to `slack-custom-bot` and passed it here, and the fuzzy
* substring pass matched it to the Slack OAuth service — `slack-custom-bot`
* contains `slack`. The tool returned a personal-OAuth authorize URL and
* reported success, so the user connected their own account instead of a
* shared bot. Failing loudly is the point: a wrong link that looks right is
* worse than an error the agent can recover from.
*/
it('rejects a service account id with a coherent recovery message, not a workspace link', async () => {
const result = await executeOAuthGetAuthLink({ providerName: 'slack-custom-bot' }, context)
expect(result.success).toBe(false)
expect(result.error).toContain('service account')
expect(result.error).toContain('service_account credential tag')
const output = result.output as { setup_url?: string; oauth_url?: string; message: string }
// The rejection must not fall into the generic catch, which would attach a
// contradicting workspace oauth_url and a "connect manually" message — the
// agent would then surface a workspace link instead of the tag.
expect(output.setup_url).toBeUndefined()
expect(output.oauth_url).toBeUndefined()
expect(output.message).toContain('service_account credential tag')
expect(output.message).not.toContain('Connect manually')
})
it.each([
'notion-service-account',
'salesforce-service-account',
'google-service-account',
'atlassian-service-account',
'SLACK-CUSTOM-BOT',
// Readable forms must be normalized (spaces/underscores → hyphens) so they
// are caught too, not passed to the fuzzy OAuth resolver.
'slack custom bot',
'google service account',
'notion_service_account',
])('rejects %s', async (providerName) => {
const result = await executeOAuthGetAuthLink({ providerName }, context)
expect(result.success).toBe(false)
expect(result.error).toContain('service_account credential tag')
})
it('still resolves ordinary OAuth providers for integrations that also offer a service account', async () => {
// `slack` and `notion` must keep working — the guard keys off the id being
// a service-account id, not off the integration having a service-account flow.
for (const providerName of ['slack', 'google-email']) {
const result = await executeOAuthGetAuthLink({ providerName }, context)
expect(result.success).toBe(true)
expect((result.output as { oauth_url: string }).oauth_url).toContain(
'/api/auth/oauth2/authorize'
)
}
})
})
@@ -3,6 +3,7 @@ import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/typ
import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getCredentialActorContext } from '@/lib/credentials/access'
import { isServiceAccountProviderId } from '@/lib/credentials/service-account-provider-ids'
import { getAllOAuthServices } from '@/lib/oauth/utils'
import type { WorkspaceAccess } from '@/lib/workspaces/permissions/utils'
@@ -14,6 +15,25 @@ export async function executeOAuthGetAuthLink(
const rawCredentialId = rawParams.credentialId || rawParams.credential_id
const credentialId = rawCredentialId ? String(rawCredentialId) : undefined
const baseUrl = getBaseUrl()
// A service account is not an OAuth provider. Catch it here — before the fuzzy
// resolver's substring match can swallow e.g. `slack-custom-bot` into the
// Slack OAuth service — and return a coherent failure directly rather than
// throwing into the generic catch below, which would attach a contradicting
// workspace `oauth_url` and "connect manually" message. Normalize spaces and
// underscores so a readable form ("slack custom bot") is caught too.
const serviceAccountId = providerName
.toLowerCase()
.trim()
.replace(/[\s_]+/g, '-')
if (isServiceAccountProviderId(serviceAccountId)) {
const message =
`"${providerName}" is a service account, not an OAuth provider. ` +
`Emit a service_account credential tag with the service's OAuth provider ` +
`value instead (e.g. "slack") — it opens the service account setup form in chat.`
return { success: false, error: message, output: { message } }
}
try {
if (!context.workspaceId || !context.userId) {
throw new Error('workspaceId and userId are required to generate an OAuth link')
@@ -8,6 +8,7 @@ import type { ToolConfig } from '@/tools/types'
import {
serializeApiKeyIntegrations,
serializeBlockSchema,
serializeCredentials,
serializeFileMeta,
serializeIntegrationSchema,
serializeKBMeta,
@@ -284,3 +285,77 @@ describe('serializeKBMeta', () => {
expect(missing).not.toHaveProperty('tagDefinitions')
})
})
function oauthTool(id: string, provider: string): ToolConfig {
return {
id,
name: id,
description: `Run ${id}`,
version: '1.0.0',
params: {},
request: { url: 'https://example.com', method: 'POST', headers: () => ({}) },
oauth: { required: true, provider },
}
}
describe('serializeIntegrationSchema — service-account auth', () => {
it('marks an OAuth service that also offers a service account, with its secret noun', () => {
// Notion connects via OAuth or via an internal integration token; the agent
// must be able to discover the second option from the same auth field.
const schema = JSON.parse(serializeIntegrationSchema(oauthTool('notion_read', 'notion')))
expect(schema.auth).toMatchObject({
type: 'oauth',
provider: 'notion',
serviceAccount: { connectNoun: 'integration secret' },
})
})
it('omits serviceAccount for an OAuth service that has no service-account flow', () => {
const schema = JSON.parse(serializeIntegrationSchema(oauthTool('gh_read', 'github')))
expect(schema.auth.type).toBe('oauth')
expect(schema.auth.serviceAccount).toBeUndefined()
})
// The preview-gate behavior (slack custom bot ↔ slack_v2) is covered in
// service-account-gate.test.ts, which mocks getBlock — the block registry is
// globally stubbed here, so slack_v2's real `preview: true` isn't observable
// through serializeIntegrationSchema.
})
describe('serializeCredentials — type distinguishes reconnect flow', () => {
const now = new Date('2026-07-21T00:00:00.000Z')
it('marks a service account so the agent reconnects it via the tag, not oauth', () => {
const json = JSON.parse(
serializeCredentials([
{
id: 'c1',
providerId: 'notion-service-account',
scope: null,
credentialType: 'service_account',
createdAt: now,
},
{
id: 'c2',
providerId: 'google-email',
scope: null,
credentialType: 'oauth',
createdAt: now,
},
])
)
expect(json[0]).toMatchObject({
id: 'c1',
provider: 'notion-service-account',
type: 'service_account',
})
expect(json[1]).toMatchObject({ id: 'c2', provider: 'google-email', type: 'oauth' })
})
it('leaves env-var credentials typeless', () => {
const json = JSON.parse(
serializeCredentials([{ providerId: 'OPENAI_API_KEY', scope: 'workspace', createdAt: now }])
)
expect(json[0].type).toBeUndefined()
})
})
+56
View File
@@ -1,18 +1,39 @@
import type { ShareAuthType } from '@/lib/api/contracts/public-shares'
import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions'
import { isHosted } from '@/lib/core/config/env-flags'
import {
getServiceAccountConnectNoun,
getServiceAccountGatingBlockType,
} from '@/lib/credentials/service-account-provider-ids'
import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types'
import { getServiceAccountProviderForProviderId } from '@/lib/oauth/utils'
import { isSubBlockHidden } from '@/lib/workflows/subblocks/visibility'
import { getBlock } from '@/blocks'
import { isCustomBlockType } from '@/blocks/custom/build-config'
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
import { isHiddenUnder } from '@/blocks/visibility/context'
import { DYNAMIC_MODEL_PROVIDERS, PROVIDER_DEFINITIONS } from '@/providers/models'
import type { ToolConfig, ToolHostingCondition } from '@/tools/types'
/** The service-account alternative to OAuth for a service, when it offers one. */
export interface VfsServiceAccountAuth {
/** Vendor noun for the secret it collects — "private app token", "server-to-server app", … */
connectNoun: string
}
export type VfsToolAuth =
| {
type: 'oauth'
required: boolean
provider: string
/**
* Present when this OAuth service also accepts a shared service-account
* credential (connect AS AN APPLICATION, not as the user). The agent emits
* a `service_account` credential tag with this entry's OAuth `provider` to
* open the in-chat setup form. Omitted when the service has no
* service-account flow, or its flow is gated by a preview block.
*/
serviceAccount?: VfsServiceAccountAuth
}
| {
type: 'api_key'
@@ -22,6 +43,33 @@ export type VfsToolAuth =
condition?: ToolHostingCondition
}
/**
* Whether an OAuth provider value also exposes a service-account flow, and the
* noun for the secret it collects. The single composition point behind both the
* per-tool `auth.serviceAccount` field and the `oauth-integrations.json`
* roll-up, so the two never disagree. Returns `undefined` when the service has
* no service-account flow, or its flow is gated by a preview block (a custom
* Slack bot needs slack_v2) — GA-only discovery, so the agent never proactively
* offers a preview flow, matching the per-viewer gate the renderer applies.
*/
export function describeServiceAccountForOAuthProvider(
oauthProvider: string
): VfsServiceAccountAuth | undefined {
const serviceAccountProviderId = getServiceAccountProviderForProviderId(oauthProvider)
if (!serviceAccountProviderId) return undefined
const gatingBlockType = getServiceAccountGatingBlockType(serviceAccountProviderId)
if (gatingBlockType) {
const gatingBlock = getBlock(gatingBlockType)
// Omit when the gating block is missing (fail-closed) or hidden by the
// canonical predicate. Passing `null` vis reduces `isHiddenUnder` to the
// static preview check — so once the block GAs and drops `preview`, it is
// no longer hidden and discovery includes it again, matching the renderer.
// Hand-rolling `?.preview ?? true` would keep it omitted forever after GA.
if (!gatingBlock || isHiddenUnder(null, gatingBlock)) return undefined
}
return { connectNoun: getServiceAccountConnectNoun(serviceAccountProviderId) }
}
export interface ComponentSerializationOptions {
hosted?: boolean
toolConfigs?: ReadonlyMap<string, ToolConfig>
@@ -33,10 +81,12 @@ export interface ComponentSerializationOptions {
*/
export function serializeToolAuth(tool: ToolConfig, hosted = isHosted): VfsToolAuth | undefined {
if (tool.oauth) {
const serviceAccount = describeServiceAccountForOAuthProvider(tool.oauth.provider)
return {
type: 'oauth',
required: tool.oauth.required,
provider: tool.oauth.provider,
...(serviceAccount ? { serviceAccount } : {}),
}
}
@@ -589,6 +639,8 @@ export function serializeCredentials(
displayName?: string | null
role?: string | null
scope: string | null
/** 'service_account' for a shared app credential; omitted/undefined for a personal OAuth connection. */
credentialType?: 'oauth' | 'service_account'
createdAt: Date
}>
): string {
@@ -599,6 +651,10 @@ export function serializeCredentials(
displayName: a.displayName || undefined,
role: a.role || undefined,
scope: a.scope || undefined,
// 'oauth' (personal connection) vs 'service_account' (shared app
// credential) — they reconnect differently, so the agent must branch on
// this. Env-var credentials carry no type.
type: a.credentialType,
connectedAt: a.createdAt.toISOString(),
})),
null,
@@ -0,0 +1,45 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetBlock } = vi.hoisted(() => ({ mockGetBlock: vi.fn() }))
vi.mock('@/blocks', () => ({ getBlock: mockGetBlock }))
import { describeServiceAccountForOAuthProvider } from '@/lib/copilot/vfs/serializers'
describe('describeServiceAccountForOAuthProvider — preview gate', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('omits a service account whose gating block is still a preview block', () => {
mockGetBlock.mockReturnValue({ type: 'slack_v2', preview: true })
expect(describeServiceAccountForOAuthProvider('slack')).toBeUndefined()
})
it('includes it once the gating block GAs and drops preview', () => {
// slack_v2's documented GA migration removes `preview`. Discovery must then
// surface the custom bot, matching what the UI shows. A hand-rolled
// `?.preview ?? true` would keep it omitted forever — the "sticks after GA"
// regression; reusing isHiddenUnder(null, block) fixes it.
mockGetBlock.mockReturnValue({ type: 'slack_v2' })
expect(describeServiceAccountForOAuthProvider('slack')).toEqual({ connectNoun: 'custom bot' })
})
it('fail-closes (omits) when the gating block is missing entirely', () => {
mockGetBlock.mockReturnValue(undefined)
expect(describeServiceAccountForOAuthProvider('slack')).toBeUndefined()
})
it('includes an ungated provider without consulting the block registry', () => {
expect(describeServiceAccountForOAuthProvider('notion')).toEqual({
connectNoun: 'integration secret',
})
expect(mockGetBlock).not.toHaveBeenCalled()
})
it('returns undefined for a provider with no service-account flow', () => {
expect(describeServiceAccountForOAuthProvider('github')).toBeUndefined()
})
})
+21 -3
View File
@@ -51,8 +51,13 @@ import {
canonicalWorkspaceFilePath,
encodeVfsPathSegments,
} from '@/lib/copilot/vfs/path-utils'
import type { DeploymentData, KbTagDefinitionSummary } from '@/lib/copilot/vfs/serializers'
import type {
DeploymentData,
KbTagDefinitionSummary,
VfsServiceAccountAuth,
} from '@/lib/copilot/vfs/serializers'
import {
describeServiceAccountForOAuthProvider,
serializeApiKeyIntegrations,
serializeApiKeys,
serializeBlockSchema,
@@ -246,7 +251,15 @@ function getStaticComponentFiles(): Map<string, string> {
let integrationCount = 0
const oauthServices = new Map<string, { provider: string; operations: string[] }>()
// `serviceAccount` marks services that also accept a shared service-account
// credential (connect AS AN APPLICATION, not as the user) — the same
// `auth.serviceAccount` shape the per-operation schemas carry, so the agent
// discovers all three auth modes (oauth / api_key / service account) from one
// uniform field instead of a separate file.
const oauthServices = new Map<
string,
{ provider: string; operations: string[]; serviceAccount?: VfsServiceAccountAuth }
>()
// Integration tools come from the shared exposed-tool set (latest version of
// each operation owned by a visible block), the same set used to build the
@@ -267,7 +280,11 @@ function getStaticComponentFiles(): Map<string, string> {
if (existing) {
existing.operations.push(operation)
} else {
oauthServices.set(service, { provider: tool.oauth.provider, operations: [operation] })
oauthServices.set(service, {
provider: tool.oauth.provider,
operations: [operation],
serviceAccount: describeServiceAccountForOAuthProvider(tool.oauth.provider),
})
}
}
}
@@ -2554,6 +2571,7 @@ export class WorkspaceVFS {
displayName: c.displayName,
role: c.role,
scope: null,
credentialType: c.type,
createdAt: c.updatedAt,
})),
])
+6
View File
@@ -481,6 +481,8 @@ export interface AccessibleOAuthCredential {
providerId: string
displayName: string
role: 'admin' | 'member'
/** Distinguishes a personal OAuth connection from a shared service account. */
type: 'oauth' | 'service_account'
updatedAt: Date
}
@@ -498,6 +500,7 @@ export async function getAccessibleOAuthCredentials(
id: credential.id,
providerId: credential.providerId,
displayName: credential.displayName,
type: credential.type,
updatedAt: credential.updatedAt,
})
.from(credential)
@@ -515,6 +518,7 @@ export async function getAccessibleOAuthCredentials(
providerId: row.providerId,
displayName: row.displayName,
role: 'admin' as const,
type: row.type as AccessibleOAuthCredential['type'],
updatedAt: row.updatedAt,
}))
}
@@ -525,6 +529,7 @@ export async function getAccessibleOAuthCredentials(
providerId: credential.providerId,
displayName: credential.displayName,
role: credentialMember.role,
type: credential.type,
updatedAt: credential.updatedAt,
})
.from(credential)
@@ -550,6 +555,7 @@ export async function getAccessibleOAuthCredentials(
providerId: row.providerId!,
displayName: row.displayName,
role: row.role,
type: row.type as AccessibleOAuthCredential['type'],
updatedAt: row.updatedAt,
}))
}
@@ -0,0 +1,65 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
getServiceAccountConnectNoun,
getServiceAccountGatingBlockType,
isServiceAccountProviderId,
} from '@/lib/credentials/service-account-provider-ids'
describe('isServiceAccountProviderId', () => {
it('recognizes every family of service-account id', () => {
expect(isServiceAccountProviderId('google-service-account')).toBe(true)
expect(isServiceAccountProviderId('atlassian-service-account')).toBe(true)
expect(isServiceAccountProviderId('slack-custom-bot')).toBe(true)
expect(isServiceAccountProviderId('notion-service-account')).toBe(true)
expect(isServiceAccountProviderId('salesforce-service-account')).toBe(true)
})
it('is case- and whitespace-insensitive', () => {
expect(isServiceAccountProviderId(' SLACK-CUSTOM-BOT ')).toBe(true)
})
it('rejects OAuth provider values and unknowns', () => {
// The distinction the oauth_get_auth_link guard depends on: `slack` is an
// OAuth provider value, not a service-account id, even though Slack offers a
// custom bot.
expect(isServiceAccountProviderId('slack')).toBe(false)
expect(isServiceAccountProviderId('google-email')).toBe(false)
expect(isServiceAccountProviderId('github')).toBe(false)
expect(isServiceAccountProviderId('')).toBe(false)
})
})
describe('getServiceAccountGatingBlockType', () => {
it('maps the custom Slack bot to slack_v2 and leaves everything else ungated', () => {
expect(getServiceAccountGatingBlockType('slack-custom-bot')).toBe('slack_v2')
expect(getServiceAccountGatingBlockType('notion-service-account')).toBeNull()
expect(getServiceAccountGatingBlockType('google-service-account')).toBeNull()
expect(getServiceAccountGatingBlockType('salesforce-service-account')).toBeNull()
})
})
describe('getServiceAccountConnectNoun', () => {
it('names the token-paste secret each provider actually collects', () => {
expect(getServiceAccountConnectNoun('notion-service-account')).toBe('integration secret')
expect(getServiceAccountConnectNoun('hubspot-service-account')).toBe('private app token')
expect(getServiceAccountConnectNoun('linear-service-account')).toBe('API key')
})
it('names the client-credential secret', () => {
expect(getServiceAccountConnectNoun('zoom-service-account')).toBe('server-to-server app')
})
it('calls a custom Slack bot a custom bot', () => {
expect(getServiceAccountConnectNoun('slack-custom-bot')).toBe('custom bot')
})
it('falls back to the generic noun for bespoke providers with no descriptor', () => {
// Google (paste a JSON key) and Atlassian (token + domain) have no
// token/client descriptor, so they read as a plain "service account".
expect(getServiceAccountConnectNoun('google-service-account')).toBe('service account')
expect(getServiceAccountConnectNoun('atlassian-service-account')).toBe('service account')
})
})
@@ -0,0 +1,77 @@
import {
getClientCredentialAccountDescriptor,
isClientCredentialAccountProviderId,
} from '@/lib/credentials/client-credential-accounts/descriptors'
import {
getTokenServiceAccountDescriptor,
isTokenServiceAccountProviderId,
} from '@/lib/credentials/token-service-accounts/descriptors'
import {
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID,
SLACK_CUSTOM_BOT_PROVIDER_ID,
} from '@/lib/oauth/types'
import type { ServiceAccountProviderId } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal'
/**
* Narrows a runtime provider-id string to the {@link ServiceAccountProviderId}
* union. Anything outside the union is unsupported by
* `ConnectServiceAccountModal`.
*
* Lives here rather than beside the integration catalog so callers that only
* need the predicate — not a slug — avoid pulling in `integrations.json` and
* the `OAUTH_PROVIDERS` walk.
*/
export function asServiceAccountProviderId(
value: string | undefined
): ServiceAccountProviderId | undefined {
if (
value === GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID ||
value === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID ||
value === SLACK_CUSTOM_BOT_PROVIDER_ID ||
isTokenServiceAccountProviderId(value) ||
isClientCredentialAccountProviderId(value)
) {
return value
}
return undefined
}
/**
* Whether a string is itself a service-account provider id
* (`slack-custom-bot`, `notion-service-account`, …) rather than an OAuth
* provider value.
*
* Note this asks what the id *is*, not whether the named integration happens
* to offer a service-account flow: `slack` is an OAuth provider value and
* returns false even though Slack also supports a custom bot.
*/
export function isServiceAccountProviderId(value: string): boolean {
return asServiceAccountProviderId(value.toLowerCase().trim()) !== undefined
}
/**
* The block type whose preview gate governs a service-account provider's setup
* surface, or `null` when the provider is ungated. A custom Slack bot is only
* usable through `slack_v2`, so its setup form must stay hidden wherever that
* block is preview-hidden — both the in-chat connect button and the tool that
* offers it read this so they can't disagree on availability.
*/
export function getServiceAccountGatingBlockType(providerId: string): string | null {
return providerId === SLACK_CUSTOM_BOT_PROVIDER_ID ? 'slack_v2' : null
}
/**
* Vendor-accurate noun for the credential a service-account provider collects
* ("private app token", "server-to-server app", …), for connect-control labels
* and agent-facing discovery. Token-paste and client-credential providers name
* their own; bespoke providers (Google JSON key, Atlassian token) fall back to
* the generic "service account". Single source shared by the connect hook and
* the VFS catalog so the wording can't drift.
*/
export function getServiceAccountConnectNoun(providerId: string): string {
if (providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) return 'custom bot'
const descriptor =
getTokenServiceAccountDescriptor(providerId) ?? getClientCredentialAccountDescriptor(providerId)
return descriptor?.connectNoun ?? 'service account'
}
@@ -3,7 +3,10 @@
*/
import { describe, expect, it } from 'vitest'
import integrationsJson from '@/lib/integrations/integrations.json'
import { resolveOAuthServiceForSlug } from '@/lib/integrations/oauth-service'
import {
resolveOAuthServiceForSlug,
resolveServiceAccountIntegration,
} from '@/lib/integrations/oauth-service'
import type { Integration } from '@/lib/integrations/types'
const INTEGRATIONS = integrationsJson.integrations as readonly Integration[]
@@ -129,3 +132,52 @@ describe('resolveOAuthServiceForSlug', () => {
expect(unexpected).toEqual([])
})
})
describe('resolveServiceAccountIntegration', () => {
it.concurrent('keeps a named service instead of collapsing to the family default', () => {
// Every Google integration issues the same google-service-account
// credential, so a fuzzy matcher can silently answer Drive for all of
// them. The user asked about Sheets; the link must land on Sheets.
expect(resolveServiceAccountIntegration('google-sheets')?.slug).toBe('google-sheets')
expect(resolveServiceAccountIntegration('gmail')?.slug).toBe('gmail')
expect(resolveServiceAccountIntegration('confluence')?.slug).toBe('confluence')
})
it.concurrent('resolves a family name to its canonical slug, not an arbitrary member', () => {
// Without an explicit canonical entry these fall through to fuzzy
// matching, which answers whichever member sorts first (BigQuery).
expect(resolveServiceAccountIntegration('google')?.slug).toBe('google-drive')
expect(resolveServiceAccountIntegration('google-service-account')?.slug).toBe('google-drive')
expect(resolveServiceAccountIntegration('atlassian')?.slug).toBe('jira')
expect(resolveServiceAccountIntegration('atlassian-service-account')?.slug).toBe('jira')
})
it.concurrent('accepts provider values, display names, and stray casing', () => {
expect(resolveServiceAccountIntegration('google-email')?.slug).toBe('gmail')
expect(resolveServiceAccountIntegration('slack-custom-bot')?.slug).toBe('slack')
expect(resolveServiceAccountIntegration('calcom')?.slug).toBe('cal-com')
expect(resolveServiceAccountIntegration('Cal.com')?.slug).toBe('cal-com')
expect(resolveServiceAccountIntegration(' NOTION ')?.slug).toBe('notion')
})
it.concurrent(
'accepts the space/underscore-normalized id forms the oauth guard steers toward',
() => {
// oauth_get_auth_link rejects `slack custom bot` / `notion_service_account`
// and tells the agent to emit a service_account tag; the renderer must then
// resolve those same readable forms or the connect control renders nothing.
expect(resolveServiceAccountIntegration('slack custom bot')?.slug).toBe('slack')
expect(resolveServiceAccountIntegration('notion_service_account')?.slug).toBe('notion')
expect(resolveServiceAccountIntegration('google service account')?.slug).toBe('google-drive')
}
)
it.concurrent('returns null rather than inventing a link for unsupported input', () => {
// The handler turns null into "use oauth_get_auth_link instead"; a wrong
// match here would send the user to a modal that cannot take their key.
expect(resolveServiceAccountIntegration('github')).toBeNull()
expect(resolveServiceAccountIntegration('dropbox')).toBeNull()
expect(resolveServiceAccountIntegration('')).toBeNull()
expect(resolveServiceAccountIntegration(' ')).toBeNull()
})
})
+102 -23
View File
@@ -1,10 +1,8 @@
import type { ComponentType } from 'react'
import { isClientCredentialAccountProviderId } from '@/lib/credentials/client-credential-accounts/descriptors'
import { isTokenServiceAccountProviderId } from '@/lib/credentials/token-service-accounts/descriptors'
import { asServiceAccountProviderId } from '@/lib/credentials/service-account-provider-ids'
import integrationsJson from '@/lib/integrations/integrations.json'
import type { Integration } from '@/lib/integrations/types'
import { getServiceConfigByServiceId } from '@/lib/oauth'
import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
import type { ServiceAccountProviderId } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal'
const INTEGRATIONS_DATA: readonly Integration[] =
@@ -29,26 +27,6 @@ export interface OAuthServiceMatch {
serviceAccountProviderId?: ServiceAccountProviderId
}
/**
* Narrows the runtime `OAuthServiceConfig.serviceAccountProviderId` string to
* the {@link ServiceAccountProviderId} union. Anything outside the union is
* unsupported by `ConnectServiceAccountModal` and is silently ignored.
*/
function asServiceAccountProviderId(
value: string | undefined
): ServiceAccountProviderId | undefined {
if (
value === 'google-service-account' ||
value === 'atlassian-service-account' ||
value === SLACK_CUSTOM_BOT_PROVIDER_ID ||
isTokenServiceAccountProviderId(value) ||
isClientCredentialAccountProviderId(value)
) {
return value
}
return undefined
}
/**
* Looks up the OAuth service entry registered under the integration's
* `oauthServiceId` — the service id its block declares on the `oauth-input`
@@ -81,3 +59,104 @@ export function resolveOAuthServiceForSlug(slug: string): OAuthServiceMatch | nu
if (!integration) return null
return resolveOAuthServiceForIntegration(integration)
}
/**
* An integration that exposes a service-account connect flow, resolved to the
* catalog slug whose detail page mounts `ConnectServiceAccountModal`.
*/
export interface ServiceAccountIntegrationMatch {
slug: string
serviceAccountProviderId: ServiceAccountProviderId
serviceName: string
providerId: string
}
/**
* Slug to prefer for a query that names a family rather than one of its
* integrations — either the shared service-account provider id or the bare
* base provider. Every Google integration issues the same
* `google-service-account` credential and every Atlassian one the same
* `atlassian-service-account`, so an unqualified request has to land
* somewhere; these are the most general surface of each family.
*
* Without the bare-provider entries, fuzzy matching resolves `google` to
* whichever Google integration sorts first in the catalog (BigQuery), which is
* both arbitrary and a poor landing page. A caller that names a specific
* integration still gets that integration.
*/
const CANONICAL_SERVICE_ACCOUNT_SLUGS: Readonly<Record<string, string>> = {
'google-service-account': 'google-drive',
google: 'google-drive',
'atlassian-service-account': 'jira',
atlassian: 'jira',
} as const
/**
* Every integration that offers a service-account flow, in catalog order.
* Built once — `resolveOAuthServiceForIntegration` walks `OAUTH_PROVIDERS` per
* entry, which is wasted work to repeat on each lookup.
*/
const SERVICE_ACCOUNT_INTEGRATIONS: readonly ServiceAccountIntegrationMatch[] =
INTEGRATIONS_DATA.flatMap((integration) => {
const match = resolveOAuthServiceForIntegration(integration)
if (!match?.serviceAccountProviderId) return []
return [
{
slug: integration.slug,
serviceAccountProviderId: match.serviceAccountProviderId,
serviceName: integration.name,
providerId: match.providerId,
},
]
})
/**
* Resolves a loosely-specified integration name to the service-account setup
* surface for it. Accepts a catalog slug (`google-sheets`), an OAuth provider
* value (`google-email`), a service-account provider id
* (`google-service-account`), or a display name (`Google Sheets`).
*
* Exact matches are tried before fuzzy ones so a caller naming a specific
* integration always lands on it: `gmail` must not fall through to Drive just
* because both issue the same Google service account. A bare service-account
* provider id names no single integration, so it resolves through
* {@link CANONICAL_SERVICE_ACCOUNT_SLUGS}.
*
* The id-based lookups also accept the space/underscore-normalized form
* (`slack custom bot`, `notion_service_account`) that `oauth_get_auth_link`'s
* guard rejects and steers toward a `service_account` tag — otherwise the chat
* renderer would fail to resolve exactly the forms the guard produced.
*
* Returns `null` when nothing matches or the named integration has no
* service-account flow — callers should fall back to OAuth rather than
* inventing a link.
*/
export function resolveServiceAccountIntegration(
providerName: string
): ServiceAccountIntegrationMatch | null {
const query = providerName.toLowerCase().trim()
if (!query) return null
// Hyphenated form for id lookups (slug / providerId / SA-id are hyphenated);
// the raw query is kept for display-name matches, which aren't hyphenated.
const idQuery = query.replace(/[\s_]+/g, '-')
const canonicalSlug = CANONICAL_SERVICE_ACCOUNT_SLUGS[idQuery]
if (canonicalSlug) {
const canonical = SERVICE_ACCOUNT_INTEGRATIONS.find((entry) => entry.slug === canonicalSlug)
if (canonical) return canonical
}
return (
SERVICE_ACCOUNT_INTEGRATIONS.find((entry) => entry.slug === idQuery) ??
SERVICE_ACCOUNT_INTEGRATIONS.find((entry) => entry.providerId.toLowerCase() === idQuery) ??
SERVICE_ACCOUNT_INTEGRATIONS.find(
(entry) => entry.serviceAccountProviderId.toLowerCase() === idQuery
) ??
SERVICE_ACCOUNT_INTEGRATIONS.find((entry) => entry.serviceName.toLowerCase() === query) ??
SERVICE_ACCOUNT_INTEGRATIONS.find(
(entry) =>
entry.serviceName.toLowerCase().includes(query) || entry.slug.replace(/-/g, ' ') === query
) ??
null
)
}