@@ -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 */
7068function 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(
224234function 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