From 43606772a9a5927f9bbfa7693ce9882a1d1cb73d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 14 Jul 2026 16:18:07 -0700 Subject: [PATCH 1/3] 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. --- apps/sim/tools/index.test.ts | 162 +++++++++++++++++++++++++++++++++++ apps/sim/tools/index.ts | 61 +++++++++++++ 2 files changed, 223 insertions(+) diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index b898543d029..cfd95a1510e 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -31,6 +31,7 @@ const { mockGetCustomToolByIdOrTitle, mockGenerateInternalToken, mockResolveWorkspaceFileReference, + mockGetEffectiveDecryptedEnv, } = vi.hoisted(() => ({ mockIsHosted: { value: false }, mockEnv: { NEXT_PUBLIC_APP_URL: 'http://localhost:3000' } as Record, @@ -46,6 +47,7 @@ const { mockGetCustomToolByIdOrTitle: vi.fn(), mockGenerateInternalToken: vi.fn(), mockResolveWorkspaceFileReference: vi.fn(), + mockGetEffectiveDecryptedEnv: vi.fn(), })) const mockSecureFetchWithPinnedIP = inputValidationMockFns.mockSecureFetchWithPinnedIP @@ -107,6 +109,10 @@ vi.mock('@/lib/core/rate-limiter/hosted-key', () => ({ getHostedKeyRateLimiter: () => mockRateLimiterFns, })) +vi.mock('@/lib/environment/utils', () => ({ + getEffectiveDecryptedEnv: (...args: unknown[]) => mockGetEffectiveDecryptedEnv(...args), +})) + vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ resolveWorkspaceFileReference: (...args: unknown[]) => mockResolveWorkspaceFileReference(...args), })) @@ -234,6 +240,26 @@ vi.mock('@/tools/registry', () => { return { success: true, output: data } }, }, + test_env_ref_tool: { + id: 'test_env_ref_tool', + name: 'Test Env Reference Tool', + description: 'Accepts a user-only API key and an llm-writable note', + version: '1.0.0', + params: { + apiKey: { type: 'string', required: true, visibility: 'user-only' }, + note: { type: 'string', required: false, visibility: 'user-or-llm' }, + }, + request: { + url: '/api/tools/test/env-ref', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (p: any) => ({ apiKey: p.apiKey, note: p.note }), + }, + transformResponse: async (response: any) => { + const data = await response.json() + return { success: true, output: data } + }, + }, test_file_array_tool: { id: 'test_file_array_tool', name: 'Test File Array Tool', @@ -1259,6 +1285,142 @@ describe('Copilot OAuth Credential Enforcement', () => { }) }) +describe('Copilot Env Variable Reference Resolution', () => { + let cleanupEnvVars: () => void + + function mockJsonFetch() { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + json: () => Promise.resolve({ ok: true }), + text: () => Promise.resolve(JSON.stringify({ ok: true })), + clone: vi.fn().mockReturnThis(), + }) + global.fetch = Object.assign(fetchMock, { preconnect: vi.fn() }) as typeof fetch + return fetchMock + } + + function sentRequestBody(fetchMock: ReturnType): Record { + return JSON.parse(fetchMock.mock.calls[0][1]?.body as string) + } + + const copilotContext = () => + createToolExecutionContext({ + workspaceId: 'workspace-456', + userId: 'user-123', + copilotToolExecution: true, + } as any) + + beforeEach(() => { + process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000' + cleanupEnvVars = setupEnvVars({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000' }) + mockGetEffectiveDecryptedEnv.mockReset() + mockGetEffectiveDecryptedEnv.mockResolvedValue({ SENTRY_AUTH_TOKEN: 'sntrys_real_token' }) + }) + + afterEach(() => { + vi.resetAllMocks() + cleanupEnvVars() + }) + + it('resolves a whole-value {{VAR}} reference in a user-only param', async () => { + const fetchMock = mockJsonFetch() + + const result = await executeTool( + 'test_env_ref_tool', + { apiKey: '{{SENTRY_AUTH_TOKEN}}' }, + { executionContext: copilotContext() } + ) + + expect(result.success).toBe(true) + expect(mockGetEffectiveDecryptedEnv).toHaveBeenCalledWith('user-123', 'workspace-456') + expect(sentRequestBody(fetchMock).apiKey).toBe('sntrys_real_token') + }) + + it('trims whitespace inside the braces like the executor resolver', async () => { + const fetchMock = mockJsonFetch() + + const result = await executeTool( + 'test_env_ref_tool', + { apiKey: '{{ SENTRY_AUTH_TOKEN }}' }, + { executionContext: copilotContext() } + ) + + expect(result.success).toBe(true) + expect(sentRequestBody(fetchMock).apiKey).toBe('sntrys_real_token') + }) + + it('never resolves references in llm-writable (user-or-llm) params', async () => { + const fetchMock = mockJsonFetch() + + const result = await executeTool( + 'test_env_ref_tool', + { apiKey: '{{SENTRY_AUTH_TOKEN}}', note: '{{SENTRY_AUTH_TOKEN}}' }, + { executionContext: copilotContext() } + ) + + expect(result.success).toBe(true) + expect(sentRequestBody(fetchMock).note).toBe('{{SENTRY_AUTH_TOKEN}}') + }) + + it('leaves embedded references untouched in user-only params', async () => { + const fetchMock = mockJsonFetch() + + const result = await executeTool( + 'test_env_ref_tool', + { apiKey: 'Bearer {{SENTRY_AUTH_TOKEN}}' }, + { executionContext: copilotContext() } + ) + + expect(result.success).toBe(true) + expect(sentRequestBody(fetchMock).apiKey).toBe('Bearer {{SENTRY_AUTH_TOKEN}}') + expect(mockGetEffectiveDecryptedEnv).not.toHaveBeenCalled() + }) + + it('fails with a clear error before any request when the variable is missing', async () => { + const fetchMock = mockJsonFetch() + + const result = await executeTool( + 'test_env_ref_tool', + { apiKey: '{{MISSING_VAR}}' }, + { executionContext: copilotContext() } + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('MISSING_VAR') + expect(result.error).toContain('apiKey') + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('does not resolve references outside copilot execution', async () => { + const fetchMock = mockJsonFetch() + + const result = await executeTool( + 'test_env_ref_tool', + { apiKey: '{{SENTRY_AUTH_TOKEN}}' }, + { executionContext: createToolExecutionContext({ userId: 'user-123' } as any) } + ) + + expect(result.success).toBe(true) + expect(mockGetEffectiveDecryptedEnv).not.toHaveBeenCalled() + expect(sentRequestBody(fetchMock).apiKey).toBe('{{SENTRY_AUTH_TOKEN}}') + }) + + it('never mutates the caller-owned params object (log-leak guard)', async () => { + mockJsonFetch() + const callerParams = { apiKey: '{{SENTRY_AUTH_TOKEN}}' } + + const result = await executeTool('test_env_ref_tool', callerParams, { + executionContext: copilotContext(), + }) + + expect(result.success).toBe(true) + expect(callerParams.apiKey).toBe('{{SENTRY_AUTH_TOKEN}}') + }) +}) + describe('Centralized Error Handling', () => { let cleanupEnvVars: () => void diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 1602aa1cb79..aa1b2b328b0 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -183,6 +183,66 @@ async function normalizeCopilotFileParams( } } +/** + * Matches a string that is exactly one {{ENV_VAR}} reference (same charset the + * executor's exact-match resolver accepts). Embedded references are + * intentionally not matched. + */ +const COPILOT_ENV_REFERENCE_PATTERN = /^\{\{([^}]+)\}\}$/ + +/** + * Resolves whole-value {{ENV_VAR}} references in user-only params for copilot + * tool executions. Chat agents never see secret values (the workspace VFS + * exposes env var names only), so they pass references; workflow runs resolve + * these in the executor, and this is the equivalent step for direct tool + * calls. Resolution is deliberately restricted to params declared + * `visibility: 'user-only'` (API keys and other operator-supplied secrets) + * and to values that are exactly one reference, so LLM-writable params (URLs, + * headers, bodies) can never be used to extract secret values. + * + * Mutates only the given params object — callers pass the per-execution copy, + * never the copilot-side tool-call state, so decrypted values cannot leak + * into failure logs or persisted chat state. + */ +async function resolveCopilotEnvReferences( + tool: ToolConfig, + params: Record, + scope: ToolExecutionScope +): Promise { + if (!scope.copilotToolExecution || !scope.userId) { + return + } + + const pending: Array<{ paramId: string; envKey: string }> = [] + for (const [paramId, paramDef] of Object.entries(tool.params || {})) { + if (paramDef?.visibility !== 'user-only') continue + const value = params[paramId] + if (typeof value !== 'string') continue + const match = COPILOT_ENV_REFERENCE_PATTERN.exec(value) + if (match) { + pending.push({ paramId, envKey: match[1].trim() }) + } + } + + if (pending.length === 0) { + return + } + + const { getEffectiveDecryptedEnv } = await import('@/lib/environment/utils') + const envVars = await getEffectiveDecryptedEnv(scope.userId, scope.workspaceId) + + for (const { paramId, envKey } of pending) { + const resolved = envVars[envKey] + if (resolved === undefined) { + throw new Error( + `Environment variable "${envKey}" referenced by parameter "${paramId}" was not found. ` + + `Check environment/variables.json for available variable names.` + ) + } + params[paramId] = resolved + } +} + function readExplicitCredentialSelector(params: Record): string | undefined { for (const key of ['credentialId', 'oauthCredential', 'credential'] as const) { const value = params[key] @@ -1025,6 +1085,7 @@ export async function executeTool( await normalizeCopilotFileParams(tool, contextParams, scope) normalizeCopilotCredentialParams(contextParams) enforceCopilotCredentialSelection(toolId, tool, contextParams, scope) + await resolveCopilotEnvReferences(tool, contextParams, scope) // Inject hosted API key if tool supports it and user didn't provide one const hostedKeyInfo = await injectHostedKeyIfNeeded( From b6f0314b50c23a68ac2f58bdbd7a966690140068 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 14 Jul 2026 16:31:11 -0700 Subject: [PATCH 2/3] refactor(tools): delegate copilot env reference resolution to the shared executor resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/sim/tools/index.ts | 39 ++++++++++++++++++--------------------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index aa1b2b328b0..3f18f8a303d 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -33,6 +33,7 @@ import { assertPermissionsAllowed } from '@/ee/access-control/utils/permission-c import { isCustomTool, isMcpTool } from '@/executor/constants' import { resolveSkillContent } from '@/executor/handlers/agent/skills-resolver' import type { ExecutionContext, UserFile } from '@/executor/types' +import { resolveEnvVarReferences } from '@/executor/utils/reference-validation' import type { ErrorInfo } from '@/tools/error-extractors' import { extractErrorMessage } from '@/tools/error-extractors' import type { @@ -183,22 +184,16 @@ async function normalizeCopilotFileParams( } } -/** - * Matches a string that is exactly one {{ENV_VAR}} reference (same charset the - * executor's exact-match resolver accepts). Embedded references are - * intentionally not matched. - */ -const COPILOT_ENV_REFERENCE_PATTERN = /^\{\{([^}]+)\}\}$/ - /** * Resolves whole-value {{ENV_VAR}} references in user-only params for copilot * tool executions. Chat agents never see secret values (the workspace VFS * exposes env var names only), so they pass references; workflow runs resolve * these in the executor, and this is the equivalent step for direct tool - * calls. Resolution is deliberately restricted to params declared - * `visibility: 'user-only'` (API keys and other operator-supplied secrets) - * and to values that are exactly one reference, so LLM-writable params (URLs, - * headers, bodies) can never be used to extract secret values. + * calls, delegating to the executor's resolver so both paths share one set of + * reference semantics. Resolution is deliberately restricted to params + * declared `visibility: 'user-only'` (API keys and other operator-supplied + * secrets) and to values that are exactly one reference, so LLM-writable + * params (URLs, headers, bodies) can never be used to extract secret values. * * Mutates only the given params object — callers pass the per-execution copy, * never the copilot-side tool-call state, so decrypted values cannot leak @@ -213,14 +208,12 @@ async function resolveCopilotEnvReferences( return } - const pending: Array<{ paramId: string; envKey: string }> = [] + const pending: Array<{ paramId: string; value: string }> = [] for (const [paramId, paramDef] of Object.entries(tool.params || {})) { if (paramDef?.visibility !== 'user-only') continue const value = params[paramId] - if (typeof value !== 'string') continue - const match = COPILOT_ENV_REFERENCE_PATTERN.exec(value) - if (match) { - pending.push({ paramId, envKey: match[1].trim() }) + if (typeof value === 'string' && value.startsWith('{{') && value.endsWith('}}')) { + pending.push({ paramId, value }) } } @@ -231,15 +224,19 @@ async function resolveCopilotEnvReferences( const { getEffectiveDecryptedEnv } = await import('@/lib/environment/utils') const envVars = await getEffectiveDecryptedEnv(scope.userId, scope.workspaceId) - for (const { paramId, envKey } of pending) { - const resolved = envVars[envKey] - if (resolved === undefined) { + for (const { paramId, value } of pending) { + const missingKeys: string[] = [] + const resolved = resolveEnvVarReferences(value, envVars, { + allowEmbedded: false, + missingKeys, + }) + if (missingKeys.length > 0) { throw new Error( - `Environment variable "${envKey}" referenced by parameter "${paramId}" was not found. ` + + `Environment variable "${missingKeys[0]}" referenced by parameter "${paramId}" was not found. ` + `Check environment/variables.json for available variable names.` ) } - params[paramId] = resolved + params[paramId] = resolved as string } } From 728917c6cb546b997a1fbaacf6171ea125388bc8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 14 Jul 2026 16:33:09 -0700 Subject: [PATCH 3/3] 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). --- apps/sim/tools/index.test.ts | 42 ++++++++++++++++++++++++++++++++++++ apps/sim/tools/index.ts | 13 +++++++++-- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index cfd95a1510e..1f5a13b5737 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -1394,6 +1394,48 @@ describe('Copilot Env Variable Reference Resolution', () => { expect(fetchMock).not.toHaveBeenCalled() }) + it('fails fast instead of forwarding the placeholder when user context is missing', async () => { + const fetchMock = mockJsonFetch() + + const result = await executeTool( + 'test_env_ref_tool', + { apiKey: '{{SENTRY_AUTH_TOKEN}}' }, + { + executionContext: createToolExecutionContext({ + workspaceId: 'workspace-456', + userId: undefined, + copilotToolExecution: true, + } as any), + } + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('authenticated user context') + expect(mockGetEffectiveDecryptedEnv).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('explains the personal-only scope when a variable is missing without a workspace context', async () => { + const fetchMock = mockJsonFetch() + + const result = await executeTool( + 'test_env_ref_tool', + { apiKey: '{{MISSING_VAR}}' }, + { + executionContext: createToolExecutionContext({ + workspaceId: undefined, + userId: 'user-123', + copilotToolExecution: true, + } as any), + } + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('only personal variables are available') + expect(mockGetEffectiveDecryptedEnv).toHaveBeenCalledWith('user-123', undefined) + expect(fetchMock).not.toHaveBeenCalled() + }) + it('does not resolve references outside copilot execution', async () => { const fetchMock = mockJsonFetch() diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 3f18f8a303d..5e597c53422 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -204,7 +204,7 @@ async function resolveCopilotEnvReferences( params: Record, scope: ToolExecutionScope ): Promise { - if (!scope.copilotToolExecution || !scope.userId) { + if (!scope.copilotToolExecution) { return } @@ -221,6 +221,12 @@ async function resolveCopilotEnvReferences( return } + if (!scope.userId) { + throw new Error( + `Cannot resolve environment variable reference in parameter "${pending[0].paramId}" without an authenticated user context.` + ) + } + const { getEffectiveDecryptedEnv } = await import('@/lib/environment/utils') const envVars = await getEffectiveDecryptedEnv(scope.userId, scope.workspaceId) @@ -231,8 +237,11 @@ async function resolveCopilotEnvReferences( missingKeys, }) if (missingKeys.length > 0) { + const scopeHint = scope.workspaceId + ? '' + : ' (no workspace context — only personal variables are available here)' throw new Error( - `Environment variable "${missingKeys[0]}" referenced by parameter "${paramId}" was not found. ` + + `Environment variable "${missingKeys[0]}" referenced by parameter "${paramId}" was not found${scopeHint}. ` + `Check environment/variables.json for available variable names.` ) }