diff --git a/apps/docs/content/docs/cli/knowledge.mdx b/apps/docs/content/docs/cli/knowledge.mdx index 1a03bb030e4..2d424c49bc1 100644 --- a/apps/docs/content/docs/cli/knowledge.mdx +++ b/apps/docs/content/docs/cli/knowledge.mdx @@ -1040,7 +1040,7 @@ sim knowledge connectors create [options] | Option | Required | Description | | --- | --- | --- | | `--connector-type ` | Yes | Registered connector type. | -| `--credential-id ` | No | OAuth credential identifier for connector types whose `auth.mode` is `oauth` (see connector types), or, when this deployment enables it, a saved credential for an `apiKey` connector type that declares `auth.savedCredential` (such as Amazon S3 with a saved AWS credential); otherwise omit it for `apiKey` connectors. | +| `--credential-id ` | No | OAuth credential identifier for connector types whose `auth.mode` is `oauth` (see connector types), or a saved credential for an `apiKey` connector type that declares `auth.savedCredential` (such as Amazon S3 with a saved AWS credential); otherwise omit it for `apiKey` connectors. | | `--api-key ` | No | Write-only API key for connector types whose `auth.mode` is `apiKey` (see connector types), or a personal access token for an OAuth connector that also accepts one, such as GitHub. Send it instead of `credentialId`. Pass a raw key, or a secret reference written as the whole value `{{SECRET_NAME}}`, which the server resolves; `$SECRET_NAME` is not a reference. | | `--source-config ` | Yes | Connector-specific source selection and filtering configuration. (JSON, or @path / @- to read a file or stdin). | | `--sync-interval-minutes ` | No | Scheduled synchronization interval in minutes; zero disables scheduling. Defaults to 1440. | diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index 5edce25dba9..6aaec498ffd 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -2649,7 +2649,7 @@ sim knowledge connectors create [options] | Option | Required | Description | | --- | --- | --- | | `--connector-type ` | Yes | Registered connector type. | -| `--credential-id ` | No | OAuth credential identifier for connector types whose `auth.mode` is `oauth` (see connector types), or, when this deployment enables it, a saved credential for an `apiKey` connector type that declares `auth.savedCredential` (such as Amazon S3 with a saved AWS credential); otherwise omit it for `apiKey` connectors. | +| `--credential-id ` | No | OAuth credential identifier for connector types whose `auth.mode` is `oauth` (see connector types), or a saved credential for an `apiKey` connector type that declares `auth.savedCredential` (such as Amazon S3 with a saved AWS credential); otherwise omit it for `apiKey` connectors. | | `--api-key ` | No | Write-only API key for connector types whose `auth.mode` is `apiKey` (see connector types), or a personal access token for an OAuth connector that also accepts one, such as GitHub. Send it instead of `credentialId`. Pass a raw key, or a secret reference written as the whole value `{{SECRET_NAME}}`, which the server resolves; `$SECRET_NAME` is not a reference. | | `--source-config ` | Yes | Connector-specific source selection and filtering configuration. (JSON, or @path / @- to read a file or stdin). | | `--sync-interval-minutes ` | No | Scheduled synchronization interval in minutes; zero disables scheduling. Defaults to 1440. | diff --git a/apps/docs/content/docs/knowledgebase/connectors.mdx b/apps/docs/content/docs/knowledgebase/connectors.mdx index 8c98e6ced2e..828d59cf760 100644 --- a/apps/docs/content/docs/knowledgebase/connectors.mdx +++ b/apps/docs/content/docs/knowledgebase/connectors.mdx @@ -46,7 +46,7 @@ API-key and personal-access-token connections use the credential shown in their | Azure DevOps | PAT with Project and Team Read, Wiki Read, Work Items Read, and Code Read. | | GitLab | PAT with `read_api` and access to the selected project; permission-aware methods have additional requirements. | | YouTube | YouTube Data API key from Google Cloud Console. | -| Amazon S3 | Secret Access Key; enter Access Key ID, region, and bucket as configuration fields. Where enabled, **Authentication → Saved AWS credential** uses a workspace AWS credential instead (access keys, an assumed IAM role, or a keyless role trusted through Sim OIDC); it signs only for Amazon S3 in a commercial or GovCloud AWS region (not China or isolated regions), so it has no Access Key ID or custom endpoint. Either way, the identity needs `s3:ListBucket` on the bucket and `s3:GetObject` on the synced objects. | +| Amazon S3 | Secret Access Key; enter Access Key ID, region, and bucket as configuration fields. **Authentication → Saved AWS credential** uses a workspace AWS credential instead (access keys, an assumed IAM role, or a keyless role trusted through Sim OIDC); it signs only for Amazon S3 in a commercial or GovCloud AWS region (not China or isolated regions), so it has no Access Key ID or custom endpoint. Either way, the identity needs `s3:ListBucket` on the bucket and `s3:GetObject` on the synced objects. | | Sentry | Auth token with `project:read` and `event:read`. | | PagerDuty | Create a [REST API key](https://support.pagerduty.com/main/docs/api-access-keys) under **Integrations → Developer Tools → API Access Keys**. | | SFTP | Password or unencrypted private key, plus a required SHA-256 host-key fingerprint; enter host, port, username, and root path as configuration fields. | diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index b490259f54c..68cbae44ef8 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -7155,7 +7155,7 @@ "description": "Registered connector type." }, "credentialId": { - "description": "OAuth credential identifier for connector types whose `auth.mode` is `oauth` (see connector types), or, when this deployment enables it, a saved credential for an `apiKey` connector type that declares `auth.savedCredential` (such as Amazon S3 with a saved AWS credential); otherwise omit it for `apiKey` connectors.", + "description": "OAuth credential identifier for connector types whose `auth.mode` is `oauth` (see connector types), or a saved credential for an `apiKey` connector type that declares `auth.savedCredential` (such as Amazon S3 with a saved AWS credential); otherwise omit it for `apiKey` connectors.", "type": "string", "minLength": 1, "maxLength": 255 diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 5e57cb57234..b14b5b9aac3 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -106,7 +106,6 @@ import { kbDocumentSortParams, } from '@/app/workspace/[workspaceId]/knowledge/[id]/search-params' import { canDeleteKnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/permissions' -import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { BrandIcon } from '@/blocks/brand-icon' @@ -421,7 +420,6 @@ export function KnowledgeBase({ const [showDocumentTagsModal, setShowDocumentTagsModal] = useState(false) const [documentForTagsId, setDocumentForTagsId] = useState(null) const showAddConnectorModal = addConnectorType != null - const savedCredentialSetupEnabled = useFeatureFlag('knowledge-connector-saved-credentials') const updateAddConnectorParam = useCallback( (value: string | null) => { void setAddConnectorType(value, { history: 'replace', scroll: false }) @@ -1474,7 +1472,6 @@ export function KnowledgeBase({ knowledgeBaseId={id} initialConnectorType={addConnectorType || undefined} isSearchIndex={knowledgeBase?.isSearchIndex} - savedCredentialSetupEnabled={savedCredentialSetupEnabled} /> )} @@ -1516,7 +1513,6 @@ export function KnowledgeBase({ isLoading={isLoadingConnectors} canEdit={userPermissions.canEdit} className='mt-0' - savedCredentialSetupEnabled={savedCredentialSetupEnabled} /> diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx index 2950343d65a..c9dfaf6988b 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx @@ -113,8 +113,6 @@ interface AddConnectorModalProps { initialSyncIntervalMinutes?: number onCreated?: (connectorType: string, connector: ConnectorData) => void setupDraftKey?: string - /** Offers a key connector's saved-credential alternative, such as a saved AWS credential for S3. */ - savedCredentialSetupEnabled?: boolean } type Step = 'select-type' | 'configure' @@ -131,7 +129,6 @@ export function AddConnectorModal({ lockedAccessMode, initialSyncIntervalMinutes = 1440, onCreated, - savedCredentialSetupEnabled = false, setupDraftKey, scope: explicitScope, }: AddConnectorModalProps) { @@ -221,9 +218,7 @@ export function AddConnectorModal({ const isMembersMode = access.accessMode === 'members' const apiKeyConfig = connectorConfig ? getConnectorApiKeyConfig(connectorConfig.auth) : undefined const savedCredentialConfig = - savedCredentialSetupEnabled && connectorConfig?.auth.mode === 'apiKey' - ? connectorConfig.auth.savedCredential - : undefined + connectorConfig?.auth.mode === 'apiKey' ? connectorConfig.auth.savedCredential : undefined const isApiKeyMode = connectorConfig?.auth.mode === 'apiKey' ? !savedCredentialConfig || useApiKey diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx index b659418ae9e..5eec846a5e9 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx @@ -31,7 +31,6 @@ interface ConnectorsSectionProps { isLoading: boolean canEdit: boolean className?: string - savedCredentialSetupEnabled?: boolean } const STATUS_CONFIG = { @@ -52,7 +51,6 @@ export function ConnectorsSection({ isLoading, canEdit, className, - savedCredentialSetupEnabled = false, }: ConnectorsSectionProps) { const scope = explicitScope ?? resourceScopeFromOwner({ workspaceId }) const [editingConnector, setEditingConnector] = useState(null) @@ -86,7 +84,6 @@ export function ConnectorsSection({ knowledgeBaseId={knowledgeBaseId} isSearchIndex={isSearchIndex} connector={editingConnector} - savedCredentialSetupEnabled={savedCredentialSetupEnabled} /> )} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx index 045814e9628..4c6b9471546 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx @@ -26,7 +26,6 @@ interface EditConnectorModalProps { knowledgeBaseId: string isSearchIndex?: boolean connector: ConnectorData - savedCredentialSetupEnabled?: boolean } export function EditConnectorModal({ @@ -36,7 +35,6 @@ export function EditConnectorModal({ isSearchIndex = false, connector, scope, - savedCredentialSetupEnabled = false, }: EditConnectorModalProps) { const [activeTab, setActiveTab] = useState('settings') const [documentFilter, setDocumentFilter] = useState('active') @@ -46,7 +44,6 @@ export function EditConnectorModal({ isSearchIndex, connector, onSaved: () => onOpenChange(false), - savedCredentialSetupEnabled, }) return ( diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/use-connector-settings-form.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/use-connector-settings-form.ts index 8cad515af54..59a35285c5b 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/use-connector-settings-form.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/use-connector-settings-form.ts @@ -127,8 +127,6 @@ interface UseConnectorSettingsFormOptions { connector: ConnectorData onSaved: (connector: ConnectorData) => void syncing?: boolean - /** Whether a key connector's saved credential may be replaced; the server refuses it while setup is off. */ - savedCredentialSetupEnabled?: boolean } /** Editable connector settings shared by the source page and knowledge-base modal. */ @@ -139,7 +137,6 @@ export function useConnectorSettingsForm({ connector, onSaved, syncing = isConnectorSyncingOrPending(connector), - savedCredentialSetupEnabled = false, }: UseConnectorSettingsFormOptions) { const liveSearch = isSearchIndex const connectorConfig = liveSearchSourceMeta( @@ -297,7 +294,7 @@ export function useConnectorSettingsForm({ accessDirty && !accessModeChanged && isContentEngineAccessMode(access.accessMode) /** Exposes credential selection for mode changes and administrator credential recovery. */ const needsWorkspaceCredential = - (usesSavedCredential && savedCredentialSetupEnabled) || + usesSavedCredential || (connectorConfig?.auth.mode === 'oauth' && isContentEngineAccessMode(access.accessMode) && (persistedAccess.accessMode === 'members' || diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index 016a072e5e2..1bdf799711e 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -12,7 +12,6 @@ import { hasDesktopBackgroundExecutor, isDesktopBackgroundExecutorAvailable, } from '@/lib/desktop/executor/availability' -import { isConnectorSavedCredentialSetupEnabled } from '@/lib/knowledge/connectors/saved-credential-rollout' import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags' import { resolveOrganizationEntryPath } from '@/lib/navigation/resolve-app-entry' import { isProjectsEnabled } from '@/lib/projects/rollout.server' @@ -136,7 +135,6 @@ async function WorkspaceContent({ desktopExecutorRegistered, projectsEnabled, simLinkOrganizationId, - connectorSavedCredentialsEnabled, ] = await Promise.all([ cookies(), orgSettings, @@ -156,7 +154,6 @@ async function WorkspaceContent({ hasDesktopBackgroundExecutor(session.user.id), isProjectsEnabled(), resolveSimLinkOrganization(principal), - isConnectorSavedCredentialSetupEnabled(), prefetchWorkspaceAccess(queryClient, workspaceId, principal), prefetchWorkspaceForkAvailability(queryClient, workspaceId, principal, hostContext), ]) @@ -172,7 +169,6 @@ async function WorkspaceContent({ 'mothership-model-selector': modelSelectorEnabled, 'mothership-plan-mode': planModeEnabled, projects: projectsEnabled, - 'knowledge-connector-saved-credentials': connectorSavedCredentialsEnabled, }} > diff --git a/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx b/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx index 02ba4adb864..5b243f9dda5 100644 --- a/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx +++ b/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx @@ -10,7 +10,6 @@ export interface WorkspaceFeatureFlags { 'mothership-plan-mode': boolean 'mothership-memory-spaces'?: boolean projects?: boolean - 'knowledge-connector-saved-credentials'?: boolean } const FeatureFlagsContext = createContext(null) diff --git a/apps/sim/connectors/aws-session.ts b/apps/sim/connectors/aws-session.ts index e0289d5b84f..d8a3aade355 100644 --- a/apps/sim/connectors/aws-session.ts +++ b/apps/sim/connectors/aws-session.ts @@ -1,3 +1,5 @@ +import { type AwsSigningKeys, awsSigningKeys } from '@/lib/internal/aws/signing-keys' + /** * The AWS keys a saved AWS credential resolved to for one connector run. * @@ -8,24 +10,13 @@ * from one place and never from tool or user input. Server-only state: the * slot is never persisted or logged. */ -export interface ConnectorAwsSession { - accessKeyId: string - secretAccessKey: string - /** Present for STS sessions (assumed and keyless roles); absent for saved long-lived keys. */ - sessionToken?: string -} +export type ConnectorAwsSession = AwsSigningKeys const AWS_SESSION_CONTEXT_KEY = 'credentialAwsSession' /** The `syncContext` entries that carry a resolved AWS session to a connector. */ export function awsSessionSyncContext(session: ConnectorAwsSession): Record { - return { - [AWS_SESSION_CONTEXT_KEY]: { - accessKeyId: session.accessKeyId, - secretAccessKey: session.secretAccessKey, - ...(session.sessionToken ? { sessionToken: session.sessionToken } : {}), - }, - } + return { [AWS_SESSION_CONTEXT_KEY]: awsSigningKeys(session) } } /** The AWS session seeded into this run, or undefined when the connector signs with inline keys. */ @@ -40,5 +31,5 @@ export function readAwsSession( if (sessionToken !== undefined && (typeof sessionToken !== 'string' || !sessionToken)) { return undefined } - return { accessKeyId, secretAccessKey, ...(sessionToken ? { sessionToken } : {}) } + return awsSigningKeys({ accessKeyId, secretAccessKey, sessionToken }) } diff --git a/apps/sim/executor/handlers/agent/handler.test.ts b/apps/sim/executor/handlers/agent/handler.test.ts index 85c92e38033..7b796c72202 100644 --- a/apps/sim/executor/handlers/agent/handler.test.ts +++ b/apps/sim/executor/handlers/agent/handler.test.ts @@ -1228,6 +1228,35 @@ describe('AgentBlockHandler', () => { expect(mockResolveBedrockCredentialSession).toHaveBeenCalledTimes(1) }) + it('resolves the AWS credential once for a fallback on the same provider', async () => { + const session = { + credentialId: 'aws-cred', + accessKeyId: 'ASIASESSIONKEY000001', + secretAccessKey: 'session-secret', + sessionToken: 'session-token', + } + mockResolveBedrockCredentialSession.mockResolvedValueOnce(session) + mockExecuteProviderRequest + .mockRejectedValueOnce(new Error('overloaded')) + .mockResolvedValueOnce(providerResponse('bedrock/claude-fallback')) + + await handler.execute(mockContext, mockBlock, { + ...baseInputs, + model: 'bedrock/claude', + bedrockAuthMode: 'credential', + bedrockCredential: 'aws-cred', + fallbackModels: [{ model: 'bedrock/claude-fallback' }], + }) + + expect(mockResolveBedrockCredentialSession).toHaveBeenCalledTimes(1) + expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(2) + const [[primaryProvider, primaryRequest], [fallbackProvider, fallbackRequest]] = + mockExecuteProviderRequest.mock.calls + expect([primaryProvider, fallbackProvider]).toEqual(['bedrock', 'bedrock']) + expect(primaryRequest.bedrockSession).toEqual(session) + expect(fallbackRequest.bedrockSession).toEqual(session) + }) + it('re-resolves tuning for the fallback: row value wins, caps clamp, undeclared values drop', async () => { const fallbackCap = getModelCapabilities('gpt-5.4-mini')?.maxOutputTokens expect(fallbackCap).toEqual(expect.any(Number)) diff --git a/apps/sim/executor/handlers/agent/handler.ts b/apps/sim/executor/handlers/agent/handler.ts index 03da301096e..bf1e876c31f 100644 --- a/apps/sim/executor/handlers/agent/handler.ts +++ b/apps/sim/executor/handlers/agent/handler.ts @@ -95,6 +95,7 @@ import { collectBlockData } from '@/executor/utils/block-data' import { stringifyJSON } from '@/executor/utils/json' import { BLOCK_MODEL_CREDENTIAL_FIELDS, + type BlockModelCredentials, resolveBlockModelCredentials, } from '@/executor/utils/model-credentials' import { @@ -2610,6 +2611,11 @@ export class AgentBlockHandler implements BlockHandler { * blacklisted, not permitted, or cannot take the attachments is skipped * rather than counted as a failed try. * + * The block's saved Vertex or Bedrock credential resolves at most once per + * call and is shared by every candidate on the primary's provider; it is + * awaited inside the attempt, so a failure fails those attempts and other + * providers' fallbacks still run. It never reaches another provider. + * * A streaming candidate is accepted only once its first chunk has arrived * (`primeStreamingExecution`), so a startup failure inside the stream still * falls through; that wait is skipped when no candidate follows, which keeps @@ -2635,6 +2641,7 @@ export class AgentBlockHandler implements BlockHandler { let resultRegistry = config.resultRegistry const failedModels: string[] = [] let lastError: unknown + let savedCredentials: Promise | undefined let lastErrorRegistries: | { error: ResolvedSecretTraceRegistry | undefined @@ -2754,6 +2761,16 @@ export class AgentBlockHandler implements BlockHandler { streaming: config.streaming, }) + const onPrimaryProvider = candidateProviderId === config.primaryProviderId + if (onPrimaryProvider) { + savedCredentials ??= resolveBlockModelCredentials( + ctx, + config.primaryProviderId, + config.modelInputs, + 'vertex-agent' + ) + } + try { let result = await this.executeProviderRequest( ctx, @@ -2762,6 +2779,7 @@ export class AgentBlockHandler implements BlockHandler { config.responseFormat, resultRegistry, config.providerErrorRegistry, + onPrimaryProvider ? savedCredentials : undefined, config.agentConversation ) if ((hasNext || config.retryPrimaryOnStreamStart) && this.isStreamingExecution(result)) { @@ -2983,18 +3001,14 @@ export class AgentBlockHandler implements BlockHandler { responseFormat: any, modelRuntimeRegistry: ResolvedSecretTraceRegistry | undefined, providerErrorRegistry: ResolvedSecretTraceRegistry | undefined, + savedCredentials: Promise | undefined, agentConversation?: AgentTurnSession ): Promise { const providerId = providerRequest.provider const model = providerRequest.model try { - const credentials = await resolveBlockModelCredentials( - ctx, - providerId, - providerRequest, - 'vertex-agent' - ) + const credentials: BlockModelCredentials = (await savedCredentials) ?? {} const { blockData, blockNameMapping } = collectBlockData(ctx) const agentMemoryRetrieval = agentConversation?.memoryId diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts index e09ab863dca..bdf4eed2d5e 100644 --- a/apps/sim/lib/api/contracts/v2/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -1934,7 +1934,7 @@ export const v2CreateKnowledgeConnectorBodySchema = z .max(255) .optional() .describe( - 'OAuth credential identifier for connector types whose `auth.mode` is `oauth` (see connector types), or, when this deployment enables it, a saved credential for an `apiKey` connector type that declares `auth.savedCredential` (such as Amazon S3 with a saved AWS credential); otherwise omit it for `apiKey` connectors.' + 'OAuth credential identifier for connector types whose `auth.mode` is `oauth` (see connector types), or a saved credential for an `apiKey` connector type that declares `auth.savedCredential` (such as Amazon S3 with a saved AWS credential); otherwise omit it for `apiKey` connectors.' ), apiKey: z .string() diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 62c24e54e99..5606ea7b2c2 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -667,7 +667,6 @@ export const env = createEnv({ AGENT_MEMORY_HISTORY: z.boolean().optional(), CREDENTIAL_GROUPS: z.boolean().optional(), // Enable Credential Groups on self-hosted (bypasses hosted requirements) KNOWLEDGE_MEMBER_ACCESS: z.boolean().optional(), // Enable per-member knowledge connectors and hybrid-by-default retrieval globally - KNOWLEDGE_CONNECTOR_SAVED_CREDENTIALS: z.boolean().optional(), // Fallback for the `knowledge-connector-saved-credentials` feature flag off AppConfig // Organizations - for self-hosted deployments ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements) diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index f2c79eb2d10..7e17073ae8b 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -108,17 +108,6 @@ const FEATURE_FLAGS = { 'Off-AppConfig falls back to PROJECTS.', fallback: 'PROJECTS', }, - 'knowledge-connector-saved-credentials': { - description: - 'Let a key-based knowledge connector that declares a saved-credential alternative (Amazon S3 ' + - 'with a saved AWS credential) be set up with that credential. Global on/off only; gates new ' + - 'setup and credential changes, never syncing of connectors already using a credential. Turn ' + - 'on only after the release that adds it is live on both the app and Trigger.dev workers. ' + - 'Once any connector uses a saved credential, rolling back to an earlier release is unsafe ' + - 'even with the flag off: older workers unschedule those connectors as credential-removed. ' + - 'Off-AppConfig falls back to KNOWLEDGE_CONNECTOR_SAVED_CREDENTIALS.', - fallback: 'KNOWLEDGE_CONNECTOR_SAVED_CREDENTIALS', - }, 'knowledge-member-access': { description: 'Organization Search (live) and the permission-aware workspace connector modes: members ' + diff --git a/apps/sim/lib/credentials/application/provider-catalog-contract.test.ts b/apps/sim/lib/credentials/application/provider-catalog-contract.test.ts index bcb7b3290c0..d222ff5d14d 100644 --- a/apps/sim/lib/credentials/application/provider-catalog-contract.test.ts +++ b/apps/sim/lib/credentials/application/provider-catalog-contract.test.ts @@ -27,6 +27,7 @@ import { listCredentialProviders } from '@/lib/credentials/application/list-cred import { listCredentialProviderCatalog } from '@/lib/credentials/application/provider-catalog' import { DATABASE_CONNECTION_DESCRIPTORS } from '@/lib/credentials/database-connections/descriptors' import { getMissingServiceAccountFields } from '@/lib/credentials/service-account-fields' +import { TOKEN_SERVICE_ACCOUNT_DESCRIPTORS } from '@/lib/credentials/token-service-accounts/descriptors' import { isWorkloadIdentityServiceAccountProviderId, WORKLOAD_IDENTITY_KEYLESS_CONFIGS, @@ -217,6 +218,47 @@ describe('v2 credential provider catalog contract', () => { ).toBeUndefined() }) + it('lists the client-credentials mode of every provider that offers one, as creation reads it', async () => { + const providers = await listProviders() + expect(responseSchema.safeParse({ data: providers, nextCursor: null }).success).toBe(true) + const offering = Object.values(TOKEN_SERVICE_ACCOUNT_DESCRIPTORS).filter( + (descriptor) => descriptor.clientCredentials + ) + expect(offering.length).toBeGreaterThan(0) + + for (const providerId of [ + ...offering.map((descriptor) => descriptor.providerId), + 'atlassian-service-account', + ]) { + const provider = providers.find( + (entry) => entry.type === 'service_account' && entry.providerId === providerId + ) + const fields = provider?.fields ?? [] + const requiredFor = (authMethod: string) => + fields + .filter((field) => + field.requiredForAuthMethods + ? field.requiredForAuthMethods.includes(authMethod) + : field.required + ) + .map((field) => field.id) + .sort() + + expect(fields.find((field) => field.id === 'authMethod')).toMatchObject({ + required: false, + defaultValue: 'token', + options: [ + { value: 'token', label: expect.any(String) }, + { value: 'client_credentials', label: expect.any(String) }, + ], + }) + expect(requiredFor('client_credentials')).toEqual( + getMissingServiceAccountFields(providerId, { authMethod: 'client_credentials' }).sort() + ) + expect(requiredFor('token')).toEqual(getMissingServiceAccountFields(providerId, {}).sort()) + } + }) + it('lists the native Claude Platform provider when filtered', async () => { const providers = await listProviders('claude') diff --git a/apps/sim/lib/credentials/application/provider-catalog.ts b/apps/sim/lib/credentials/application/provider-catalog.ts index 1933f5089cc..9a8befd7ae3 100644 --- a/apps/sim/lib/credentials/application/provider-catalog.ts +++ b/apps/sim/lib/credentials/application/provider-catalog.ts @@ -7,8 +7,12 @@ import { } from '@/lib/credentials/client-credential-accounts/descriptors' import { getDatabaseConnectionDescriptor } from '@/lib/credentials/database-connections/descriptors' import { + CLIENT_CREDENTIALS_AUTH_METHOD, + isTokenServiceAccountFieldRequired, SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID, + TOKEN_AUTH_METHOD, TOKEN_SERVICE_ACCOUNT_DESCRIPTORS, + type TokenServiceAccountDescriptor, type TokenServiceAccountField, } from '@/lib/credentials/token-service-accounts/descriptors' import { @@ -117,15 +121,14 @@ const ATLASSIAN_SERVICE_ACCOUNT_DOCS_URL = 'https://docs.sim.ai/integrations/atlassian-service-account' function providerField( - field: TokenServiceAccountField | ClientCredentialAccountField + field: TokenServiceAccountField | ClientCredentialAccountField, + required: boolean ): CredentialProviderField { return { id: field.id, label: field.label, placeholder: field.placeholder, - required: - !('optional' in field && field.optional) && - !('defaultValue' in field && field.defaultValue !== undefined), + required, secret: field.secret, multiline: 'multiline' in field && field.multiline === true, ...('requiredForAuthMethods' in field && field.requiredForAuthMethods @@ -143,6 +146,74 @@ function providerField( } } +/** + * A token provider's fields. One that also offers a client-credentials grant adds `authMethod`, + * `clientId`, and `clientSecret`, read the way its create builder reads them: `authMethod: + * 'client_credentials'` sends the client ID and secret in place of the token (and of any field + * that belongs to the pasted-token sign-in only), and an omitted `authMethod` keeps the token. + */ +function tokenProviderFields(descriptor: TokenServiceAccountDescriptor): CredentialProviderField[] { + const { clientCredentials } = descriptor + const fields = descriptor.fields.map((field) => { + const catalogField = providerField( + field, + isTokenServiceAccountFieldRequired(field, TOKEN_AUTH_METHOD) + ) + return clientCredentials && field.id === 'apiToken' + ? { ...catalogField, requiredForAuthMethods: [TOKEN_AUTH_METHOD] } + : catalogField + }) + if (!clientCredentials) return fields + return [...fields, ...clientCredentialModeFields(clientCredentials)] +} + +/** + * The fields a headless caller sends to pick and fill a provider's OAuth client-credentials mode + * instead of its pasted token: the mode selector, and the client ID and secret that mode requires. + */ +function clientCredentialModeFields(mode: { + label: string + tokenLabel: string + helpText?: string +}): CredentialProviderField[] { + const clientCredentialMethods = [CLIENT_CREDENTIALS_AUTH_METHOD] + return [ + { + id: 'authMethod', + label: 'Authentication method', + placeholder: TOKEN_AUTH_METHOD, + required: false, + secret: false, + multiline: false, + options: [ + { value: TOKEN_AUTH_METHOD, label: mode.tokenLabel }, + { value: CLIENT_CREDENTIALS_AUTH_METHOD, label: mode.label }, + ], + defaultValue: TOKEN_AUTH_METHOD, + hint: `Send ${CLIENT_CREDENTIALS_AUTH_METHOD} with clientId and clientSecret instead of the fields required only for ${TOKEN_AUTH_METHOD}.`, + }, + { + id: 'clientId', + label: 'Client ID', + placeholder: 'Paste the client ID', + required: false, + secret: false, + multiline: false, + requiredForAuthMethods: clientCredentialMethods, + ...(mode.helpText ? { hint: mode.helpText } : {}), + }, + { + id: 'clientSecret', + label: 'Client secret', + placeholder: 'Paste the client secret', + required: false, + secret: true, + multiline: false, + requiredForAuthMethods: clientCredentialMethods, + }, + ] +} + interface KeylessCatalogMode { keyless: CredentialProviderKeylessMode /** Where keyless works, when narrower than the provider. */ @@ -351,7 +422,8 @@ function getServiceAccountDescriptor(providerId: string): ServiceAccountDescript if (providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) { return { name: 'Atlassian service account', - description: 'Connect Jira and Confluence with an Atlassian API token.', + description: + 'Connect Jira and Confluence with an Atlassian API token or a service account OAuth 2.0 credential.', docsUrl: ATLASSIAN_SERVICE_ACCOUNT_DOCS_URL, fields: [ { @@ -361,6 +433,7 @@ function getServiceAccountDescriptor(providerId: string): ServiceAccountDescript required: true, secret: true, multiline: false, + requiredForAuthMethods: [TOKEN_AUTH_METHOD], }, { id: 'domain', @@ -383,6 +456,11 @@ function getServiceAccountDescriptor(providerId: string): ServiceAccountDescript ], hint: 'Select the product this token can access. Defaults to Jira.', }, + ...clientCredentialModeFields({ + label: 'Service account OAuth 2.0', + tokenLabel: 'API token', + helpText: "The client ID of the service account's OAuth 2.0 credential.", + }), ], } } @@ -484,7 +562,7 @@ function getServiceAccountDescriptor(providerId: string): ServiceAccountDescript docsUrl: tokenDescriptor.docsUrl, helpText: tokenDescriptor.helpText, fields: [ - ...tokenDescriptor.fields.map(providerField), + ...tokenProviderFields(tokenDescriptor), { id: 'serviceAccountJson', label: 'Connection configuration', @@ -510,7 +588,7 @@ function getServiceAccountDescriptor(providerId: string): ServiceAccountDescript description: `Connect ${tokenDescriptor.serviceLabel} with a ${tokenDescriptor.tokenNoun}.`, docsUrl: tokenDescriptor.docsUrl, helpText: tokenDescriptor.helpText, - fields: tokenDescriptor.fields.map(providerField), + fields: tokenProviderFields(tokenDescriptor), } } @@ -525,7 +603,7 @@ function getServiceAccountDescriptor(providerId: string): ServiceAccountDescript description: `Connect ${clientDescriptor.serviceLabel} with a ${clientDescriptor.connectNoun}.`, docsUrl: clientDescriptor.docsUrl, helpText: clientDescriptor.helpText, - fields: clientDescriptor.fields.map(providerField), + fields: clientDescriptor.fields.map((field) => providerField(field, !field.optional)), } } diff --git a/apps/sim/lib/credentials/client-credential-accounts/vanta-token.integration.ts b/apps/sim/lib/credentials/client-credential-accounts/vanta-token.integration.ts index f015a7bf441..7ae898d91c7 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/vanta-token.integration.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/vanta-token.integration.ts @@ -12,6 +12,7 @@ import { invalidateVantaClientToken, resolveVantaClientToken, } from '@/lib/credentials/client-credential-accounts/vanta-token' +import { REJECTED_TOKEN_MINIMUM_AGE_MS } from '@/lib/credentials/token-request-resilience' import { fetchVantaWithAuth } from '@/lib/internal/vanta/client' const READ = 'vanta-api.all:read' @@ -40,6 +41,17 @@ function sha256Json(value: unknown): string { return createHash('sha256').update(JSON.stringify(value)).digest('hex') } +/** + * Backdates the row holding this token past {@link REJECTED_TOKEN_MINIMUM_AGE_MS}, so a rejection + * of it expires it rather than keeping a token minted within the last minute. + */ +async function ageToken(apiDomain: string, accessToken: string) { + await db + .update(clientCredentialToken) + .set({ updatedAt: new Date(Date.now() - REJECTED_TOKEN_MINIMUM_AGE_MS - 1000) }) + .where(eq(clientCredentialToken.accessTokenDigest, sha256Json([apiDomain, accessToken]))) +} + /** Row ids and digests exactly as the build before the shared resolver derived them. */ function previousBuildKeys(input: { clientId: string; clientSecret: string }, scope: string) { const apiDomain = 'https://api.vanta.com' @@ -246,6 +258,7 @@ describe('Vanta application token lifecycle against shared storage', () => { .set({ expiresAt: new Date(Date.now() - 1000) }) .where(eq(clientCredentialToken.accessTokenDigest, tokenDigest)) } else { + await ageToken(first.apiDomain, first.accessToken) await invalidateVantaClientToken(first.accessToken, first.apiDomain) } const count = tokenRequests @@ -276,6 +289,7 @@ describe('Vanta application token lifecycle against shared storage', () => { it('changes a revoked token only once when several requests reject the same token', async () => { const input = fields('rejected-token') const first = await resolveVantaClientToken(input) + await ageToken(first.apiDomain, first.accessToken) await Promise.all( Array.from({ length: 8 }, () => invalidateVantaClientToken(first.accessToken, first.apiDomain) @@ -315,14 +329,17 @@ describe('Vanta application token lifecycle against shared storage', () => { expect(stored).not.toContain(result.accessToken) }) - it('recovers on the next authorized resolution after a saved token is rejected', async () => { + it('keeps a token minted within the last minute when a request rejects it, and renews an older one', async () => { const input = fields('saved-rejected') const first = await resolveVantaClientToken(input) - const response = await fetchVantaWithAuth( - first, - async () => new Response(null, { status: 401 }) - ) - expect(response.status).toBe(401) + const rejected = async () => new Response(null, { status: 401 }) + expect((await fetchVantaWithAuth(first, rejected)).status).toBe(401) + const count = tokenRequests + expect((await resolveVantaClientToken(input)).accessToken).toBe(first.accessToken) + expect(tokenRequests).toBe(count) + + await ageToken(first.apiDomain, first.accessToken) + expect((await fetchVantaWithAuth(first, rejected)).status).toBe(401) const next = await resolveVantaClientToken(input) expect(next.accessToken).not.toBe(first.accessToken) expect(await isActive(next.accessToken)).toBe(true) @@ -331,10 +348,12 @@ describe('Vanta application token lifecycle against shared storage', () => { it('does not invalidate a newer token or another deployment after a stale token rejection', async () => { const input = fields('saved-stale-rejected') const first = await resolveVantaClientToken(input) + await ageToken(first.apiDomain, first.accessToken) await invalidateVantaClientToken(first.accessToken, 'https://api.vanta-gov.com') expect((await resolveVantaClientToken(input)).accessToken).toBe(first.accessToken) await invalidateVantaClientToken(first.accessToken, first.apiDomain) const next = await resolveVantaClientToken(input) + await ageToken(next.apiDomain, next.accessToken) await invalidateVantaClientToken(first.accessToken, first.apiDomain) expect((await resolveVantaClientToken(input)).accessToken).toBe(next.accessToken) expect(await isActive(next.accessToken)).toBe(true) @@ -491,6 +510,7 @@ describe('Vanta token rows shared with the previous build during a rolling deplo const input = fields('legacy-invalidated') const keys = previousBuildKeys(input, READ) await storePreviousBuildToken(keys, 'legacy-rejected-token', READ, 3_600_000) + await ageToken('https://api.vanta.com', 'legacy-rejected-token') await invalidateVantaClientToken('legacy-rejected-token', 'https://api.vanta.com') diff --git a/apps/sim/lib/credentials/client-credential-accounts/vanta-token.ts b/apps/sim/lib/credentials/client-credential-accounts/vanta-token.ts index a3ea31abb02..2f813e18958 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/vanta-token.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/vanta-token.ts @@ -5,6 +5,7 @@ import { invalidateCoordinatedClientToken, resolveCoordinatedClientToken, } from '@/lib/credentials/coordinated-client-token' +import { REJECTED_TOKEN_MINIMUM_AGE_MS } from '@/lib/credentials/token-request-resilience' import { fetchProvider, isTransientProviderStatus, @@ -39,7 +40,11 @@ export interface VantaClientTokenResult { apiDomain: string } -/** Expires only the rejected token; a concurrent replacement remains usable. */ +/** + * Expires only the rejected token; a concurrent replacement remains usable. A token minted within + * {@link REJECTED_TOKEN_MINIMUM_AGE_MS} is kept, since Vanta limits token mints and the 401 may + * come from the endpoint rather than the token. + */ export async function invalidateVantaClientToken( accessToken: string, apiDomain: string @@ -47,7 +52,10 @@ export async function invalidateVantaClientToken( if (apiDomain !== 'https://api.vanta.com' && apiDomain !== 'https://api.vanta-gov.com') { throw new Error('Invalid Vanta API domain') } - await invalidateCoordinatedClientToken(accessToken, { accessTokenDigestTag: apiDomain }) + await invalidateCoordinatedClientToken(accessToken, { + accessTokenDigestTag: apiDomain, + minimumAgeMs: REJECTED_TOKEN_MINIMUM_AGE_MS, + }) } function unavailable(reason: string): TokenServiceAccountValidationError { diff --git a/apps/sim/lib/credentials/coordinated-client-token.integration.ts b/apps/sim/lib/credentials/coordinated-client-token.integration.ts index b2454165b52..34e0b6f4905 100644 --- a/apps/sim/lib/credentials/coordinated-client-token.integration.ts +++ b/apps/sim/lib/credentials/coordinated-client-token.integration.ts @@ -375,6 +375,22 @@ describe('coordinated token resolution while the provider fails', () => { expect(previousBuildDecodesFailure.coordinated(payload)).toBe(true) }) + it('reports a fresh refusal under its own code, which the shared row cannot store', async () => { + const exchange = issuingOnce('egress-refused', 4 * 60_000, async () => { + throw new TokenServiceAccountValidationError('host_not_allowed', 400, { step: 'mint' }) + }) + await resolveCoordinatedClientToken(input('egress-refused', exchange)) + + await expect( + resolveCoordinatedClientToken(input('egress-refused', exchange)) + ).rejects.toMatchObject({ + code: 'host_not_allowed', + status: 400, + replayForMs: expect.any(Number), + }) + expect(exchange).toHaveBeenCalledTimes(2) + }) + it('never serves a still-valid token to a different secret whose refresh fails', async () => { const exchange = issuingOnce('rotated-refresh', 4 * 60_000, failing(429)) await resolveCoordinatedClientToken(input('rotated-refresh', exchange)) diff --git a/apps/sim/lib/credentials/coordinated-client-token.ts b/apps/sim/lib/credentials/coordinated-client-token.ts index aa6dd77b900..7c8db976776 100644 --- a/apps/sim/lib/credentials/coordinated-client-token.ts +++ b/apps/sim/lib/credentials/coordinated-client-token.ts @@ -245,6 +245,21 @@ function replayedFailure( ) } +/** + * A fresh exchange failure as it reaches the caller: the provider's own code, status, detail, and + * message, which a stored memo may not be able to hold, with how long it is now replayed. + */ +function freshFailure( + error: TokenServiceAccountValidationError, + replayMs: number +): TokenServiceAccountValidationError { + return new TokenServiceAccountValidationError(error.code, error.status, error.logDetail, { + message: error.userMessage, + retryAfterMs: error.retryAfterMs, + replayForMs: Math.max(replayMs, 0), + }) +} + /** * The outage or throttling streak before this exchange, read from the application's last failure * memo for this secret once its replay has ended. A refusal, or anything unreadable, starts a new @@ -480,7 +495,7 @@ export async function resolveCoordinatedClientToken( .set({ expiresAt: new Date(0), updatedAt: new Date() }) .where(eq(clientCredentialToken.id, applicationKey)) } - return { failure: replayedFailure(failure, step, replayMs) } + return { failure: freshFailure(error, replayMs) } } const fallback = await fallbackFrom(applicationRow) if (fallback) { @@ -491,7 +506,7 @@ export async function resolveCoordinatedClientToken( }) return fallback } - return { failure: replayedFailure(failure, step, replayMs) } + return { failure: freshFailure(error, replayMs) } } }) return settle(outcome) diff --git a/apps/sim/lib/credentials/google-workload-identity.server.test.ts b/apps/sim/lib/credentials/google-workload-identity.server.test.ts index 0361b6ebe1c..0de02046d10 100644 --- a/apps/sim/lib/credentials/google-workload-identity.server.test.ts +++ b/apps/sim/lib/credentials/google-workload-identity.server.test.ts @@ -420,7 +420,11 @@ describe('getting a keyless Google Cloud token for a run', () => { await expect( resolve({ credentialId: 'cred-dwd', impersonateEmail: 'alice@example.com' }) - ).rejects.toMatchObject({ code: 'unsupported_use' }) + ).rejects.toMatchObject({ + code: 'unsupported_use', + logDetail: { step: 'google_keyless_delegation' }, + userMessage: expect.any(String), + }) expect(fetch).not.toHaveBeenCalled() }) @@ -431,6 +435,8 @@ describe('getting a keyless Google Cloud token for a run', () => { await expect(resolve({ credentialId: 'cred-workspace', service })).rejects.toMatchObject({ code: 'unsupported_use', + logDetail: { step: 'google_keyless_service' }, + userMessage: expect.any(String), }) expect(fetch).not.toHaveBeenCalled() } @@ -755,7 +761,11 @@ describe('getting a delegated Google Workspace token for a run', () => { await expect( resolve({ credentialId: 'dwd-other-user', impersonateEmail: 'bob@example.com' }) - ).rejects.toMatchObject({ code: 'unsupported_use' }) + ).rejects.toMatchObject({ + code: 'unsupported_use', + logDetail: { step: 'google_keyless_delegation_user' }, + userMessage: expect.any(String), + }) expect(fetch).not.toHaveBeenCalled() }) @@ -778,7 +788,11 @@ describe('getting a delegated Google Workspace token for a run', () => { await expect( resolve({ credentialId: `dwd-${service}`, service, scopes: [CALENDAR_SCOPE] }) - ).rejects.toMatchObject({ code: 'unsupported_use' }) + ).rejects.toMatchObject({ + code: 'unsupported_use', + logDetail: { step: 'google_keyless_delegation_scope' }, + userMessage: expect.any(String), + }) expect(fetch).not.toHaveBeenCalled() } ) diff --git a/apps/sim/lib/credentials/google-workload-identity.server.ts b/apps/sim/lib/credentials/google-workload-identity.server.ts index 1d7ef75254e..6d4a75d2957 100644 --- a/apps/sim/lib/credentials/google-workload-identity.server.ts +++ b/apps/sim/lib/credentials/google-workload-identity.server.ts @@ -842,8 +842,18 @@ export interface ResolveGoogleWorkloadIdentityTokenInput { service?: string } -function unsupportedUse(step: string, reason: string): TokenServiceAccountValidationError { - return new TokenServiceAccountValidationError('unsupported_use', 400, { step, reason }) +/** A refusal whose message tells the user what to change; `reason` stays in the log detail. */ +function unsupportedUse( + step: string, + reason: string, + message: string +): TokenServiceAccountValidationError { + return new TokenServiceAccountValidationError( + 'unsupported_use', + 400, + { step, reason }, + { message } + ) } /** Reads the stored secret, or fails in the shared service-account codes. */ @@ -926,20 +936,23 @@ export async function resolveGoogleWorkloadIdentityToken( if (input.impersonateEmail && input.impersonateEmail.trim().toLowerCase() !== delegation.user) { throw unsupportedUse( 'google_keyless_delegation_user', - 'this keyless Google credential acts only as its delegated Workspace user' + 'this keyless Google credential acts only as its delegated Workspace user', + 'This keyless Google credential acts only as the Workspace user it was delegated for. Clear the Impersonated Account or set it to that user, or use a JSON key credential to act as other users' ) } } else { if (input.impersonateEmail) { throw unsupportedUse( 'google_keyless_delegation', - 'this keyless Google credential has no domain-wide delegation for this service' + 'this keyless Google credential has no domain-wide delegation for this service', + 'This keyless Google credential has no domain-wide delegation for this service, so it cannot act as a Workspace user. Clear the Impersonated Account, reconnect the credential to delegate this service, or use a JSON key credential instead' ) } if (!input.service || !GOOGLE_KEYLESS_SERVICES.has(input.service)) { throw unsupportedUse( 'google_keyless_service', - 'this keyless Google credential works only with Google Cloud APIs and the Workspace services it was delegated' + 'this keyless Google credential works only with Google Cloud APIs and the Workspace services it was delegated', + 'This keyless Google credential works only with Google Cloud tools such as BigQuery and, through domain-wide delegation, with Gmail, Google Drive, Google Docs, Google Sheets, and Google Calendar. Reconnect it to delegate this service, or use a JSON key credential instead' ) } } @@ -953,7 +966,8 @@ export async function resolveGoogleWorkloadIdentityToken( if (!scopes) { throw unsupportedUse( 'google_keyless_delegation_scope', - 'this keyless Google credential’s domain-wide delegation does not authorize the scopes this use needs' + 'this keyless Google credential’s domain-wide delegation does not authorize the scopes this use needs', + 'This keyless Google credential’s domain-wide delegation does not cover the scopes this tool needs. Reconnect it to delegate this service, or use a JSON key credential instead' ) } const scopeKey = [...new Set(scopes)].sort().join(' ') diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts index d6e1e6ae4f8..cb2bbea5176 100644 --- a/apps/sim/lib/credentials/service-account-secret.ts +++ b/apps/sim/lib/credentials/service-account-secret.ts @@ -66,8 +66,12 @@ import { isTokenServiceAccountFieldRequired, isTokenServiceAccountProviderId, SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID, + TOKEN_AUTH_METHOD, TOKEN_SERVICE_ACCOUNT_SECRET_TYPE, + type TokenServiceAccountAuthMethod, type TokenServiceAccountDescriptor, + type TokenServiceAccountField, + tokenFieldBelongsToAuthMethod, } from '@/lib/credentials/token-service-accounts/descriptors' import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' import { @@ -632,13 +636,15 @@ async function buildTokenServiceAccountSecret( const apiToken = descriptor.fields.find((field) => field.id === 'apiToken')?.preserveWhitespace ? fields.apiToken : fields.apiToken?.trim() - const domainField = descriptor.fields.find((field) => field.id === 'domain') + const domainField = tokenModeField(descriptor, 'domain', TOKEN_AUTH_METHOD) const domain = fields.domain?.trim() || domainField?.defaultValue - const requiresDomain = Boolean(domainField && !domainField.optional) + const requiresDomain = Boolean( + domainField && isTokenServiceAccountFieldRequired(domainField, TOKEN_AUTH_METHOD) + ) const providerWorkspaceId = fields.providerWorkspaceId?.trim() - const workspaceField = descriptor.fields.find((field) => field.id === 'providerWorkspaceId') + const workspaceField = tokenModeField(descriptor, 'providerWorkspaceId', TOKEN_AUTH_METHOD) const requiresWorkspace = Boolean( - workspaceField && isTokenServiceAccountFieldRequired(workspaceField) + workspaceField && isTokenServiceAccountFieldRequired(workspaceField, TOKEN_AUTH_METHOD) ) if ( !apiToken || @@ -646,7 +652,9 @@ async function buildTokenServiceAccountSecret( (requiresDomain && !domain) || (requiresWorkspace && !providerWorkspaceId) ) { - const requiredFields = descriptor.fields.filter(isTokenServiceAccountFieldRequired) + const requiredFields = descriptor.fields.filter((field) => + isTokenServiceAccountFieldRequired(field, TOKEN_AUTH_METHOD) + ) throw new ServiceAccountSecretError( `${requiredFields.map((field) => field.id).join(' and ')} ${requiredFields.length > 1 ? 'are' : 'is'} required for ${descriptor.serviceLabel} service account credentials` ) @@ -676,6 +684,17 @@ async function buildTokenServiceAccountSecret( } } +/** The descriptor field with this id that the given sign-in mode shows and stores. */ +function tokenModeField( + descriptor: TokenServiceAccountDescriptor, + id: TokenServiceAccountField['id'], + authMethod: TokenServiceAccountAuthMethod +): TokenServiceAccountField | undefined { + return descriptor.fields.find( + (field) => field.id === id && tokenFieldBelongsToAuthMethod(field, authMethod) + ) +} + /** * Builds a token provider's client-credentials secret: mints a token with the submitted client * ID and secret, proves it with the provider's own token check (the same request the pasted-token @@ -695,20 +714,22 @@ async function buildTokenClientCredentialSecret( } const clientId = fields.clientId?.trim() const clientSecret = fields.clientSecret?.trim() - const domainField = descriptor.fields.find((field) => field.id === 'domain') - const domain = fields.domain?.trim() || domainField?.defaultValue - const requiresDomain = Boolean(domainField) + const domainField = tokenModeField(descriptor, 'domain', CLIENT_CREDENTIALS_AUTH_METHOD) + const domain = domainField ? fields.domain?.trim() || domainField.defaultValue : undefined + const requiresDomain = Boolean( + domainField && isTokenServiceAccountFieldRequired(domainField, CLIENT_CREDENTIALS_AUTH_METHOD) + ) if (!clientId || !clientSecret || (requiresDomain && !domain)) { throw new ServiceAccountSecretError( `clientId, clientSecret${requiresDomain ? ', and domain are' : ' are'} required for ${descriptor.serviceLabel} client credentials` ) } const validation = await proveMintedToken( - () => mint({ clientId, clientSecret, ...(requiresDomain ? { domain } : {}) }), + () => mint({ clientId, clientSecret, ...(domain ? { domain } : {}) }), (accessToken) => validator({ apiToken: accessToken, - ...(requiresDomain ? { domain } : {}), + ...(domain ? { domain } : {}), authMethod: CLIENT_CREDENTIALS_AUTH_METHOD, }) ) @@ -719,7 +740,7 @@ async function buildTokenClientCredentialSecret( authMethod: CLIENT_CREDENTIALS_AUTH_METHOD, clientId, clientSecret, - ...(requiresDomain ? { domain: validation.normalizedDomain ?? domain } : {}), + ...(domain ? { domain: validation.normalizedDomain ?? domain } : {}), metadata: { ...validation.storedMetadata, ...principalMetadata }, } const { encrypted } = await encryptSecret(JSON.stringify(blob)) diff --git a/apps/sim/lib/credentials/token-request-resilience.ts b/apps/sim/lib/credentials/token-request-resilience.ts index 6d8ed310c42..7d2b4d8d685 100644 --- a/apps/sim/lib/credentials/token-request-resilience.ts +++ b/apps/sim/lib/credentials/token-request-resilience.ts @@ -31,6 +31,14 @@ export const TOKEN_EXPIRY_MARGIN_MS = 30_000 */ export const TOKEN_FAILURE_STREAK_RETENTION_MS = 5 * 60_000 +/** + * A tool call's 401 (and an internal operation's 403) can come from the endpoint rather than the + * token, so a caller expiring a token on one passes this as `minimumAgeMs`: a token minted within + * the last minute is kept, and a call that keeps failing re-mints its application's token at most + * once a minute, instead of once per call. + */ +export const REJECTED_TOKEN_MINIMUM_AGE_MS = 60_000 + /** A refusal of the credentials is replayed this long, so a revoked secret does not hammer the provider. */ const PERMANENT_FAILURE_REPLAY_MS = 30_000 /** diff --git a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts index f7834764ea5..5f145ec5990 100644 --- a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts @@ -158,8 +158,13 @@ interface TokenServiceAccountClientCredentials { /** `authMethod` value selecting the client-credentials mode of a provider whose default is a pasted token (a token provider, or the Atlassian service account). */ export const CLIENT_CREDENTIALS_AUTH_METHOD = 'client_credentials' as const +/** `authMethod` value selecting a token provider's pasted-token mode, its default. */ +export const TOKEN_AUTH_METHOD = 'token' as const + /** A token provider's sign-in modes: the pasted token (the default) or its client-credentials grant. */ -export type TokenServiceAccountAuthMethod = 'token' | typeof CLIENT_CREDENTIALS_AUTH_METHOD +export type TokenServiceAccountAuthMethod = + | typeof TOKEN_AUTH_METHOD + | typeof CLIENT_CREDENTIALS_AUTH_METHOD /** Whether a descriptor field belongs to the given sign-in mode. */ export function tokenFieldBelongsToAuthMethod( @@ -2647,7 +2652,7 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< label: 'Username', placeholder: 'Enter the ServiceNow username', secret: false, - requiredForAuthMethods: ['token'], + requiredForAuthMethods: [TOKEN_AUTH_METHOD], }, { id: 'domain', @@ -2692,11 +2697,14 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< { id: 'domain', label: 'Data residency', - placeholder: 'us or eu', + placeholder: 'Select a region', secret: false, + options: [ + { value: 'us', label: 'US (app.amplitude.com)' }, + { value: 'eu', label: 'EU (app.eu.amplitude.com)' }, + ], defaultValue: 'us', - hintPattern: /^(us|eu|US|EU)$/, - hintMessage: 'Enter us or eu, the region your Amplitude project stores data in.', + hintMessage: 'us (default) or eu: the region your Amplitude project stores data in.', }, ], docsUrl: 'https://docs.sim.ai/integrations/amplitude', @@ -2806,10 +2814,14 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< { id: 'domain', label: 'Token type', - placeholder: 'account, workspace, or project', + placeholder: 'Select a token type', secret: false, - hintPattern: /^(account|workspace|project)$/i, - hintMessage: 'Enter account, workspace, or project: the scope you chose for this token.', + options: [ + { value: 'account', label: 'Account token' }, + { value: 'workspace', label: 'Workspace token' }, + { value: 'project', label: 'Project token' }, + ], + hintMessage: 'account, workspace, or project: the scope you chose for this token.', }, { id: 'providerWorkspaceId', @@ -2856,9 +2868,13 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< /** * Whether a create must give the field: every field is required unless it is optional or has a - * default. + * default. Given a sign-in mode, a field that does not belong to that mode is not required either. */ -export function isTokenServiceAccountFieldRequired(field: TokenServiceAccountField): boolean { +export function isTokenServiceAccountFieldRequired( + field: TokenServiceAccountField, + authMethod?: TokenServiceAccountAuthMethod +): boolean { + if (authMethod && !tokenFieldBelongsToAuthMethod(field, authMethod)) return false return !field.optional && field.defaultValue === undefined } @@ -2880,8 +2896,7 @@ export const TOKEN_SERVICE_ACCOUNT_CLIENT_CREDENTIAL_FIELDS: Record< 'clientSecret', ...descriptor.fields.flatMap((field) => field.id === 'apiToken' || - !tokenFieldBelongsToAuthMethod(field, CLIENT_CREDENTIALS_AUTH_METHOD) || - !isTokenServiceAccountFieldRequired(field) + !isTokenServiceAccountFieldRequired(field, CLIENT_CREDENTIALS_AUTH_METHOD) ? [] : [field.id] ), @@ -2899,7 +2914,9 @@ export const TOKEN_SERVICE_ACCOUNT_REQUIRED_FIELDS: Record [ descriptor.providerId, - descriptor.fields.filter(isTokenServiceAccountFieldRequired).map((field) => field.id), + descriptor.fields + .filter((field) => isTokenServiceAccountFieldRequired(field, TOKEN_AUTH_METHOD)) + .map((field) => field.id), ]) ) diff --git a/apps/sim/lib/credentials/token-service-accounts/errors.ts b/apps/sim/lib/credentials/token-service-accounts/errors.ts index 6a6b70f6b24..a4ff91cfc10 100644 --- a/apps/sim/lib/credentials/token-service-accounts/errors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/errors.ts @@ -40,6 +40,8 @@ export type TokenServiceAccountValidationCode = | 'grant_not_permitted' export interface TokenServiceAccountValidationErrorOptions { + /** See {@link TokenServiceAccountValidationError.userMessage}. */ + message?: string /** See {@link TokenServiceAccountValidationError.retryAfterMs}. */ retryAfterMs?: number /** See {@link TokenServiceAccountValidationError.replayForMs}. */ @@ -54,21 +56,28 @@ export class TokenServiceAccountValidationError extends Error { public readonly retryAfterMs?: number /** - * Set when this is a remembered failure being replayed rather than a fresh provider answer: how - * much longer, in milliseconds, it is replayed before the provider is asked again. It says when - * the provider will next be asked, not that asking will then succeed (a replayed refusal of the - * credentials carries it too). + * How much longer, in milliseconds, this failure is replayed to later callers before the provider + * is asked again, whether it was just answered or is itself a replay. It says when the provider + * will next be asked, not that asking will then succeed (a refusal of the credentials carries it + * too). */ public readonly replayForMs?: number + /** + * A message for the user naming what the credential refused and what to change. Absent when the + * code alone says enough; the error's `message` is then just its code. + */ + public readonly userMessage?: string + constructor( public readonly code: TokenServiceAccountValidationCode, public readonly status: number, public readonly logDetail?: Record, options: TokenServiceAccountValidationErrorOptions = {} ) { - super(code) + super(options.message ?? code) this.name = 'TokenServiceAccountValidationError' + if (options.message !== undefined) this.userMessage = options.message if (options.retryAfterMs !== undefined) this.retryAfterMs = options.retryAfterMs if (options.replayForMs !== undefined) this.replayForMs = options.replayForMs } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/http.ts b/apps/sim/lib/credentials/token-service-accounts/validators/http.ts index 9ed727b85ef..8bf862b6e36 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/http.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/http.ts @@ -54,10 +54,11 @@ interface TokenCheck { */ defaultDomain?: string /** - * The domain picks one of the vendor's own region hosts from a fixed list, never a host the user - * typed, so one failing to resolve is the vendor being unavailable rather than the user's typo. + * The domain is one of a fixed set of vendor-defined choices (a region, a token type), never a + * host the user typed, so a host that fails to resolve is the vendor being unavailable rather than + * the user's typo. */ - domainIsVendorRegion?: boolean + domainIsFixedChoice?: boolean /** * The request goes to a host the user chose (self-hosted or white-label), so * it runs through the same SSRF-guarded egress profile as the tools instead @@ -254,7 +255,7 @@ export function tokenCheckValidator(check: TokenCheck): TokenServiceAccountValid }, 'verify', // A host built from the typed domain or account id that doesn't exist is the user's typo. - (typedDomain === undefined || check.domainIsVendorRegion) && workspaceId === undefined + (typedDomain === undefined || check.domainIsFixedChoice) && workspaceId === undefined ? undefined : { dnsFailureCode: 'site_not_found' } ) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts index 8ce7f724ef3..5aff8544b73 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts @@ -734,7 +734,7 @@ describe('Amplitude saved project key verification', () => { providerWorkspaceId: ' api-key ', domain: 'EU', }) - ).resolves.toMatchObject({ normalizedDomain: 'eu', normalizedWorkspaceId: 'api-key' }) + ).resolves.toMatchObject({ normalizedDomain: 'eu', displayName: 'Amplitude (eu)' }) expect(fetch).toHaveBeenCalledWith( 'https://analytics.eu.amplitude.com/api/2/events/list', expect.objectContaining({ @@ -745,6 +745,40 @@ describe('Amplitude saved project key verification', () => { ) }) + it('names a credential in the default US region after the service alone', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ data: [] }))) + + await expect( + validateAmplitudeServiceAccount({ + apiToken: 'secret-key', + providerWorkspaceId: 'api-key', + domain: 'us', + }) + ).resolves.toMatchObject({ normalizedDomain: 'us', displayName: 'Amplitude' }) + }) + + it.each(['us', 'eu'])( + 'reports an unresolvable %s host as Amplitude being unavailable, not a typo', + async (domain) => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockRejectedValue( + Object.assign(new Error('getaddrinfo ENOTFOUND amplitude.com'), { code: 'ENOTFOUND' }) + ) + ) + + await expect( + validateAmplitudeServiceAccount({ + apiToken: 'secret-key', + providerWorkspaceId: 'api-key', + domain, + }) + ).rejects.toMatchObject({ code: 'provider_unavailable' }) + } + ) + it('rejects a key pair Amplitude answers with its invalid-key 403', async () => { vi.stubGlobal( 'fetch', @@ -1188,6 +1222,21 @@ describe('Railway saved token verification', () => { expect(fetch).not.toHaveBeenCalled() }) + it('reports an unresolvable Railway host as Railway being unavailable, not a typo', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockRejectedValue( + Object.assign(new Error('getaddrinfo ENOTFOUND backboard.railway.com'), { + code: 'ENOTFOUND', + }) + ) + ) + + await expect( + validateRailwayServiceAccount({ apiToken: 'acct', domain: 'account' }) + ).rejects.toMatchObject({ code: 'provider_unavailable' }) + }) + it('rejects a token Railway answers with a GraphQL authorization error', async () => { vi.stubGlobal( 'fetch', diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts index 4df3d6b5717..afe102f6dd4 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts @@ -84,7 +84,7 @@ function vendorRegion(regions: CredentialRegions) { return { domain: allowedDomain(regions), defaultDomain: regions.defaultRegion, - domainIsVendorRegion: true, + domainIsFixedChoice: true, } } @@ -184,28 +184,28 @@ const AMPLITUDE_SECRET_KEY = /^[\x21-\x7e]{1,256}$/ * Amplitude's Dashboard REST API lists a project's visible events for the API key and secret key * sent as HTTP Basic credentials, on the region's host the tools build with `getDashboardHost`. * Amplitude answers 403 when either key is invalid (401 is a malformed header), so the check - * proves the pair and its region together; it is a read with no billing cost. + * proves the pair and its region together; it is a read with no billing cost. The region is one of + * Amplitude's own hosts, and the API key reaches only the Basic header, never the host or path, so + * it is checked with the secret key rather than as an account id that could steer the request. * https://amplitude.com/docs/apis/analytics/dashboard-rest */ export const validateAmplitudeServiceAccount = tokenCheckValidator({ serviceLabel: 'Amplitude', domain: allowedDomain(US_OR_EU), - workspaceId: ({ providerWorkspaceId }) => { + defaultDomain: 'us', + domainIsFixedChoice: true, + request: ({ apiToken, providerWorkspaceId, domain }) => { const apiKey = (providerWorkspaceId ?? '').trim() - if (!AMPLITUDE_API_KEY.test(apiKey)) throw new Error('invalid API key') - return apiKey - }, - request: ({ apiToken, providerWorkspaceId = '', domain }) => { - if (!AMPLITUDE_SECRET_KEY.test(apiToken)) { + if (!AMPLITUDE_API_KEY.test(apiKey) || !AMPLITUDE_SECRET_KEY.test(apiToken)) { throw new TokenServiceAccountValidationError('invalid_credentials', 400, { step: 'verify', - reason: 'invalid Amplitude secret key', + reason: 'invalid Amplitude API key or secret key', }) } return { url: `${getDashboardHost(domain)}/api/2/events/list`, headers: { - Authorization: `Basic ${Buffer.from(`${providerWorkspaceId}:${apiToken}`).toString('base64')}`, + Authorization: `Basic ${Buffer.from(`${apiKey}:${apiToken}`).toString('base64')}`, Accept: 'application/json', }, } @@ -1063,6 +1063,7 @@ const RAILWAY_WORKSPACE_ID = /^[A-Za-z0-9-]{1,64}$/ export const validateRailwayServiceAccount = tokenCheckValidator({ serviceLabel: 'Railway', domain: allowedDomain(RAILWAY_TOKEN_TYPES), + domainIsFixedChoice: true, request: ({ apiToken, domain, providerWorkspaceId = '' }) => { if (domain === 'workspace' && !RAILWAY_WORKSPACE_ID.test(providerWorkspaceId)) { throw new TokenServiceAccountValidationError('invalid_credentials', 400, { diff --git a/apps/sim/lib/credentials/workload-identity/audiences.ts b/apps/sim/lib/credentials/workload-identity/audiences.ts index 350f82db45f..9f00c580028 100644 --- a/apps/sim/lib/credentials/workload-identity/audiences.ts +++ b/apps/sim/lib/credentials/workload-identity/audiences.ts @@ -125,29 +125,25 @@ export function workloadIdentityProviderFor( } /** - * Relying parties that expect one audience for every credential. Google expects - * each credential's own workload identity provider as its audience (see - * `googleWorkloadIdentityAudience`), so it has no entry here. - */ -type FixedAudienceWorkloadIdentityProvider = Exclude - -/** - * The default `aud` claim for each relying party, used when a keyless credential - * sets no `audience` of its own (see `workloadIdentityAudienceSchema`). AWS STS - * conventionally uses its own client ID; Databricks federation policies list - * whatever audience the customer enters, so Sim publishes one default to paste. Snowflake accepts + * The default `aud` claim for each relying party that expects one audience for + * every credential, used when a keyless credential sets no `audience` of its own + * (see `workloadIdentityAudienceSchema`). AWS STS conventionally uses its own + * client ID; Databricks federation policies list whatever audience the customer + * enters, so Sim publishes one default to paste. Snowflake accepts * `snowflakecomputing.com` without any `OIDC_AUDIENCE_LIST` on the user, so the * customer's `CREATE USER` names only the issuer and subject. Microsoft Entra * federated identity credentials take one audience, and Microsoft recommends * `api://AzureADTokenExchange` * (https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation-create-trust). + * Google expects each credential's own workload identity provider as its + * audience (see `googleWorkloadIdentityAudience`), so it has no entry here. */ -export const WORKLOAD_IDENTITY_AUDIENCES: Record = { +export const WORKLOAD_IDENTITY_AUDIENCES = { aws: 'sts.amazonaws.com', databricks: 'sim-databricks', snowflake: 'snowflakecomputing.com', microsoft: 'api://AzureADTokenExchange', -} +} as const satisfies Partial> /** The provider's default audience, or undefined when each credential has its own (Google). */ export function fixedWorkloadIdentityAudience( diff --git a/apps/sim/lib/internal/aws/signing-keys.ts b/apps/sim/lib/internal/aws/signing-keys.ts new file mode 100644 index 00000000000..40ee31bca77 --- /dev/null +++ b/apps/sim/lib/internal/aws/signing-keys.ts @@ -0,0 +1,20 @@ +import type { AwsSessionCredentials } from '@/lib/credentials/aws-service-account.server' + +/** + * The keys that sign a request for a resolved AWS session, without its lifetime: + * the key pair, plus the session token for an STS session (assumed and keyless + * roles). Long-lived saved keys have no session token. + */ +export type AwsSigningKeys = Pick< + AwsSessionCredentials, + 'accessKeyId' | 'secretAccessKey' | 'sessionToken' +> + +/** The {@link AwsSigningKeys} of a session, with `sessionToken` left out when it has none. */ +export function awsSigningKeys(session: AwsSigningKeys): AwsSigningKeys { + return { + accessKeyId: session.accessKeyId, + secretAccessKey: session.secretAccessKey, + ...(session.sessionToken ? { sessionToken: session.sessionToken } : {}), + } +} diff --git a/apps/sim/lib/internal/llm/bedrock-credential.ts b/apps/sim/lib/internal/llm/bedrock-credential.ts index dd98e4d9ebb..f3edb435ea8 100644 --- a/apps/sim/lib/internal/llm/bedrock-credential.ts +++ b/apps/sim/lib/internal/llm/bedrock-credential.ts @@ -2,6 +2,7 @@ import { authorizeCredentialUseForAuth } from '@/lib/auth/credential-access' import { AuthType } from '@/lib/auth/hybrid' import { resolveAuthorizedAwsCredential } from '@/lib/internal/aws/credentials' import { awsCredentialFailureMessage, isTransientAwsFailure } from '@/lib/internal/aws/failure' +import { awsSigningKeys } from '@/lib/internal/aws/signing-keys' import { recordCredentialAccess } from '@/lib/oauth/token-resolution' import { AWS_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' import type { BedrockCredentialSession } from '@/providers/types' @@ -108,10 +109,5 @@ export async function resolveBedrockCredentialSession({ executionId, }) - return { - credentialId: access.resolvedCredentialId, - accessKeyId: session.accessKeyId, - secretAccessKey: session.secretAccessKey, - ...(session.sessionToken ? { sessionToken: session.sessionToken } : {}), - } + return { credentialId: access.resolvedCredentialId, ...awsSigningKeys(session) } } diff --git a/apps/sim/lib/internal/vanta/client.ts b/apps/sim/lib/internal/vanta/client.ts index 772961d6afe..d0f44eeba9a 100644 --- a/apps/sim/lib/internal/vanta/client.ts +++ b/apps/sim/lib/internal/vanta/client.ts @@ -8,7 +8,11 @@ export function getVantaBaseUrl(apiDomain: string): string { return apiDomain } -/** Expires a rejected saved-account token so the next authorized call can renew it. */ +/** + * Expires a rejected saved-account token so the next authorized call can renew it. A token minted + * within the last minute is kept, since Vanta limits token mints and the 401 may come from the + * endpoint rather than the token. + */ export async function fetchVantaWithAuth( params: VantaCredentialParams, doFetch: (accessToken: string) => Promise, diff --git a/apps/sim/lib/knowledge/application/connector-access.test.ts b/apps/sim/lib/knowledge/application/connector-access.test.ts index 1ea4ac82c75..5c16d98cb6c 100644 --- a/apps/sim/lib/knowledge/application/connector-access.test.ts +++ b/apps/sim/lib/knowledge/application/connector-access.test.ts @@ -365,7 +365,6 @@ describe('connector access application boundary', () => { }) it('refuses moving a key connector that stores its key onto a saved credential', async () => { - featureFlagsMockFns.mockIsFeatureEnabled.mockResolvedValue(true) mocks.meta.mockReturnValue({ name: 'Amazon S3', auth: savedAwsAuth }) await expect( updateKnowledgeConnectorAccess.execute({ @@ -377,7 +376,6 @@ describe('connector access application boundary', () => { }) it('lets a connector already on a saved credential move to another one', async () => { - featureFlagsMockFns.mockIsFeatureEnabled.mockResolvedValue(true) mocks.meta.mockReturnValue({ name: 'Amazon S3', auth: savedAwsAuth }) mocks.connector.mockResolvedValue({ ...row, diff --git a/apps/sim/lib/knowledge/application/connector-access.ts b/apps/sim/lib/knowledge/application/connector-access.ts index f0f37152b7c..55d76f03c1c 100644 --- a/apps/sim/lib/knowledge/application/connector-access.ts +++ b/apps/sim/lib/knowledge/application/connector-access.ts @@ -42,7 +42,6 @@ import { sourceIdentityBinding, } from '@/lib/knowledge/connectors/member-provisioning' import { assertConnectorMirrorsSourceAcls } from '@/lib/knowledge/connectors/mirrored-access' -import { requireConnectorSavedCredentialSetup } from '@/lib/knowledge/connectors/saved-credential-rollout' import { type ConnectorAccessTarget, performUpdateKnowledgeConnectorAccess, @@ -434,7 +433,6 @@ async function requireUsableCredential(input: { `This ${input.connectorMeta.name} connector signs with its stored key. Create a new connector to use a saved credential.` ) } - await requireConnectorSavedCredentialSetup(auth, input.credentialId) const service = requireConnectorCredentialService(input.connectorMeta) const token = await resolveConnectorCredentialAccessToken({ principal: input.principal, diff --git a/apps/sim/lib/knowledge/application/connectors.test.ts b/apps/sim/lib/knowledge/application/connectors.test.ts index c7dbf6c5df3..ed8162baf23 100644 --- a/apps/sim/lib/knowledge/application/connectors.test.ts +++ b/apps/sim/lib/knowledge/application/connectors.test.ts @@ -8,7 +8,7 @@ import { credentialsAccessMockFns, } from '@sim/testing/mocks/credentials-access.mock' import { environmentUtilsMockFns } from '@sim/testing/mocks/environment-utils.mock' -import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock' +import { featureFlagsMock } from '@sim/testing/mocks/feature-flags.mock' import { internalAwsCredentialsMock, internalAwsCredentialsMockFns, @@ -359,7 +359,6 @@ describe('knowledge connector application use cases', () => { }) it('refuses another service’s credential for an S3 connector before resolving it', async () => { - featureFlagsMockFns.mockIsFeatureEnabled.mockResolvedValueOnce(true) mocks.getCredentialActorContext.mockResolvedValueOnce({ credential: { id: 'credential-1', diff --git a/apps/sim/lib/knowledge/application/connectors.ts b/apps/sim/lib/knowledge/application/connectors.ts index 735932b5178..d98fb353444 100644 --- a/apps/sim/lib/knowledge/application/connectors.ts +++ b/apps/sim/lib/knowledge/application/connectors.ts @@ -81,7 +81,6 @@ import { readConnectorPermissionSummaries, readConnectorPermissionSummary, } from '@/lib/knowledge/connectors/permission-config.server' -import { requireConnectorSavedCredentialSetup } from '@/lib/knowledge/connectors/saved-credential-rollout' import { MEMBER_OBSERVATION_STALE_AFTER_HOURS } from '@/lib/knowledge/connectors/sync-limits' import { connectorIsLive } from '@/lib/knowledge/connectors/sync-lock' import { @@ -754,7 +753,6 @@ async function executeCreateKnowledgeConnector({ if (input.apiKey && input.credentialId) { throw new OrchestrationError('validation', 'Choose either an OAuth account or an API key') } - await requireConnectorSavedCredentialSetup(connectorMeta.auth, input.credentialId) /** A key connector's saved credential must be of its declared service, as on an access update. */ const savedCredentialService = connectorMeta.auth.mode === 'apiKey' && connectorMeta.auth.savedCredential && input.credentialId diff --git a/apps/sim/lib/knowledge/connectors/access-token.ts b/apps/sim/lib/knowledge/connectors/access-token.ts index 2c0027906fd..985a6c5d777 100644 --- a/apps/sim/lib/knowledge/connectors/access-token.ts +++ b/apps/sim/lib/knowledge/connectors/access-token.ts @@ -7,6 +7,7 @@ import { } from '@/lib/core/resource-scope' import { resolveCredentialTokenIdentity } from '@/lib/credentials/access' import { awsCredentialFailureMessage, isTransientAwsFailure } from '@/lib/internal/aws/failure' +import { awsSigningKeys } from '@/lib/internal/aws/signing-keys' import type { ConnectorAccessMode } from '@/lib/knowledge/connectors/access-modes' import { getServiceAccountToken, @@ -116,11 +117,7 @@ async function resolveConnectorAwsToken( } return { accessToken: '', - awsSession: { - accessKeyId: session.accessKeyId, - secretAccessKey: session.secretAccessKey, - ...(session.sessionToken ? { sessionToken: session.sessionToken } : {}), - }, + awsSession: awsSigningKeys(session), ...(session.expiration ? { expiresAt: session.expiration } : {}), } } diff --git a/apps/sim/lib/knowledge/connectors/saved-credential-rollout.test.ts b/apps/sim/lib/knowledge/connectors/saved-credential-rollout.test.ts deleted file mode 100644 index d18540824c6..00000000000 --- a/apps/sim/lib/knowledge/connectors/saved-credential-rollout.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Pins the setup gate for a key connector's saved-credential alternative. Workers from an - * earlier release treat a key connector without a key as one whose credential was removed and - * unschedule it, so no such connector may be set up until the switch is turned on. - */ -import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) - -import { requireConnectorSavedCredentialSetup } from '@/lib/knowledge/connectors/saved-credential-rollout' -import { databricksConnectorMeta } from '@/connectors/databricks/meta' -import { s3ConnectorMeta } from '@/connectors/s3/meta' - -const mockIsFeatureEnabled = featureFlagsMockFns.mockIsFeatureEnabled - -describe('requireConnectorSavedCredentialSetup', () => { - beforeEach(() => { - mockIsFeatureEnabled.mockReset() - }) - - it('refuses to bind a saved credential to S3 while setup is off', async () => { - mockIsFeatureEnabled.mockResolvedValueOnce(false) - - await expect( - requireConnectorSavedCredentialSetup(s3ConnectorMeta.auth, 'aws-credential') - ).rejects.toThrow(/cannot use a saved credential yet/) - }) - - it('allows a saved credential once setup is on', async () => { - mockIsFeatureEnabled.mockResolvedValueOnce(true) - - await expect( - requireConnectorSavedCredentialSetup(s3ConnectorMeta.auth, 'aws-credential') - ).resolves.toBeUndefined() - }) - - it('leaves key setup, and key-only connectors, untouched by the switch', async () => { - await requireConnectorSavedCredentialSetup(s3ConnectorMeta.auth, undefined) - await requireConnectorSavedCredentialSetup(databricksConnectorMeta.auth, 'stray-credential') - - expect(mockIsFeatureEnabled).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/lib/knowledge/connectors/saved-credential-rollout.ts b/apps/sim/lib/knowledge/connectors/saved-credential-rollout.ts deleted file mode 100644 index 3f10b42fa70..00000000000 --- a/apps/sim/lib/knowledge/connectors/saved-credential-rollout.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { isFeatureEnabled } from '@/lib/core/config/feature-flags' -import { OrchestrationError } from '@/lib/core/orchestration/types' -import type { ConnectorAuthConfig } from '@/connectors/types' - -/** - * Whether a key-based connector may be set up with its saved-credential alternative. - * - * A global switch that gates setup only. Workers from an older release read a key - * connector without a key as one whose credential was removed and unschedule it, so - * the switch stays off until every app and worker runs a release that reads these rows. - * Turning it off later does not protect rows already set up: once any exist, a release - * rollback unschedules them as credential-removed, and they stay idle until edited or - * synced manually. - */ -export function isConnectorSavedCredentialSetupEnabled(): Promise { - return isFeatureEnabled('knowledge-connector-saved-credentials') -} - -/** Refuses to bind a saved credential to a key-based connector while setup is not enabled. */ -export async function requireConnectorSavedCredentialSetup( - auth: ConnectorAuthConfig, - credentialId: string | null | undefined -): Promise { - if (auth.mode !== 'apiKey' || !auth.savedCredential || !credentialId) return - if (!(await isConnectorSavedCredentialSetupEnabled())) { - throw new OrchestrationError( - 'validation', - 'This source cannot use a saved credential yet. Use its API key instead.' - ) - } -} diff --git a/apps/sim/lib/oauth/token-resolution.test.ts b/apps/sim/lib/oauth/token-resolution.test.ts index b263d27afc1..9c0de2e8eb0 100644 --- a/apps/sim/lib/oauth/token-resolution.test.ts +++ b/apps/sim/lib/oauth/token-resolution.test.ts @@ -789,20 +789,37 @@ describe('keyless Google Cloud credential refusals', () => { oauth: { required: true, provider: 'google-drive' }, }) mockResolveServiceAccountToken.mockReset() + }) + + it('falls back to a provider-neutral message when the refusal carries none', async () => { mockResolveServiceAccountToken.mockRejectedValue( new TokenServiceAccountValidationError('unsupported_use', 400) ) + const result = await resolveCredentialAccessToken({ + requestId: 'google-keyless', + credentialId: 'google-keyless-1', + toolId: 'google-drive-tool', + authenticate, + }) + expect(result).toMatchObject({ + ok: false, + status: 400, + code: 'unsupported_use', + error: 'This credential cannot be used here', + }) }) - it('tells the caller to use a JSON key instead of asking to reconnect', async () => { + it('tells the caller to fix the host when egress refuses it, instead of a generic 401', async () => { + mockResolveServiceAccountToken.mockRejectedValue( + new TokenServiceAccountValidationError('host_not_allowed', 400) + ) const result = await resolveCredentialAccessToken({ - requestId: 'google-keyless', + requestId: 'servicenow-host', credentialId: 'google-keyless-1', toolId: 'google-drive-tool', authenticate, }) - expect(result).toMatchObject({ ok: false, status: 400, code: 'unsupported_use' }) - expect(result.ok ? '' : result.error).toMatch(/use a JSON key credential instead/) + expect(result).toMatchObject({ ok: false, status: 400, code: 'host_not_allowed' }) }) }) diff --git a/apps/sim/lib/oauth/token-resolution.ts b/apps/sim/lib/oauth/token-resolution.ts index faa5252e909..7776067db83 100644 --- a/apps/sim/lib/oauth/token-resolution.ts +++ b/apps/sim/lib/oauth/token-resolution.ts @@ -465,6 +465,16 @@ export async function resolveCredentialToken( error: 'Credential host not found — reconnect the credential with a valid host', } } + // The stored host is one Sim's egress policy refuses (a private or + // metadata address): a host problem, not a bad secret. + if (error.code === 'host_not_allowed') { + return { + ok: false, + status: 400, + code: error.code, + error: 'Credential host is not allowed — reconnect the credential with a public host', + } + } // A revoked/rotated-away or misconfigured stored secret — surface the // code so runtime consumers can prompt to reconnect the credential // rather than showing a generic auth failure. @@ -490,8 +500,7 @@ export async function resolveCredentialToken( ok: false, status: 400, code: error.code, - error: - 'This credential cannot be used here. A keyless Google credential works only with Google Cloud tools such as BigQuery and, through domain-wide delegation, with Gmail, Google Drive, Google Docs, Google Sheets, and Google Calendar, as its own Workspace user and for the scopes it was delegated. Reconnect it to add one of those services, or use a JSON key credential instead', + error: error.userMessage ?? 'This credential cannot be used here', } } if (error.code === 'cloud_mismatch') { diff --git a/apps/sim/providers/types.ts b/apps/sim/providers/types.ts index 281f67a9548..e737cec886d 100644 --- a/apps/sim/providers/types.ts +++ b/apps/sim/providers/types.ts @@ -1,6 +1,6 @@ import type { BillingAttributionSnapshot } from '@/lib/billing/core/attribution' import { UserFailure } from '@/lib/core/errors/user-failure' -import type { AwsSessionCredentials } from '@/lib/credentials/aws-service-account.server' +import type { AwsSigningKeys } from '@/lib/internal/aws/signing-keys' import type { CustomBlockInputFieldType } from '@/blocks/custom/build-config' import type { ProviderTimingSegment, StreamingExecution, UserFile } from '@/executor/types' @@ -196,14 +196,9 @@ export interface EvaluationInput { /** * Signing credentials resolved from a saved AWS credential for one Bedrock - * request. `sessionToken` is present for STS sessions (assumed and keyless - * roles) and absent for long-lived keys; `credentialId` names the saved - * credential the session came from. + * request; `credentialId` names the saved credential the session came from. */ -export type BedrockCredentialSession = Pick< - AwsSessionCredentials, - 'accessKeyId' | 'secretAccessKey' | 'sessionToken' -> & { credentialId: string } +export type BedrockCredentialSession = AwsSigningKeys & { credentialId: string } export interface ProviderRequest { evaluation?: EvaluationInput diff --git a/apps/sim/scripts/saved-credential-contract.test.ts b/apps/sim/scripts/saved-credential-contract.test.ts index 5e692c735e0..17c755c9128 100644 --- a/apps/sim/scripts/saved-credential-contract.test.ts +++ b/apps/sim/scripts/saved-credential-contract.test.ts @@ -163,6 +163,9 @@ describe('hostSteeringParams', () => { /** Any real service-account provider: the fixture tool's URL builder, not the provider, is under test. */ const PROVIDER = 'jama' +/** A provider with a saved-credential policy whose picker also accepts OAuth accounts. */ +const MIXED_PROVIDER = 'apollo' + /** A native token block: a service-account picker and one tool, no legacy block. */ function nativeBlock(toolId: string): BlockConfig { return { @@ -244,12 +247,13 @@ describe('savedCredentialContractErrors', () => { (credentialKind) => { const block = nativeBlock('probe_fixture') block.subBlocks[0].credentialKind = 'any' + block.subBlocks[0].serviceId = MIXED_PROVIDER const errors = savedCredentialContractErrors( block, [], nativeHostTool({ required: true, - provider: PROVIDER, + provider: MIXED_PROVIDER, credentialKind, credentialParams: { accessToken: 'accessToken' }, credentialSuppliedParams: ['accessToken'], @@ -316,35 +320,10 @@ describe('savedCredentialContractErrors', () => { ) expect(errors).toContainEqual( - expect.stringContaining('tokenReplacesParams needs a credentialParams entry filled with') + expect.stringContaining('needs credentialParams to fill "accessToken"') ) }) - it('passes a token-replaced sign-in when the token fills a param not named accessToken', () => { - const executable = { - ...operationTool('token_fixture', { - authToken: { type: 'string' }, - username: { type: 'string' }, - password: { type: 'string' }, - }), - oauth: { - required: false, - provider: PROVIDER, - credentialKind: 'service-account', - credentialParams: { authToken: 'accessToken' }, - tokenReplacesParams: ['username', 'password'], - credentialSuppliedParams: ['authToken', 'username', 'password'], - } satisfies OAuthConfig, - } - - const errors = savedCredentialContractErrors(nativeBlock('token_fixture'), [], { - metadata: () => executable as ToolMetadata, - executable: () => executable, - }) - - expect(errors).toEqual([]) - }) - it('still fails a credential-supplied secret that neither the credential nor its token fills', () => { const errors = savedCredentialContractErrors( nativeBlock('minting_fixture'), diff --git a/apps/sim/scripts/saved-credential-contract.ts b/apps/sim/scripts/saved-credential-contract.ts index f067061cfd4..704ef0a1f8d 100644 --- a/apps/sim/scripts/saved-credential-contract.ts +++ b/apps/sim/scripts/saved-credential-contract.ts @@ -250,14 +250,9 @@ export function savedCredentialContractErrors( const takesSavedCredential = (block.tools?.access ?? []).some( (toolId) => (tools.metadata(toolId)?.oauth?.credentialSuppliedParams?.length ?? 0) > 0 ) - const picker = block.subBlocks.find( - (subBlock) => - isSavedCredentialPicker(subBlock) && - (subBlock.credentialKind === 'service-account' || takesSavedCredential) - ) + const picker = block.subBlocks.find(isSavedCredentialPicker) // A legacy block shares its successor's tools but takes the secret inline. - if (!picker && legacies.length === 0) return errors - if (!takesSavedCredential && !picker) return errors + if (!picker && (legacies.length === 0 || !takesSavedCredential)) return errors if (!picker?.serviceId) { errors.push( `Block "${block.type}": its tools take a saved credential but it has no service-account picker with a serviceId and canonicalParamId 'oauthCredential'.` @@ -327,10 +322,10 @@ export function savedCredentialContractErrors( if (!OPERATION_RESOLVED_CREDENTIAL_PROVIDERS.has(oauth.provider)) { if ( oauth.tokenReplacesParams?.length && - !Object.values(oauth.credentialParams ?? {}).includes('accessToken') + oauth.credentialParams?.accessToken !== 'accessToken' ) { errors.push( - `Block "${block.type}" → tool "${toolId}": tokenReplacesParams needs a credentialParams entry filled with the credential's token.` + `Block "${block.type}" → tool "${toolId}": tokenReplacesParams needs credentialParams to fill "accessToken" with the minted token.` ) } const replacedByToken = new Set(oauth.tokenReplacesParams ?? []) diff --git a/apps/sim/tools/apollo/account_bulk_create.ts b/apps/sim/tools/apollo/account_bulk_create.ts index 085d3ca9636..f331d39aee1 100644 --- a/apps/sim/tools/apollo/account_bulk_create.ts +++ b/apps/sim/tools/apollo/account_bulk_create.ts @@ -2,7 +2,7 @@ import type { ApolloAccountBulkCreateParams, ApolloAccountBulkCreateResponse, } from '@/tools/apollo/types' -import { getApolloAuthHeaders } from '@/tools/apollo/utils' +import { APOLLO_CREDENTIAL_TYPE_PARAM, getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -18,12 +18,7 @@ export const apolloAccountBulkCreateTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { - credentialType: { - type: 'string', - required: false, - visibility: 'hidden', - description: 'Stored credential type supplied by credential resolution', - }, + ...APOLLO_CREDENTIAL_TYPE_PARAM, apiKey: { type: 'string', required: true, diff --git a/apps/sim/tools/apollo/account_bulk_update.ts b/apps/sim/tools/apollo/account_bulk_update.ts index 18802f87834..098167beb3e 100644 --- a/apps/sim/tools/apollo/account_bulk_update.ts +++ b/apps/sim/tools/apollo/account_bulk_update.ts @@ -2,7 +2,7 @@ import type { ApolloAccountBulkUpdateParams, ApolloAccountBulkUpdateResponse, } from '@/tools/apollo/types' -import { getApolloAuthHeaders } from '@/tools/apollo/utils' +import { APOLLO_CREDENTIAL_TYPE_PARAM, getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -18,12 +18,7 @@ export const apolloAccountBulkUpdateTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { - credentialType: { - type: 'string', - required: false, - visibility: 'hidden', - description: 'Stored credential type supplied by credential resolution', - }, + ...APOLLO_CREDENTIAL_TYPE_PARAM, apiKey: { type: 'string', required: true, diff --git a/apps/sim/tools/apollo/account_create.ts b/apps/sim/tools/apollo/account_create.ts index bb91ef681c0..ec60693f8e0 100644 --- a/apps/sim/tools/apollo/account_create.ts +++ b/apps/sim/tools/apollo/account_create.ts @@ -1,5 +1,5 @@ import type { ApolloAccountCreateParams, ApolloAccountCreateResponse } from '@/tools/apollo/types' -import { getApolloAuthHeaders } from '@/tools/apollo/utils' +import { APOLLO_CREDENTIAL_TYPE_PARAM, getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -14,12 +14,7 @@ export const apolloAccountCreateTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { - credentialType: { - type: 'string', - required: false, - visibility: 'hidden', - description: 'Stored credential type supplied by credential resolution', - }, + ...APOLLO_CREDENTIAL_TYPE_PARAM, apiKey: { type: 'string', required: true, diff --git a/apps/sim/tools/apollo/account_search.ts b/apps/sim/tools/apollo/account_search.ts index 62cac583c10..49d2c235de3 100644 --- a/apps/sim/tools/apollo/account_search.ts +++ b/apps/sim/tools/apollo/account_search.ts @@ -1,5 +1,5 @@ import type { ApolloAccountSearchParams, ApolloAccountSearchResponse } from '@/tools/apollo/types' -import { getApolloAuthHeaders } from '@/tools/apollo/utils' +import { APOLLO_CREDENTIAL_TYPE_PARAM, getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -15,12 +15,7 @@ export const apolloAccountSearchTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { - credentialType: { - type: 'string', - required: false, - visibility: 'hidden', - description: 'Stored credential type supplied by credential resolution', - }, + ...APOLLO_CREDENTIAL_TYPE_PARAM, apiKey: { type: 'string', required: true, diff --git a/apps/sim/tools/apollo/account_update.ts b/apps/sim/tools/apollo/account_update.ts index 85bc5f47061..600293e4b77 100644 --- a/apps/sim/tools/apollo/account_update.ts +++ b/apps/sim/tools/apollo/account_update.ts @@ -1,5 +1,5 @@ import type { ApolloAccountUpdateParams, ApolloAccountUpdateResponse } from '@/tools/apollo/types' -import { getApolloAuthHeaders } from '@/tools/apollo/utils' +import { APOLLO_CREDENTIAL_TYPE_PARAM, getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -14,12 +14,7 @@ export const apolloAccountUpdateTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { - credentialType: { - type: 'string', - required: false, - visibility: 'hidden', - description: 'Stored credential type supplied by credential resolution', - }, + ...APOLLO_CREDENTIAL_TYPE_PARAM, apiKey: { type: 'string', required: true, diff --git a/apps/sim/tools/apollo/contact_bulk_create.ts b/apps/sim/tools/apollo/contact_bulk_create.ts index 7f83e11575e..035bb6336b4 100644 --- a/apps/sim/tools/apollo/contact_bulk_create.ts +++ b/apps/sim/tools/apollo/contact_bulk_create.ts @@ -2,7 +2,7 @@ import type { ApolloContactBulkCreateParams, ApolloContactBulkCreateResponse, } from '@/tools/apollo/types' -import { getApolloAuthHeaders } from '@/tools/apollo/utils' +import { APOLLO_CREDENTIAL_TYPE_PARAM, getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -18,12 +18,7 @@ export const apolloContactBulkCreateTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { - credentialType: { - type: 'string', - required: false, - visibility: 'hidden', - description: 'Stored credential type supplied by credential resolution', - }, + ...APOLLO_CREDENTIAL_TYPE_PARAM, apiKey: { type: 'string', required: true, diff --git a/apps/sim/tools/apollo/contact_bulk_update.ts b/apps/sim/tools/apollo/contact_bulk_update.ts index e7e3c4152b1..1a61bfce940 100644 --- a/apps/sim/tools/apollo/contact_bulk_update.ts +++ b/apps/sim/tools/apollo/contact_bulk_update.ts @@ -2,7 +2,7 @@ import type { ApolloContactBulkUpdateParams, ApolloContactBulkUpdateResponse, } from '@/tools/apollo/types' -import { getApolloAuthHeaders } from '@/tools/apollo/utils' +import { APOLLO_CREDENTIAL_TYPE_PARAM, getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -18,12 +18,7 @@ export const apolloContactBulkUpdateTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { - credentialType: { - type: 'string', - required: false, - visibility: 'hidden', - description: 'Stored credential type supplied by credential resolution', - }, + ...APOLLO_CREDENTIAL_TYPE_PARAM, apiKey: { type: 'string', required: true, diff --git a/apps/sim/tools/apollo/contact_create.ts b/apps/sim/tools/apollo/contact_create.ts index a0ee5ec827c..5cee7a4e839 100644 --- a/apps/sim/tools/apollo/contact_create.ts +++ b/apps/sim/tools/apollo/contact_create.ts @@ -1,5 +1,5 @@ import type { ApolloContactCreateParams, ApolloContactCreateResponse } from '@/tools/apollo/types' -import { getApolloAuthHeaders } from '@/tools/apollo/utils' +import { APOLLO_CREDENTIAL_TYPE_PARAM, getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -14,12 +14,7 @@ export const apolloContactCreateTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { - credentialType: { - type: 'string', - required: false, - visibility: 'hidden', - description: 'Stored credential type supplied by credential resolution', - }, + ...APOLLO_CREDENTIAL_TYPE_PARAM, apiKey: { type: 'string', required: true, diff --git a/apps/sim/tools/apollo/contact_search.ts b/apps/sim/tools/apollo/contact_search.ts index db34f61c14c..1f3af7a3865 100644 --- a/apps/sim/tools/apollo/contact_search.ts +++ b/apps/sim/tools/apollo/contact_search.ts @@ -1,5 +1,5 @@ import type { ApolloContactSearchParams, ApolloContactSearchResponse } from '@/tools/apollo/types' -import { getApolloAuthHeaders } from '@/tools/apollo/utils' +import { APOLLO_CREDENTIAL_TYPE_PARAM, getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -14,12 +14,7 @@ export const apolloContactSearchTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { - credentialType: { - type: 'string', - required: false, - visibility: 'hidden', - description: 'Stored credential type supplied by credential resolution', - }, + ...APOLLO_CREDENTIAL_TYPE_PARAM, apiKey: { type: 'string', required: true, diff --git a/apps/sim/tools/apollo/contact_update.ts b/apps/sim/tools/apollo/contact_update.ts index aebff7c4c3e..f6ab4557f72 100644 --- a/apps/sim/tools/apollo/contact_update.ts +++ b/apps/sim/tools/apollo/contact_update.ts @@ -1,5 +1,5 @@ import type { ApolloContactUpdateParams, ApolloContactUpdateResponse } from '@/tools/apollo/types' -import { getApolloAuthHeaders } from '@/tools/apollo/utils' +import { APOLLO_CREDENTIAL_TYPE_PARAM, getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -14,12 +14,7 @@ export const apolloContactUpdateTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { - credentialType: { - type: 'string', - required: false, - visibility: 'hidden', - description: 'Stored credential type supplied by credential resolution', - }, + ...APOLLO_CREDENTIAL_TYPE_PARAM, apiKey: { type: 'string', required: true, diff --git a/apps/sim/tools/apollo/email_accounts.ts b/apps/sim/tools/apollo/email_accounts.ts index 84b2404f155..e2d28c36a57 100644 --- a/apps/sim/tools/apollo/email_accounts.ts +++ b/apps/sim/tools/apollo/email_accounts.ts @@ -1,5 +1,5 @@ import type { ApolloEmailAccountsParams, ApolloEmailAccountsResponse } from '@/tools/apollo/types' -import { getApolloAuthHeaders } from '@/tools/apollo/utils' +import { APOLLO_CREDENTIAL_TYPE_PARAM, getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -14,12 +14,7 @@ export const apolloEmailAccountsTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { - credentialType: { - type: 'string', - required: false, - visibility: 'hidden', - description: 'Stored credential type supplied by credential resolution', - }, + ...APOLLO_CREDENTIAL_TYPE_PARAM, apiKey: { type: 'string', required: true, diff --git a/apps/sim/tools/apollo/opportunity_create.ts b/apps/sim/tools/apollo/opportunity_create.ts index deae8c5dbdd..6a68b575dcd 100644 --- a/apps/sim/tools/apollo/opportunity_create.ts +++ b/apps/sim/tools/apollo/opportunity_create.ts @@ -2,7 +2,7 @@ import type { ApolloOpportunityCreateParams, ApolloOpportunityCreateResponse, } from '@/tools/apollo/types' -import { getApolloAuthHeaders } from '@/tools/apollo/utils' +import { APOLLO_CREDENTIAL_TYPE_PARAM, getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -17,12 +17,7 @@ export const apolloOpportunityCreateTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { - credentialType: { - type: 'string', - required: false, - visibility: 'hidden', - description: 'Stored credential type supplied by credential resolution', - }, + ...APOLLO_CREDENTIAL_TYPE_PARAM, apiKey: { type: 'string', required: true, diff --git a/apps/sim/tools/apollo/opportunity_get.ts b/apps/sim/tools/apollo/opportunity_get.ts index 888976cfbdc..4bcb3e8de41 100644 --- a/apps/sim/tools/apollo/opportunity_get.ts +++ b/apps/sim/tools/apollo/opportunity_get.ts @@ -1,5 +1,5 @@ import type { ApolloOpportunityGetParams, ApolloOpportunityGetResponse } from '@/tools/apollo/types' -import { getApolloAuthHeaders } from '@/tools/apollo/utils' +import { APOLLO_CREDENTIAL_TYPE_PARAM, getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -14,12 +14,7 @@ export const apolloOpportunityGetTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { - credentialType: { - type: 'string', - required: false, - visibility: 'hidden', - description: 'Stored credential type supplied by credential resolution', - }, + ...APOLLO_CREDENTIAL_TYPE_PARAM, apiKey: { type: 'string', required: true, diff --git a/apps/sim/tools/apollo/opportunity_search.ts b/apps/sim/tools/apollo/opportunity_search.ts index bf75c8b2182..3059a415879 100644 --- a/apps/sim/tools/apollo/opportunity_search.ts +++ b/apps/sim/tools/apollo/opportunity_search.ts @@ -2,7 +2,7 @@ import type { ApolloOpportunitySearchParams, ApolloOpportunitySearchResponse, } from '@/tools/apollo/types' -import { getApolloAuthHeaders } from '@/tools/apollo/utils' +import { APOLLO_CREDENTIAL_TYPE_PARAM, getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -17,12 +17,7 @@ export const apolloOpportunitySearchTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { - credentialType: { - type: 'string', - required: false, - visibility: 'hidden', - description: 'Stored credential type supplied by credential resolution', - }, + ...APOLLO_CREDENTIAL_TYPE_PARAM, apiKey: { type: 'string', required: true, diff --git a/apps/sim/tools/apollo/opportunity_update.ts b/apps/sim/tools/apollo/opportunity_update.ts index b695f47c38d..d48489a85b2 100644 --- a/apps/sim/tools/apollo/opportunity_update.ts +++ b/apps/sim/tools/apollo/opportunity_update.ts @@ -2,7 +2,7 @@ import type { ApolloOpportunityUpdateParams, ApolloOpportunityUpdateResponse, } from '@/tools/apollo/types' -import { getApolloAuthHeaders } from '@/tools/apollo/utils' +import { APOLLO_CREDENTIAL_TYPE_PARAM, getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -17,12 +17,7 @@ export const apolloOpportunityUpdateTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { - credentialType: { - type: 'string', - required: false, - visibility: 'hidden', - description: 'Stored credential type supplied by credential resolution', - }, + ...APOLLO_CREDENTIAL_TYPE_PARAM, apiKey: { type: 'string', required: true, diff --git a/apps/sim/tools/apollo/organization_bulk_enrich.ts b/apps/sim/tools/apollo/organization_bulk_enrich.ts index 2040e9e1225..c2b0bfeb8ab 100644 --- a/apps/sim/tools/apollo/organization_bulk_enrich.ts +++ b/apps/sim/tools/apollo/organization_bulk_enrich.ts @@ -2,7 +2,7 @@ import type { ApolloOrganizationBulkEnrichParams, ApolloOrganizationBulkEnrichResponse, } from '@/tools/apollo/types' -import { getApolloAuthHeaders } from '@/tools/apollo/utils' +import { APOLLO_CREDENTIAL_TYPE_PARAM, getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -17,12 +17,7 @@ export const apolloOrganizationBulkEnrichTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { - credentialType: { - type: 'string', - required: false, - visibility: 'hidden', - description: 'Stored credential type supplied by credential resolution', - }, + ...APOLLO_CREDENTIAL_TYPE_PARAM, apiKey: { type: 'string', required: true, diff --git a/apps/sim/tools/apollo/organization_enrich.ts b/apps/sim/tools/apollo/organization_enrich.ts index b6bb9c5b57b..f52ea1fbcb1 100644 --- a/apps/sim/tools/apollo/organization_enrich.ts +++ b/apps/sim/tools/apollo/organization_enrich.ts @@ -2,7 +2,7 @@ import type { ApolloOrganizationEnrichParams, ApolloOrganizationEnrichResponse, } from '@/tools/apollo/types' -import { getApolloAuthHeaders } from '@/tools/apollo/utils' +import { APOLLO_CREDENTIAL_TYPE_PARAM, getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -17,12 +17,7 @@ export const apolloOrganizationEnrichTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { - credentialType: { - type: 'string', - required: false, - visibility: 'hidden', - description: 'Stored credential type supplied by credential resolution', - }, + ...APOLLO_CREDENTIAL_TYPE_PARAM, apiKey: { type: 'string', required: true, diff --git a/apps/sim/tools/apollo/organization_search.ts b/apps/sim/tools/apollo/organization_search.ts index efa732a7bcd..c47c292e763 100644 --- a/apps/sim/tools/apollo/organization_search.ts +++ b/apps/sim/tools/apollo/organization_search.ts @@ -2,7 +2,7 @@ import type { ApolloOrganizationSearchParams, ApolloOrganizationSearchResponse, } from '@/tools/apollo/types' -import { getApolloAuthHeaders } from '@/tools/apollo/utils' +import { APOLLO_CREDENTIAL_TYPE_PARAM, getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -17,12 +17,7 @@ export const apolloOrganizationSearchTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { - credentialType: { - type: 'string', - required: false, - visibility: 'hidden', - description: 'Stored credential type supplied by credential resolution', - }, + ...APOLLO_CREDENTIAL_TYPE_PARAM, apiKey: { type: 'string', required: true, diff --git a/apps/sim/tools/apollo/people_bulk_enrich.ts b/apps/sim/tools/apollo/people_bulk_enrich.ts index 4271de39a32..3c9bfa0ac00 100644 --- a/apps/sim/tools/apollo/people_bulk_enrich.ts +++ b/apps/sim/tools/apollo/people_bulk_enrich.ts @@ -2,7 +2,7 @@ import type { ApolloPeopleBulkEnrichParams, ApolloPeopleBulkEnrichResponse, } from '@/tools/apollo/types' -import { getApolloAuthHeaders } from '@/tools/apollo/utils' +import { APOLLO_CREDENTIAL_TYPE_PARAM, getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -17,12 +17,7 @@ export const apolloPeopleBulkEnrichTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { - credentialType: { - type: 'string', - required: false, - visibility: 'hidden', - description: 'Stored credential type supplied by credential resolution', - }, + ...APOLLO_CREDENTIAL_TYPE_PARAM, apiKey: { type: 'string', required: true, diff --git a/apps/sim/tools/apollo/people_enrich.ts b/apps/sim/tools/apollo/people_enrich.ts index aa775186370..4691b453a6c 100644 --- a/apps/sim/tools/apollo/people_enrich.ts +++ b/apps/sim/tools/apollo/people_enrich.ts @@ -1,5 +1,5 @@ import type { ApolloPeopleEnrichParams, ApolloPeopleEnrichResponse } from '@/tools/apollo/types' -import { getApolloAuthHeaders } from '@/tools/apollo/utils' +import { APOLLO_CREDENTIAL_TYPE_PARAM, getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -14,12 +14,7 @@ export const apolloPeopleEnrichTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { - credentialType: { - type: 'string', - required: false, - visibility: 'hidden', - description: 'Stored credential type supplied by credential resolution', - }, + ...APOLLO_CREDENTIAL_TYPE_PARAM, apiKey: { type: 'string', required: true, diff --git a/apps/sim/tools/apollo/people_search.ts b/apps/sim/tools/apollo/people_search.ts index 8bb9a5ace4a..0bba604b0f4 100644 --- a/apps/sim/tools/apollo/people_search.ts +++ b/apps/sim/tools/apollo/people_search.ts @@ -1,5 +1,9 @@ import type { ApolloPeopleSearchParams, ApolloPeopleSearchResponse } from '@/tools/apollo/types' -import { getApolloAuthHeaders, isApolloOAuthCredential } from '@/tools/apollo/utils' +import { + APOLLO_CREDENTIAL_TYPE_PARAM, + getApolloAuthHeaders, + isApolloOAuthCredential, +} from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -21,12 +25,7 @@ export const apolloPeopleSearchTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { - credentialType: { - type: 'string', - required: false, - visibility: 'hidden', - description: 'Stored credential type supplied by credential resolution', - }, + ...APOLLO_CREDENTIAL_TYPE_PARAM, apiKey: { type: 'string', required: true, diff --git a/apps/sim/tools/apollo/sequence_add_contacts.ts b/apps/sim/tools/apollo/sequence_add_contacts.ts index 718f191358b..0d06d20b202 100644 --- a/apps/sim/tools/apollo/sequence_add_contacts.ts +++ b/apps/sim/tools/apollo/sequence_add_contacts.ts @@ -2,7 +2,7 @@ import type { ApolloSequenceAddContactsParams, ApolloSequenceAddContactsResponse, } from '@/tools/apollo/types' -import { getApolloAuthHeaders } from '@/tools/apollo/utils' +import { APOLLO_CREDENTIAL_TYPE_PARAM, getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -17,12 +17,7 @@ export const apolloSequenceAddContactsTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { - credentialType: { - type: 'string', - required: false, - visibility: 'hidden', - description: 'Stored credential type supplied by credential resolution', - }, + ...APOLLO_CREDENTIAL_TYPE_PARAM, apiKey: { type: 'string', required: true, diff --git a/apps/sim/tools/apollo/sequence_search.ts b/apps/sim/tools/apollo/sequence_search.ts index 7d284e7cae5..d90921028d7 100644 --- a/apps/sim/tools/apollo/sequence_search.ts +++ b/apps/sim/tools/apollo/sequence_search.ts @@ -1,5 +1,5 @@ import type { ApolloSequenceSearchParams, ApolloSequenceSearchResponse } from '@/tools/apollo/types' -import { getApolloAuthHeaders } from '@/tools/apollo/utils' +import { APOLLO_CREDENTIAL_TYPE_PARAM, getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -14,12 +14,7 @@ export const apolloSequenceSearchTool: ToolConfig< oauth: APOLLO_CREDENTIAL_OAUTH, params: { - credentialType: { - type: 'string', - required: false, - visibility: 'hidden', - description: 'Stored credential type supplied by credential resolution', - }, + ...APOLLO_CREDENTIAL_TYPE_PARAM, apiKey: { type: 'string', required: true, diff --git a/apps/sim/tools/apollo/task_create.ts b/apps/sim/tools/apollo/task_create.ts index cfb318e9aab..afcc6cb21e8 100644 --- a/apps/sim/tools/apollo/task_create.ts +++ b/apps/sim/tools/apollo/task_create.ts @@ -1,5 +1,5 @@ import type { ApolloTaskCreateParams, ApolloTaskCreateResponse } from '@/tools/apollo/types' -import { getApolloAuthHeaders } from '@/tools/apollo/utils' +import { APOLLO_CREDENTIAL_TYPE_PARAM, getApolloAuthHeaders } from '@/tools/apollo/utils' import { APOLLO_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' @@ -11,12 +11,7 @@ export const apolloTaskCreateTool: ToolConfig