Skip to content

Commit f2efc31

Browse files
committed
improvement(credentials): resolve agent fallback credentials once, Vanta 401 grace, true refusal codes, headless client-credential modes, and registry cleanups
1 parent 6c6e020 commit f2efc31

59 files changed

Lines changed: 702 additions & 372 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/sim/connectors/aws-session.ts‎

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { type AwsSigningKeys, awsSigningKeys } from '@/lib/internal/aws/signing-keys'
2+
13
/**
24
* The AWS keys a saved AWS credential resolved to for one connector run.
35
*
@@ -8,24 +10,13 @@
810
* from one place and never from tool or user input. Server-only state: the
911
* slot is never persisted or logged.
1012
*/
11-
export interface ConnectorAwsSession {
12-
accessKeyId: string
13-
secretAccessKey: string
14-
/** Present for STS sessions (assumed and keyless roles); absent for saved long-lived keys. */
15-
sessionToken?: string
16-
}
13+
export type ConnectorAwsSession = AwsSigningKeys
1714

1815
const AWS_SESSION_CONTEXT_KEY = 'credentialAwsSession'
1916

2017
/** The `syncContext` entries that carry a resolved AWS session to a connector. */
2118
export function awsSessionSyncContext(session: ConnectorAwsSession): Record<string, unknown> {
22-
return {
23-
[AWS_SESSION_CONTEXT_KEY]: {
24-
accessKeyId: session.accessKeyId,
25-
secretAccessKey: session.secretAccessKey,
26-
...(session.sessionToken ? { sessionToken: session.sessionToken } : {}),
27-
},
28-
}
19+
return { [AWS_SESSION_CONTEXT_KEY]: awsSigningKeys(session) }
2920
}
3021

3122
/** The AWS session seeded into this run, or undefined when the connector signs with inline keys. */
@@ -40,5 +31,5 @@ export function readAwsSession(
4031
if (sessionToken !== undefined && (typeof sessionToken !== 'string' || !sessionToken)) {
4132
return undefined
4233
}
43-
return { accessKeyId, secretAccessKey, ...(sessionToken ? { sessionToken } : {}) }
34+
return awsSigningKeys({ accessKeyId, secretAccessKey, sessionToken })
4435
}

‎apps/sim/executor/handlers/agent/handler.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1228,6 +1228,35 @@ describe('AgentBlockHandler', () => {
12281228
expect(mockResolveBedrockCredentialSession).toHaveBeenCalledTimes(1)
12291229
})
12301230

