Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 204 additions & 0 deletions apps/sim/tools/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const {
mockGetCustomToolByIdOrTitle,
mockGenerateInternalToken,
mockResolveWorkspaceFileReference,
mockGetEffectiveDecryptedEnv,
} = vi.hoisted(() => ({
mockIsHosted: { value: false },
mockEnv: { NEXT_PUBLIC_APP_URL: 'http://localhost:3000' } as Record<string, string | undefined>,
Expand All @@ -46,6 +47,7 @@ const {
mockGetCustomToolByIdOrTitle: vi.fn(),
mockGenerateInternalToken: vi.fn(),
mockResolveWorkspaceFileReference: vi.fn(),
mockGetEffectiveDecryptedEnv: vi.fn(),
}))

const mockSecureFetchWithPinnedIP = inputValidationMockFns.mockSecureFetchWithPinnedIP
Expand Down Expand Up @@ -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),
}))
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -1259,6 +1285,184 @@ 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<typeof vi.fn>): Record<string, unknown> {
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('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()

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

Expand Down
67 changes: 67 additions & 0 deletions apps/sim/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -183,6 +184,71 @@ async function normalizeCopilotFileParams(
}
}

/**
* 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, 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
* into failure logs or persisted chat state.
*/
async function resolveCopilotEnvReferences(
tool: ToolConfig,
params: Record<string, unknown>,
scope: ToolExecutionScope
): Promise<void> {
if (!scope.copilotToolExecution) {
return
}

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' && value.startsWith('{{') && value.endsWith('}}')) {
pending.push({ paramId, value })
}
}

if (pending.length === 0) {
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)
Comment thread
waleedlatif1 marked this conversation as resolved.

for (const { paramId, value } of pending) {
const missingKeys: string[] = []
const resolved = resolveEnvVarReferences(value, envVars, {
allowEmbedded: false,
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${scopeHint}. ` +
`Check environment/variables.json for available variable names.`
)
}
params[paramId] = resolved as string
}
}

function readExplicitCredentialSelector(params: Record<string, unknown>): string | undefined {
for (const key of ['credentialId', 'oauthCredential', 'credential'] as const) {
const value = params[key]
Expand Down Expand Up @@ -1025,6 +1091,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(
Expand Down
Loading