Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .agents/skills/add-block/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand Down
19 changes: 19 additions & 0 deletions .claude/commands/add-block.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand Down
19 changes: 19 additions & 0 deletions .cursor/commands/add-block.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,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 Sim’s 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)
}
)
})
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { createElement, useMemo, useState } from 'react'
import { createElement, lazy, Suspense, useMemo, useState } from 'react'
import {
ArrowRight,
Button,
Expand All @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -62,13 +70,25 @@ 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',
'sim_key',
'credential_id',
'link',
'secret_input',
'service_account',
] as const

export type CredentialTagType = (typeof CREDENTIAL_TAG_TYPES)[number]
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Comment thread
cursor[bot] marked this conversation as resolved.
}
// 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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading