22 * @vitest -environment node
33 */
44import type { PersonalApiKeyPrincipal , WorkspaceApiKeyPrincipal } from '@sim/auth/principal'
5- import { beforeEach , describe , expect , it , vi } from 'vitest'
5+ import { resetEnvFlagsMock , setEnvFlags } from '@sim/testing/mocks'
6+ import { afterAll , beforeEach , describe , expect , it , vi } from 'vitest'
67
78const mocks = vi . hoisted ( ( ) => ( {
89 loadWorkspace : vi . fn ( ) ,
@@ -69,8 +70,8 @@ vi.mock('@/blocks/registry', () => ({
6970 getBlockMeta : vi . fn ( ( ) => ( { tags : [ ] } ) ) ,
7071} ) )
7172
72- vi . mock ( '@/tools/metadata ' , ( ) => ( {
73- getToolMetadata : ( toolId : string ) =>
73+ vi . mock ( '@/tools/utils ' , ( ) => ( {
74+ getTool : ( toolId : string ) =>
7475 Object . hasOwn ( TOOL_METADATA , toolId ) ? TOOL_METADATA [ toolId ] : undefined ,
7576} ) )
7677
@@ -98,10 +99,27 @@ const TOOL_METADATA: Record<string, Record<string, unknown>> = {
9899 slack_message : {
99100 id : 'slack_message' ,
100101 name : 'Slack Send Message' ,
101- params : { text : { type : 'string' , required : true } } ,
102+ params : { text : { type : 'string' , required : true , visibility : 'user-or-llm' } } ,
102103 oauth : { required : true , provider : 'slack' } ,
103104 } ,
104- firecrawl_scrape : { id : 'firecrawl_scrape' , name : 'Firecrawl Scrape' , params : { } } ,
105+ firecrawl_scrape : {
106+ id : 'firecrawl_scrape' ,
107+ name : 'Firecrawl Scrape' ,
108+ params : {
109+ url : { type : 'string' , required : true , visibility : 'user-or-llm' } ,
110+ apiKey : { type : 'string' , required : true , visibility : 'user-only' } ,
111+ } ,
112+ hosting : { apiKeyParam : 'apiKey' } ,
113+ } ,
114+ zendesk_get_ticket : {
115+ id : 'zendesk_get_ticket' ,
116+ name : 'Zendesk Get Ticket' ,
117+ params : {
118+ subdomain : { type : 'string' , required : true , visibility : 'user-only' } ,
119+ apiToken : { type : 'string' , required : true , visibility : 'user-only' } ,
120+ ticketId : { type : 'string' , required : true , visibility : 'user-or-llm' } ,
121+ } ,
122+ } ,
105123 preview_call : { id : 'preview_call' , name : 'Preview Call' , params : { } } ,
106124 confluence_read_v2 : { id : 'confluence_read_v2' , name : 'Confluence Read' , params : { } } ,
107125}
@@ -148,6 +166,7 @@ const previewBlock = block({
148166 preview : true ,
149167 tools : { access : [ 'preview_call' ] } ,
150168} )
169+ const zendeskBlock = block ( { type : 'zendesk' , tools : { access : [ 'zendesk_get_ticket' ] } } )
151170const confluenceBlock = block ( {
152171 type : 'confluence_v2' ,
153172 tools : { access : [ 'confluence_read_v2' ] } ,
@@ -156,20 +175,35 @@ const confluenceBlock = block({
156175function run ( input : Partial < Parameters < typeof executeToolForCaller . execute > [ 0 ] [ 'input' ] > = { } ) {
157176 return executeToolForCaller . execute ( {
158177 principal,
159- input : { workspaceId : WORKSPACE_ID , toolId : 'firecrawl_scrape' , input : { } , ...input } ,
178+ input : {
179+ workspaceId : WORKSPACE_ID ,
180+ toolId : 'firecrawl_scrape' ,
181+ input : { url : 'https://example.com' } ,
182+ ...input ,
183+ } ,
160184 } )
161185}
162186
163187describe ( 'executeToolForCaller' , ( ) => {
188+ afterAll ( resetEnvFlagsMock )
189+
164190 beforeEach ( ( ) => {
165191 vi . clearAllMocks ( )
192+ // Hosted-key injection only happens where Sim hosts keys.
193+ setEnvFlags ( { isHosted : true } )
166194 mocks . loadWorkspace . mockResolvedValue ( workspaceContext )
167195 mocks . resolvePermission . mockResolvedValue ( 'write' )
168196 mocks . allowedIntegrationTypes . mockResolvedValue ( null )
169197 mocks . getBlockVisibility . mockResolvedValue ( { revealed : new Set ( ) , disabled : new Set ( ) } )
170198 mocks . listCustomBlocks . mockResolvedValue ( [ ] )
171199 mocks . isDeploymentAvailable . mockReturnValue ( true )
172- mocks . getAllBlocks . mockReturnValue ( [ slackBlock , firecrawlBlock , previewBlock , confluenceBlock ] )
200+ mocks . getAllBlocks . mockReturnValue ( [
201+ slackBlock ,
202+ firecrawlBlock ,
203+ previewBlock ,
204+ confluenceBlock ,
205+ zendeskBlock ,
206+ ] )
173207 mocks . executeRegistryTool . mockResolvedValue ( { success : true , output : { markdown : '# Hi' } } )
174208 mocks . resolveBillingAttribution . mockResolvedValue ( { workspaceId : WORKSPACE_ID } )
175209 } )
@@ -247,6 +281,77 @@ describe('executeToolForCaller', () => {
247281 } )
248282 } )
249283
284+ /**
285+ * The workflow path validates `user-only` parameters during serialization.
286+ * This path has no serialization step, so without an explicit check a missing
287+ * credential reached the provider as `undefined`.
288+ */
289+ it ( 'refuses a missing required user-only input, naming every one of them' , async ( ) => {
290+ await expect (
291+ run ( { toolId : 'zendesk_get_ticket' , input : { ticketId : '42' } } )
292+ ) . rejects . toMatchObject ( {
293+ code : 'validation' ,
294+ message : expect . stringContaining ( 'input.subdomain' ) ,
295+ } )
296+ expect ( mocks . executeRegistryTool ) . not . toHaveBeenCalled ( )
297+ } )
298+
299+ it ( 'names the missing inputs together rather than one per round trip' , async ( ) => {
300+ await expect (
301+ run ( { toolId : 'zendesk_get_ticket' , input : { ticketId : '42' } } )
302+ ) . rejects . toMatchObject ( { message : expect . stringContaining ( 'input.apiToken' ) } )
303+ } )
304+
305+ it ( 'treats a blank string as missing, the way the merge validator does' , async ( ) => {
306+ await expect (
307+ run ( { toolId : 'zendesk_get_ticket' , input : { ticketId : '4' , subdomain : '' , apiToken : 't' } } )
308+ ) . rejects . toMatchObject ( { code : 'validation' } )
309+ } )
310+
311+ it ( 'runs once every required user-only input is supplied' , async ( ) => {
312+ await expect (
313+ run ( {
314+ toolId : 'zendesk_get_ticket' ,
315+ input : { ticketId : '42' , subdomain : 'acme' , apiToken : 'tok' } ,
316+ } )
317+ ) . resolves . toMatchObject ( { status : 'succeeded' } )
318+ } )
319+
320+ /**
321+ * `firecrawl_scrape` declares `apiKey` required and `user-only`, and Sim
322+ * supplies it. Rejecting the omission would make every hosted-key tool
323+ * uncallable without a key the caller does not need to have.
324+ */
325+ it ( 'does not require a key the deployment hosts' , async ( ) => {
326+ await expect ( run ( { input : { url : 'https://example.com' } } ) ) . resolves . toMatchObject ( {
327+ status : 'succeeded' ,
328+ } )
329+ } )
330+
331+ /**
332+ * Self-hosted supplies no hosted keys — `injectHostedKeyIfNeeded` short-circuits
333+ * on `isHosted` — so the exemption must lift with it, or the caller is told a
334+ * key is optional and the provider disagrees.
335+ */
336+ it ( 'does require that key on a deployment that hosts none' , async ( ) => {
337+ setEnvFlags ( { isHosted : false } )
338+
339+ await expect ( run ( { input : { url : 'https://example.com' } } ) ) . rejects . toMatchObject ( {
340+ code : 'validation' ,
341+ message : expect . stringContaining ( 'input.apiKey' ) ,
342+ } )
343+ } )
344+
345+ it ( 'accepts a {{VAR}} reference as a present value, leaving resolution to the executor' , async ( ) => {
346+ await run ( {
347+ toolId : 'zendesk_get_ticket' ,
348+ input : { ticketId : '4' , subdomain : 'acme' , apiToken : '{{ZENDESK_TOKEN}}' } ,
349+ } )
350+
351+ const [ , params ] = mocks . executeRegistryTool . mock . calls [ 0 ]
352+ expect ( params . apiToken ) . toBe ( '{{ZENDESK_TOKEN}}' )
353+ } )
354+
250355 it ( 'requires a credential for an OAuth tool before it dispatches' , async ( ) => {
251356 await expect ( run ( { toolId : 'slack_message' , input : { text : 'hi' } } ) ) . rejects . toMatchObject ( {
252357 code : 'validation' ,
0 commit comments