Skip to content

Commit c203f51

Browse files
authored
fix(tools): resolve {{ENV_VAR}} references in user-only params for copilot tool executions (#5679)
* fix(tools): resolve {{ENV_VAR}} references in user-only params for copilot tool executions Chat-invoked integration tools with API-key auth have been broken since the mothership tool-dispatch rewrite (#4090) removed the orchestrator's env reference resolution. Agents pass {{VAR}} references (the VFS exposes env var names only), and the literal placeholder was sent to the provider, producing auth failures like Sentry's 401 Invalid token. Resolution is deliberately narrower than the removed deep resolver: only whole-value references on params declared visibility user-only, gated to copilot executions, resolving against the same personal+workspace env merge workflow runs use. LLM-writable params (urls, headers, bodies) never resolve, so references cannot be used to extract secrets. Missing variables fail fast with an actionable error instead of a provider-side 401. * refactor(tools): delegate copilot env reference resolution to the shared executor resolver Replaces the hand-rolled exact-match regex with resolveEnvVarReferences (allowEmbedded: false), the same resolver used by workflow runs, MCP config, and webhooks — one set of reference semantics instead of two that can drift. Behavior is identical; all existing tests pass unchanged. * fix(tools): fail fast on env references without user context, clarify personal-only scope errors Addresses review: a copilot execution missing userId now errors explicitly instead of forwarding the literal placeholder upstream, and a missing-variable error without a workspace context explains that only personal variables are in scope there (matching workflow-run resolution semantics).
1 parent 515dafa commit c203f51

2 files changed

Lines changed: 271 additions & 0 deletions

File tree

apps/sim/tools/index.test.ts

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ const {
3131
mockGetCustomToolByIdOrTitle,
3232
mockGenerateInternalToken,
3333
mockResolveWorkspaceFileReference,
34+
mockGetEffectiveDecryptedEnv,
3435
} = vi.hoisted(() => ({
3536
mockIsHosted: { value: false },
3637
mockEnv: { NEXT_PUBLIC_APP_URL: 'http://localhost:3000' } as Record<string, string | undefined>,
@@ -46,6 +47,7 @@ const {
4647
mockGetCustomToolByIdOrTitle: vi.fn(),
4748
mockGenerateInternalToken: vi.fn(),
4849
mockResolveWorkspaceFileReference: vi.fn(),
50+
mockGetEffectiveDecryptedEnv: vi.fn(),
4951
}))
5052

5153
const mockSecureFetchWithPinnedIP = inputValidationMockFns.mockSecureFetchWithPinnedIP
@@ -107,6 +109,10 @@ vi.mock('@/lib/core/rate-limiter/hosted-key', () => ({
107109
getHostedKeyRateLimiter: () => mockRateLimiterFns,
108110
}))
109111

112+
vi.mock('@/lib/environment/utils', () => ({
113+
getEffectiveDecryptedEnv: (...args: unknown[]) => mockGetEffectiveDecryptedEnv(...args),
114+
}))
115+
110116
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
111117
resolveWorkspaceFileReference: (...args: unknown[]) => mockResolveWorkspaceFileReference(...args),
112118
}))
@@ -234,6 +240,26 @@ vi.mock('@/tools/registry', () => {
234240
return { success: true, output: data }
235241
},
236242
},
243+
test_env_ref_tool: {
244+
id: 'test_env_ref_tool',
245+
name: 'Test Env Reference Tool',
246+
description: 'Accepts a user-only API key and an llm-writable note',
247+
version: '1.0.0',
248+
params: {
249+
apiKey: { type: 'string', required: true, visibility: 'user-only' },
250+
note: { type: 'string', required: false, visibility: 'user-or-llm' },
251+
},
252+
request: {
253+
url: '/api/tools/test/env-ref',
254+
method: 'POST',
255+
headers: () => ({ 'Content-Type': 'application/json' }),
256+
body: (p: any) => ({ apiKey: p.apiKey, note: p.note }),
257+
},
258+
transformResponse: async (response: any) => {
259+
const data = await response.json()
260+
return { success: true, output: data }
261+
},
262+
},
237263
test_file_array_tool: {
238264
id: 'test_file_array_tool',
239265
name: 'Test File Array Tool',
@@ -1259,6 +1285,184 @@ describe('Copilot OAuth Credential Enforcement', () => {
12591285
})
12601286
})
12611287

1288+
describe('Copilot Env Variable Reference Resolution', () => {
1289+
let cleanupEnvVars: () => void
1290+
1291+
function mockJsonFetch() {
1292+
const fetchMock = vi.fn().mockResolvedValue({
1293+
ok: true,
1294+
status: 200,
1295+
statusText: 'OK',
1296+
headers: new Headers(),
1297+
json: () => Promise.resolve({ ok: true }),
1298+
text: () => Promise.resolve(JSON.stringify({ ok: true })),
1299+
clone: vi.fn().mockReturnThis(),
1300+
})
1301+
global.fetch = Object.assign(fetchMock, { preconnect: vi.fn() }) as typeof fetch
1302+
return fetchMock
1303+
}
1304+
1305+
function sentRequestBody(fetchMock: ReturnType<typeof vi.fn>): Record<string, unknown> {
1306+
return JSON.parse(fetchMock.mock.calls[0][1]?.body as string)
1307+
}
1308+
1309+
const copilotContext = () =>
1310+
createToolExecutionContext({
1311+
workspaceId: 'workspace-456',
1312+
userId: 'user-123',
1313+
copilotToolExecution: true,
1314+
} as any)
1315+
1316+
beforeEach(() => {
1317+
process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000'
1318+
cleanupEnvVars = setupEnvVars({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000' })
1319+
mockGetEffectiveDecryptedEnv.mockReset()
1320+
mockGetEffectiveDecryptedEnv.mockResolvedValue({ SENTRY_AUTH_TOKEN: 'sntrys_real_token' })
1321+
})
1322+
1323+
afterEach(() => {
1324+
vi.resetAllMocks()
1325+
cleanupEnvVars()
1326+
})
1327+
1328+
it('resolves a whole-value {{VAR}} reference in a user-only param', async () => {
1329+
const fetchMock = mockJsonFetch()
1330+
1331+
const result = await executeTool(
1332+
'test_env_ref_tool',
1333+
{ apiKey: '{{SENTRY_AUTH_TOKEN}}' },
1334+
{ executionContext: copilotContext() }
1335+
)
1336+
1337+
expect(result.success).toBe(true)
1338+
expect(mockGetEffectiveDecryptedEnv).toHaveBeenCalledWith('user-123', 'workspace-456')
1339+
expect(sentRequestBody(fetchMock).apiKey).toBe('sntrys_real_token')
1340+
})
1341+
1342+
it('trims whitespace inside the braces like the executor resolver', async () => {
1343+
const fetchMock = mockJsonFetch()
1344+
1345+
const result = await executeTool(
1346+
'test_env_ref_tool',
1347+
{ apiKey: '{{ SENTRY_AUTH_TOKEN }}' },
1348+
{ executionContext: copilotContext() }
1349+
)
1350+
1351+
expect(result.success).toBe(true)
1352+
expect(sentRequestBody(fetchMock).apiKey).toBe('sntrys_real_token')
1353+
})
1354+
1355+
it('never resolves references in llm-writable (user-or-llm) params', async () => {
1356+
const fetchMock = mockJsonFetch()
1357+
1358+
const result = await executeTool(
1359+
'test_env_ref_tool',
1360+
{ apiKey: '{{SENTRY_AUTH_TOKEN}}', note: '{{SENTRY_AUTH_TOKEN}}' },
1361+
{ executionContext: copilotContext() }
1362+
)
1363+
1364+
expect(result.success).toBe(true)
1365+
expect(sentRequestBody(fetchMock).note).toBe('{{SENTRY_AUTH_TOKEN}}')
1366+
})
1367+
1368+
it('leaves embedded references untouched in user-only params', async () => {
1369+
const fetchMock = mockJsonFetch()
1370+
1371+
const result = await executeTool(
1372+
'test_env_ref_tool',
1373+
{ apiKey: 'Bearer {{SENTRY_AUTH_TOKEN}}' },
1374+
{ executionContext: copilotContext() }
1375+
)
1376+
1377+
expect(result.success).toBe(true)
1378+
expect(sentRequestBody(fetchMock).apiKey).toBe('Bearer {{SENTRY_AUTH_TOKEN}}')
1379+
expect(mockGetEffectiveDecryptedEnv).not.toHaveBeenCalled()
1380+
})
1381+
1382+
it('fails with a clear error before any request when the variable is missing', async () => {
1383+
const fetchMock = mockJsonFetch()
1384+
1385+
const result = await executeTool(
1386+
'test_env_ref_tool',
1387+
{ apiKey: '{{MISSING_VAR}}' },
1388+
{ executionContext: copilotContext() }
1389+
)
1390+
1391+
expect(result.success).toBe(false)
1392+
expect(result.error).toContain('MISSING_VAR')
1393+
expect(result.error).toContain('apiKey')
1394+
expect(fetchMock).not.toHaveBeenCalled()
1395+
})
1396+
1397+
it('fails fast instead of forwarding the placeholder when user context is missing', async () => {
1398+
const fetchMock = mockJsonFetch()
1399+
1400+
const result = await executeTool(
1401+
'test_env_ref_tool',
1402+
{ apiKey: '{{SENTRY_AUTH_TOKEN}}' },
1403+
{
1404+
executionContext: createToolExecutionContext({
1405+
workspaceId: 'workspace-456',
1406+
userId: undefined,
1407+
copilotToolExecution: true,
1408+
} as any),
1409+
}
1410+
)
1411+
1412+
expect(result.success).toBe(false)
1413+
expect(result.error).toContain('authenticated user context')
1414+
expect(mockGetEffectiveDecryptedEnv).not.toHaveBeenCalled()
1415+
expect(fetchMock).not.toHaveBeenCalled()
1416+
})
1417+
1418+
it('explains the personal-only scope when a variable is missing without a workspace context', async () => {
1419+
const fetchMock = mockJsonFetch()
1420+
1421+
const result = await executeTool(
1422+
'test_env_ref_tool',
1423+
{ apiKey: '{{MISSING_VAR}}' },
1424+
{
1425+
executionContext: createToolExecutionContext({
1426+
workspaceId: undefined,
1427+
userId: 'user-123',
1428+
copilotToolExecution: true,
1429+
} as any),
1430+
}
1431+
)
1432+
1433+
expect(result.success).toBe(false)
1434+
expect(result.error).toContain('only personal variables are available')
1435+
expect(mockGetEffectiveDecryptedEnv).toHaveBeenCalledWith('user-123', undefined)
1436+
expect(fetchMock).not.toHaveBeenCalled()
1437+
})
1438+
1439+
it('does not resolve references outside copilot execution', async () => {
1440+
const fetchMock = mockJsonFetch()
1441+
1442+
const result = await executeTool(
1443+
'test_env_ref_tool',
1444+
{ apiKey: '{{SENTRY_AUTH_TOKEN}}' },
1445+
{ executionContext: createToolExecutionContext({ userId: 'user-123' } as any) }
1446+
)
1447+
1448+
expect(result.success).toBe(true)
1449+
expect(mockGetEffectiveDecryptedEnv).not.toHaveBeenCalled()
1450+
expect(sentRequestBody(fetchMock).apiKey).toBe('{{SENTRY_AUTH_TOKEN}}')
1451+
})
1452+
1453+
it('never mutates the caller-owned params object (log-leak guard)', async () => {
1454+
mockJsonFetch()
1455+
const callerParams = { apiKey: '{{SENTRY_AUTH_TOKEN}}' }
1456+
1457+
const result = await executeTool('test_env_ref_tool', callerParams, {
1458+
executionContext: copilotContext(),
1459+
})
1460+
1461+
expect(result.success).toBe(true)
1462+
expect(callerParams.apiKey).toBe('{{SENTRY_AUTH_TOKEN}}')
1463+
})
1464+
})
1465+
12621466
describe('Centralized Error Handling', () => {
12631467
let cleanupEnvVars: () => void
12641468

apps/sim/tools/index.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { assertPermissionsAllowed } from '@/ee/access-control/utils/permission-c
3333
import { isCustomTool, isMcpTool } from '@/executor/constants'
3434
import { resolveSkillContent } from '@/executor/handlers/agent/skills-resolver'
3535
import type { ExecutionContext, UserFile } from '@/executor/types'
36+
import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
3637
import type { ErrorInfo } from '@/tools/error-extractors'
3738
import { extractErrorMessage } from '@/tools/error-extractors'
3839
import type {
@@ -183,6 +184,71 @@ async function normalizeCopilotFileParams(
183184
}
184185
}
185186

187+
/**
188+
* Resolves whole-value {{ENV_VAR}} references in user-only params for copilot
189+
* tool executions. Chat agents never see secret values (the workspace VFS
190+
* exposes env var names only), so they pass references; workflow runs resolve
191+
* these in the executor, and this is the equivalent step for direct tool
192+
* calls, delegating to the executor's resolver so both paths share one set of
193+
* reference semantics. Resolution is deliberately restricted to params
194+
* declared `visibility: 'user-only'` (API keys and other operator-supplied
195+
* secrets) and to values that are exactly one reference, so LLM-writable
196+
* params (URLs, headers, bodies) can never be used to extract secret values.
197+
*
198+
* Mutates only the given params object — callers pass the per-execution copy,
199+
* never the copilot-side tool-call state, so decrypted values cannot leak
200+
* into failure logs or persisted chat state.
201+
*/
202+
async function resolveCopilotEnvReferences(
203+
tool: ToolConfig,
204+
params: Record<string, unknown>,
205+
scope: ToolExecutionScope
206+
): Promise<void> {
207+
if (!scope.copilotToolExecution) {
208+
return
209+
}
210+
211+
const pending: Array<{ paramId: string; value: string }> = []
212+
for (const [paramId, paramDef] of Object.entries(tool.params || {})) {
213+
if (paramDef?.visibility !== 'user-only') continue
214+
const value = params[paramId]
215+
if (typeof value === 'string' && value.startsWith('{{') && value.endsWith('}}')) {
216+
pending.push({ paramId, value })
217+
}
218+
}
219+
220+
if (pending.length === 0) {
221+
return
222+
}
223+
224+
if (!scope.userId) {
225+
throw new Error(
226+
`Cannot resolve environment variable reference in parameter "${pending[0].paramId}" without an authenticated user context.`
227+
)
228+
}
229+
230+
const { getEffectiveDecryptedEnv } = await import('@/lib/environment/utils')
231+
const envVars = await getEffectiveDecryptedEnv(scope.userId, scope.workspaceId)
232+
233+
for (const { paramId, value } of pending) {
234+
const missingKeys: string[] = []
235+
const resolved = resolveEnvVarReferences(value, envVars, {
236+
allowEmbedded: false,
237+
missingKeys,
238+
})
239+
if (missingKeys.length > 0) {
240+
const scopeHint = scope.workspaceId
241+
? ''
242+
: ' (no workspace context — only personal variables are available here)'
243+
throw new Error(
244+
`Environment variable "${missingKeys[0]}" referenced by parameter "${paramId}" was not found${scopeHint}. ` +
245+
`Check environment/variables.json for available variable names.`
246+
)
247+
}
248+
params[paramId] = resolved as string
249+
}
250+
}
251+
186252
function readExplicitCredentialSelector(params: Record<string, unknown>): string | undefined {
187253
for (const key of ['credentialId', 'oauthCredential', 'credential'] as const) {
188254
const value = params[key]
@@ -1025,6 +1091,7 @@ export async function executeTool(
10251091
await normalizeCopilotFileParams(tool, contextParams, scope)
10261092
normalizeCopilotCredentialParams(contextParams)
10271093
enforceCopilotCredentialSelection(toolId, tool, contextParams, scope)
1094+
await resolveCopilotEnvReferences(tool, contextParams, scope)
10281095

10291096
// Inject hosted API key if tool supports it and user didn't provide one
10301097
const hostedKeyInfo = await injectHostedKeyIfNeeded(

0 commit comments

Comments
 (0)