Skip to content

Commit e9567b6

Browse files
committed
fix(tools): decide hosted-key admission on registry-resolved params
1 parent 57bf024 commit e9567b6

2 files changed

Lines changed: 83 additions & 40 deletions

File tree

‎apps/sim/lib/tool-execution/application/execute-tool.test.ts‎

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,18 @@ const TOOL_METADATA: Record<string, Record<string, unknown>> = {
126126
},
127127
hosting: { apiKeyParam: 'apiKey' },
128128
},
129+
image_generate: {
130+
id: 'image_generate',
131+
name: 'Image Generate',
132+
params: {
133+
provider: { type: 'string', required: true, visibility: 'user-only' },
134+
apiKey: { type: 'string', required: true, visibility: 'user-only' },
135+
},
136+
hosting: {
137+
apiKeyParam: 'apiKey',
138+
enabled: (params: { provider?: unknown }) => params.provider === 'falai',
139+
},
140+
},
129141
snowflake_execute_sql: {
130142
id: 'snowflake_execute_sql',
131143
name: 'Snowflake Execute SQL',
@@ -204,6 +216,7 @@ function block(overrides: Partial<BlockConfig> & { type: string }): BlockConfig
204216
const fileBlock = block({ type: 'file_v5', tools: { access: ['file_read'] } })
205217
const slackBlock = block({ type: 'slack', tools: { access: ['slack_message'] } })
206218
const firecrawlBlock = block({ type: 'firecrawl', tools: { access: ['firecrawl_scrape'] } })
219+
const imageBlock = block({ type: 'image_generator', tools: { access: ['image_generate'] } })
207220
const previewBlock = block({
208221
type: 'preview_thing',
209222
preview: true,
@@ -245,6 +258,7 @@ describe('executeToolForCaller', () => {
245258
fileBlock,
246259
slackBlock,
247260
firecrawlBlock,
261+
imageBlock,
248262
previewBlock,
249263
confluenceBlock,
250264
zendeskBlock,
@@ -511,38 +525,55 @@ describe('executeToolForCaller', () => {
511525
expect(mocks.recordUsage).not.toHaveBeenCalled()
512526
})
513527

514-
it.each([
515-
['the key is omitted', { input: { url: 'https://a.co' } }],
528+
const referencedImageCall = {
529+
toolId: 'image_generate',
530+
input: { provider: '{{IMAGE_PROVIDER}}', apiKey: '{{IMAGE_KEY}}' },
531+
}
532+
533+
it.each<[string, Parameters<typeof run>[0], Record<string, string>]>([
534+
['the key is omitted', { input: { url: 'https://a.co' } }, {}],
516535
[
517536
'the key references an empty variable',
518537
{ input: { url: 'https://a.co', apiKey: '{{FIRECRAWL_KEY}}' } },
538+
{ FIRECRAWL_KEY: ' ' },
519539
],
520-
])('refuses a hosted-key call over the usage limit when %s', async (_case, input) => {
540+
[
541+
'a reference selects the hosted provider',
542+
referencedImageCall,
543+
{ IMAGE_PROVIDER: 'falai', IMAGE_KEY: '' },
544+
],
545+
])('refuses a hosted-key call over the usage limit when %s', async (_case, input, env) => {
521546
mocks.checkUsageLimits.mockResolvedValue({ isExceeded: true, message: 'Usage limit exceeded' })
522-
environmentUtilsMockFns.mockGetEffectiveDecryptedEnv.mockResolvedValue({ FIRECRAWL_KEY: ' ' })
547+
environmentUtilsMockFns.mockGetEffectiveDecryptedEnv.mockResolvedValue(env)
523548

524549
await expect(run(input)).rejects.toBeInstanceOf(ToolUsageLimitExceededError)
525550
})
526551

527-
it.each([
528-
['the caller brings their own key', { input: { url: 'https://a.co', apiKey: 'sk-own' } }],
552+
it.each<[string, Parameters<typeof run>[0], Record<string, string>]>([
553+
['the caller brings their own key', { input: { url: 'https://a.co', apiKey: 'sk-own' } }, {}],
529554
[
530555
'the caller references a variable holding their own key',
531556
{ input: { url: 'https://a.co', apiKey: '{{FIRECRAWL_KEY}}' } },
557+
{ FIRECRAWL_KEY: 'fc-own' },
532558
],
533559
[
534560
'the reference pads the variable name',
535561
{ input: { url: 'https://a.co', apiKey: '{{ FIRECRAWL_KEY }}' } },
562+
{ FIRECRAWL_KEY: 'fc-own' },
563+
],
564+
[
565+
'a reference selects a provider Sim does not host',
566+
referencedImageCall,
567+
{ IMAGE_PROVIDER: 'openai', IMAGE_KEY: '' },
536568
],
537569
[
538570
'the tool has no hosted key',
539571
{ toolId: 'zendesk_get_ticket', input: { ticketId: '4', subdomain: 'a', apiToken: 't' } },
572+
{},
540573
],
541-
])('does not gate on usage when %s', async (_case, input) => {
574+
])('does not gate on usage when %s', async (_case, input, env) => {
542575
mocks.checkUsageLimits.mockResolvedValue({ isExceeded: true, message: 'Usage limit exceeded' })
543-
environmentUtilsMockFns.mockGetEffectiveDecryptedEnv.mockResolvedValue({
544-
FIRECRAWL_KEY: 'fc-own',
545-
})
576+
environmentUtilsMockFns.mockGetEffectiveDecryptedEnv.mockResolvedValue(env)
546577

547578
await expect(run(input)).resolves.toMatchObject({ status: 'succeeded' })
548579
})

‎apps/sim/lib/tool-execution/application/execute-tool.ts‎

Lines changed: 42 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,12 @@ export interface ExecuteToolResult {
5858
* Mirrors `injectHostedKeyIfNeeded`'s tests, in its order, so the two cannot
5959
* disagree about whether a value is coming: the tool declares `hosting`, the
6060
* deployment hosts keys, any `enabled` predicate accepts these params, and the
61-
* caller has not brought a key of their own — which wins where present. A
62-
* `{{VAR}}` reference is not one yet: the registry resolves it, and a variable
63-
* holding an empty value falls through to Sim's key.
61+
* caller has not brought a key of their own — which wins where present.
6462
*
6563
* Pre-dispatch only: the required-input exemption (a parameter Sim will fill is
66-
* not missing) and usage admission. It is deliberately NOT the metering gate — it cannot see
67-
* a BYOK key, which the registry injects while reporting the call as *not*
68-
* hosted, so after dispatch the registry's own verdict is read instead.
64+
* not missing) and usage admission. It is deliberately NOT the metering gate —
65+
* it cannot see a BYOK key, which the registry injects while reporting the call
66+
* as *not* hosted, so after dispatch the registry's own verdict is read instead.
6967
*/
7068
function hostedKeyParamFor(
7169
tool: ExecutableToolConfig,
@@ -74,32 +72,44 @@ function hostedKeyParamFor(
7472
if (!isHosted || !tool.hosting) return undefined
7573
if (tool.hosting.enabled && !tool.hosting.enabled(params)) return undefined
7674
const supplied = params[tool.hosting.apiKeyParam]
77-
if (typeof supplied === 'string' && supplied.trim().length > 0 && !isEnvVarReference(supplied)) {
78-
return undefined
79-
}
75+
if (typeof supplied === 'string' && supplied.trim().length > 0) return undefined
8076
return tool.hosting.apiKeyParam
8177
}
8278

8379
/**
84-
* Whether a `{{VAR}}` key resolves to a key of the caller's own.
80+
* `params` as the registry holds them when it decides on Sim's key.
8581
*
86-
* Resolved exactly as the registry resolves it — same environment, same
87-
* options — before it decides on Sim's key. A variable that is missing or
88-
* empty leaves the parameter for Sim's key to fill.
82+
* The registry resolves each whole-value `{{VAR}}` in a `user-only` parameter
83+
* before `injectHostedKeyIfNeeded` runs, so a reference can supply the key, or
84+
* the value an `enabled` predicate reads, and an empty variable leaves the key
85+
* for Sim's to fill. Resolved the same way here — same environment, same
86+
* options. A missing variable stays as written: the registry refuses the call
87+
* on it before any key is spent.
8988
*/
90-
async function referencesOwnKey(
91-
value: unknown,
89+
async function resolveUserOnlyReferences(
90+
tool: ExecutableToolConfig,
91+
params: Record<string, unknown>,
9292
userId: string,
9393
workspaceId: string
94-
): Promise<boolean> {
95-
if (typeof value !== 'string' || !isEnvVarReference(value)) return false
96-
const missingKeys: string[] = []
97-
const resolved = resolveEnvVarReferences(
98-
value,
99-
await getEffectiveDecryptedEnv(userId, workspaceId),
100-
{ allowEmbedded: false, missingKeys }
101-
)
102-
return missingKeys.length === 0 && typeof resolved === 'string' && resolved.trim().length > 0
94+
): Promise<Record<string, unknown>> {
95+
const referenced = Object.entries(tool.params ?? {})
96+
.filter(([name, declaration]) => {
97+
const value = params[name]
98+
return (
99+
declaration?.visibility === 'user-only' &&
100+
typeof value === 'string' &&
101+
isEnvVarReference(value)
102+
)
103+
})
104+
.map(([name]) => name)
105+
if (referenced.length === 0) return params
106+
107+
const env = await getEffectiveDecryptedEnv(userId, workspaceId)
108+
const resolved = { ...params }
109+
for (const name of referenced) {
110+
resolved[name] = resolveEnvVarReferences(params[name], env, { allowEmbedded: false })
111+
}
112+
return resolved
103113
}
104114

105115
/**
@@ -224,9 +234,10 @@ function assertNoUndeclaredInputs(
224234
function assertRequiredCallerInputsPresent(
225235
tool: ExecutableToolConfig,
226236
toolId: string,
227-
params: Record<string, unknown>,
228-
hostedKeyParam: string | undefined
237+
params: Record<string, unknown>
229238
): void {
239+
const hostedKeyParam = hostedKeyParamFor(tool, params)
240+
230241
const missing = Object.entries(tool.params ?? {})
231242
.filter(([name, declaration]) => {
232243
if (!declaration?.required) return false
@@ -326,8 +337,7 @@ export const executeToolForCaller = defineAuthorizedWorkspaceUseCase({
326337
...input.input,
327338
...(input.credentialId ? { [selector ?? 'credential']: input.credentialId } : {}),
328339
}
329-
const hostedKeyParam = hostedKeyParamFor(tool, callerParams)
330-
assertRequiredCallerInputsPresent(tool, toolId, callerParams, hostedKeyParam)
340+
assertRequiredCallerInputsPresent(tool, toolId, callerParams)
331341

332342
const userId = principalUserId(principal)
333343
if (!userId) {
@@ -345,8 +355,10 @@ export const executeToolForCaller = defineAuthorizedWorkspaceUseCase({
345355
* see that key — the same standing every workflow run is held to.
346356
*/
347357
if (
348-
hostedKeyParam &&
349-
!(await referencesOwnKey(callerParams[hostedKeyParam], userId, context.workspaceId))
358+
hostedKeyParamFor(
359+
tool,
360+
await resolveUserOnlyReferences(tool, callerParams, userId, context.workspaceId)
361+
)
350362
) {
351363
const usage = await checkExecutionUsageLimits(billingAttribution)
352364
if (usage.isExceeded) {

0 commit comments

Comments
 (0)