1231+
it('resolves the AWS credential once for a fallback on the same provider', async () => {
1232+
const session = {
1233+
credentialId: 'aws-cred',
1234+
accessKeyId: 'ASIASESSIONKEY000001',
1235+
secretAccessKey: 'session-secret',
1236+
sessionToken: 'session-token',
1237+
}
1238+
mockResolveBedrockCredentialSession.mockResolvedValueOnce(session)
1239+
mockExecuteProviderRequest
1240+
.mockRejectedValueOnce(new Error('overloaded'))
1241+
.mockResolvedValueOnce(providerResponse('bedrock/claude-fallback'))
1242+
1243+
await handler.execute(mockContext, mockBlock, {
1244+
...baseInputs,
1245+
model: 'bedrock/claude',
1246+
bedrockAuthMode: 'credential',
1247+
bedrockCredential: 'aws-cred',
1248+
fallbackModels: [{ model: 'bedrock/claude-fallback' }],
1249+
})
1250+
1251+
expect(mockResolveBedrockCredentialSession).toHaveBeenCalledTimes(1)
1252+
expect(mockExecuteProviderRequest).toHaveBeenCalledTimes(2)
1253+
const [[primaryProvider, primaryRequest], [fallbackProvider, fallbackRequest]] =
1254+
mockExecuteProviderRequest.mock.calls
1255+
expect([primaryProvider, fallbackProvider]).toEqual(['bedrock', 'bedrock'])
1256+
expect(primaryRequest.bedrockSession).toEqual(session)
1257+
expect(fallbackRequest.bedrockSession).toEqual(session)
1258+
})
1259+
12311260
it('re-resolves tuning for the fallback: row value wins, caps clamp, undeclared values drop', async () => {
12321261
const fallbackCap = getModelCapabilities('gpt-5.4-mini')?.maxOutputTokens
12331262
expect(fallbackCap).toEqual(expect.any(Number))

‎apps/sim/executor/handlers/agent/handler.ts‎

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ import { collectBlockData } from '@/executor/utils/block-data'
9595
import { stringifyJSON } from '@/executor/utils/json'
9696
import {
9797
BLOCK_MODEL_CREDENTIAL_FIELDS,
98+
type BlockModelCredentials,
9899
resolveBlockModelCredentials,
99100
} from '@/executor/utils/model-credentials'
100101
import {
@@ -2610,6 +2611,11 @@ export class AgentBlockHandler implements BlockHandler {
26102611
* blacklisted, not permitted, or cannot take the attachments is skipped
26112612
* rather than counted as a failed try.
26122613
*
2614+
* The block's saved Vertex or Bedrock credential resolves at most once per
2615+
* call and is shared by every candidate on the primary's provider; it is
2616+
* awaited inside the attempt, so a failure fails those attempts and other
2617+
* providers' fallbacks still run. It never reaches another provider.
2618+
*
26132619
* A streaming candidate is accepted only once its first chunk has arrived
26142620
* (`primeStreamingExecution`), so a startup failure inside the stream still
26152621
* falls through; that wait is skipped when no candidate follows, which keeps
@@ -2635,6 +2641,7 @@ export class AgentBlockHandler implements BlockHandler {
26352641
let resultRegistry = config.resultRegistry
26362642
const failedModels: string[] = []
26372643
let lastError: unknown
2644+
let savedCredentials: Promise<BlockModelCredentials> | undefined
26382645
let lastErrorRegistries:
26392646
| {
26402647
error: ResolvedSecretTraceRegistry | undefined
@@ -2754,6 +2761,16 @@ export class AgentBlockHandler implements BlockHandler {
27542761
streaming: config.streaming,
27552762
})
27562763

2764+
const onPrimaryProvider = candidateProviderId === config.primaryProviderId
2765+
if (onPrimaryProvider) {
2766+
savedCredentials ??= resolveBlockModelCredentials(
2767+
ctx,
2768+
config.primaryProviderId,
2769+
config.modelInputs,
2770+
'vertex-agent'
2771+
)
2772+
}
2773+
27572774
try {
27582775
let result = await this.executeProviderRequest(
27592776
ctx,
@@ -2762,6 +2779,7 @@ export class AgentBlockHandler implements BlockHandler {
27622779
config.responseFormat,
27632780
resultRegistry,
27642781
config.providerErrorRegistry,
2782+
onPrimaryProvider ? savedCredentials : undefined,
27652783
config.agentConversation
27662784
)
27672785
if ((hasNext || config.retryPrimaryOnStreamStart) && this.isStreamingExecution(result)) {
@@ -2983,18 +3001,14 @@ export class AgentBlockHandler implements BlockHandler {
29833001
responseFormat: any,
29843002
modelRuntimeRegistry: ResolvedSecretTraceRegistry | undefined,
29853003
providerErrorRegistry: ResolvedSecretTraceRegistry | undefined,
3004+
savedCredentials: Promise<BlockModelCredentials> | undefined,
29863005
agentConversation?: AgentTurnSession
29873006
): Promise<BlockOutput | StreamingExecution> {
29883007
const providerId = providerRequest.provider
29893008
const model = providerRequest.model
29903009

29913010
try {
2992-
const credentials = await resolveBlockModelCredentials(
2993-
ctx,
2994-
providerId,
2995-
providerRequest,
2996-
'vertex-agent'
2997-
)
3011+
const credentials: BlockModelCredentials = (await savedCredentials) ?? {}
29983012

29993013
const { blockData, blockNameMapping } = collectBlockData(ctx)
30003014
const agentMemoryRetrieval = agentConversation?.memoryId

‎apps/sim/lib/credentials/application/provider-catalog-contract.test.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { listCredentialProviders } from '@/lib/credentials/application/list-cred
2727
import { listCredentialProviderCatalog } from '@/lib/credentials/application/provider-catalog'
2828
import { DATABASE_CONNECTION_DESCRIPTORS } from '@/lib/credentials/database-connections/descriptors'
2929
import { getMissingServiceAccountFields } from '@/lib/credentials/service-account-fields'
30+
import { TOKEN_SERVICE_ACCOUNT_DESCRIPTORS } from '@/lib/credentials/token-service-accounts/descriptors'
3031
import {
3132
isWorkloadIdentityServiceAccountProviderId,
3233
WORKLOAD_IDENTITY_KEYLESS_CONFIGS,
@@ -217,6 +218,47 @@ describe('v2 credential provider catalog contract', () => {
217218
).toBeUndefined()
218219
})
219220

221+
it('lists the client-credentials mode of every provider that offers one, as creation reads it', async () => {
222+
const providers = await listProviders()
223+
expect(responseSchema.safeParse({ data: providers, nextCursor: null }).success).toBe(true)
224+
const offering = Object.values(TOKEN_SERVICE_ACCOUNT_DESCRIPTORS).filter(
225+
(descriptor) => descriptor.clientCredentials
226+
)
227+
expect(offering.length).toBeGreaterThan(0)
228+
229+
for (const providerId of [
230+
...offering.map((descriptor) => descriptor.providerId),
231+
'atlassian-service-account',
232+
]) {
233+
const provider = providers.find(
234+
(entry) => entry.type === 'service_account' && entry.providerId === providerId
235+
)
236+
const fields = provider?.fields ?? []
237+
const requiredFor = (authMethod: string) =>
238+
fields
239+
.filter((field) =>
240+
field.requiredForAuthMethods
241+
? field.requiredForAuthMethods.includes(authMethod)
242+
: field.required
243+
)
244+
.map((field) => field.id)
245+
.sort()
246+
247+
expect(fields.find((field) => field.id === 'authMethod')).toMatchObject({
248+
required: false,
249+
defaultValue: 'token',
250+
options: [
251+
{ value: 'token', label: expect.any(String) },
252+
{ value: 'client_credentials', label: expect.any(String) },
253+
],
254+
})
255+
expect(requiredFor('client_credentials')).toEqual(
256+
getMissingServiceAccountFields(providerId, { authMethod: 'client_credentials' }).sort()
257+
)
258+
expect(requiredFor('token')).toEqual(getMissingServiceAccountFields(providerId, {}).sort())
259+
}
260+
})
261+
220262
it('lists the native Claude Platform provider when filtered', async () => {
221263
const providers = await listProviders('claude')
222264

‎apps/sim/lib/credentials/application/provider-catalog.ts‎

Lines changed: 86 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,12 @@ import {
77
} from '@/lib/credentials/client-credential-accounts/descriptors'
88
import { getDatabaseConnectionDescriptor } from '@/lib/credentials/database-connections/descriptors'
99
import {
10+
CLIENT_CREDENTIALS_AUTH_METHOD,
11+
isTokenServiceAccountFieldRequired,
1012
SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID,
13+
TOKEN_AUTH_METHOD,
1114
TOKEN_SERVICE_ACCOUNT_DESCRIPTORS,
15+
type TokenServiceAccountDescriptor,
1216
type TokenServiceAccountField,
1317
} from '@/lib/credentials/token-service-accounts/descriptors'
1418
import {
@@ -117,15 +121,14 @@ const ATLASSIAN_SERVICE_ACCOUNT_DOCS_URL =
117121
'https://docs.sim.ai/integrations/atlassian-service-account'
118122

119123
function providerField(
120-
field: TokenServiceAccountField | ClientCredentialAccountField
124+
field: TokenServiceAccountField | ClientCredentialAccountField,
125+
required: boolean
121126
): CredentialProviderField {
122127
return {
123128
id: field.id,
124129
label: field.label,
125130
placeholder: field.placeholder,
126-
required:
127-
!('optional' in field && field.optional) &&
128-
!('defaultValue' in field && field.defaultValue !== undefined),
131+
required,
129132
secret: field.secret,
130133
multiline: 'multiline' in field && field.multiline === true,
131134
...('requiredForAuthMethods' in field && field.requiredForAuthMethods
@@ -143,6 +146,74 @@ function providerField(
143146
}
144147
}
145148

149+
/**
150+
* A token provider's fields. One that also offers a client-credentials grant adds `authMethod`,
151+
* `clientId`, and `clientSecret`, read the way its create builder reads them: `authMethod:
152+
* 'client_credentials'` sends the client ID and secret in place of the token (and of any field
153+
* that belongs to the pasted-token sign-in only), and an omitted `authMethod` keeps the token.
154+
*/
155+
function tokenProviderFields(descriptor: TokenServiceAccountDescriptor): CredentialProviderField[] {
156+
const { clientCredentials } = descriptor
157+
const fields = descriptor.fields.map((field) => {
158+
const catalogField = providerField(
159+
field,
160+
isTokenServiceAccountFieldRequired(field, TOKEN_AUTH_METHOD)
161+
)
162+
return clientCredentials && field.id === 'apiToken'
163+
? { ...catalogField, requiredForAuthMethods: [TOKEN_AUTH_METHOD] }
164+
: catalogField
165+
})
166+
if (!clientCredentials) return fields
167+
return [...fields, ...clientCredentialModeFields(clientCredentials)]
168+
}
169+
170+
/**
171+
* The fields a headless caller sends to pick and fill a provider's OAuth client-credentials mode
172+
* instead of its pasted token: the mode selector, and the client ID and secret that mode requires.
173+
*/
174+
function clientCredentialModeFields(mode: {
175+
label: string
176+
tokenLabel: string
177+
helpText?: string
178+
}): CredentialProviderField[] {
179+
const clientCredentialMethods = [CLIENT_CREDENTIALS_AUTH_METHOD]
180+
return [
181+
{
182+
id: 'authMethod',
183+
label: 'Authentication method',
184+
placeholder: TOKEN_AUTH_METHOD,
185+
required: false,
186+
secret: false,
187+
multiline: false,
188+
options: [
189+
{ value: TOKEN_AUTH_METHOD, label: mode.tokenLabel },
190+
{ value: CLIENT_CREDENTIALS_AUTH_METHOD, label: mode.label },
191+
],
192+
defaultValue: TOKEN_AUTH_METHOD,
193+
hint: `Send ${CLIENT_CREDENTIALS_AUTH_METHOD} with clientId and clientSecret instead of the fields required only for ${TOKEN_AUTH_METHOD}.`,
194+
},
195+
{
196+
id: 'clientId',
197+
label: 'Client ID',
198+
placeholder: 'Paste the client ID',
199+
required: false,
200+
secret: false,
201+
multiline: false,
202+
requiredForAuthMethods: clientCredentialMethods,
203+
...(mode.helpText ? { hint: mode.helpText } : {}),
204+
},
205+
{
206+
id: 'clientSecret',
207+
label: 'Client secret',
208+
placeholder: 'Paste the client secret',
209+
required: false,
210+
secret: true,
211+
multiline: false,
212+
requiredForAuthMethods: clientCredentialMethods,
213+
},
214+
]
215+
}
216+
146217
interface KeylessCatalogMode {
147218
keyless: CredentialProviderKeylessMode
148219
/** Where keyless works, when narrower than the provider. */
@@ -351,7 +422,8 @@ function getServiceAccountDescriptor(providerId: string): ServiceAccountDescript
351422
if (providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
352423
return {
353424
name: 'Atlassian service account',
354-
description: 'Connect Jira and Confluence with an Atlassian API token.',
425+
description:
426+
'Connect Jira and Confluence with an Atlassian API token or a service account OAuth 2.0 credential.',
355427
docsUrl: ATLASSIAN_SERVICE_ACCOUNT_DOCS_URL,
356428
fields: [
357429
{
@@ -361,6 +433,7 @@ function getServiceAccountDescriptor(providerId: string): ServiceAccountDescript
361433
required: true,
362434
secret: true,
363435
multiline: false,
436+
requiredForAuthMethods: [TOKEN_AUTH_METHOD],
364437
},
365438
{
366439
id: 'domain',
@@ -383,6 +456,11 @@ function getServiceAccountDescriptor(providerId: string): ServiceAccountDescript
383456
],
384457
hint: 'Select the product this token can access. Defaults to Jira.',
385458
},
459+
...clientCredentialModeFields({
460+
label: 'Service account OAuth 2.0',
461+
tokenLabel: 'API token',
462+
helpText: "The client ID of the service account's OAuth 2.0 credential.",
463+
}),
386464
],
387465
}
388466
}
@@ -484,7 +562,7 @@ function getServiceAccountDescriptor(providerId: string): ServiceAccountDescript
484562
docsUrl: tokenDescriptor.docsUrl,
485563
helpText: tokenDescriptor.helpText,
486564
fields: [
487-
...tokenDescriptor.fields.map(providerField),
565+
...tokenProviderFields(tokenDescriptor),
488566
{
489567
id: 'serviceAccountJson',
490568
label: 'Connection configuration',
@@ -510,7 +588,7 @@ function getServiceAccountDescriptor(providerId: string): ServiceAccountDescript
510588
description: `Connect ${tokenDescriptor.serviceLabel} with a ${tokenDescriptor.tokenNoun}.`,
511589
docsUrl: tokenDescriptor.docsUrl,
512590
helpText: tokenDescriptor.helpText,
513-
fields: tokenDescriptor.fields.map(providerField),
591+
fields: tokenProviderFields(tokenDescriptor),
514592
}
515593
}
516594

@@ -525,7 +603,7 @@ function getServiceAccountDescriptor(providerId: string): ServiceAccountDescript
525603
description: `Connect ${clientDescriptor.serviceLabel} with a ${clientDescriptor.connectNoun}.`,
526604
docsUrl: clientDescriptor.docsUrl,
527605
helpText: clientDescriptor.helpText,
528-
fields: clientDescriptor.fields.map(providerField),
606+
fields: clientDescriptor.fields.map((field) => providerField(field, !field.optional)),
529607
}
530608
}
531609

0 commit comments

Comments
 (0)