From 350c585c6cb763c5cf0848281b0c93eaf78869b7 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 20 Jul 2026 11:32:56 -0700 Subject: [PATCH 01/10] feat(copilot): add service_account_get_setup_link handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) Claude-Session: https://claude.ai/code/session_01Phx1MLjf8Ui3M3VpwisZds --- .../lib/copilot/generated/tool-catalog-v1.ts | 21 ++++ .../lib/copilot/generated/tool-schemas-v1.ts | 14 +++ .../tool-executor/register-handlers.ts | 3 + .../copilot/tools/handlers/service-account.ts | 79 ++++++++++++++ apps/sim/lib/copilot/tools/tool-display.ts | 1 + .../lib/integrations/oauth-service.test.ts | 52 ++++++++- apps/sim/lib/integrations/oauth-service.ts | 101 ++++++++++++++++++ 7 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 apps/sim/lib/copilot/tools/handlers/service-account.ts diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 6379a9ff6ff..1725c3f059f 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -91,6 +91,7 @@ export interface ToolCatalogEntry { | 'search_library_docs' | 'search_online' | 'search_patterns' + | 'service_account_get_setup_link' | 'set_block_enabled' | 'set_environment_variables' | 'set_global_workflow_variables' @@ -189,6 +190,7 @@ export interface ToolCatalogEntry { | 'search_library_docs' | 'search_online' | 'search_patterns' + | 'service_account_get_setup_link' | 'set_block_enabled' | 'set_environment_variables' | 'set_global_workflow_variables' @@ -3828,6 +3830,24 @@ export const SearchPatterns: ToolCatalogEntry = { }, } +export const ServiceAccountGetSetupLink: ToolCatalogEntry = { + id: 'service_account_get_setup_link', + name: 'service_account_get_setup_link', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + providerName: { + type: 'string', + description: + 'The integration to set up a service account for. Pass the most specific service the user needs (e.g. `google-sheets`, `gmail`, `jira`, `slack`, `notion`); a slug, OAuth provider value, service-account provider id, or display name resolves case-insensitively.', + }, + }, + required: ['providerName'], + }, +} + export const SetBlockEnabled: ToolCatalogEntry = { id: 'set_block_enabled', name: 'set_block_enabled', @@ -4932,6 +4952,7 @@ export const TOOL_CATALOG: Record = { [SearchLibraryDocs.id]: SearchLibraryDocs, [SearchOnline.id]: SearchOnline, [SearchPatterns.id]: SearchPatterns, + [ServiceAccountGetSetupLink.id]: ServiceAccountGetSetupLink, [SetBlockEnabled.id]: SetBlockEnabled, [SetEnvironmentVariables.id]: SetEnvironmentVariables, [SetGlobalWorkflowVariables.id]: SetGlobalWorkflowVariables, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 95caf84ac66..694a1354dcc 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -3618,6 +3618,20 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + service_account_get_setup_link: { + parameters: { + type: 'object', + properties: { + providerName: { + type: 'string', + description: + 'The integration to set up a service account for. Pass the most specific service the user needs (e.g. `google-sheets`, `gmail`, `jira`, `slack`, `notion`); a slug, OAuth provider value, service-account provider id, or display name resolves case-insensitively.', + }, + }, + required: ['providerName'], + }, + resultSchema: undefined, + }, set_block_enabled: { parameters: { type: 'object', diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts index 7b5c9086716..01c6fea86d0 100644 --- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts +++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts @@ -48,6 +48,7 @@ import { RunFromBlock, RunWorkflow, RunWorkflowUntilBlock, + ServiceAccountGetSetupLink, SetBlockEnabled, SetGlobalWorkflowVariables, UpdateDeploymentVersion, @@ -92,6 +93,7 @@ import { executeGetPlatformActions } from '../tools/handlers/platform' import { executeOpenResource } from '../tools/handlers/resources' import { executeRestoreResource } from '../tools/handlers/restore-resource' import { executeRunCode } from '../tools/handlers/run-code' +import { executeServiceAccountGetSetupLink } from '../tools/handlers/service-account' import { executeVfsGlob, executeVfsGrep, executeVfsRead } from '../tools/handlers/vfs' import { executeVfsCp, executeVfsMkdir, executeVfsMv } from '../tools/handlers/vfs-mutate' import { @@ -195,6 +197,7 @@ function buildHandlerMap(): Record { [ManageCredential.id]: h(executeManageCredential), [OauthGetAuthLink.id]: h(executeOAuthGetAuthLink), [OauthRequestAccess.id]: h(executeOAuthRequestAccess), + [ServiceAccountGetSetupLink.id]: h(executeServiceAccountGetSetupLink), [OpenResource.id]: h(executeOpenResource), [RestoreResource.id]: h(executeRestoreResource), [GetPlatformActions.id]: h(executeGetPlatformActions), diff --git a/apps/sim/lib/copilot/tools/handlers/service-account.ts b/apps/sim/lib/copilot/tools/handlers/service-account.ts new file mode 100644 index 00000000000..cf179d6d262 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/service-account.ts @@ -0,0 +1,79 @@ +import { toError } from '@sim/utils/errors' +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { + listServiceAccountIntegrationNames, + resolveServiceAccountIntegration, +} from '@/lib/integrations/oauth-service' +import { + CONNECT_MODE, + CONNECT_QUERY_PARAM, +} from '@/app/workspace/[workspaceId]/integrations/connect-route' + +/** + * Returns a link that opens the integration detail page with the + * service-account connect modal already open, so the user supplies the key + * material in Sim's own form. The agent never receives or relays the secret — + * it only hands over the link, which it surfaces as a `` link tag. + */ +export async function executeServiceAccountGetSetupLink( + rawParams: Record, + context: ExecutionContext +): Promise { + const providerName = String(rawParams.providerName || rawParams.provider_name || '') + const baseUrl = getBaseUrl() + + try { + if (!context.workspaceId || !context.userId) { + throw new Error('workspaceId and userId are required to generate a service account link') + } + + // Connecting a credential mutates the workspace, so gate on write here + // rather than letting the user discover they lack access after clicking. + await ensureWorkspaceAccess(context.workspaceId, context.userId, 'write') + + const match = resolveServiceAccountIntegration(providerName) + if (!match) { + throw new Error( + `"${providerName}" has no service account flow. Integrations that do: ` + + `${listServiceAccountIntegrationNames().join(', ')}. Use oauth_get_auth_link instead.` + ) + } + + const url = new URL(`${baseUrl}/workspace/${context.workspaceId}/integrations/${match.slug}`) + url.searchParams.set(CONNECT_QUERY_PARAM, CONNECT_MODE.serviceAccount) + + return { + success: true, + output: { + message: `Service account setup link generated for ${match.serviceName}.`, + setup_url: url.toString(), + instructions: + `Open this URL to set up a ${match.serviceName} service account: ${url.toString()}. ` + + `The form collects the credential — never ask the user to paste key material into chat.`, + provider: match.serviceName, + // The OAuth provider value, NOT the service-account provider id — the + // `` link renderer derives the button's label and icon from + // this via `getServiceConfigByProviderId`, and a service-account id + // resolves to whichever family member is registered first: tagging + // `google-service-account` labels a Google Sheets link "Connect Gmail". + providerId: match.providerId, + serviceAccountProviderId: match.serviceAccountProviderId, + }, + } + } catch (err) { + const workspaceUrl = context.workspaceId + ? `${baseUrl}/workspace/${context.workspaceId}/integrations` + : `${baseUrl}/workspace` + return { + success: false, + error: toError(err).message, + output: { + message: `Could not generate a service account setup link for ${providerName}. Browse the integrations page instead.`, + setup_url: workspaceUrl, + error: toError(err).message, + }, + } + } +} diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 5aee46abeb9..efda28e3aee 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -467,6 +467,7 @@ const TOOL_TITLES: Record = { run_block: 'Running block', search_documentation: 'Searching documentation', search_patterns: 'Searching patterns', + service_account_get_setup_link: 'Getting service account setup link', set_block_enabled: 'Toggling block', set_environment_variables: 'Setting environment variables', set_global_workflow_variables: 'Setting workflow variables', diff --git a/apps/sim/lib/integrations/oauth-service.test.ts b/apps/sim/lib/integrations/oauth-service.test.ts index 4cdf616ae4a..cc4a22b76c5 100644 --- a/apps/sim/lib/integrations/oauth-service.test.ts +++ b/apps/sim/lib/integrations/oauth-service.test.ts @@ -3,7 +3,11 @@ */ import { describe, expect, it } from 'vitest' import integrationsJson from '@/lib/integrations/integrations.json' -import { resolveOAuthServiceForSlug } from '@/lib/integrations/oauth-service' +import { + listServiceAccountIntegrationNames, + resolveOAuthServiceForSlug, + resolveServiceAccountIntegration, +} from '@/lib/integrations/oauth-service' import type { Integration } from '@/lib/integrations/types' const INTEGRATIONS = integrationsJson.integrations as readonly Integration[] @@ -129,3 +133,49 @@ 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('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() + }) + + it.concurrent('reports a service-account provider for every match it returns', () => { + const names = listServiceAccountIntegrationNames() + expect(names.length).toBeGreaterThan(0) + for (const name of names) { + const match = resolveServiceAccountIntegration(name) + expect(match?.serviceAccountProviderId).toBeTruthy() + } + }) +}) diff --git a/apps/sim/lib/integrations/oauth-service.ts b/apps/sim/lib/integrations/oauth-service.ts index 4cba5ce809e..9e642d32ce4 100644 --- a/apps/sim/lib/integrations/oauth-service.ts +++ b/apps/sim/lib/integrations/oauth-service.ts @@ -81,3 +81,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> = { + '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}. + * + * 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 + + const canonicalSlug = CANONICAL_SERVICE_ACCOUNT_SLUGS[query] + if (canonicalSlug) { + const canonical = SERVICE_ACCOUNT_INTEGRATIONS.find((entry) => entry.slug === canonicalSlug) + if (canonical) return canonical + } + + return ( + SERVICE_ACCOUNT_INTEGRATIONS.find((entry) => entry.slug === query) ?? + SERVICE_ACCOUNT_INTEGRATIONS.find((entry) => entry.providerId.toLowerCase() === query) ?? + SERVICE_ACCOUNT_INTEGRATIONS.find( + (entry) => entry.serviceAccountProviderId.toLowerCase() === query + ) ?? + 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 + ) +} + +/** + * Names every integration that offers a service-account flow, for error copy + * that has to tell the agent what it could have asked for instead. + */ +export function listServiceAccountIntegrationNames(): string[] { + return SERVICE_ACCOUNT_INTEGRATIONS.map((entry) => entry.serviceName) +} From dea73d3c6b68a977d6daf4b13669da6c952c5c0e Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 20 Jul 2026 13:21:02 -0700 Subject: [PATCH 02/10] fix(copilot): reject service account ids in oauth_get_auth_link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01Phx1MLjf8Ui3M3VpwisZds --- .../lib/copilot/tools/handlers/oauth.test.ts | 55 +++++++++++++++++++ apps/sim/lib/copilot/tools/handlers/oauth.ts | 14 +++++ .../service-account-provider-ids.ts | 45 +++++++++++++++ apps/sim/lib/integrations/oauth-service.ts | 24 +------- 4 files changed, 115 insertions(+), 23 deletions(-) create mode 100644 apps/sim/lib/credentials/service-account-provider-ids.ts diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.test.ts b/apps/sim/lib/copilot/tools/handlers/oauth.test.ts index 30870b9d636..b739a578d70 100644 --- a/apps/sim/lib/copilot/tools/handlers/oauth.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/oauth.test.ts @@ -207,3 +207,58 @@ 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 instead of degrading to the OAuth flow', 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_get_setup_link') + // The failure output must not carry an authorize URL the agent could + // surface as a Connect button anyway. + expect((result.output as { setup_url?: string }).setup_url).toBeUndefined() + expect((result.output as { oauth_url: string }).oauth_url).not.toContain( + '/api/auth/oauth2/authorize' + ) + }) + + it.each([ + 'notion-service-account', + 'salesforce-service-account', + 'google-service-account', + 'atlassian-service-account', + 'SLACK-CUSTOM-BOT', + ])('rejects %s', async (providerName) => { + const result = await executeOAuthGetAuthLink({ providerName }, context) + expect(result.success).toBe(false) + expect(result.error).toContain('service_account_get_setup_link') + }) + + 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' + ) + } + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.ts b/apps/sim/lib/copilot/tools/handlers/oauth.ts index e3b55c36bae..a5dfc262610 100644 --- a/apps/sim/lib/copilot/tools/handlers/oauth.ts +++ b/apps/sim/lib/copilot/tools/handlers/oauth.ts @@ -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' @@ -106,6 +107,19 @@ async function generateOAuthLink( const allServices = getAllOAuthServices() const normalizedInput = providerName.toLowerCase().trim() + // Reject a service-account id before the fuzzy pass below can swallow it. + // That pass matches on substring containment, so `slack-custom-bot` contains + // `slack` and silently resolves to the Slack OAuth service — the tool then + // returns a personal-OAuth authorize URL, reports success, and the user + // connects their own account when they asked for a shared bot. + if (isServiceAccountProviderId(normalizedInput)) { + throw new Error( + `"${providerName}" is a service account, not an OAuth provider. ` + + `Call service_account_get_setup_link with this provider instead — ` + + `it returns a link that opens the service account setup form.` + ) + } + const matched = allServices.find((s) => s.providerId === normalizedInput) || allServices.find((s) => s.name.toLowerCase() === normalizedInput) || diff --git a/apps/sim/lib/credentials/service-account-provider-ids.ts b/apps/sim/lib/credentials/service-account-provider-ids.ts new file mode 100644 index 00000000000..89be3a31e8e --- /dev/null +++ b/apps/sim/lib/credentials/service-account-provider-ids.ts @@ -0,0 +1,45 @@ +import { isClientCredentialAccountProviderId } from '@/lib/credentials/client-credential-accounts/descriptors' +import { 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 +} diff --git a/apps/sim/lib/integrations/oauth-service.ts b/apps/sim/lib/integrations/oauth-service.ts index 9e642d32ce4..b313fdb8ea3 100644 --- a/apps/sim/lib/integrations/oauth-service.ts +++ b/apps/sim/lib/integrations/oauth-service.ts @@ -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` From 53b7e0f3b068eb891835f91e6f0c0e3f77fd4148 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 20 Jul 2026 13:38:03 -0700 Subject: [PATCH 03/10] feat(copilot): open the service account form in-chat instead of linking out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01Phx1MLjf8Ui3M3VpwisZds --- .../special-tags/special-tags.test.ts | 55 +++++++++++ .../components/special-tags/special-tags.tsx | 91 ++++++++++++++++++- .../[block]/integration-block-detail.tsx | 41 +++------ .../connect-service-account-modal/index.ts | 4 + .../use-service-account-connect.ts | 76 ++++++++++++++++ .../copilot/tools/handlers/service-account.ts | 20 ++-- 6 files changed, 247 insertions(+), 40 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect.ts diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts index 638e3c3a747..a1a80e3b959 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts @@ -155,3 +155,58 @@ describe('parseSpecialTags with ', () => { ]) }) }) + +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: ${body}`, 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(`${body}`, 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: {"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( + `${JSON.stringify({ type: 'service_account' })}`, + false + ) + expect(segments.some((segment) => segment.type === 'credential')).toBe(false) + }) + + it('rejects a blank provider', () => { + const { segments } = parseSpecialTags( + `${JSON.stringify({ type: 'service_account', provider: ' ' })}`, + false + ) + expect(segments.some((segment) => segment.type === 'credential')).toBe(false) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index a566cd3e9c2..31595ca78f9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -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] @@ -225,6 +245,12 @@ 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. + if (value.type === 'service_account') { + 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 @@ -865,6 +891,65 @@ function SecretInputDisplay({ data }: { data: CredentialTagData }) { ) } +/** + * Inline "set up a service account" control rendered for + * `{"type":"service_account","provider":"slack"}`. + * + * 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, + }) + + // 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 + + return ( + <> + + {open && ( + + + + )} + + ) +} + function CredentialLinkDisplay({ data }: { data: CredentialTagData }) { const { canEdit } = useUserPermissionsContext() @@ -916,6 +1001,10 @@ function CredentialDisplay({ data }: { data: CredentialTagData }) { return } + if (data.type === 'service_account') { + return + } + 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 diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx index 2d7abb20569..b11854646f4 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx @@ -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(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/index.ts b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/index.ts index e09a3c81a74..f9c7989e1e9 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/index.ts @@ -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' diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect.ts b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect.ts new file mode 100644 index 00000000000..d71482aec65 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect.ts @@ -0,0 +1,76 @@ +'use client' + +import { type ComponentType, useMemo } from 'react' +import { getClientCredentialAccountDescriptor } from '@/lib/credentials/client-credential-accounts/descriptors' +import { getTokenServiceAccountDescriptor } from '@/lib/credentials/token-service-accounts/descriptors' +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(() => { + if (!isSlackBot) return false + const v2 = getBlock('slack_v2') + return !v2 || isHiddenUnder(overlayVisibility(), v2) + }, [isSlackBot, blockOverlayVersion]) + + return useMemo(() => { + if (!serviceAccountProviderId || !serviceName || !serviceIcon) return null + + const nounDescriptor = + getTokenServiceAccountDescriptor(serviceAccountProviderId) ?? + getClientCredentialAccountDescriptor(serviceAccountProviderId) + + const label = isSlackBot + ? 'Set up a custom bot' + : nounDescriptor + ? `Add ${nounDescriptor.connectNoun}` + : 'Add service account' + + return { serviceAccountProviderId, serviceName, serviceIcon, label, hidden } + }, [serviceAccountProviderId, serviceName, serviceIcon, isSlackBot, hidden]) +} diff --git a/apps/sim/lib/copilot/tools/handlers/service-account.ts b/apps/sim/lib/copilot/tools/handlers/service-account.ts index cf179d6d262..b6c437ec972 100644 --- a/apps/sim/lib/copilot/tools/handlers/service-account.ts +++ b/apps/sim/lib/copilot/tools/handlers/service-account.ts @@ -47,19 +47,21 @@ export async function executeServiceAccountGetSetupLink( return { success: true, output: { - message: `Service account setup link generated for ${match.serviceName}.`, - setup_url: url.toString(), + message: `Service account setup available for ${match.serviceName}.`, instructions: - `Open this URL to set up a ${match.serviceName} service account: ${url.toString()}. ` + - `The form collects the credential — never ask the user to paste key material into chat.`, + `Emit {"type":"service_account","provider":"${match.providerId}"} ` + + `to open the ${match.serviceName} setup form directly in this chat. Only fall back to ` + + `setup_url when you cannot render a tag (headless/MCP). The form collects the ` + + `credential — never ask the user to paste key material into chat.`, provider: match.serviceName, - // The OAuth provider value, NOT the service-account provider id — the - // `` link renderer derives the button's label and icon from - // this via `getServiceConfigByProviderId`, and a service-account id - // resolves to whichever family member is registered first: tagging - // `google-service-account` labels a Google Sheets link "Connect Gmail". + // The OAuth provider value, NOT the service-account provider id — both + // the tag renderer and the link renderer resolve display metadata from + // this, and a service-account id resolves to whichever family member is + // registered first: `google-service-account` labels Google Sheets "Gmail". providerId: match.providerId, serviceAccountProviderId: match.serviceAccountProviderId, + /** Headless fallback only; interactive chat should emit the tag. */ + setup_url: url.toString(), }, } } catch (err) { From 10fdfd47853ce3f890702be8ba94ed8507e47731 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 20 Jul 2026 15:32:22 -0700 Subject: [PATCH 04/10] fix(copilot): gate service account tool on the same preview flag as the UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01Phx1MLjf8Ui3M3VpwisZds --- .../use-service-account-connect.ts | 13 +- .../tools/handlers/service-account.test.ts | 112 ++++++++++++++++++ .../copilot/tools/handlers/service-account.ts | 21 ++++ .../service-account-provider-ids.ts | 11 ++ 4 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 apps/sim/lib/copilot/tools/handlers/service-account.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect.ts b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect.ts index d71482aec65..aab919d9e5a 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect.ts +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect.ts @@ -2,6 +2,7 @@ import { type ComponentType, useMemo } from 'react' import { getClientCredentialAccountDescriptor } from '@/lib/credentials/client-credential-accounts/descriptors' +import { getServiceAccountGatingBlockType } from '@/lib/credentials/service-account-provider-ids' import { getTokenServiceAccountDescriptor } from '@/lib/credentials/token-service-accounts/descriptors' 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' @@ -53,10 +54,14 @@ export function useServiceAccountConnectTarget({ const isSlackBot = serviceAccountProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID const hidden = useMemo(() => { - if (!isSlackBot) return false - const v2 = getBlock('slack_v2') - return !v2 || isHiddenUnder(overlayVisibility(), v2) - }, [isSlackBot, blockOverlayVersion]) + 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 diff --git a/apps/sim/lib/copilot/tools/handlers/service-account.test.ts b/apps/sim/lib/copilot/tools/handlers/service-account.test.ts new file mode 100644 index 00000000000..0c72114568b --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/service-account.test.ts @@ -0,0 +1,112 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockEnsureWorkspaceAccess, mockGetBlockVisibility, mockGetBlock } = vi.hoisted(() => ({ + mockEnsureWorkspaceAccess: vi.fn(), + mockGetBlockVisibility: vi.fn(), + mockGetBlock: vi.fn(), +})) + +vi.mock('@/lib/copilot/tools/handlers/access', () => ({ + ensureWorkspaceAccess: mockEnsureWorkspaceAccess, +})) + +vi.mock('@/lib/copilot/block-visibility', () => ({ + getBlockVisibilityForCopilot: mockGetBlockVisibility, +})) + +vi.mock('@/blocks', () => ({ + getBlock: mockGetBlock, +})) + +import type { ExecutionContext } from '@/lib/copilot/request/types' +import { executeServiceAccountGetSetupLink } from '@/lib/copilot/tools/handlers/service-account' + +const BASE_URL = 'https://sim.test' +const context = { + workspaceId: 'ws-1', + userId: 'user-1', + chatId: 'chat-1', +} as unknown as ExecutionContext + +/** slack_v2 revealed → visible. Empty → preview-hidden (fail-closed). */ +function visibility(revealed: string[] = []) { + return { + revealed: new Set(revealed), + disabled: new Set(), + previewTagged: new Set(), + } +} + +interface Output { + setup_url?: string + provider?: string + providerId?: string + serviceAccountProviderId?: string + instructions?: string +} + +describe('executeServiceAccountGetSetupLink', () => { + beforeEach(() => { + vi.clearAllMocks() + process.env.NEXT_PUBLIC_APP_URL = BASE_URL + mockEnsureWorkspaceAccess.mockResolvedValue({ canWrite: true }) + mockGetBlockVisibility.mockResolvedValue(visibility()) + mockGetBlock.mockReturnValue({ type: 'slack_v2', preview: true }) + }) + + it('resolves an ungated provider to an in-chat setup tag, not a bare link', async () => { + const result = await executeServiceAccountGetSetupLink({ providerName: 'notion' }, context) + + expect(result.success).toBe(true) + const output = result.output as Output + // The provider on the tag is the OAuth provider value so the button label + // resolves; the service-account id rides alongside. + expect(output.providerId).toBe('notion') + expect(output.serviceAccountProviderId).toBe('notion-service-account') + expect(output.setup_url).toContain('/integrations/notion?connect=service-account') + // The agent is steered to emit the tag, not surface the URL as a link. + expect(output.instructions).toContain('service_account') + // An ungated provider never consults block visibility. + expect(mockGetBlockVisibility).not.toHaveBeenCalled() + }) + + it('rejects an unsupported provider', async () => { + const result = await executeServiceAccountGetSetupLink({ providerName: 'github' }, context) + expect(result.success).toBe(false) + expect(result.error).toContain('no service account flow') + }) + + describe('preview gating (slack custom bot ↔ slack_v2)', () => { + it('rejects slack-custom-bot when slack_v2 is preview-hidden, so the agent falls back to OAuth', async () => { + // This is the exact production symptom: the tool used to return success, + // the agent said "here's the setup form", and the in-chat button hid + // itself because slack_v2 is preview-gated — leaving no form at all. + mockGetBlockVisibility.mockResolvedValue(visibility([])) + + const result = await executeServiceAccountGetSetupLink({ providerName: 'slack' }, context) + + expect(result.success).toBe(false) + expect(result.error).toContain('OAuth') + expect((result.output as Output).setup_url).toContain('/integrations') + // Crucially no service-account setup surface is offered. + expect((result.output as Output).instructions).toBeUndefined() + }) + + it('offers slack-custom-bot once slack_v2 is revealed for the viewer', async () => { + mockGetBlockVisibility.mockResolvedValue(visibility(['slack_v2'])) + + const result = await executeServiceAccountGetSetupLink({ providerName: 'slack' }, context) + + expect(result.success).toBe(true) + expect((result.output as Output).serviceAccountProviderId).toBe('slack-custom-bot') + }) + + it('does not consult visibility for a non-slack provider', async () => { + await executeServiceAccountGetSetupLink({ providerName: 'google-sheets' }, context) + expect(mockGetBlockVisibility).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/service-account.ts b/apps/sim/lib/copilot/tools/handlers/service-account.ts index b6c437ec972..58981e59919 100644 --- a/apps/sim/lib/copilot/tools/handlers/service-account.ts +++ b/apps/sim/lib/copilot/tools/handlers/service-account.ts @@ -1,7 +1,9 @@ import { toError } from '@sim/utils/errors' +import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { getBaseUrl } from '@/lib/core/utils/urls' +import { getServiceAccountGatingBlockType } from '@/lib/credentials/service-account-provider-ids' import { listServiceAccountIntegrationNames, resolveServiceAccountIntegration, @@ -10,6 +12,8 @@ import { CONNECT_MODE, CONNECT_QUERY_PARAM, } from '@/app/workspace/[workspaceId]/integrations/connect-route' +import { getBlock } from '@/blocks' +import { isHiddenUnder } from '@/blocks/visibility/context' /** * Returns a link that opens the integration detail page with the @@ -41,6 +45,23 @@ export async function executeServiceAccountGetSetupLink( ) } + // The in-chat button hides itself when the provider's gating block is + // preview-hidden for the viewer (a custom Slack bot needs slack_v2). Reject + // here on the same predicate so the tool never reports success for a form + // the UI will silently drop — the agent would promise a form that never + // appears. Fall the agent back to OAuth instead. + const gatingBlockType = getServiceAccountGatingBlockType(match.serviceAccountProviderId) + if (gatingBlockType) { + const visibility = await getBlockVisibilityForCopilot(context.userId, context.workspaceId) + const gatingBlock = getBlock(gatingBlockType) + if (!gatingBlock || isHiddenUnder(visibility, gatingBlock)) { + throw new Error( + `${match.serviceName} service account setup isn't available in this workspace yet. ` + + `Use oauth_get_auth_link to connect ${match.serviceName} via OAuth instead.` + ) + } + } + const url = new URL(`${baseUrl}/workspace/${context.workspaceId}/integrations/${match.slug}`) url.searchParams.set(CONNECT_QUERY_PARAM, CONNECT_MODE.serviceAccount) diff --git a/apps/sim/lib/credentials/service-account-provider-ids.ts b/apps/sim/lib/credentials/service-account-provider-ids.ts index 89be3a31e8e..3af38ac0a1c 100644 --- a/apps/sim/lib/credentials/service-account-provider-ids.ts +++ b/apps/sim/lib/credentials/service-account-provider-ids.ts @@ -43,3 +43,14 @@ export function asServiceAccountProviderId( 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 +} From ed3ffad1902ab6b484edb2a49af7d7fc1da23cbb Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 20 Jul 2026 17:29:06 -0700 Subject: [PATCH 05/10] feat(copilot): make the tool own service-account discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../use-service-account-connect.ts | 15 ++--- .../tools/handlers/service-account.test.ts | 5 ++ .../copilot/tools/handlers/service-account.ts | 15 ++++- .../service-account-provider-ids.test.ts | 65 +++++++++++++++++++ .../service-account-provider-ids.ts | 25 ++++++- 5 files changed, 110 insertions(+), 15 deletions(-) create mode 100644 apps/sim/lib/credentials/service-account-provider-ids.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect.ts b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect.ts index aab919d9e5a..fdeda1993b1 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect.ts +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect.ts @@ -1,9 +1,10 @@ 'use client' import { type ComponentType, useMemo } from 'react' -import { getClientCredentialAccountDescriptor } from '@/lib/credentials/client-credential-accounts/descriptors' -import { getServiceAccountGatingBlockType } from '@/lib/credentials/service-account-provider-ids' -import { getTokenServiceAccountDescriptor } from '@/lib/credentials/token-service-accounts/descriptors' +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' @@ -66,15 +67,9 @@ export function useServiceAccountConnectTarget({ return useMemo(() => { if (!serviceAccountProviderId || !serviceName || !serviceIcon) return null - const nounDescriptor = - getTokenServiceAccountDescriptor(serviceAccountProviderId) ?? - getClientCredentialAccountDescriptor(serviceAccountProviderId) - const label = isSlackBot ? 'Set up a custom bot' - : nounDescriptor - ? `Add ${nounDescriptor.connectNoun}` - : 'Add service account' + : `Add ${getServiceAccountConnectNoun(serviceAccountProviderId)}` return { serviceAccountProviderId, serviceName, serviceIcon, label, hidden } }, [serviceAccountProviderId, serviceName, serviceIcon, isSlackBot, hidden]) diff --git a/apps/sim/lib/copilot/tools/handlers/service-account.test.ts b/apps/sim/lib/copilot/tools/handlers/service-account.test.ts index 0c72114568b..96e6df07543 100644 --- a/apps/sim/lib/copilot/tools/handlers/service-account.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/service-account.test.ts @@ -45,6 +45,7 @@ interface Output { provider?: string providerId?: string serviceAccountProviderId?: string + connectNoun?: string instructions?: string } @@ -69,6 +70,10 @@ describe('executeServiceAccountGetSetupLink', () => { expect(output.setup_url).toContain('/integrations/notion?connect=service-account') // The agent is steered to emit the tag, not surface the URL as a link. expect(output.instructions).toContain('service_account') + // The tool carries the secret's noun so the agent can tell the user what to + // prepare — this is the discovery surface, replacing the reverted VFS field. + expect(output.connectNoun).toBe('integration secret') + expect(output.instructions).toContain('integration secret') // An ungated provider never consults block visibility. expect(mockGetBlockVisibility).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/copilot/tools/handlers/service-account.ts b/apps/sim/lib/copilot/tools/handlers/service-account.ts index 58981e59919..007113bcba2 100644 --- a/apps/sim/lib/copilot/tools/handlers/service-account.ts +++ b/apps/sim/lib/copilot/tools/handlers/service-account.ts @@ -3,7 +3,10 @@ import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { getBaseUrl } from '@/lib/core/utils/urls' -import { getServiceAccountGatingBlockType } from '@/lib/credentials/service-account-provider-ids' +import { + getServiceAccountConnectNoun, + getServiceAccountGatingBlockType, +} from '@/lib/credentials/service-account-provider-ids' import { listServiceAccountIntegrationNames, resolveServiceAccountIntegration, @@ -65,6 +68,8 @@ export async function executeServiceAccountGetSetupLink( const url = new URL(`${baseUrl}/workspace/${context.workspaceId}/integrations/${match.slug}`) url.searchParams.set(CONNECT_QUERY_PARAM, CONNECT_MODE.serviceAccount) + const connectNoun = getServiceAccountConnectNoun(match.serviceAccountProviderId) + return { success: true, output: { @@ -72,8 +77,9 @@ export async function executeServiceAccountGetSetupLink( instructions: `Emit {"type":"service_account","provider":"${match.providerId}"} ` + `to open the ${match.serviceName} setup form directly in this chat. Only fall back to ` + - `setup_url when you cannot render a tag (headless/MCP). The form collects the ` + - `credential — never ask the user to paste key material into chat.`, + `setup_url when you cannot render a tag (headless/MCP). Before or alongside the tag, tell ` + + `the user they'll need a ${connectNoun} — the form collects it, so never ask them to paste ` + + `key material into chat.`, provider: match.serviceName, // The OAuth provider value, NOT the service-account provider id — both // the tag renderer and the link renderer resolve display metadata from @@ -81,6 +87,9 @@ export async function executeServiceAccountGetSetupLink( // registered first: `google-service-account` labels Google Sheets "Gmail". providerId: match.providerId, serviceAccountProviderId: match.serviceAccountProviderId, + // The vendor noun for the secret the form collects ("private app token", + // "server-to-server app"), so the agent can tell the user what to prepare. + connectNoun, /** Headless fallback only; interactive chat should emit the tag. */ setup_url: url.toString(), }, diff --git a/apps/sim/lib/credentials/service-account-provider-ids.test.ts b/apps/sim/lib/credentials/service-account-provider-ids.test.ts new file mode 100644 index 00000000000..2e89d228f68 --- /dev/null +++ b/apps/sim/lib/credentials/service-account-provider-ids.test.ts @@ -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') + }) +}) diff --git a/apps/sim/lib/credentials/service-account-provider-ids.ts b/apps/sim/lib/credentials/service-account-provider-ids.ts index 3af38ac0a1c..9e838b0a313 100644 --- a/apps/sim/lib/credentials/service-account-provider-ids.ts +++ b/apps/sim/lib/credentials/service-account-provider-ids.ts @@ -1,5 +1,11 @@ -import { isClientCredentialAccountProviderId } from '@/lib/credentials/client-credential-accounts/descriptors' -import { isTokenServiceAccountProviderId } from '@/lib/credentials/token-service-accounts/descriptors' +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, @@ -54,3 +60,18 @@ export function isServiceAccountProviderId(value: string): boolean { 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' +} From 89b2d623cf20c43cc03233641eef527208129f39 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 21 Jul 2026 11:19:17 -0700 Subject: [PATCH 06/10] feat(copilot): make service-account setup a direct tag, no tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../lib/copilot/generated/tool-catalog-v1.ts | 21 ---- .../lib/copilot/generated/tool-schemas-v1.ts | 14 --- .../tool-executor/register-handlers.ts | 3 - .../lib/copilot/tools/handlers/oauth.test.ts | 4 +- apps/sim/lib/copilot/tools/handlers/oauth.ts | 4 +- .../tools/handlers/service-account.test.ts | 117 ------------------ .../copilot/tools/handlers/service-account.ts | 111 ----------------- apps/sim/lib/copilot/tools/tool-display.ts | 1 - apps/sim/lib/copilot/vfs/serializers.test.ts | 39 ++++++ apps/sim/lib/copilot/vfs/serializers.ts | 41 ++++++ apps/sim/lib/copilot/vfs/workspace-vfs.ts | 23 +++- 11 files changed, 104 insertions(+), 274 deletions(-) delete mode 100644 apps/sim/lib/copilot/tools/handlers/service-account.test.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/service-account.ts diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 1725c3f059f..6379a9ff6ff 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -91,7 +91,6 @@ export interface ToolCatalogEntry { | 'search_library_docs' | 'search_online' | 'search_patterns' - | 'service_account_get_setup_link' | 'set_block_enabled' | 'set_environment_variables' | 'set_global_workflow_variables' @@ -190,7 +189,6 @@ export interface ToolCatalogEntry { | 'search_library_docs' | 'search_online' | 'search_patterns' - | 'service_account_get_setup_link' | 'set_block_enabled' | 'set_environment_variables' | 'set_global_workflow_variables' @@ -3830,24 +3828,6 @@ export const SearchPatterns: ToolCatalogEntry = { }, } -export const ServiceAccountGetSetupLink: ToolCatalogEntry = { - id: 'service_account_get_setup_link', - name: 'service_account_get_setup_link', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - providerName: { - type: 'string', - description: - 'The integration to set up a service account for. Pass the most specific service the user needs (e.g. `google-sheets`, `gmail`, `jira`, `slack`, `notion`); a slug, OAuth provider value, service-account provider id, or display name resolves case-insensitively.', - }, - }, - required: ['providerName'], - }, -} - export const SetBlockEnabled: ToolCatalogEntry = { id: 'set_block_enabled', name: 'set_block_enabled', @@ -4952,7 +4932,6 @@ export const TOOL_CATALOG: Record = { [SearchLibraryDocs.id]: SearchLibraryDocs, [SearchOnline.id]: SearchOnline, [SearchPatterns.id]: SearchPatterns, - [ServiceAccountGetSetupLink.id]: ServiceAccountGetSetupLink, [SetBlockEnabled.id]: SetBlockEnabled, [SetEnvironmentVariables.id]: SetEnvironmentVariables, [SetGlobalWorkflowVariables.id]: SetGlobalWorkflowVariables, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 694a1354dcc..95caf84ac66 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -3618,20 +3618,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - service_account_get_setup_link: { - parameters: { - type: 'object', - properties: { - providerName: { - type: 'string', - description: - 'The integration to set up a service account for. Pass the most specific service the user needs (e.g. `google-sheets`, `gmail`, `jira`, `slack`, `notion`); a slug, OAuth provider value, service-account provider id, or display name resolves case-insensitively.', - }, - }, - required: ['providerName'], - }, - resultSchema: undefined, - }, set_block_enabled: { parameters: { type: 'object', diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts index 01c6fea86d0..7b5c9086716 100644 --- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts +++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts @@ -48,7 +48,6 @@ import { RunFromBlock, RunWorkflow, RunWorkflowUntilBlock, - ServiceAccountGetSetupLink, SetBlockEnabled, SetGlobalWorkflowVariables, UpdateDeploymentVersion, @@ -93,7 +92,6 @@ import { executeGetPlatformActions } from '../tools/handlers/platform' import { executeOpenResource } from '../tools/handlers/resources' import { executeRestoreResource } from '../tools/handlers/restore-resource' import { executeRunCode } from '../tools/handlers/run-code' -import { executeServiceAccountGetSetupLink } from '../tools/handlers/service-account' import { executeVfsGlob, executeVfsGrep, executeVfsRead } from '../tools/handlers/vfs' import { executeVfsCp, executeVfsMkdir, executeVfsMv } from '../tools/handlers/vfs-mutate' import { @@ -197,7 +195,6 @@ function buildHandlerMap(): Record { [ManageCredential.id]: h(executeManageCredential), [OauthGetAuthLink.id]: h(executeOAuthGetAuthLink), [OauthRequestAccess.id]: h(executeOAuthRequestAccess), - [ServiceAccountGetSetupLink.id]: h(executeServiceAccountGetSetupLink), [OpenResource.id]: h(executeOpenResource), [RestoreResource.id]: h(executeRestoreResource), [GetPlatformActions.id]: h(executeGetPlatformActions), diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.test.ts b/apps/sim/lib/copilot/tools/handlers/oauth.test.ts index b739a578d70..9b86268b23b 100644 --- a/apps/sim/lib/copilot/tools/handlers/oauth.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/oauth.test.ts @@ -229,7 +229,7 @@ describe('executeOAuthGetAuthLink service account rejection', () => { expect(result.success).toBe(false) expect(result.error).toContain('service account') - expect(result.error).toContain('service_account_get_setup_link') + expect(result.error).toContain('service_account credential tag') // The failure output must not carry an authorize URL the agent could // surface as a Connect button anyway. expect((result.output as { setup_url?: string }).setup_url).toBeUndefined() @@ -247,7 +247,7 @@ describe('executeOAuthGetAuthLink service account rejection', () => { ])('rejects %s', async (providerName) => { const result = await executeOAuthGetAuthLink({ providerName }, context) expect(result.success).toBe(false) - expect(result.error).toContain('service_account_get_setup_link') + expect(result.error).toContain('service_account credential tag') }) it('still resolves ordinary OAuth providers for integrations that also offer a service account', async () => { diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.ts b/apps/sim/lib/copilot/tools/handlers/oauth.ts index a5dfc262610..165ea83abcf 100644 --- a/apps/sim/lib/copilot/tools/handlers/oauth.ts +++ b/apps/sim/lib/copilot/tools/handlers/oauth.ts @@ -115,8 +115,8 @@ async function generateOAuthLink( if (isServiceAccountProviderId(normalizedInput)) { throw new Error( `"${providerName}" is a service account, not an OAuth provider. ` + - `Call service_account_get_setup_link with this provider instead — ` + - `it returns a link that opens the service account setup form.` + `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.` ) } diff --git a/apps/sim/lib/copilot/tools/handlers/service-account.test.ts b/apps/sim/lib/copilot/tools/handlers/service-account.test.ts deleted file mode 100644 index 96e6df07543..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/service-account.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockEnsureWorkspaceAccess, mockGetBlockVisibility, mockGetBlock } = vi.hoisted(() => ({ - mockEnsureWorkspaceAccess: vi.fn(), - mockGetBlockVisibility: vi.fn(), - mockGetBlock: vi.fn(), -})) - -vi.mock('@/lib/copilot/tools/handlers/access', () => ({ - ensureWorkspaceAccess: mockEnsureWorkspaceAccess, -})) - -vi.mock('@/lib/copilot/block-visibility', () => ({ - getBlockVisibilityForCopilot: mockGetBlockVisibility, -})) - -vi.mock('@/blocks', () => ({ - getBlock: mockGetBlock, -})) - -import type { ExecutionContext } from '@/lib/copilot/request/types' -import { executeServiceAccountGetSetupLink } from '@/lib/copilot/tools/handlers/service-account' - -const BASE_URL = 'https://sim.test' -const context = { - workspaceId: 'ws-1', - userId: 'user-1', - chatId: 'chat-1', -} as unknown as ExecutionContext - -/** slack_v2 revealed → visible. Empty → preview-hidden (fail-closed). */ -function visibility(revealed: string[] = []) { - return { - revealed: new Set(revealed), - disabled: new Set(), - previewTagged: new Set(), - } -} - -interface Output { - setup_url?: string - provider?: string - providerId?: string - serviceAccountProviderId?: string - connectNoun?: string - instructions?: string -} - -describe('executeServiceAccountGetSetupLink', () => { - beforeEach(() => { - vi.clearAllMocks() - process.env.NEXT_PUBLIC_APP_URL = BASE_URL - mockEnsureWorkspaceAccess.mockResolvedValue({ canWrite: true }) - mockGetBlockVisibility.mockResolvedValue(visibility()) - mockGetBlock.mockReturnValue({ type: 'slack_v2', preview: true }) - }) - - it('resolves an ungated provider to an in-chat setup tag, not a bare link', async () => { - const result = await executeServiceAccountGetSetupLink({ providerName: 'notion' }, context) - - expect(result.success).toBe(true) - const output = result.output as Output - // The provider on the tag is the OAuth provider value so the button label - // resolves; the service-account id rides alongside. - expect(output.providerId).toBe('notion') - expect(output.serviceAccountProviderId).toBe('notion-service-account') - expect(output.setup_url).toContain('/integrations/notion?connect=service-account') - // The agent is steered to emit the tag, not surface the URL as a link. - expect(output.instructions).toContain('service_account') - // The tool carries the secret's noun so the agent can tell the user what to - // prepare — this is the discovery surface, replacing the reverted VFS field. - expect(output.connectNoun).toBe('integration secret') - expect(output.instructions).toContain('integration secret') - // An ungated provider never consults block visibility. - expect(mockGetBlockVisibility).not.toHaveBeenCalled() - }) - - it('rejects an unsupported provider', async () => { - const result = await executeServiceAccountGetSetupLink({ providerName: 'github' }, context) - expect(result.success).toBe(false) - expect(result.error).toContain('no service account flow') - }) - - describe('preview gating (slack custom bot ↔ slack_v2)', () => { - it('rejects slack-custom-bot when slack_v2 is preview-hidden, so the agent falls back to OAuth', async () => { - // This is the exact production symptom: the tool used to return success, - // the agent said "here's the setup form", and the in-chat button hid - // itself because slack_v2 is preview-gated — leaving no form at all. - mockGetBlockVisibility.mockResolvedValue(visibility([])) - - const result = await executeServiceAccountGetSetupLink({ providerName: 'slack' }, context) - - expect(result.success).toBe(false) - expect(result.error).toContain('OAuth') - expect((result.output as Output).setup_url).toContain('/integrations') - // Crucially no service-account setup surface is offered. - expect((result.output as Output).instructions).toBeUndefined() - }) - - it('offers slack-custom-bot once slack_v2 is revealed for the viewer', async () => { - mockGetBlockVisibility.mockResolvedValue(visibility(['slack_v2'])) - - const result = await executeServiceAccountGetSetupLink({ providerName: 'slack' }, context) - - expect(result.success).toBe(true) - expect((result.output as Output).serviceAccountProviderId).toBe('slack-custom-bot') - }) - - it('does not consult visibility for a non-slack provider', async () => { - await executeServiceAccountGetSetupLink({ providerName: 'google-sheets' }, context) - expect(mockGetBlockVisibility).not.toHaveBeenCalled() - }) - }) -}) diff --git a/apps/sim/lib/copilot/tools/handlers/service-account.ts b/apps/sim/lib/copilot/tools/handlers/service-account.ts deleted file mode 100644 index 007113bcba2..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/service-account.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { toError } from '@sim/utils/errors' -import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility' -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' -import { getBaseUrl } from '@/lib/core/utils/urls' -import { - getServiceAccountConnectNoun, - getServiceAccountGatingBlockType, -} from '@/lib/credentials/service-account-provider-ids' -import { - listServiceAccountIntegrationNames, - resolveServiceAccountIntegration, -} from '@/lib/integrations/oauth-service' -import { - CONNECT_MODE, - CONNECT_QUERY_PARAM, -} from '@/app/workspace/[workspaceId]/integrations/connect-route' -import { getBlock } from '@/blocks' -import { isHiddenUnder } from '@/blocks/visibility/context' - -/** - * Returns a link that opens the integration detail page with the - * service-account connect modal already open, so the user supplies the key - * material in Sim's own form. The agent never receives or relays the secret — - * it only hands over the link, which it surfaces as a `` link tag. - */ -export async function executeServiceAccountGetSetupLink( - rawParams: Record, - context: ExecutionContext -): Promise { - const providerName = String(rawParams.providerName || rawParams.provider_name || '') - const baseUrl = getBaseUrl() - - try { - if (!context.workspaceId || !context.userId) { - throw new Error('workspaceId and userId are required to generate a service account link') - } - - // Connecting a credential mutates the workspace, so gate on write here - // rather than letting the user discover they lack access after clicking. - await ensureWorkspaceAccess(context.workspaceId, context.userId, 'write') - - const match = resolveServiceAccountIntegration(providerName) - if (!match) { - throw new Error( - `"${providerName}" has no service account flow. Integrations that do: ` + - `${listServiceAccountIntegrationNames().join(', ')}. Use oauth_get_auth_link instead.` - ) - } - - // The in-chat button hides itself when the provider's gating block is - // preview-hidden for the viewer (a custom Slack bot needs slack_v2). Reject - // here on the same predicate so the tool never reports success for a form - // the UI will silently drop — the agent would promise a form that never - // appears. Fall the agent back to OAuth instead. - const gatingBlockType = getServiceAccountGatingBlockType(match.serviceAccountProviderId) - if (gatingBlockType) { - const visibility = await getBlockVisibilityForCopilot(context.userId, context.workspaceId) - const gatingBlock = getBlock(gatingBlockType) - if (!gatingBlock || isHiddenUnder(visibility, gatingBlock)) { - throw new Error( - `${match.serviceName} service account setup isn't available in this workspace yet. ` + - `Use oauth_get_auth_link to connect ${match.serviceName} via OAuth instead.` - ) - } - } - - const url = new URL(`${baseUrl}/workspace/${context.workspaceId}/integrations/${match.slug}`) - url.searchParams.set(CONNECT_QUERY_PARAM, CONNECT_MODE.serviceAccount) - - const connectNoun = getServiceAccountConnectNoun(match.serviceAccountProviderId) - - return { - success: true, - output: { - message: `Service account setup available for ${match.serviceName}.`, - instructions: - `Emit {"type":"service_account","provider":"${match.providerId}"} ` + - `to open the ${match.serviceName} setup form directly in this chat. Only fall back to ` + - `setup_url when you cannot render a tag (headless/MCP). Before or alongside the tag, tell ` + - `the user they'll need a ${connectNoun} — the form collects it, so never ask them to paste ` + - `key material into chat.`, - provider: match.serviceName, - // The OAuth provider value, NOT the service-account provider id — both - // the tag renderer and the link renderer resolve display metadata from - // this, and a service-account id resolves to whichever family member is - // registered first: `google-service-account` labels Google Sheets "Gmail". - providerId: match.providerId, - serviceAccountProviderId: match.serviceAccountProviderId, - // The vendor noun for the secret the form collects ("private app token", - // "server-to-server app"), so the agent can tell the user what to prepare. - connectNoun, - /** Headless fallback only; interactive chat should emit the tag. */ - setup_url: url.toString(), - }, - } - } catch (err) { - const workspaceUrl = context.workspaceId - ? `${baseUrl}/workspace/${context.workspaceId}/integrations` - : `${baseUrl}/workspace` - return { - success: false, - error: toError(err).message, - output: { - message: `Could not generate a service account setup link for ${providerName}. Browse the integrations page instead.`, - setup_url: workspaceUrl, - error: toError(err).message, - }, - } - } -} diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index efda28e3aee..5aee46abeb9 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -467,7 +467,6 @@ const TOOL_TITLES: Record = { run_block: 'Running block', search_documentation: 'Searching documentation', search_patterns: 'Searching patterns', - service_account_get_setup_link: 'Getting service account setup link', set_block_enabled: 'Toggling block', set_environment_variables: 'Setting environment variables', set_global_workflow_variables: 'Setting workflow variables', diff --git a/apps/sim/lib/copilot/vfs/serializers.test.ts b/apps/sim/lib/copilot/vfs/serializers.test.ts index 728db16cd6d..b33cb287b79 100644 --- a/apps/sim/lib/copilot/vfs/serializers.test.ts +++ b/apps/sim/lib/copilot/vfs/serializers.test.ts @@ -284,3 +284,42 @@ 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() + }) + + it('omits serviceAccount when the flow is gated by a preview block (slack custom bot ↔ slack_v2)', () => { + // slack_v2 is preview: true, so the shared schema must not leak the custom + // bot — parallel to how preview tools stay out of the shared aggregates. + const schema = JSON.parse(serializeIntegrationSchema(oauthTool('slack_send', 'slack'))) + expect(schema.auth.type).toBe('oauth') + expect(schema.auth.serviceAccount).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index 6e3ff25277c..f25d0317e79 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -1,18 +1,38 @@ 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 { 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 +42,25 @@ 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 && (getBlock(gatingBlockType)?.preview ?? true)) return undefined + return { connectNoun: getServiceAccountConnectNoun(serviceAccountProviderId) } +} + export interface ComponentSerializationOptions { hosted?: boolean toolConfigs?: ReadonlyMap @@ -33,10 +72,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 } : {}), } } diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index fb8e7550199..81c5303c410 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -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 { let integrationCount = 0 - const oauthServices = new Map() + // `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 { 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), + }) } } } From f87a46877d0d55b378e11ce5d50efe5676c262d7 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 21 Jul 2026 15:45:28 -0700 Subject: [PATCH 07/10] feat(copilot): support service-account reconnect from chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../special-tags/special-tags.test.ts | 20 ++++++++++ .../components/special-tags/special-tags.tsx | 24 ++++++++++-- apps/sim/lib/copilot/vfs/serializers.test.ts | 39 +++++++++++++++++++ apps/sim/lib/copilot/vfs/serializers.ts | 6 +++ apps/sim/lib/copilot/vfs/workspace-vfs.ts | 1 + apps/sim/lib/credentials/environment.ts | 6 +++ 6 files changed, 92 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts index a1a80e3b959..b988a3137c6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts @@ -209,4 +209,24 @@ describe('service_account tag validation', () => { ) 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(`${body}`, 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(`${body}`, false) + expect(segments.some((segment) => segment.type === 'credential')).toBe(false) + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index 31595ca78f9..95eb2c682ae 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -108,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 { @@ -247,8 +252,10 @@ function isCredentialTagData(value: unknown): value is CredentialTagData { } // 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. + // so it never carries a `value`, but it is useless without a provider. An + // optional `credentialId` reconnects an existing service account in place. if (value.type === 'service_account') { + if (value.credentialId !== undefined && typeof value.credentialId !== 'string') 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 @@ -915,12 +922,21 @@ function ServiceAccountConnectDisplay({ data }: { data: CredentialTagData }) { 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 ( <> {open && ( @@ -943,6 +957,8 @@ function ServiceAccountConnectDisplay({ data }: { data: CredentialTagData }) { serviceAccountProviderId={target.serviceAccountProviderId} serviceName={target.serviceName} serviceIcon={target.serviceIcon} + credentialId={reconnectCredentialId} + credentialDisplayName={reconnectCredential?.displayName ?? undefined} /> )} diff --git a/apps/sim/lib/copilot/vfs/serializers.test.ts b/apps/sim/lib/copilot/vfs/serializers.test.ts index b33cb287b79..4ffea40b66b 100644 --- a/apps/sim/lib/copilot/vfs/serializers.test.ts +++ b/apps/sim/lib/copilot/vfs/serializers.test.ts @@ -8,6 +8,7 @@ import type { ToolConfig } from '@/tools/types' import { serializeApiKeyIntegrations, serializeBlockSchema, + serializeCredentials, serializeFileMeta, serializeIntegrationSchema, serializeKBMeta, @@ -323,3 +324,41 @@ describe('serializeIntegrationSchema — service-account auth', () => { expect(schema.auth.serviceAccount).toBeUndefined() }) }) + +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() + }) +}) diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index f25d0317e79..e04ad39238a 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -627,6 +627,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 { @@ -637,6 +639,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, diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 81c5303c410..b077e581649 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -2570,6 +2570,7 @@ export class WorkspaceVFS { displayName: c.displayName, role: c.role, scope: null, + credentialType: c.type, createdAt: c.updatedAt, })), ]) diff --git a/apps/sim/lib/credentials/environment.ts b/apps/sim/lib/credentials/environment.ts index 5fd7b711adf..9f2332b7cb1 100644 --- a/apps/sim/lib/credentials/environment.ts +++ b/apps/sim/lib/credentials/environment.ts @@ -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, })) } From f354e03c0f123adf83fe234ea807831cf73646a4 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 21 Jul 2026 16:25:09 -0700 Subject: [PATCH 08/10] fix(copilot): coherent service-account rejection in oauth_get_auth_link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../lib/copilot/tools/handlers/oauth.test.ts | 21 ++++++++---- apps/sim/lib/copilot/tools/handlers/oauth.ts | 32 +++++++++++-------- .../lib/integrations/oauth-service.test.ts | 10 ------ apps/sim/lib/integrations/oauth-service.ts | 8 ----- 4 files changed, 33 insertions(+), 38 deletions(-) diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.test.ts b/apps/sim/lib/copilot/tools/handlers/oauth.test.ts index 9b86268b23b..1168528e56a 100644 --- a/apps/sim/lib/copilot/tools/handlers/oauth.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/oauth.test.ts @@ -224,18 +224,20 @@ describe('executeOAuthGetAuthLink service account rejection', () => { * 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 instead of degrading to the OAuth flow', async () => { + 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') - // The failure output must not carry an authorize URL the agent could - // surface as a Connect button anyway. - expect((result.output as { setup_url?: string }).setup_url).toBeUndefined() - expect((result.output as { oauth_url: string }).oauth_url).not.toContain( - '/api/auth/oauth2/authorize' - ) + 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([ @@ -244,6 +246,11 @@ describe('executeOAuthGetAuthLink service account rejection', () => { '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) diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.ts b/apps/sim/lib/copilot/tools/handlers/oauth.ts index 165ea83abcf..e392833e6e8 100644 --- a/apps/sim/lib/copilot/tools/handlers/oauth.ts +++ b/apps/sim/lib/copilot/tools/handlers/oauth.ts @@ -15,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') @@ -107,19 +126,6 @@ async function generateOAuthLink( const allServices = getAllOAuthServices() const normalizedInput = providerName.toLowerCase().trim() - // Reject a service-account id before the fuzzy pass below can swallow it. - // That pass matches on substring containment, so `slack-custom-bot` contains - // `slack` and silently resolves to the Slack OAuth service — the tool then - // returns a personal-OAuth authorize URL, reports success, and the user - // connects their own account when they asked for a shared bot. - if (isServiceAccountProviderId(normalizedInput)) { - throw new Error( - `"${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.` - ) - } - const matched = allServices.find((s) => s.providerId === normalizedInput) || allServices.find((s) => s.name.toLowerCase() === normalizedInput) || diff --git a/apps/sim/lib/integrations/oauth-service.test.ts b/apps/sim/lib/integrations/oauth-service.test.ts index cc4a22b76c5..fbcffa8c6cf 100644 --- a/apps/sim/lib/integrations/oauth-service.test.ts +++ b/apps/sim/lib/integrations/oauth-service.test.ts @@ -4,7 +4,6 @@ import { describe, expect, it } from 'vitest' import integrationsJson from '@/lib/integrations/integrations.json' import { - listServiceAccountIntegrationNames, resolveOAuthServiceForSlug, resolveServiceAccountIntegration, } from '@/lib/integrations/oauth-service' @@ -169,13 +168,4 @@ describe('resolveServiceAccountIntegration', () => { expect(resolveServiceAccountIntegration('')).toBeNull() expect(resolveServiceAccountIntegration(' ')).toBeNull() }) - - it.concurrent('reports a service-account provider for every match it returns', () => { - const names = listServiceAccountIntegrationNames() - expect(names.length).toBeGreaterThan(0) - for (const name of names) { - const match = resolveServiceAccountIntegration(name) - expect(match?.serviceAccountProviderId).toBeTruthy() - } - }) }) diff --git a/apps/sim/lib/integrations/oauth-service.ts b/apps/sim/lib/integrations/oauth-service.ts index b313fdb8ea3..e393c0a1138 100644 --- a/apps/sim/lib/integrations/oauth-service.ts +++ b/apps/sim/lib/integrations/oauth-service.ts @@ -152,11 +152,3 @@ export function resolveServiceAccountIntegration( null ) } - -/** - * Names every integration that offers a service-account flow, for error copy - * that has to tell the agent what it could have asked for instead. - */ -export function listServiceAccountIntegrationNames(): string[] { - return SERVICE_ACCOUNT_INTEGRATIONS.map((entry) => entry.serviceName) -} From 98bb4d78019ffa30710a752f7877d94598d9121f Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 21 Jul 2026 16:38:35 -0700 Subject: [PATCH 09/10] fix(copilot): service-account discovery must un-gate after the block GAs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- apps/sim/lib/copilot/vfs/serializers.test.ts | 11 ++--- apps/sim/lib/copilot/vfs/serializers.ts | 11 ++++- .../copilot/vfs/service-account-gate.test.ts | 45 +++++++++++++++++++ 3 files changed, 59 insertions(+), 8 deletions(-) create mode 100644 apps/sim/lib/copilot/vfs/service-account-gate.test.ts diff --git a/apps/sim/lib/copilot/vfs/serializers.test.ts b/apps/sim/lib/copilot/vfs/serializers.test.ts index 4ffea40b66b..daf3c3c8086 100644 --- a/apps/sim/lib/copilot/vfs/serializers.test.ts +++ b/apps/sim/lib/copilot/vfs/serializers.test.ts @@ -316,13 +316,10 @@ describe('serializeIntegrationSchema — service-account auth', () => { expect(schema.auth.serviceAccount).toBeUndefined() }) - it('omits serviceAccount when the flow is gated by a preview block (slack custom bot ↔ slack_v2)', () => { - // slack_v2 is preview: true, so the shared schema must not leak the custom - // bot — parallel to how preview tools stay out of the shared aggregates. - const schema = JSON.parse(serializeIntegrationSchema(oauthTool('slack_send', 'slack'))) - 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', () => { diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index e04ad39238a..66b074ba3ef 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -11,6 +11,7 @@ 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' @@ -57,7 +58,15 @@ export function describeServiceAccountForOAuthProvider( const serviceAccountProviderId = getServiceAccountProviderForProviderId(oauthProvider) if (!serviceAccountProviderId) return undefined const gatingBlockType = getServiceAccountGatingBlockType(serviceAccountProviderId) - if (gatingBlockType && (getBlock(gatingBlockType)?.preview ?? true)) return undefined + 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) } } diff --git a/apps/sim/lib/copilot/vfs/service-account-gate.test.ts b/apps/sim/lib/copilot/vfs/service-account-gate.test.ts new file mode 100644 index 00000000000..e525dae124b --- /dev/null +++ b/apps/sim/lib/copilot/vfs/service-account-gate.test.ts @@ -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() + }) +}) From 523c4e927570a559efb9021e6c727ced28111116 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 21 Jul 2026 16:53:31 -0700 Subject: [PATCH 10/10] fix(copilot): align SA resolver normalization and reject blank credentialId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../components/special-tags/special-tags.test.ts | 9 +++++++++ .../components/special-tags/special-tags.tsx | 10 ++++++++-- apps/sim/lib/integrations/oauth-service.test.ts | 12 ++++++++++++ apps/sim/lib/integrations/oauth-service.ts | 16 ++++++++++++---- 4 files changed, 41 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts index b988a3137c6..2d6899c8c2a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts @@ -229,4 +229,13 @@ describe('service_account tag validation', () => { const { segments } = parseSpecialTags(`${body}`, 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(`${body}`, false) + expect(segments.some((segment) => segment.type === 'credential')).toBe(false) + } + ) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index 95eb2c682ae..ed0ca999edf 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -253,9 +253,15 @@ function isCredentialTagData(value: unknown): value is CredentialTagData { // 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. + // 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 && typeof value.credentialId !== 'string') return false + 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 diff --git a/apps/sim/lib/integrations/oauth-service.test.ts b/apps/sim/lib/integrations/oauth-service.test.ts index fbcffa8c6cf..dbd70af7c58 100644 --- a/apps/sim/lib/integrations/oauth-service.test.ts +++ b/apps/sim/lib/integrations/oauth-service.test.ts @@ -160,6 +160,18 @@ describe('resolveServiceAccountIntegration', () => { 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. diff --git a/apps/sim/lib/integrations/oauth-service.ts b/apps/sim/lib/integrations/oauth-service.ts index e393c0a1138..6ef45d07179 100644 --- a/apps/sim/lib/integrations/oauth-service.ts +++ b/apps/sim/lib/integrations/oauth-service.ts @@ -122,6 +122,11 @@ const SERVICE_ACCOUNT_INTEGRATIONS: readonly ServiceAccountIntegrationMatch[] = * 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. @@ -131,18 +136,21 @@ export function resolveServiceAccountIntegration( ): 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[query] + 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 === query) ?? - SERVICE_ACCOUNT_INTEGRATIONS.find((entry) => entry.providerId.toLowerCase() === query) ?? + 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() === query + (entry) => entry.serviceAccountProviderId.toLowerCase() === idQuery ) ?? SERVICE_ACCOUNT_INTEGRATIONS.find((entry) => entry.serviceName.toLowerCase() === query) ?? SERVICE_ACCOUNT_INTEGRATIONS.find(