Compare commits
3 Commits
fix/copilo
...
fix/copilo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
896d6b5529 | ||
|
|
fefcb61f8b | ||
|
|
24173bb008 |
@@ -59,7 +59,7 @@ export default function StatusIndicator() {
|
||||
href={statusUrl}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={`flex min-w-[165px] items-center gap-[6px] whitespace-nowrap text-[12px] transition-colors ${STATUS_COLORS[status]}`}
|
||||
className={`flex items-center gap-[6px] whitespace-nowrap text-[12px] transition-colors ${STATUS_COLORS[status]}`}
|
||||
aria-label={`System status: ${message}`}
|
||||
>
|
||||
<StatusDotIcon status={status} className='h-[6px] w-[6px]' aria-hidden='true' />
|
||||
|
||||
@@ -22,7 +22,7 @@ export default async function StudioIndex({
|
||||
? filtered.sort((a, b) => {
|
||||
if (a.featured && !b.featured) return -1
|
||||
if (!a.featured && b.featured) return 1
|
||||
return new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
return 0
|
||||
})
|
||||
: filtered
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import type { AgentCapabilities, AgentSkill } from '@/lib/a2a/types'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { getRedisClient } from '@/lib/core/config/redis'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('A2AAgentCardAPI')
|
||||
|
||||
@@ -96,11 +95,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<Ro
|
||||
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const workspaceAccess = await checkWorkspaceAccess(existingAgent.workspaceId, auth.userId)
|
||||
if (!workspaceAccess.canWrite) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
if (
|
||||
@@ -166,11 +160,6 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise
|
||||
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const workspaceAccess = await checkWorkspaceAccess(existingAgent.workspaceId, auth.userId)
|
||||
if (!workspaceAccess.canWrite) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
await db.delete(a2aAgent).where(eq(a2aAgent.id, agentId))
|
||||
|
||||
logger.info(`Deleted A2A agent: ${agentId}`)
|
||||
@@ -205,11 +194,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<R
|
||||
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const workspaceAccess = await checkWorkspaceAccess(existingAgent.workspaceId, auth.userId)
|
||||
if (!workspaceAccess.canWrite) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const action = body.action as 'publish' | 'unpublish' | 'refresh'
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { getBrandConfig } from '@/lib/branding/branding'
|
||||
import { acquireLock, getRedisClient, releaseLock } from '@/lib/core/config/redis'
|
||||
import { validateExternalUrl } from '@/lib/core/security/input-validation'
|
||||
import { SSE_HEADERS } from '@/lib/core/utils/sse'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { markExecutionCancelled } from '@/lib/execution/cancellation'
|
||||
@@ -1119,13 +1118,17 @@ async function handlePushNotificationSet(
|
||||
)
|
||||
}
|
||||
|
||||
const urlValidation = validateExternalUrl(
|
||||
params.pushNotificationConfig.url,
|
||||
'Push notification URL'
|
||||
)
|
||||
if (!urlValidation.isValid) {
|
||||
try {
|
||||
const url = new URL(params.pushNotificationConfig.url)
|
||||
if (url.protocol !== 'https:') {
|
||||
return NextResponse.json(
|
||||
createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Push notification URL must use HTTPS'),
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
createError(id, A2A_ERROR_CODES.INVALID_PARAMS, urlValidation.error || 'Invalid URL'),
|
||||
createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Invalid push notification URL'),
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -104,11 +104,17 @@ export async function POST(req: NextRequest) {
|
||||
})
|
||||
|
||||
// Build execution params starting with LLM-provided arguments
|
||||
// Resolve all {{ENV_VAR}} references in the arguments (deep for nested objects)
|
||||
// Resolve all {{ENV_VAR}} references in the arguments
|
||||
const executionParams: Record<string, any> = resolveEnvVarReferences(
|
||||
toolArgs,
|
||||
decryptedEnvVars,
|
||||
{ deep: true }
|
||||
{
|
||||
resolveExactMatch: true,
|
||||
allowEmbedded: true,
|
||||
trimKeys: true,
|
||||
onMissing: 'keep',
|
||||
deep: true,
|
||||
}
|
||||
) as Record<string, any>
|
||||
|
||||
logger.info(`[${tracker.requestId}] Resolved env var references in arguments`, {
|
||||
|
||||
@@ -84,14 +84,6 @@ vi.mock('@/lib/execution/isolated-vm', () => ({
|
||||
|
||||
vi.mock('@sim/logger', () => loggerMock)
|
||||
|
||||
vi.mock('@/lib/auth/hybrid', () => ({
|
||||
checkHybridAuth: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-123',
|
||||
authType: 'session',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/execution/e2b', () => ({
|
||||
executeInE2B: vi.fn(),
|
||||
}))
|
||||
@@ -118,24 +110,6 @@ describe('Function Execute API Route', () => {
|
||||
})
|
||||
|
||||
describe('Security Tests', () => {
|
||||
it('should reject unauthorized requests', async () => {
|
||||
const { checkHybridAuth } = await import('@/lib/auth/hybrid')
|
||||
vi.mocked(checkHybridAuth).mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return "test"',
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(data).toHaveProperty('error', 'Unauthorized')
|
||||
})
|
||||
|
||||
it.concurrent('should use isolated-vm for secure sandboxed execution', async () => {
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return "test"',
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { isE2bEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { executeInE2B } from '@/lib/execution/e2b'
|
||||
@@ -582,12 +581,6 @@ export async function POST(req: NextRequest) {
|
||||
let resolvedCode = '' // Store resolved code for error reporting
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(req)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized function execution attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
|
||||
const { DEFAULT_EXECUTION_TIMEOUT_MS } = await import('@/lib/execution/constants')
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
|
||||
import { McpClient } from '@/lib/mcp/client'
|
||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||
import { resolveMcpConfigEnvVars } from '@/lib/mcp/resolve-config'
|
||||
import type { McpTransport } from '@/lib/mcp/types'
|
||||
import type { McpServerConfig, McpTransport } from '@/lib/mcp/types'
|
||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||
import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
|
||||
|
||||
const logger = createLogger('McpServerTestAPI')
|
||||
|
||||
@@ -18,6 +19,30 @@ function isUrlBasedTransport(transport: McpTransport): boolean {
|
||||
return transport === 'streamable-http'
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve environment variables in strings
|
||||
*/
|
||||
function resolveEnvVars(value: string, envVars: Record<string, string>): string {
|
||||
const missingVars: string[] = []
|
||||
const resolvedValue = resolveEnvVarReferences(value, envVars, {
|
||||
allowEmbedded: true,
|
||||
resolveExactMatch: true,
|
||||
trimKeys: true,
|
||||
onMissing: 'keep',
|
||||
deep: false,
|
||||
missingKeys: missingVars,
|
||||
}) as string
|
||||
|
||||
if (missingVars.length > 0) {
|
||||
const uniqueMissing = Array.from(new Set(missingVars))
|
||||
uniqueMissing.forEach((envKey) => {
|
||||
logger.warn(`Environment variable "${envKey}" not found in MCP server test`)
|
||||
})
|
||||
}
|
||||
|
||||
return resolvedValue
|
||||
}
|
||||
|
||||
interface TestConnectionRequest {
|
||||
name: string
|
||||
transport: McpTransport
|
||||
@@ -71,30 +96,39 @@ export const POST = withMcpAuth('write')(
|
||||
)
|
||||
}
|
||||
|
||||
// Build initial config for resolution
|
||||
const initialConfig = {
|
||||
let resolvedUrl = body.url
|
||||
let resolvedHeaders = body.headers || {}
|
||||
|
||||
try {
|
||||
const envVars = await getEffectiveDecryptedEnv(userId, workspaceId)
|
||||
|
||||
if (resolvedUrl) {
|
||||
resolvedUrl = resolveEnvVars(resolvedUrl, envVars)
|
||||
}
|
||||
|
||||
const resolvedHeadersObj: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(resolvedHeaders)) {
|
||||
resolvedHeadersObj[key] = resolveEnvVars(value, envVars)
|
||||
}
|
||||
resolvedHeaders = resolvedHeadersObj
|
||||
} catch (envError) {
|
||||
logger.warn(
|
||||
`[${requestId}] Failed to resolve environment variables, using raw values:`,
|
||||
envError
|
||||
)
|
||||
}
|
||||
|
||||
const testConfig: McpServerConfig = {
|
||||
id: `test-${requestId}`,
|
||||
name: body.name,
|
||||
transport: body.transport,
|
||||
url: body.url,
|
||||
headers: body.headers || {},
|
||||
url: resolvedUrl,
|
||||
headers: resolvedHeaders,
|
||||
timeout: body.timeout || 10000,
|
||||
retries: 1, // Only one retry for tests
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
// Resolve env vars using shared utility (non-strict mode for testing)
|
||||
const { config: testConfig, missingVars } = await resolveMcpConfigEnvVars(
|
||||
initialConfig,
|
||||
userId,
|
||||
workspaceId,
|
||||
{ strict: false }
|
||||
)
|
||||
|
||||
if (missingVars.length > 0) {
|
||||
logger.warn(`[${requestId}] Some environment variables not found:`, { missingVars })
|
||||
}
|
||||
|
||||
const testSecurityPolicy = {
|
||||
requireConsent: false,
|
||||
auditLevel: 'none' as const,
|
||||
|
||||
@@ -3,9 +3,7 @@ import { account } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import type { StreamingExecution } from '@/executor/types'
|
||||
import { executeProviderRequest } from '@/providers'
|
||||
@@ -22,11 +20,6 @@ export async function POST(request: NextRequest) {
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Provider API request started`, {
|
||||
timestamp: new Date().toISOString(),
|
||||
userAgent: request.headers.get('User-Agent'),
|
||||
@@ -92,13 +85,6 @@ export async function POST(request: NextRequest) {
|
||||
verbosity,
|
||||
})
|
||||
|
||||
if (workspaceId) {
|
||||
const workspaceAccess = await checkWorkspaceAccess(workspaceId, auth.userId)
|
||||
if (!workspaceAccess.hasAccess) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
let finalApiKey: string | undefined = apiKey
|
||||
try {
|
||||
if (provider === 'vertex' && vertexCredential) {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { createA2AClient } from '@/lib/a2a/utils'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { validateExternalUrl } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -40,18 +39,6 @@ export async function POST(request: NextRequest) {
|
||||
const body = await request.json()
|
||||
const validatedData = A2ASetPushNotificationSchema.parse(body)
|
||||
|
||||
const urlValidation = validateExternalUrl(validatedData.webhookUrl, 'Webhook URL')
|
||||
if (!urlValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid webhook URL`, { error: urlValidation.error })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: urlValidation.error,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] A2A set push notification request`, {
|
||||
agentUrl: validatedData.agentUrl,
|
||||
taskId: validatedData.taskId,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { buildDeleteQuery, createMySQLConnection, executeQuery } from '@/app/api/tools/mysql/utils'
|
||||
|
||||
const logger = createLogger('MySQLDeleteAPI')
|
||||
@@ -22,12 +21,6 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized MySQL delete attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = DeleteSchema.parse(body)
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createMySQLConnection, executeQuery, validateQuery } from '@/app/api/tools/mysql/utils'
|
||||
|
||||
const logger = createLogger('MySQLExecuteAPI')
|
||||
@@ -21,12 +20,6 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized MySQL execute attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = ExecuteSchema.parse(body)
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { buildInsertQuery, createMySQLConnection, executeQuery } from '@/app/api/tools/mysql/utils'
|
||||
|
||||
const logger = createLogger('MySQLInsertAPI')
|
||||
@@ -43,12 +42,6 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized MySQL insert attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = InsertSchema.parse(body)
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createMySQLConnection, executeIntrospect } from '@/app/api/tools/mysql/utils'
|
||||
|
||||
const logger = createLogger('MySQLIntrospectAPI')
|
||||
@@ -20,12 +19,6 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized MySQL introspect attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = IntrospectSchema.parse(body)
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createMySQLConnection, executeQuery, validateQuery } from '@/app/api/tools/mysql/utils'
|
||||
|
||||
const logger = createLogger('MySQLQueryAPI')
|
||||
@@ -21,12 +20,6 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized MySQL query attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = QuerySchema.parse(body)
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { buildUpdateQuery, createMySQLConnection, executeQuery } from '@/app/api/tools/mysql/utils'
|
||||
|
||||
const logger = createLogger('MySQLUpdateAPI')
|
||||
@@ -41,12 +40,6 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized MySQL update attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = UpdateSchema.parse(body)
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createPostgresConnection, executeDelete } from '@/app/api/tools/postgresql/utils'
|
||||
|
||||
const logger = createLogger('PostgreSQLDeleteAPI')
|
||||
@@ -22,12 +21,6 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized PostgreSQL delete attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = DeleteSchema.parse(body)
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import {
|
||||
createPostgresConnection,
|
||||
executeQuery,
|
||||
@@ -25,12 +24,6 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized PostgreSQL execute attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = ExecuteSchema.parse(body)
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createPostgresConnection, executeInsert } from '@/app/api/tools/postgresql/utils'
|
||||
|
||||
const logger = createLogger('PostgreSQLInsertAPI')
|
||||
@@ -43,12 +42,6 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized PostgreSQL insert attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
const params = InsertSchema.parse(body)
|
||||
|
||||
@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createPostgresConnection, executeIntrospect } from '@/app/api/tools/postgresql/utils'
|
||||
|
||||
const logger = createLogger('PostgreSQLIntrospectAPI')
|
||||
@@ -21,12 +20,6 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized PostgreSQL introspect attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = IntrospectSchema.parse(body)
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createPostgresConnection, executeQuery } from '@/app/api/tools/postgresql/utils'
|
||||
|
||||
const logger = createLogger('PostgreSQLQueryAPI')
|
||||
@@ -21,12 +20,6 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized PostgreSQL query attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = QuerySchema.parse(body)
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createPostgresConnection, executeUpdate } from '@/app/api/tools/postgresql/utils'
|
||||
|
||||
const logger = createLogger('PostgreSQLUpdateAPI')
|
||||
@@ -41,12 +40,6 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized PostgreSQL update attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = UpdateSchema.parse(body)
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createSSHConnection, escapeShellArg, executeSSHCommand } from '@/app/api/tools/ssh/utils'
|
||||
|
||||
const logger = createLogger('SSHCheckCommandExistsAPI')
|
||||
@@ -21,12 +20,6 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized SSH check command exists attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = CheckCommandExistsSchema.parse(body)
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import type { Client, SFTPWrapper, Stats } from 'ssh2'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import {
|
||||
createSSHConnection,
|
||||
getFileType,
|
||||
@@ -40,15 +39,10 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized SSH check file exists attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = CheckFileExistsSchema.parse(body)
|
||||
|
||||
// Validate authentication
|
||||
if (!params.password && !params.privateKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Either password or privateKey must be provided' },
|
||||
|
||||
@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import {
|
||||
createSSHConnection,
|
||||
escapeShellArg,
|
||||
@@ -28,15 +27,10 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized SSH create directory attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = CreateDirectorySchema.parse(body)
|
||||
|
||||
// Validate authentication
|
||||
if (!params.password && !params.privateKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Either password or privateKey must be provided' },
|
||||
@@ -59,6 +53,7 @@ export async function POST(request: NextRequest) {
|
||||
const dirPath = sanitizePath(params.path)
|
||||
const escapedPath = escapeShellArg(dirPath)
|
||||
|
||||
// Check if directory already exists
|
||||
const checkResult = await executeSSHCommand(
|
||||
client,
|
||||
`test -d '${escapedPath}' && echo "exists"`
|
||||
@@ -75,6 +70,7 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
}
|
||||
|
||||
// Create directory
|
||||
const mkdirFlag = params.recursive ? '-p' : ''
|
||||
const command = `mkdir ${mkdirFlag} -m ${params.permissions} '${escapedPath}'`
|
||||
const result = await executeSSHCommand(client, command)
|
||||
|
||||
@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import {
|
||||
createSSHConnection,
|
||||
escapeShellArg,
|
||||
@@ -28,15 +27,10 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized SSH delete file attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = DeleteFileSchema.parse(body)
|
||||
|
||||
// Validate authentication
|
||||
if (!params.password && !params.privateKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Either password or privateKey must be provided' },
|
||||
@@ -59,6 +53,7 @@ export async function POST(request: NextRequest) {
|
||||
const filePath = sanitizePath(params.path)
|
||||
const escapedPath = escapeShellArg(filePath)
|
||||
|
||||
// Check if path exists
|
||||
const checkResult = await executeSSHCommand(
|
||||
client,
|
||||
`test -e '${escapedPath}' && echo "exists"`
|
||||
@@ -67,6 +62,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: `Path does not exist: ${filePath}` }, { status: 404 })
|
||||
}
|
||||
|
||||
// Build delete command
|
||||
let command: string
|
||||
if (params.recursive) {
|
||||
command = params.force ? `rm -rf '${escapedPath}'` : `rm -r '${escapedPath}'`
|
||||
|
||||
@@ -4,7 +4,6 @@ import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import type { Client, SFTPWrapper } from 'ssh2'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils'
|
||||
|
||||
const logger = createLogger('SSHDownloadFileAPI')
|
||||
@@ -35,15 +34,10 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized SSH download file attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = DownloadFileSchema.parse(body)
|
||||
|
||||
// Validate authentication
|
||||
if (!params.password && !params.privateKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Either password or privateKey must be provided' },
|
||||
|
||||
@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createSSHConnection, executeSSHCommand, sanitizeCommand } from '@/app/api/tools/ssh/utils'
|
||||
|
||||
const logger = createLogger('SSHExecuteCommandAPI')
|
||||
@@ -22,15 +21,10 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized SSH execute command attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = ExecuteCommandSchema.parse(body)
|
||||
|
||||
// Validate authentication
|
||||
if (!params.password && !params.privateKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Either password or privateKey must be provided' },
|
||||
@@ -50,6 +44,7 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
|
||||
try {
|
||||
// Build command with optional working directory
|
||||
let command = sanitizeCommand(params.command)
|
||||
if (params.workingDirectory) {
|
||||
command = `cd "${params.workingDirectory}" && ${command}`
|
||||
|
||||
@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createSSHConnection, escapeShellArg, executeSSHCommand } from '@/app/api/tools/ssh/utils'
|
||||
|
||||
const logger = createLogger('SSHExecuteScriptAPI')
|
||||
@@ -23,15 +22,10 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized SSH execute script attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = ExecuteScriptSchema.parse(body)
|
||||
|
||||
// Validate authentication
|
||||
if (!params.password && !params.privateKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Either password or privateKey must be provided' },
|
||||
@@ -51,10 +45,13 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
|
||||
try {
|
||||
// Create a temporary script file, execute it, and clean up
|
||||
const scriptPath = `/tmp/sim_script_${requestId}.sh`
|
||||
const escapedScriptPath = escapeShellArg(scriptPath)
|
||||
const escapedInterpreter = escapeShellArg(params.interpreter)
|
||||
|
||||
// Build the command to create, execute, and clean up the script
|
||||
// Note: heredoc with quoted delimiter ('SIMEOF') prevents variable expansion
|
||||
let command = `cat > '${escapedScriptPath}' << 'SIMEOF'
|
||||
${params.script}
|
||||
SIMEOF
|
||||
|
||||
@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createSSHConnection, executeSSHCommand } from '@/app/api/tools/ssh/utils'
|
||||
|
||||
const logger = createLogger('SSHGetSystemInfoAPI')
|
||||
@@ -20,15 +19,10 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized SSH get system info attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = GetSystemInfoSchema.parse(body)
|
||||
|
||||
// Validate authentication
|
||||
if (!params.password && !params.privateKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Either password or privateKey must be provided' },
|
||||
|
||||
@@ -3,7 +3,6 @@ import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import type { Client, FileEntry, SFTPWrapper } from 'ssh2'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import {
|
||||
createSSHConnection,
|
||||
getFileType,
|
||||
@@ -61,15 +60,10 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized SSH list directory attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = ListDirectorySchema.parse(body)
|
||||
|
||||
// Validate authentication
|
||||
if (!params.password && !params.privateKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Either password or privateKey must be provided' },
|
||||
|
||||
@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import {
|
||||
createSSHConnection,
|
||||
escapeShellArg,
|
||||
@@ -28,16 +27,9 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized SSH move/rename attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = MoveRenameSchema.parse(body)
|
||||
|
||||
// Validate SSH authentication
|
||||
if (!params.password && !params.privateKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Either password or privateKey must be provided' },
|
||||
|
||||
@@ -3,7 +3,6 @@ import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import type { Client, SFTPWrapper } from 'ssh2'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils'
|
||||
|
||||
const logger = createLogger('SSHReadFileContentAPI')
|
||||
@@ -36,12 +35,6 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized SSH read file content attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = ReadFileContentSchema.parse(body)
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import type { Client, SFTPWrapper } from 'ssh2'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils'
|
||||
|
||||
const logger = createLogger('SSHUploadFileAPI')
|
||||
@@ -38,12 +37,6 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized SSH upload file attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = UploadFileSchema.parse(body)
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import type { Client, SFTPWrapper } from 'ssh2'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils'
|
||||
|
||||
const logger = createLogger('SSHWriteFileContentAPI')
|
||||
@@ -37,15 +36,10 @@ export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized SSH write file content attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const params = WriteFileContentSchema.parse(body)
|
||||
|
||||
// Validate authentication
|
||||
if (!params.password && !params.privateKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Either password or privateKey must be provided' },
|
||||
|
||||
@@ -7,7 +7,6 @@ import { getSession } from '@/lib/auth'
|
||||
import { validateInteger } from '@/lib/core/security/input-validation'
|
||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { resolveEnvVarsInObject } from '@/lib/webhooks/env-resolver'
|
||||
import {
|
||||
cleanupExternalWebhook,
|
||||
createExternalWebhookSubscription,
|
||||
@@ -113,9 +112,9 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
}
|
||||
}
|
||||
|
||||
const originalProviderConfig = providerConfig
|
||||
let resolvedProviderConfig = providerConfig
|
||||
if (providerConfig) {
|
||||
const { resolveEnvVarsInObject } = await import('@/lib/webhooks/env-resolver')
|
||||
const webhookDataForResolve = await db
|
||||
.select({
|
||||
workspaceId: workflow.workspaceId,
|
||||
@@ -231,23 +230,19 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
hasFailedCountUpdate: failedCount !== undefined,
|
||||
})
|
||||
|
||||
// Merge providerConfig to preserve credential-related fields
|
||||
let finalProviderConfig = webhooks[0].webhook.providerConfig
|
||||
if (providerConfig !== undefined && originalProviderConfig) {
|
||||
if (providerConfig !== undefined) {
|
||||
const existingConfig = existingProviderConfig
|
||||
finalProviderConfig = {
|
||||
...originalProviderConfig,
|
||||
...nextProviderConfig,
|
||||
credentialId: existingConfig.credentialId,
|
||||
credentialSetId: existingConfig.credentialSetId,
|
||||
userId: existingConfig.userId,
|
||||
historyId: existingConfig.historyId,
|
||||
lastCheckedTimestamp: existingConfig.lastCheckedTimestamp,
|
||||
setupCompleted: existingConfig.setupCompleted,
|
||||
externalId: existingConfig.externalId,
|
||||
}
|
||||
for (const [key, value] of Object.entries(nextProviderConfig)) {
|
||||
if (!(key in originalProviderConfig)) {
|
||||
;(finalProviderConfig as Record<string, unknown>)[key] = value
|
||||
}
|
||||
externalId: nextProviderConfig.externalId ?? existingConfig.externalId,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { resolveEnvVarsInObject } from '@/lib/webhooks/env-resolver'
|
||||
import { createExternalWebhookSubscription } from '@/lib/webhooks/provider-subscriptions'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
@@ -299,10 +298,14 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
let savedWebhook: any = null
|
||||
const originalProviderConfig = providerConfig || {}
|
||||
let savedWebhook: any = null // Variable to hold the result of save/update
|
||||
|
||||
// Use the original provider config - Gmail/Outlook configuration functions will inject userId automatically
|
||||
const finalProviderConfig = providerConfig || {}
|
||||
|
||||
const { resolveEnvVarsInObject } = await import('@/lib/webhooks/env-resolver')
|
||||
let resolvedProviderConfig = await resolveEnvVarsInObject(
|
||||
originalProviderConfig,
|
||||
finalProviderConfig,
|
||||
userId,
|
||||
workflowRecord.workspaceId || undefined
|
||||
)
|
||||
@@ -466,8 +469,6 @@ export async function POST(request: NextRequest) {
|
||||
providerConfig: providerConfigOverride,
|
||||
})
|
||||
|
||||
const configToSave = { ...originalProviderConfig }
|
||||
|
||||
try {
|
||||
const result = await createExternalWebhookSubscription(
|
||||
request,
|
||||
@@ -476,13 +477,7 @@ export async function POST(request: NextRequest) {
|
||||
userId,
|
||||
requestId
|
||||
)
|
||||
const updatedConfig = result.updatedProviderConfig as Record<string, unknown>
|
||||
for (const [key, value] of Object.entries(updatedConfig)) {
|
||||
if (!(key in originalProviderConfig)) {
|
||||
configToSave[key] = value
|
||||
}
|
||||
}
|
||||
resolvedProviderConfig = updatedConfig
|
||||
resolvedProviderConfig = result.updatedProviderConfig as Record<string, unknown>
|
||||
externalSubscriptionCreated = result.externalSubscriptionCreated
|
||||
} catch (err) {
|
||||
logger.error(`[${requestId}] Error creating external webhook subscription`, err)
|
||||
@@ -495,22 +490,25 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Now save to database (only if subscription succeeded or provider doesn't need external subscription)
|
||||
try {
|
||||
if (targetWebhookId) {
|
||||
logger.info(`[${requestId}] Updating existing webhook for path: ${finalPath}`, {
|
||||
webhookId: targetWebhookId,
|
||||
provider,
|
||||
hasCredentialId: !!(configToSave as any)?.credentialId,
|
||||
credentialId: (configToSave as any)?.credentialId,
|
||||
hasCredentialId: !!(resolvedProviderConfig as any)?.credentialId,
|
||||
credentialId: (resolvedProviderConfig as any)?.credentialId,
|
||||
})
|
||||
const updatedResult = await db
|
||||
.update(webhook)
|
||||
.set({
|
||||
blockId,
|
||||
provider,
|
||||
providerConfig: configToSave,
|
||||
providerConfig: resolvedProviderConfig,
|
||||
credentialSetId:
|
||||
((configToSave as Record<string, unknown>)?.credentialSetId as string | null) || null,
|
||||
((resolvedProviderConfig as Record<string, unknown>)?.credentialSetId as
|
||||
| string
|
||||
| null) || null,
|
||||
isActive: true,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
@@ -533,9 +531,11 @@ export async function POST(request: NextRequest) {
|
||||
blockId,
|
||||
path: finalPath,
|
||||
provider,
|
||||
providerConfig: configToSave,
|
||||
providerConfig: resolvedProviderConfig,
|
||||
credentialSetId:
|
||||
((configToSave as Record<string, unknown>)?.credentialSetId as string | null) || null,
|
||||
((resolvedProviderConfig as Record<string, unknown>)?.credentialSetId as
|
||||
| string
|
||||
| null) || null,
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@@ -549,7 +549,7 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { cleanupExternalWebhook } = await import('@/lib/webhooks/provider-subscriptions')
|
||||
await cleanupExternalWebhook(
|
||||
createTempWebhookData(configToSave),
|
||||
createTempWebhookData(resolvedProviderConfig),
|
||||
workflowRecord,
|
||||
requestId
|
||||
)
|
||||
|
||||
@@ -116,6 +116,7 @@ type AsyncExecutionParams = {
|
||||
userId: string
|
||||
input: any
|
||||
triggerType: CoreTriggerType
|
||||
preflighted?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,6 +139,7 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
|
||||
userId,
|
||||
input,
|
||||
triggerType,
|
||||
preflighted: params.preflighted,
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -274,6 +276,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
requestId
|
||||
)
|
||||
|
||||
const shouldPreflightEnvVars = isAsyncMode && isTriggerDevEnabled
|
||||
const preprocessResult = await preprocessExecution({
|
||||
workflowId,
|
||||
userId,
|
||||
@@ -282,7 +285,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
requestId,
|
||||
checkDeployment: !shouldUseDraftState,
|
||||
loggingSession,
|
||||
preflightEnvVars: shouldPreflightEnvVars,
|
||||
useDraftState: shouldUseDraftState,
|
||||
envUserId: isClientSession ? userId : undefined,
|
||||
})
|
||||
|
||||
if (!preprocessResult.success) {
|
||||
@@ -314,6 +319,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
userId: actorUserId,
|
||||
input,
|
||||
triggerType: loggingTriggerType,
|
||||
preflighted: shouldPreflightEnvVars,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -94,9 +94,7 @@ export default function Logs() {
|
||||
const [previewLogId, setPreviewLogId] = useState<string | null>(null)
|
||||
|
||||
const activeLogId = isPreviewOpen ? previewLogId : selectedLogId
|
||||
const activeLogQuery = useLogDetail(activeLogId ?? undefined, {
|
||||
refetchInterval: isLive ? 3000 : false,
|
||||
})
|
||||
const activeLogQuery = useLogDetail(activeLogId ?? undefined)
|
||||
|
||||
const logFilters = useMemo(
|
||||
() => ({
|
||||
@@ -115,7 +113,7 @@ export default function Logs() {
|
||||
|
||||
const logsQuery = useLogsList(workspaceId, logFilters, {
|
||||
enabled: Boolean(workspaceId) && isInitialized.current,
|
||||
refetchInterval: isLive ? 3000 : false,
|
||||
refetchInterval: isLive ? 5000 : false,
|
||||
})
|
||||
|
||||
const dashboardFilters = useMemo(
|
||||
@@ -134,7 +132,7 @@ export default function Logs() {
|
||||
|
||||
const dashboardStatsQuery = useDashboardStats(workspaceId, dashboardFilters, {
|
||||
enabled: Boolean(workspaceId) && isInitialized.current,
|
||||
refetchInterval: isLive ? 3000 : false,
|
||||
refetchInterval: isLive ? 5000 : false,
|
||||
})
|
||||
|
||||
const logs = useMemo(() => {
|
||||
@@ -162,6 +160,12 @@ export default function Logs() {
|
||||
}
|
||||
}, [debouncedSearchQuery, setStoreSearchQuery])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive || !selectedLogId) return
|
||||
const interval = setInterval(() => activeLogQuery.refetch(), 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [isLive, selectedLogId, activeLogQuery])
|
||||
|
||||
const handleLogClick = useCallback(
|
||||
(log: WorkflowLog) => {
|
||||
if (selectedLogId === log.id && isSidebarOpen) {
|
||||
@@ -275,11 +279,8 @@ export default function Logs() {
|
||||
setIsVisuallyRefreshing(true)
|
||||
setTimeout(() => setIsVisuallyRefreshing(false), REFRESH_SPINNER_DURATION_MS)
|
||||
logsQuery.refetch()
|
||||
if (selectedLogId) {
|
||||
activeLogQuery.refetch()
|
||||
}
|
||||
}
|
||||
}, [isLive, logsQuery, activeLogQuery, selectedLogId])
|
||||
}, [isLive, logsQuery])
|
||||
|
||||
const prevIsFetchingRef = useRef(logsQuery.isFetching)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -78,10 +78,9 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
mode,
|
||||
setMode,
|
||||
isAborting,
|
||||
maskCredentialValue,
|
||||
} = useCopilotStore()
|
||||
|
||||
const maskCredentialValue = useCopilotStore((s) => s.maskCredentialValue)
|
||||
|
||||
const messageCheckpoints = isUser ? allMessageCheckpoints[message.id] || [] : []
|
||||
const hasCheckpoints = messageCheckpoints.length > 0 && messageCheckpoints.some((cp) => cp?.id)
|
||||
|
||||
@@ -266,7 +265,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
}
|
||||
return null
|
||||
})
|
||||
}, [message.contentBlocks, isActivelyStreaming, parsedTags, isLastMessage])
|
||||
}, [message.contentBlocks, isActivelyStreaming, parsedTags, isLastMessage, maskCredentialValue])
|
||||
|
||||
if (isUser) {
|
||||
return (
|
||||
|
||||
@@ -38,7 +38,6 @@ import type { GenerationType } from '@/blocks/types'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
|
||||
import { useTagSelection } from '@/hooks/kb/use-tag-selection'
|
||||
import { createShouldHighlightEnvVar, useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
|
||||
|
||||
const logger = createLogger('Code')
|
||||
|
||||
@@ -89,27 +88,21 @@ interface CodePlaceholder {
|
||||
/**
|
||||
* Creates a syntax highlighter function with custom reference and environment variable highlighting.
|
||||
* @param effectiveLanguage - The language to use for syntax highlighting
|
||||
* @param shouldHighlightReference - Function to determine if a block reference should be highlighted
|
||||
* @param shouldHighlightEnvVar - Function to determine if an env var should be highlighted
|
||||
* @param shouldHighlightReference - Function to determine if a reference should be highlighted
|
||||
* @returns A function that highlights code with syntax and custom highlights
|
||||
*/
|
||||
const createHighlightFunction = (
|
||||
effectiveLanguage: 'javascript' | 'python' | 'json',
|
||||
shouldHighlightReference: (part: string) => boolean,
|
||||
shouldHighlightEnvVar: (varName: string) => boolean
|
||||
shouldHighlightReference: (part: string) => boolean
|
||||
) => {
|
||||
return (codeToHighlight: string): string => {
|
||||
const placeholders: CodePlaceholder[] = []
|
||||
let processedCode = codeToHighlight
|
||||
|
||||
processedCode = processedCode.replace(createEnvVarPattern(), (match) => {
|
||||
const varName = match.slice(2, -2).trim()
|
||||
if (shouldHighlightEnvVar(varName)) {
|
||||
const placeholder = `__ENV_VAR_${placeholders.length}__`
|
||||
placeholders.push({ placeholder, original: match, type: 'env' })
|
||||
return placeholder
|
||||
}
|
||||
return match
|
||||
const placeholder = `__ENV_VAR_${placeholders.length}__`
|
||||
placeholders.push({ placeholder, original: match, type: 'env' })
|
||||
return placeholder
|
||||
})
|
||||
|
||||
processedCode = processedCode.replace(createReferencePattern(), (match) => {
|
||||
@@ -219,7 +212,6 @@ export const Code = memo(function Code({
|
||||
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
|
||||
const emitTagSelection = useTagSelection(blockId, subBlockId)
|
||||
const [languageValue] = useSubBlockValue<string>(blockId, 'language')
|
||||
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
|
||||
|
||||
const effectiveLanguage = (languageValue as 'javascript' | 'python' | 'json') || language
|
||||
|
||||
@@ -611,15 +603,9 @@ export const Code = memo(function Code({
|
||||
[generateCodeStream, isPromptVisible, isAiStreaming]
|
||||
)
|
||||
|
||||
const shouldHighlightEnvVar = useMemo(
|
||||
() => createShouldHighlightEnvVar(availableEnvVars),
|
||||
[availableEnvVars]
|
||||
)
|
||||
|
||||
const highlightCode = useMemo(
|
||||
() =>
|
||||
createHighlightFunction(effectiveLanguage, shouldHighlightReference, shouldHighlightEnvVar),
|
||||
[effectiveLanguage, shouldHighlightReference, shouldHighlightEnvVar]
|
||||
() => createHighlightFunction(effectiveLanguage, shouldHighlightReference),
|
||||
[effectiveLanguage, shouldHighlightReference]
|
||||
)
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
@@ -35,7 +35,6 @@ import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
|
||||
import { useTagSelection } from '@/hooks/kb/use-tag-selection'
|
||||
import { createShouldHighlightEnvVar, useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
const logger = createLogger('ConditionInput')
|
||||
@@ -124,11 +123,6 @@ export function ConditionInput({
|
||||
|
||||
const emitTagSelection = useTagSelection(blockId, subBlockId)
|
||||
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
|
||||
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
|
||||
const shouldHighlightEnvVar = useMemo(
|
||||
() => createShouldHighlightEnvVar(availableEnvVars),
|
||||
[availableEnvVars]
|
||||
)
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const inputRefs = useRef<Map<string, HTMLTextAreaElement>>(new Map())
|
||||
@@ -1142,18 +1136,14 @@ export function ConditionInput({
|
||||
let processedCode = codeToHighlight
|
||||
|
||||
processedCode = processedCode.replace(createEnvVarPattern(), (match) => {
|
||||
const varName = match.slice(2, -2).trim()
|
||||
if (shouldHighlightEnvVar(varName)) {
|
||||
const placeholder = `__ENV_VAR_${placeholders.length}__`
|
||||
placeholders.push({
|
||||
placeholder,
|
||||
original: match,
|
||||
type: 'env',
|
||||
shouldHighlight: true,
|
||||
})
|
||||
return placeholder
|
||||
}
|
||||
return match
|
||||
const placeholder = `__ENV_VAR_${placeholders.length}__`
|
||||
placeholders.push({
|
||||
placeholder,
|
||||
original: match,
|
||||
type: 'env',
|
||||
shouldHighlight: true,
|
||||
})
|
||||
return placeholder
|
||||
})
|
||||
|
||||
processedCode = processedCode.replace(
|
||||
|
||||
@@ -7,7 +7,6 @@ import { createCombinedPattern } from '@/executor/utils/reference-validation'
|
||||
|
||||
export interface HighlightContext {
|
||||
accessiblePrefixes?: Set<string>
|
||||
availableEnvVars?: Set<string>
|
||||
highlightAll?: boolean
|
||||
}
|
||||
|
||||
@@ -44,17 +43,9 @@ export function formatDisplayText(text: string, context?: HighlightContext): Rea
|
||||
return false
|
||||
}
|
||||
|
||||
const shouldHighlightEnvVar = (varName: string): boolean => {
|
||||
if (context?.highlightAll) {
|
||||
return true
|
||||
}
|
||||
if (context?.availableEnvVars === undefined) {
|
||||
return true
|
||||
}
|
||||
return context.availableEnvVars.has(varName)
|
||||
}
|
||||
|
||||
const nodes: ReactNode[] = []
|
||||
// Match variable references without allowing nested brackets to prevent matching across references
|
||||
// e.g., "<3. text <real.ref>" should match "<3" and "<real.ref>", not the whole string
|
||||
const regex = createCombinedPattern()
|
||||
let lastIndex = 0
|
||||
let key = 0
|
||||
@@ -74,16 +65,11 @@ export function formatDisplayText(text: string, context?: HighlightContext): Rea
|
||||
}
|
||||
|
||||
if (matchText.startsWith(REFERENCE.ENV_VAR_START)) {
|
||||
const varName = matchText.slice(2, -2).trim()
|
||||
if (shouldHighlightEnvVar(varName)) {
|
||||
nodes.push(
|
||||
<span key={key++} className='text-[var(--brand-secondary)]'>
|
||||
{matchText}
|
||||
</span>
|
||||
)
|
||||
} else {
|
||||
nodes.push(<span key={key++}>{matchText}</span>)
|
||||
}
|
||||
nodes.push(
|
||||
<span key={key++} className='text-[var(--brand-secondary)]'>
|
||||
{matchText}
|
||||
</span>
|
||||
)
|
||||
} else {
|
||||
const split = splitReferenceSegment(matchText)
|
||||
|
||||
|
||||
@@ -19,9 +19,6 @@ interface TextProps {
|
||||
* - Automatically detects and renders HTML content safely
|
||||
* - Applies prose styling for HTML content (links, code, lists, etc.)
|
||||
* - Falls back to plain text rendering for non-HTML content
|
||||
*
|
||||
* Note: This component renders trusted, internally-defined content only
|
||||
* (e.g., trigger setup instructions). It is NOT used for user-generated content.
|
||||
*/
|
||||
export function Text({ blockId, subBlockId, content, className }: TextProps) {
|
||||
const containsHtml = /<[^>]+>/.test(content)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { highlight, languages } from '@/components/emcn'
|
||||
import {
|
||||
isLikelyReferenceSegment,
|
||||
@@ -10,7 +9,6 @@ import { checkTagTrigger } from '@/app/workspace/[workspaceId]/w/[workflowId]/co
|
||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||
import { normalizeName, REFERENCE } from '@/executor/constants'
|
||||
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
|
||||
import { createShouldHighlightEnvVar, useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
@@ -55,9 +53,6 @@ const SUBFLOW_CONFIG = {
|
||||
* @returns Subflow editor state and handlers
|
||||
*/
|
||||
export function useSubflowEditor(currentBlock: BlockState | null, currentBlockId: string | null) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
const editorContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -86,13 +81,6 @@ export function useSubflowEditor(currentBlock: BlockState | null, currentBlockId
|
||||
// Get accessible prefixes for tag dropdown
|
||||
const accessiblePrefixes = useAccessibleReferencePrefixes(currentBlockId || '')
|
||||
|
||||
// Get available env vars for highlighting validation
|
||||
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
|
||||
const shouldHighlightEnvVar = useMemo(
|
||||
() => createShouldHighlightEnvVar(availableEnvVars),
|
||||
[availableEnvVars]
|
||||
)
|
||||
|
||||
// Collaborative actions
|
||||
const {
|
||||
collaborativeUpdateLoopType,
|
||||
@@ -152,13 +140,9 @@ export function useSubflowEditor(currentBlock: BlockState | null, currentBlockId
|
||||
let processedCode = code
|
||||
|
||||
processedCode = processedCode.replace(createEnvVarPattern(), (match) => {
|
||||
const varName = match.slice(2, -2).trim()
|
||||
if (shouldHighlightEnvVar(varName)) {
|
||||
const placeholder = `__ENV_VAR_${placeholders.length}__`
|
||||
placeholders.push({ placeholder, original: match, type: 'env' })
|
||||
return placeholder
|
||||
}
|
||||
return match
|
||||
const placeholder = `__ENV_VAR_${placeholders.length}__`
|
||||
placeholders.push({ placeholder, original: match, type: 'env' })
|
||||
return placeholder
|
||||
})
|
||||
|
||||
// Use [^<>]+ to prevent matching across nested brackets (e.g., "<3 <real.ref>" should match separately)
|
||||
@@ -190,7 +174,7 @@ export function useSubflowEditor(currentBlock: BlockState | null, currentBlockId
|
||||
|
||||
return highlightedCode
|
||||
},
|
||||
[shouldHighlightReference, shouldHighlightEnvVar]
|
||||
[shouldHighlightReference]
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useStoreWithEqualityFn } from 'zustand/traditional'
|
||||
import { Badge, Tooltip } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createMcpToolId } from '@/lib/mcp/shared'
|
||||
import { createMcpToolId } from '@/lib/mcp/utils'
|
||||
import { getProviderIdFromServiceId } from '@/lib/oauth'
|
||||
import { BLOCK_DIMENSIONS, HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import {
|
||||
|
||||
@@ -448,7 +448,7 @@ export const SearchModal = memo(function SearchModal({
|
||||
}, [workspaces, workflows, pages, blocks, triggers, tools, toolOperations, docs])
|
||||
|
||||
const sectionOrder = useMemo<SearchItem['type'][]>(
|
||||
() => ['block', 'tool', 'trigger', 'doc', 'tool-operation', 'workflow', 'workspace', 'page'],
|
||||
() => ['block', 'tool', 'tool-operation', 'trigger', 'workflow', 'workspace', 'page', 'doc'],
|
||||
[]
|
||||
)
|
||||
|
||||
|
||||
@@ -102,47 +102,6 @@ function calculateAliasScore(
|
||||
return { score: 0, matchType: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate multi-word match score
|
||||
* Each word in the query must appear somewhere in the field
|
||||
* Returns a score based on how well the words match
|
||||
*/
|
||||
function calculateMultiWordScore(
|
||||
queryWords: string[],
|
||||
field: string
|
||||
): { score: number; matchType: 'word-boundary' | 'substring' | null } {
|
||||
const normalizedField = field.toLowerCase().trim()
|
||||
const fieldWords = normalizedField.split(/[\s\-_/:]+/)
|
||||
|
||||
let allWordsMatch = true
|
||||
let totalScore = 0
|
||||
let hasWordBoundary = false
|
||||
|
||||
for (const queryWord of queryWords) {
|
||||
const wordBoundaryMatch = fieldWords.some((fw) => fw.startsWith(queryWord))
|
||||
const substringMatch = normalizedField.includes(queryWord)
|
||||
|
||||
if (wordBoundaryMatch) {
|
||||
totalScore += SCORE_WORD_BOUNDARY
|
||||
hasWordBoundary = true
|
||||
} else if (substringMatch) {
|
||||
totalScore += SCORE_SUBSTRING_MATCH
|
||||
} else {
|
||||
allWordsMatch = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!allWordsMatch) {
|
||||
return { score: 0, matchType: null }
|
||||
}
|
||||
|
||||
return {
|
||||
score: totalScore / queryWords.length,
|
||||
matchType: hasWordBoundary ? 'word-boundary' : 'substring',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search items using tiered matching algorithm
|
||||
* Returns items sorted by relevance (highest score first)
|
||||
@@ -158,8 +117,6 @@ export function searchItems<T extends SearchableItem>(
|
||||
}
|
||||
|
||||
const results: SearchResult<T>[] = []
|
||||
const queryWords = normalizedQuery.toLowerCase().split(/\s+/).filter(Boolean)
|
||||
const isMultiWord = queryWords.length > 1
|
||||
|
||||
for (const item of items) {
|
||||
const nameMatch = calculateFieldScore(normalizedQuery, item.name)
|
||||
@@ -170,35 +127,16 @@ export function searchItems<T extends SearchableItem>(
|
||||
|
||||
const aliasMatch = calculateAliasScore(normalizedQuery, item.aliases)
|
||||
|
||||
let nameScore = nameMatch.score
|
||||
let descScore = descMatch.score * DESCRIPTION_WEIGHT
|
||||
const nameScore = nameMatch.score
|
||||
const descScore = descMatch.score * DESCRIPTION_WEIGHT
|
||||
const aliasScore = aliasMatch.score
|
||||
|
||||
let bestMatchType = nameMatch.matchType
|
||||
|
||||
// For multi-word queries, also try matching each word independently and take the better score
|
||||
if (isMultiWord) {
|
||||
const multiWordNameMatch = calculateMultiWordScore(queryWords, item.name)
|
||||
if (multiWordNameMatch.score > nameScore) {
|
||||
nameScore = multiWordNameMatch.score
|
||||
bestMatchType = multiWordNameMatch.matchType
|
||||
}
|
||||
|
||||
if (item.description) {
|
||||
const multiWordDescMatch = calculateMultiWordScore(queryWords, item.description)
|
||||
const multiWordDescScore = multiWordDescMatch.score * DESCRIPTION_WEIGHT
|
||||
if (multiWordDescScore > descScore) {
|
||||
descScore = multiWordDescScore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bestScore = Math.max(nameScore, descScore, aliasScore)
|
||||
|
||||
if (bestScore > 0) {
|
||||
let matchType: SearchResult<T>['matchType'] = 'substring'
|
||||
if (nameScore >= descScore && nameScore >= aliasScore) {
|
||||
matchType = bestMatchType || 'substring'
|
||||
matchType = nameMatch.matchType || 'substring'
|
||||
} else if (aliasScore >= descScore) {
|
||||
matchType = 'alias'
|
||||
} else {
|
||||
|
||||
@@ -39,7 +39,6 @@ import {
|
||||
useRefreshMcpServer,
|
||||
useStoredMcpTools,
|
||||
} from '@/hooks/queries/mcp'
|
||||
import { useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { FormField, McpServerSkeleton } from './components'
|
||||
@@ -158,7 +157,6 @@ interface FormattedInputProps {
|
||||
scrollLeft: number
|
||||
showEnvVars: boolean
|
||||
envVarProps: EnvVarDropdownConfig
|
||||
availableEnvVars?: Set<string>
|
||||
className?: string
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
onScroll: (scrollLeft: number) => void
|
||||
@@ -171,7 +169,6 @@ function FormattedInput({
|
||||
scrollLeft,
|
||||
showEnvVars,
|
||||
envVarProps,
|
||||
availableEnvVars,
|
||||
className,
|
||||
onChange,
|
||||
onScroll,
|
||||
@@ -193,7 +190,7 @@ function FormattedInput({
|
||||
/>
|
||||
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden px-[8px] py-[6px] font-medium font-sans text-sm'>
|
||||
<div className='whitespace-nowrap' style={{ transform: `translateX(-${scrollLeft}px)` }}>
|
||||
{formatDisplayText(value, { availableEnvVars })}
|
||||
{formatDisplayText(value)}
|
||||
</div>
|
||||
</div>
|
||||
{showEnvVars && (
|
||||
@@ -224,7 +221,6 @@ interface HeaderRowProps {
|
||||
envSearchTerm: string
|
||||
cursorPosition: number
|
||||
workspaceId: string
|
||||
availableEnvVars?: Set<string>
|
||||
onInputChange: (field: InputFieldType, value: string, index?: number) => void
|
||||
onHeaderScroll: (key: string, scrollLeft: number) => void
|
||||
onEnvVarSelect: (value: string) => void
|
||||
@@ -242,7 +238,6 @@ function HeaderRow({
|
||||
envSearchTerm,
|
||||
cursorPosition,
|
||||
workspaceId,
|
||||
availableEnvVars,
|
||||
onInputChange,
|
||||
onHeaderScroll,
|
||||
onEnvVarSelect,
|
||||
@@ -270,7 +265,6 @@ function HeaderRow({
|
||||
scrollLeft={headerScrollLeft[`key-${index}`] || 0}
|
||||
showEnvVars={isKeyActive}
|
||||
envVarProps={envVarProps}
|
||||
availableEnvVars={availableEnvVars}
|
||||
className='flex-1'
|
||||
onChange={(e) => onInputChange('header-key', e.target.value, index)}
|
||||
onScroll={(scrollLeft) => onHeaderScroll(`key-${index}`, scrollLeft)}
|
||||
@@ -282,7 +276,6 @@ function HeaderRow({
|
||||
scrollLeft={headerScrollLeft[`value-${index}`] || 0}
|
||||
showEnvVars={isValueActive}
|
||||
envVarProps={envVarProps}
|
||||
availableEnvVars={availableEnvVars}
|
||||
className='flex-1'
|
||||
onChange={(e) => onInputChange('header-value', e.target.value, index)}
|
||||
onScroll={(scrollLeft) => onHeaderScroll(`value-${index}`, scrollLeft)}
|
||||
@@ -378,7 +371,6 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
const deleteServerMutation = useDeleteMcpServer()
|
||||
const refreshServerMutation = useRefreshMcpServer()
|
||||
const { testResult, isTestingConnection, testConnection, clearTestResult } = useMcpServerTest()
|
||||
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
|
||||
|
||||
const urlInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
@@ -1069,7 +1061,6 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
onSelect: handleEnvVarSelect,
|
||||
onClose: resetEnvVarState,
|
||||
}}
|
||||
availableEnvVars={availableEnvVars}
|
||||
onChange={(e) => handleInputChange('url', e.target.value)}
|
||||
onScroll={(scrollLeft) => handleUrlScroll(scrollLeft)}
|
||||
/>
|
||||
@@ -1103,7 +1094,6 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
envSearchTerm={envSearchTerm}
|
||||
cursorPosition={cursorPosition}
|
||||
workspaceId={workspaceId}
|
||||
availableEnvVars={availableEnvVars}
|
||||
onInputChange={handleInputChange}
|
||||
onHeaderScroll={handleHeaderScroll}
|
||||
onEnvVarSelect={handleEnvVarSelect}
|
||||
|
||||
@@ -6,8 +6,8 @@ import { useParams } from 'next/navigation'
|
||||
import { Combobox, Label, Switch, Tooltip } from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { USAGE_THRESHOLDS } from '@/lib/billing/client/consts'
|
||||
import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade'
|
||||
import { USAGE_THRESHOLDS } from '@/lib/billing/client/usage-visualization'
|
||||
import { getEffectiveSeats } from '@/lib/billing/subscriptions/utils'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import { getFilledPillColor, USAGE_PILL_COLORS, USAGE_THRESHOLDS } from '@/lib/billing/client'
|
||||
import {
|
||||
getFilledPillColor,
|
||||
USAGE_PILL_COLORS,
|
||||
USAGE_THRESHOLDS,
|
||||
} from '@/lib/billing/client/usage-visualization'
|
||||
|
||||
const PILL_COUNT = 5
|
||||
|
||||
|
||||
@@ -5,14 +5,13 @@ import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { USAGE_PILL_COLORS, USAGE_THRESHOLDS } from '@/lib/billing/client/consts'
|
||||
import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade'
|
||||
import {
|
||||
getBillingStatus,
|
||||
getFilledPillColor,
|
||||
getSubscriptionStatus,
|
||||
getUsage,
|
||||
} from '@/lib/billing/client/utils'
|
||||
USAGE_PILL_COLORS,
|
||||
USAGE_THRESHOLDS,
|
||||
} from '@/lib/billing/client/usage-visualization'
|
||||
import { getBillingStatus, getSubscriptionStatus, getUsage } from '@/lib/billing/client/utils'
|
||||
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import { useSocket } from '@/app/workspace/providers/socket-provider'
|
||||
import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
|
||||
@@ -4,6 +4,8 @@ import { task } from '@trigger.dev/sdk'
|
||||
import { Cron } from 'croner'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import type { ZodRecord, ZodString } from 'zod'
|
||||
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
|
||||
import { preprocessExecution } from '@/lib/execution/preprocessing'
|
||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
|
||||
@@ -120,6 +122,7 @@ async function runWorkflowExecution({
|
||||
loggingSession,
|
||||
requestId,
|
||||
executionId,
|
||||
EnvVarsSchema,
|
||||
}: {
|
||||
payload: ScheduleExecutionPayload
|
||||
workflowRecord: WorkflowRecord
|
||||
@@ -127,6 +130,7 @@ async function runWorkflowExecution({
|
||||
loggingSession: LoggingSession
|
||||
requestId: string
|
||||
executionId: string
|
||||
EnvVarsSchema: ZodRecord<ZodString, ZodString>
|
||||
}): Promise<RunWorkflowResult> {
|
||||
try {
|
||||
logger.debug(`[${requestId}] Loading deployed workflow ${payload.workflowId}`)
|
||||
@@ -152,12 +156,31 @@ async function runWorkflowExecution({
|
||||
throw new Error(`Workflow ${payload.workflowId} has no associated workspace`)
|
||||
}
|
||||
|
||||
const personalEnvUserId = workflowRecord.userId
|
||||
|
||||
const { personalEncrypted, workspaceEncrypted } = await getPersonalAndWorkspaceEnv(
|
||||
personalEnvUserId,
|
||||
workspaceId
|
||||
)
|
||||
|
||||
const variables = EnvVarsSchema.parse({
|
||||
...personalEncrypted,
|
||||
...workspaceEncrypted,
|
||||
})
|
||||
|
||||
const input = {
|
||||
_context: {
|
||||
workflowId: payload.workflowId,
|
||||
},
|
||||
}
|
||||
|
||||
await loggingSession.safeStart({
|
||||
userId: actorUserId,
|
||||
workspaceId,
|
||||
variables: variables || {},
|
||||
deploymentVersionId,
|
||||
})
|
||||
|
||||
const metadata: ExecutionMetadata = {
|
||||
requestId,
|
||||
executionId,
|
||||
@@ -256,6 +279,7 @@ export type ScheduleExecutionPayload = {
|
||||
failedCount?: number
|
||||
now: string
|
||||
scheduledFor?: string
|
||||
preflighted?: boolean
|
||||
}
|
||||
|
||||
function calculateNextRunTime(
|
||||
@@ -295,6 +319,9 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) {
|
||||
executionId,
|
||||
})
|
||||
|
||||
const zod = await import('zod')
|
||||
const EnvVarsSchema = zod.z.record(zod.z.string())
|
||||
|
||||
try {
|
||||
const loggingSession = new LoggingSession(
|
||||
payload.workflowId,
|
||||
@@ -312,6 +339,7 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) {
|
||||
checkRateLimit: true,
|
||||
checkDeployment: true,
|
||||
loggingSession,
|
||||
preflightEnvVars: !payload.preflighted,
|
||||
})
|
||||
|
||||
if (!preprocessResult.success) {
|
||||
@@ -454,6 +482,7 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) {
|
||||
loggingSession,
|
||||
requestId,
|
||||
executionId,
|
||||
EnvVarsSchema,
|
||||
})
|
||||
|
||||
if (executionResult.status === 'skip') {
|
||||
|
||||
@@ -17,7 +17,6 @@ import { getWorkflowById } from '@/lib/workflows/utils'
|
||||
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
|
||||
import type { ExecutionMetadata } from '@/executor/execution/types'
|
||||
import type { ExecutionResult } from '@/executor/types'
|
||||
import { safeAssign } from '@/tools/safe-assign'
|
||||
import { getTrigger, isTriggerValid } from '@/triggers'
|
||||
|
||||
const logger = createLogger('TriggerWebhookExecution')
|
||||
@@ -398,7 +397,7 @@ async function executeWebhookJobInternal(
|
||||
requestId,
|
||||
userId: payload.userId,
|
||||
})
|
||||
safeAssign(input, processedInput as Record<string, unknown>)
|
||||
Object.assign(input, processedInput)
|
||||
}
|
||||
} else {
|
||||
logger.debug(`[${requestId}] No valid triggerId found for block ${payload.blockId}`)
|
||||
|
||||
@@ -20,6 +20,7 @@ export type WorkflowExecutionPayload = {
|
||||
input?: any
|
||||
triggerType?: CoreTriggerType
|
||||
metadata?: Record<string, any>
|
||||
preflighted?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,6 +52,7 @@ export async function executeWorkflowJob(payload: WorkflowExecutionPayload) {
|
||||
checkRateLimit: true,
|
||||
checkDeployment: true,
|
||||
loggingSession: loggingSession,
|
||||
preflightEnvVars: !payload.preflighted,
|
||||
})
|
||||
|
||||
if (!preprocessResult.success) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { McpIcon } from '@/components/icons'
|
||||
import { createMcpToolId } from '@/lib/mcp/shared'
|
||||
import { createMcpToolId } from '@/lib/mcp/utils'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import type { ToolResponse } from '@/tools/types'
|
||||
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ArrowRight, ChevronRight } from 'lucide-react'
|
||||
|
||||
interface ContactButtonProps {
|
||||
href: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function ContactButton({ href, children }: ContactButtonProps) {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
borderRadius: '10px',
|
||||
background: 'linear-gradient(to bottom, #8357ff, #6f3dfa)',
|
||||
border: '1px solid #6f3dfa',
|
||||
boxShadow: 'inset 0 2px 4px 0 #9b77ff',
|
||||
paddingTop: '6px',
|
||||
paddingBottom: '6px',
|
||||
paddingLeft: '12px',
|
||||
paddingRight: '10px',
|
||||
fontSize: '15px',
|
||||
fontWeight: 500,
|
||||
color: '#ffffff',
|
||||
textDecoration: 'none',
|
||||
opacity: isHovered ? 0.9 : 1,
|
||||
transition: 'opacity 200ms',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<span style={{ display: 'inline-flex' }}>
|
||||
{isHovered ? (
|
||||
<ArrowRight style={{ height: '16px', width: '16px' }} aria-hidden='true' />
|
||||
) : (
|
||||
<ChevronRight style={{ height: '16px', width: '16px' }} aria-hidden='true' />
|
||||
)}
|
||||
</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
---
|
||||
slug: enterprise
|
||||
title: 'Build with Sim for Enterprise'
|
||||
description: 'Access control, BYOK, self-hosted deployments, on-prem Copilot, SSO & SAML, whitelabeling, Admin API, and flexible data retention—enterprise features for teams with strict security and compliance requirements.'
|
||||
date: 2026-01-23
|
||||
updated: 2026-01-23
|
||||
authors:
|
||||
- vik
|
||||
readingTime: 10
|
||||
tags: [Enterprise, Security, Self-Hosted, SSO, SAML, Compliance, BYOK, Access Control, Copilot, Whitelabel, API, Import, Export]
|
||||
ogImage: /studio/enterprise/cover.png
|
||||
ogAlt: 'Sim Enterprise features overview'
|
||||
about: ['Enterprise Software', 'Security', 'Compliance', 'Self-Hosting']
|
||||
timeRequired: PT10M
|
||||
canonical: https://sim.ai/studio/enterprise
|
||||
featured: false
|
||||
draft: true
|
||||
---
|
||||
|
||||
We've been working with security teams at larger organizations to bring Sim into environments with strict compliance and data handling requirements. This post covers the enterprise capabilities we've built: granular access control, bring-your-own-keys, self-hosted deployments, on-prem Copilot, SSO & SAML, whitelabeling, compliance, and programmatic management via the Admin API.
|
||||
|
||||
## Access Control
|
||||
|
||||

|
||||
|
||||
Permission groups let administrators control what features and integrations are available to different teams within an organization. This isn't just UI filtering—restrictions are enforced at the execution layer.
|
||||
|
||||
### Model Provider Restrictions
|
||||
|
||||

|
||||
|
||||
Allowlist specific providers while blocking others. Users in a restricted group see only approved providers in the model selector. A workflow that tries to use an unapproved provider won't execute.
|
||||
|
||||
This is useful when you've approved certain providers for production use, negotiated enterprise agreements with specific vendors, or need to comply with data residency requirements that only certain providers meet.
|
||||
|
||||
### Integration Controls
|
||||
|
||||

|
||||
|
||||
Restrict which workflow blocks appear in the editor. Disable the HTTP block to prevent arbitrary external API calls. Block access to integrations that haven't completed your security review.
|
||||
|
||||
### Platform Feature Toggles
|
||||
|
||||

|
||||
|
||||
Control access to platform capabilities per permission group:
|
||||
|
||||
- **[Knowledge Base](https://docs.sim.ai/blocks/knowledge)** — Disable document uploads if RAG workflows aren't approved
|
||||
- **[MCP Tools](https://docs.sim.ai/mcp)** — Block deployment of workflows as external tool endpoints
|
||||
- **Custom Tools** — Prevent creation of arbitrary HTTP integrations
|
||||
- **Invitations** — Disable self-service team invitations to maintain centralized control
|
||||
|
||||
Users not assigned to any permission group have full access, so restrictions are opt-in per team rather than requiring you to grant permissions to everyone.
|
||||
|
||||
---
|
||||
|
||||
## Bring Your Own Keys
|
||||
|
||||

|
||||
|
||||
When you configure your own API keys for model providers—OpenAI, Anthropic, Google, Azure OpenAI, AWS Bedrock, or any supported provider—your prompts and completions route directly between Sim and that provider. The traffic doesn't pass through our infrastructure.
|
||||
|
||||
This matters because LLM requests contain the context you've assembled: customer data, internal documents, proprietary business logic. With your own keys, you maintain a direct relationship with your model provider. Their data handling policies and compliance certifications apply to your usage without an intermediary.
|
||||
|
||||
BYOK is available to everyone, not just enterprise plans. Connect your credentials in workspace settings, and all model calls use your keys. For self-hosted deployments, this is the default—there are no Sim-managed keys involved.
|
||||
|
||||
A healthcare organization can use Azure OpenAI with their BAA-covered subscription. A financial services firm can route through their approved API gateway with additional logging controls. The workflow builder stays the same; only the underlying data flow changes.
|
||||
|
||||
---
|
||||
|
||||
## Self-Hosted Deployments
|
||||
|
||||

|
||||
|
||||
Run Sim entirely on your infrastructure. Deploy with [Docker Compose](https://docs.sim.ai/self-hosting/docker) or [Helm charts](https://docs.sim.ai/self-hosting/kubernetes) for Kubernetes—the application, WebSocket server, and PostgreSQL database all stay within your network.
|
||||
|
||||
**Single-node** — Docker Compose setup for smaller teams getting started.
|
||||
|
||||
**High availability** — Multi-replica Kubernetes deployments with horizontal pod autoscaling.
|
||||
|
||||
**Air-gapped** — No external network access required. Pair with [Ollama](https://docs.sim.ai/self-hosting/ollama) or [vLLM](https://docs.sim.ai/self-hosting/vllm) for local model inference.
|
||||
|
||||
Enterprise features like access control, SSO, and organization management are enabled through environment variables—no connection to our billing infrastructure required.
|
||||
|
||||
---
|
||||
|
||||
## On-Prem Copilot
|
||||
|
||||
Copilot—our context-aware AI assistant for building and debugging workflows—can run entirely within your self-hosted deployment using your own LLM keys.
|
||||
|
||||
When you configure Copilot with your API credentials, all assistant interactions route directly to your chosen provider. The prompts Copilot generates—which include context from your workflows, execution logs, and workspace configuration—never leave your network. You get the same capabilities as the hosted version: natural language workflow generation, error diagnosis, documentation lookup, and iterative editing through diffs.
|
||||
|
||||
This is particularly relevant for organizations where the context Copilot needs to be helpful is also the context that can't leave the building. Your workflow definitions, block configurations, and execution traces stay within your infrastructure even when you're asking Copilot for help debugging a failure or generating a new integration.
|
||||
|
||||
---
|
||||
|
||||
## SSO & SAML
|
||||
|
||||

|
||||
|
||||
Integrate with your existing identity provider through SAML 2.0 or OIDC. We support Okta, Azure AD (Entra ID), Google Workspace, OneLogin, Auth0, JumpCloud, Ping Identity, ADFS, and any compliant identity provider.
|
||||
|
||||
Once enabled, users authenticate through your IdP instead of Sim credentials. Your MFA policies apply automatically. Session management ties to your IdP—logout there terminates Sim sessions. Account deprovisioning immediately revokes access.
|
||||
|
||||
New users are provisioned on first SSO login based on IdP attributes. No invitation emails, no password setup, no manual account creation required.
|
||||
|
||||
This centralizes your authentication and audit trail. Your security team's policies apply to Sim access through the same system that tracks everything else.
|
||||
|
||||
---
|
||||
|
||||
## Whitelabeling
|
||||
|
||||
Customize Sim's appearance to match your brand. For self-hosted deployments, whitelabeling is configured through environment variables—no code changes required.
|
||||
|
||||
**Brand name & logo** — Replace "Sim" with your company name and logo throughout the interface.
|
||||
|
||||
**Theme colors** — Set primary, accent, and background colors to align with your brand palette.
|
||||
|
||||
**Support & documentation links** — Point help links to your internal documentation and support channels instead of ours.
|
||||
|
||||
**Legal pages** — Redirect terms of service and privacy policy links to your own policies.
|
||||
|
||||
This is useful for internal platforms, customer-facing deployments, or any scenario where you want Sim to feel like a native part of your product rather than a third-party tool.
|
||||
|
||||
---
|
||||
|
||||
## Compliance & Data Retention
|
||||
|
||||

|
||||
|
||||
Sim maintains **SOC 2 Type II** certification with annual audits covering security, availability, and confidentiality controls. We share our SOC 2 report directly with prospective customers under NDA.
|
||||
|
||||
**HIPAA** — Business Associate Agreements available for healthcare organizations. Requires self-hosted deployment or dedicated infrastructure.
|
||||
|
||||
**Data Retention** — Configure how long workflow execution traces, inputs, and outputs are stored before automatic deletion. We work with enterprise customers to set retention policies that match their compliance requirements.
|
||||
|
||||
We provide penetration test reports, architecture documentation, and completed security questionnaires (SIG, CAIQ, and custom formats) for your vendor review process.
|
||||
|
||||
---
|
||||
|
||||
## Admin API
|
||||
|
||||
Manage Sim programmatically through the Admin API. Every operation available in the UI has a corresponding API endpoint, enabling infrastructure-as-code workflows and integration with your existing tooling.
|
||||
|
||||
**User & Organization Management** — Provision users, create organizations, assign roles, and manage team membership. Integrate with your HR systems to automatically onboard and offboard employees.
|
||||
|
||||
**Workspace Administration** — Create workspaces, configure settings, and manage access. Useful for setting up isolated environments for different teams or clients.
|
||||
|
||||
**Workflow Lifecycle** — Deploy, undeploy, and manage workflow versions programmatically. Build CI/CD pipelines that promote workflows from development to staging to production.
|
||||
|
||||
The API uses standard REST conventions with JSON payloads. Authentication is via API keys scoped to your organization.
|
||||
|
||||
---
|
||||
|
||||
## Import & Export
|
||||
|
||||
Move workflows between environments, create backups, and maintain version control inside or outside of Sim.
|
||||
|
||||
**Workflow Export** — Export individual workflows or entire folders as JSON. The export includes block configurations, connections, environment variable references, and metadata. Use this to back up critical workflows or move them between Sim instances.
|
||||
|
||||
**Workspace Export** — Export an entire workspace as a ZIP archive containing all workflows, folder structure, and configuration. Useful for disaster recovery or migrating to a self-hosted deployment.
|
||||
|
||||
**Import** — Import workflows into any workspace. Sim handles ID remapping and validates the structure before import. This enables workflow templates, sharing between teams, and restoring from backups.
|
||||
|
||||
**Version History** — Each deployment creates a version snapshot. Roll back to previous versions if a deployment causes issues. The Admin API exposes version history for integration with your change management processes.
|
||||
|
||||
For teams practicing GitOps, export workflows to your repository and use the Admin API to deploy from CI/CD pipelines.
|
||||
|
||||
---
|
||||
|
||||
## Get Started
|
||||
|
||||
Enterprise features are available now. Check out our [self-hosting](https://docs.sim.ai/self-hosting) and [enterprise](https://docs.sim.ai/enterprise) docs to get started.
|
||||
|
||||
*Questions about enterprise deployments?*
|
||||
|
||||
<ContactButton href="https://form.typeform.com/to/jqCO12pF">Contact Us</ContactButton>
|
||||
@@ -29,13 +29,10 @@ Your workspace is indexed for hybrid retrieval. When you ask a question, Copilot
|
||||
|
||||
Copilot supports slash commands that trigger specialized capabilities:
|
||||
|
||||
- `/fast` — uses a faster model for quick responses when you need speed over depth
|
||||
- `/research` — performs multi-step web research on a topic, synthesizing results from multiple sources
|
||||
- `/actions` — enables agentic mode where Copilot can take actions on your behalf, like modifying blocks or creating workflows
|
||||
- `/search` — searches the web for relevant information
|
||||
- `/read` — reads and extracts content from a URL
|
||||
- `/scrape` — scrapes structured data from web pages
|
||||
- `/crawl` — crawls multiple pages from a website to gather comprehensive information
|
||||
- `/deep-research` — performs multi-step web research on a topic, synthesizing results from multiple sources
|
||||
- `/api-docs` — fetches and parses API documentation from a URL, extracting endpoints, parameters, and authentication requirements
|
||||
- `/test` — runs your current workflow with sample inputs and reports results inline
|
||||
- `/build` — generates a complete workflow from a natural language description, wiring up blocks and configuring integrations
|
||||
|
||||
Use `@` commands to pull specific context into your conversation. `@block` references a specific block's configuration and recent outputs. `@workflow` includes the full workflow structure. `@logs` pulls in recent execution traces. This lets you ask targeted questions like "why is `@Slack1` returning an error?" and Copilot has the exact context it needs to diagnose the issue.
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { db } from '@sim/db'
|
||||
import { mcpServers } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, inArray, isNull } from 'drizzle-orm'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import {
|
||||
containsUserFileWithMetadata,
|
||||
@@ -83,6 +86,10 @@ export class BlockExecutor {
|
||||
|
||||
resolvedInputs = this.resolver.resolveInputs(ctx, node.id, block.config.params, block)
|
||||
|
||||
if (block.metadata?.id === BlockType.AGENT && resolvedInputs.tools) {
|
||||
resolvedInputs = await this.filterUnavailableMcpToolsForLog(ctx, resolvedInputs)
|
||||
}
|
||||
|
||||
if (blockLog) {
|
||||
blockLog.input = resolvedInputs
|
||||
}
|
||||
@@ -430,6 +437,60 @@ export class BlockExecutor {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out unavailable MCP tools from agent inputs for logging.
|
||||
* Only includes tools from servers with 'connected' status.
|
||||
*/
|
||||
private async filterUnavailableMcpToolsForLog(
|
||||
ctx: ExecutionContext,
|
||||
inputs: Record<string, any>
|
||||
): Promise<Record<string, any>> {
|
||||
const tools = inputs.tools
|
||||
if (!Array.isArray(tools) || tools.length === 0) return inputs
|
||||
|
||||
const mcpTools = tools.filter((t: any) => t.type === 'mcp')
|
||||
if (mcpTools.length === 0) return inputs
|
||||
|
||||
const serverIds = [
|
||||
...new Set(mcpTools.map((t: any) => t.params?.serverId).filter(Boolean)),
|
||||
] as string[]
|
||||
if (serverIds.length === 0) return inputs
|
||||
|
||||
const availableServerIds = new Set<string>()
|
||||
if (ctx.workspaceId && serverIds.length > 0) {
|
||||
try {
|
||||
const servers = await db
|
||||
.select({ id: mcpServers.id, connectionStatus: mcpServers.connectionStatus })
|
||||
.from(mcpServers)
|
||||
.where(
|
||||
and(
|
||||
eq(mcpServers.workspaceId, ctx.workspaceId),
|
||||
inArray(mcpServers.id, serverIds),
|
||||
isNull(mcpServers.deletedAt)
|
||||
)
|
||||
)
|
||||
|
||||
for (const server of servers) {
|
||||
if (server.connectionStatus === 'connected') {
|
||||
availableServerIds.add(server.id)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to check MCP server availability for logging:', error)
|
||||
return inputs
|
||||
}
|
||||
}
|
||||
|
||||
const filteredTools = tools.filter((tool: any) => {
|
||||
if (tool.type !== 'mcp') return true
|
||||
const serverId = tool.params?.serverId
|
||||
if (!serverId) return false
|
||||
return availableServerIds.has(serverId)
|
||||
})
|
||||
|
||||
return { ...inputs, tools: filteredTools }
|
||||
}
|
||||
|
||||
private preparePauseResumeSelfReference(
|
||||
ctx: ExecutionContext,
|
||||
node: DAGNode,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { StartBlockPath } from '@/lib/workflows/triggers/triggers'
|
||||
import type { BlockOutput } from '@/blocks/types'
|
||||
import { DAGBuilder } from '@/executor/dag/builder'
|
||||
import { BlockExecutor } from '@/executor/execution/block-executor'
|
||||
import { EdgeManager } from '@/executor/execution/edge-manager'
|
||||
@@ -23,6 +24,7 @@ const logger = createLogger('DAGExecutor')
|
||||
|
||||
export interface DAGExecutorOptions {
|
||||
workflow: SerializedWorkflow
|
||||
currentBlockStates?: Record<string, BlockOutput>
|
||||
envVarValues?: Record<string, string>
|
||||
workflowInput?: WorkflowInput
|
||||
workflowVariables?: Record<string, unknown>
|
||||
|
||||
@@ -28,23 +28,6 @@ export interface EnvVarResolveOptions {
|
||||
missingKeys?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard defaults for env var resolution across all contexts.
|
||||
*
|
||||
* - `resolveExactMatch: true` - Resolves `{{VAR}}` when it's the entire value
|
||||
* - `allowEmbedded: true` - Resolves `{{VAR}}` embedded in strings like `https://{{HOST}}/api`
|
||||
* - `trimKeys: true` - `{{ VAR }}` works the same as `{{VAR}}` (whitespace tolerant)
|
||||
* - `onMissing: 'keep'` - Unknown patterns pass through (e.g., Grafana's `{{instance}}`)
|
||||
* - `deep: false` - Only processes strings by default; set `true` for nested objects
|
||||
*/
|
||||
export const ENV_VAR_RESOLVE_DEFAULTS: Required<Omit<EnvVarResolveOptions, 'missingKeys'>> = {
|
||||
resolveExactMatch: true,
|
||||
allowEmbedded: true,
|
||||
trimKeys: true,
|
||||
onMissing: 'keep',
|
||||
deep: false,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Resolve {{ENV_VAR}} references in values using provided env vars.
|
||||
*/
|
||||
@@ -54,11 +37,11 @@ export function resolveEnvVarReferences(
|
||||
options: EnvVarResolveOptions = {}
|
||||
): unknown {
|
||||
const {
|
||||
allowEmbedded = ENV_VAR_RESOLVE_DEFAULTS.allowEmbedded,
|
||||
resolveExactMatch = ENV_VAR_RESOLVE_DEFAULTS.resolveExactMatch,
|
||||
trimKeys = ENV_VAR_RESOLVE_DEFAULTS.trimKeys,
|
||||
onMissing = ENV_VAR_RESOLVE_DEFAULTS.onMissing,
|
||||
deep = ENV_VAR_RESOLVE_DEFAULTS.deep,
|
||||
allowEmbedded = true,
|
||||
resolveExactMatch = true,
|
||||
trimKeys = false,
|
||||
onMissing = 'keep',
|
||||
deep = true,
|
||||
} = options
|
||||
|
||||
if (typeof value === 'string') {
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
import type { InputFormatField } from '@/lib/workflows/types'
|
||||
import type { NormalizedBlockOutput, UserFile } from '@/executor/types'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import { safeAssign } from '@/tools/safe-assign'
|
||||
|
||||
type ExecutionKind = 'chat' | 'manual' | 'api'
|
||||
|
||||
@@ -347,7 +346,7 @@ function buildLegacyStarterOutput(
|
||||
const finalObject = isPlainObject(finalInput) ? finalInput : undefined
|
||||
|
||||
if (finalObject) {
|
||||
safeAssign(output, finalObject)
|
||||
Object.assign(output, finalObject)
|
||||
output.input = { ...finalObject }
|
||||
} else {
|
||||
output.input = finalInput
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { sanitizeForHttp, sanitizeHeaders } from '@/lib/mcp/shared'
|
||||
import type { McpTransport } from '@/lib/mcp/types'
|
||||
import { sanitizeForHttp, sanitizeHeaders } from '@/lib/mcp/utils'
|
||||
|
||||
const logger = createLogger('useMcpServerTest')
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useCallback, useMemo } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { McpIcon } from '@/components/icons'
|
||||
import { createMcpToolId } from '@/lib/mcp/shared'
|
||||
import { createMcpToolId } from '@/lib/mcp/utils'
|
||||
import { mcpKeys, useMcpToolsQuery } from '@/hooks/queries/mcp'
|
||||
|
||||
const logger = createLogger('useMcpTools')
|
||||
|
||||
@@ -144,17 +144,11 @@ export function useLogsList(
|
||||
})
|
||||
}
|
||||
|
||||
interface UseLogDetailOptions {
|
||||
enabled?: boolean
|
||||
refetchInterval?: number | false
|
||||
}
|
||||
|
||||
export function useLogDetail(logId: string | undefined, options?: UseLogDetailOptions) {
|
||||
export function useLogDetail(logId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: logKeys.detail(logId),
|
||||
queryFn: () => fetchLogDetail(logId as string),
|
||||
enabled: Boolean(logId) && (options?.enabled ?? true),
|
||||
refetchInterval: options?.refetchInterval ?? false,
|
||||
enabled: Boolean(logId),
|
||||
staleTime: 30 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { sanitizeForHttp, sanitizeHeaders } from '@/lib/mcp/shared'
|
||||
import type { McpServerStatusConfig, McpTool, StoredMcpTool } from '@/lib/mcp/types'
|
||||
import { sanitizeForHttp, sanitizeHeaders } from '@/lib/mcp/utils'
|
||||
|
||||
const logger = createLogger('McpQueries')
|
||||
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import { usePersonalEnvironment, useWorkspaceEnvironment } from '@/hooks/queries/environment'
|
||||
|
||||
export function useAvailableEnvVarKeys(workspaceId?: string): Set<string> | undefined {
|
||||
const { data: personalEnv, isLoading: personalLoading } = usePersonalEnvironment()
|
||||
const { data: workspaceEnvData, isLoading: workspaceLoading } = useWorkspaceEnvironment(
|
||||
workspaceId || ''
|
||||
)
|
||||
|
||||
return useMemo(() => {
|
||||
if (personalLoading || (workspaceId && workspaceLoading)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const keys = new Set<string>()
|
||||
|
||||
if (personalEnv) {
|
||||
Object.keys(personalEnv).forEach((key) => keys.add(key))
|
||||
}
|
||||
|
||||
if (workspaceId && workspaceEnvData) {
|
||||
if (workspaceEnvData.workspace) {
|
||||
Object.keys(workspaceEnvData.workspace).forEach((key) => keys.add(key))
|
||||
}
|
||||
if (workspaceEnvData.personal) {
|
||||
Object.keys(workspaceEnvData.personal).forEach((key) => keys.add(key))
|
||||
}
|
||||
}
|
||||
|
||||
return keys
|
||||
}, [personalEnv, workspaceEnvData, personalLoading, workspaceLoading, workspaceId])
|
||||
}
|
||||
|
||||
export function createShouldHighlightEnvVar(
|
||||
availableEnvVars: Set<string> | undefined
|
||||
): (varName: string) => boolean {
|
||||
return (varName: string): boolean => {
|
||||
if (availableEnvVars === undefined) {
|
||||
return true
|
||||
}
|
||||
return availableEnvVars.has(varName)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { a2aPushNotificationConfig, a2aTask } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { secureFetchWithPinnedIP, validateUrlWithDNS } from '@/lib/core/security/input-validation'
|
||||
|
||||
const logger = createLogger('A2APushNotifications')
|
||||
|
||||
@@ -46,17 +45,7 @@ export async function deliverPushNotification(taskId: string, state: TaskState):
|
||||
}
|
||||
|
||||
try {
|
||||
const urlValidation = await validateUrlWithDNS(config.url, 'webhook URL')
|
||||
if (!urlValidation.isValid || !urlValidation.resolvedIP) {
|
||||
logger.error('Push notification URL validation failed', {
|
||||
taskId,
|
||||
url: config.url,
|
||||
error: urlValidation.error,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const response = await secureFetchWithPinnedIP(config.url, urlValidation.resolvedIP, {
|
||||
const response = await fetch(config.url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
@@ -70,7 +59,7 @@ export async function deliverPushNotification(taskId: string, state: TaskState):
|
||||
artifacts: (task.artifacts as Artifact[]) || [],
|
||||
},
|
||||
}),
|
||||
timeout: 30000,
|
||||
signal: AbortSignal.timeout(30000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -256,17 +256,6 @@ export const auth = betterAuth({
|
||||
return { data: account }
|
||||
},
|
||||
after: async (account) => {
|
||||
try {
|
||||
const { ensureUserStatsExists } = await import('@/lib/billing/core/usage')
|
||||
await ensureUserStatsExists(account.userId)
|
||||
} catch (error) {
|
||||
logger.error('[databaseHooks.account.create.after] Failed to ensure user stats', {
|
||||
userId: account.userId,
|
||||
accountId: account.id,
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
if (account.providerId === 'salesforce') {
|
||||
const updates: {
|
||||
accessTokenExpiresAt?: Date
|
||||
@@ -473,6 +462,7 @@ export const auth = betterAuth({
|
||||
},
|
||||
emailVerification: {
|
||||
autoSignInAfterVerification: true,
|
||||
// onEmailVerification is called by the emailOTP plugin when email is verified via OTP
|
||||
onEmailVerification: async (user) => {
|
||||
if (isHosted && user.email) {
|
||||
try {
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* Number of pills to display in usage indicators.
|
||||
*/
|
||||
export const USAGE_PILL_COUNT = 8
|
||||
|
||||
/**
|
||||
* Usage percentage thresholds for visual states.
|
||||
*/
|
||||
export const USAGE_THRESHOLDS = {
|
||||
/** Warning threshold (yellow/orange state) */
|
||||
WARNING: 75,
|
||||
/** Critical threshold (red state) */
|
||||
CRITICAL: 90,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Color values for usage pill states using CSS variables
|
||||
*/
|
||||
export const USAGE_PILL_COLORS = {
|
||||
/** Unfilled pill color (gray) */
|
||||
UNFILLED: 'var(--surface-7)',
|
||||
/** Normal filled pill color (blue) */
|
||||
FILLED: 'var(--brand-secondary)',
|
||||
/** Warning state pill color (yellow/orange) */
|
||||
WARNING: 'var(--warning)',
|
||||
/** Critical/limit reached pill color (red) */
|
||||
AT_LIMIT: 'var(--text-error)',
|
||||
} as const
|
||||
@@ -1,7 +1,3 @@
|
||||
export {
|
||||
USAGE_PILL_COLORS,
|
||||
USAGE_THRESHOLDS,
|
||||
} from './consts'
|
||||
export type {
|
||||
BillingStatus,
|
||||
SubscriptionData,
|
||||
@@ -12,7 +8,6 @@ export {
|
||||
canUpgrade,
|
||||
getBillingStatus,
|
||||
getDaysRemainingInPeriod,
|
||||
getFilledPillColor,
|
||||
getRemainingBudget,
|
||||
getSubscriptionStatus,
|
||||
getUsage,
|
||||
|
||||
150
apps/sim/lib/billing/client/usage-visualization.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Shared utilities for consistent usage visualization across the application.
|
||||
*
|
||||
* This module provides a single source of truth for how usage metrics are
|
||||
* displayed visually through "pills" or progress indicators.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Number of pills to display in usage indicators.
|
||||
*
|
||||
* Using 8 pills provides:
|
||||
* - 12.5% granularity per pill
|
||||
* - Good balance between precision and visual clarity
|
||||
* - Consistent representation across panel and settings
|
||||
*/
|
||||
export const USAGE_PILL_COUNT = 8
|
||||
|
||||
/**
|
||||
* Usage percentage thresholds for visual states.
|
||||
*/
|
||||
export const USAGE_THRESHOLDS = {
|
||||
/** Warning threshold (yellow/orange state) */
|
||||
WARNING: 75,
|
||||
/** Critical threshold (red state) */
|
||||
CRITICAL: 90,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Color values for usage pill states using CSS variables
|
||||
*/
|
||||
export const USAGE_PILL_COLORS = {
|
||||
/** Unfilled pill color (gray) */
|
||||
UNFILLED: 'var(--surface-7)',
|
||||
/** Normal filled pill color (blue) */
|
||||
FILLED: 'var(--brand-secondary)',
|
||||
/** Warning state pill color (yellow/orange) */
|
||||
WARNING: 'var(--warning)',
|
||||
/** Critical/limit reached pill color (red) */
|
||||
AT_LIMIT: 'var(--text-error)',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Calculate the number of filled pills based on usage percentage.
|
||||
*
|
||||
* Uses Math.ceil() to ensure even minimal usage (0.01%) shows visual feedback.
|
||||
* This provides better UX by making it clear that there is some usage, even if small.
|
||||
*
|
||||
* @param percentUsed - The usage percentage (0-100). Can be a decimal (e.g., 0.315 for 0.315%)
|
||||
* @returns Number of pills that should be filled (0 to USAGE_PILL_COUNT)
|
||||
*
|
||||
* @example
|
||||
* calculateFilledPills(0.315) // Returns 1 (shows feedback for 0.315% usage)
|
||||
* calculateFilledPills(50) // Returns 4 (50% of 8 pills)
|
||||
* calculateFilledPills(100) // Returns 8 (completely filled)
|
||||
* calculateFilledPills(150) // Returns 8 (clamped to maximum)
|
||||
*/
|
||||
export function calculateFilledPills(percentUsed: number): number {
|
||||
// Clamp percentage to valid range [0, 100]
|
||||
const safePercent = Math.min(Math.max(percentUsed, 0), 100)
|
||||
|
||||
// Calculate filled pills using ceil to show feedback for any usage
|
||||
return Math.ceil((safePercent / 100) * USAGE_PILL_COUNT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if usage has reached the limit (all pills filled).
|
||||
*
|
||||
* @param percentUsed - The usage percentage (0-100)
|
||||
* @returns true if all pills should be filled (at or over limit)
|
||||
*/
|
||||
export function isUsageAtLimit(percentUsed: number): boolean {
|
||||
return calculateFilledPills(percentUsed) >= USAGE_PILL_COUNT
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate color for a pill based on its state.
|
||||
*
|
||||
* @param isFilled - Whether this pill should be filled
|
||||
* @param isAtLimit - Whether usage has reached the limit
|
||||
* @returns CSS color value
|
||||
*/
|
||||
export function getPillColor(isFilled: boolean, isAtLimit: boolean): string {
|
||||
if (!isFilled) return USAGE_PILL_COLORS.UNFILLED
|
||||
if (isAtLimit) return USAGE_PILL_COLORS.AT_LIMIT
|
||||
return USAGE_PILL_COLORS.FILLED
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate filled pill color based on usage thresholds.
|
||||
*
|
||||
* @param isCritical - Whether usage is at critical level (blocked or >= 90%)
|
||||
* @param isWarning - Whether usage is at warning level (>= 75% but < critical)
|
||||
* @returns CSS color value for filled pills
|
||||
*/
|
||||
export function getFilledPillColor(isCritical: boolean, isWarning: boolean): string {
|
||||
if (isCritical) return USAGE_PILL_COLORS.AT_LIMIT
|
||||
if (isWarning) return USAGE_PILL_COLORS.WARNING
|
||||
return USAGE_PILL_COLORS.FILLED
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine usage state based on percentage and blocked status.
|
||||
*
|
||||
* @param percentUsed - The usage percentage (0-100)
|
||||
* @param isBlocked - Whether the account is blocked
|
||||
* @returns Object containing isCritical and isWarning flags
|
||||
*/
|
||||
export function getUsageState(
|
||||
percentUsed: number,
|
||||
isBlocked = false
|
||||
): { isCritical: boolean; isWarning: boolean } {
|
||||
const isCritical = isBlocked || percentUsed >= USAGE_THRESHOLDS.CRITICAL
|
||||
const isWarning = !isCritical && percentUsed >= USAGE_THRESHOLDS.WARNING
|
||||
return { isCritical, isWarning }
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an array of pill states for rendering.
|
||||
*
|
||||
* @param percentUsed - The usage percentage (0-100)
|
||||
* @param isBlocked - Whether the account is blocked
|
||||
* @returns Array of pill states with colors
|
||||
*
|
||||
* @example
|
||||
* const pills = generatePillStates(50)
|
||||
* pills.forEach((pill, index) => (
|
||||
* <Pill key={index} color={pill.color} filled={pill.filled} />
|
||||
* ))
|
||||
*/
|
||||
export function generatePillStates(
|
||||
percentUsed: number,
|
||||
isBlocked = false
|
||||
): Array<{
|
||||
filled: boolean
|
||||
color: string
|
||||
index: number
|
||||
}> {
|
||||
const filledCount = calculateFilledPills(percentUsed)
|
||||
const { isCritical, isWarning } = getUsageState(percentUsed, isBlocked)
|
||||
const filledColor = getFilledPillColor(isCritical, isWarning)
|
||||
|
||||
return Array.from({ length: USAGE_PILL_COUNT }, (_, index) => {
|
||||
const filled = index < filledCount
|
||||
return {
|
||||
filled,
|
||||
color: filled ? filledColor : USAGE_PILL_COLORS.UNFILLED,
|
||||
index,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
*/
|
||||
|
||||
import { DEFAULT_FREE_CREDITS } from '@/lib/billing/constants'
|
||||
import { USAGE_PILL_COLORS } from './consts'
|
||||
import type { BillingStatus, SubscriptionData, UsageData } from './types'
|
||||
|
||||
const defaultUsage: UsageData = {
|
||||
@@ -37,35 +36,9 @@ export function getSubscriptionStatus(subscriptionData: SubscriptionData | null
|
||||
|
||||
/**
|
||||
* Get usage data from subscription data
|
||||
* Validates and sanitizes all numeric values to prevent crashes from malformed data
|
||||
*/
|
||||
export function getUsage(subscriptionData: SubscriptionData | null | undefined): UsageData {
|
||||
const usage = subscriptionData?.usage
|
||||
|
||||
if (!usage) {
|
||||
return defaultUsage
|
||||
}
|
||||
|
||||
return {
|
||||
current:
|
||||
typeof usage.current === 'number' && Number.isFinite(usage.current) ? usage.current : 0,
|
||||
limit:
|
||||
typeof usage.limit === 'number' && Number.isFinite(usage.limit)
|
||||
? usage.limit
|
||||
: DEFAULT_FREE_CREDITS,
|
||||
percentUsed:
|
||||
typeof usage.percentUsed === 'number' && Number.isFinite(usage.percentUsed)
|
||||
? usage.percentUsed
|
||||
: 0,
|
||||
isWarning: Boolean(usage.isWarning),
|
||||
isExceeded: Boolean(usage.isExceeded),
|
||||
billingPeriodStart: usage.billingPeriodStart ?? null,
|
||||
billingPeriodEnd: usage.billingPeriodEnd ?? null,
|
||||
lastPeriodCost:
|
||||
typeof usage.lastPeriodCost === 'number' && Number.isFinite(usage.lastPeriodCost)
|
||||
? usage.lastPeriodCost
|
||||
: 0,
|
||||
}
|
||||
return subscriptionData?.usage ?? defaultUsage
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,16 +100,3 @@ export function canUpgrade(subscriptionData: SubscriptionData | null | undefined
|
||||
const status = getSubscriptionStatus(subscriptionData)
|
||||
return status.plan === 'free' || status.plan === 'pro'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate filled pill color based on usage thresholds.
|
||||
*
|
||||
* @param isCritical - Whether usage is at critical level (blocked or >= 90%)
|
||||
* @param isWarning - Whether usage is at warning level (>= 75% but < critical)
|
||||
* @returns CSS color value for filled pills
|
||||
*/
|
||||
export function getFilledPillColor(isCritical: boolean, isWarning: boolean): string {
|
||||
if (isCritical) return USAGE_PILL_COLORS.AT_LIMIT
|
||||
if (isWarning) return USAGE_PILL_COLORS.WARNING
|
||||
return USAGE_PILL_COLORS.FILLED
|
||||
}
|
||||
|
||||
@@ -96,32 +96,11 @@ export async function handleNewUser(userId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a userStats record exists for a user.
|
||||
* Creates one with default values if missing.
|
||||
* This is a fallback for cases where the user.create.after hook didn't fire
|
||||
* (e.g., OAuth account linking to existing users).
|
||||
*
|
||||
*/
|
||||
export async function ensureUserStatsExists(userId: string): Promise<void> {
|
||||
await db
|
||||
.insert(userStats)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
userId: userId,
|
||||
currentUsageLimit: getFreeTierLimit().toString(),
|
||||
usageLimitUpdatedAt: new Date(),
|
||||
})
|
||||
.onConflictDoNothing({ target: userStats.userId })
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive usage data for a user
|
||||
*/
|
||||
export async function getUserUsageData(userId: string): Promise<UsageData> {
|
||||
try {
|
||||
await ensureUserStatsExists(userId)
|
||||
|
||||
const [userStatsData, subscription] = await Promise.all([
|
||||
db.select().from(userStats).where(eq(userStats.userId, userId)).limit(1),
|
||||
getHighestPrioritySubscription(userId),
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
|
||||
|
||||
@@ -557,8 +556,6 @@ export async function removeUserFromOrganization(
|
||||
const restoreResult = await restoreUserProSubscription(userId)
|
||||
billingActions.proRestored = restoreResult.restored
|
||||
billingActions.usageRestored = restoreResult.usageRestored
|
||||
|
||||
await syncUsageLimitsFromSubscription(userId)
|
||||
}
|
||||
} catch (postRemoveError) {
|
||||
logger.error('Post-removal personal Pro restore check failed', {
|
||||
|
||||
@@ -3,6 +3,9 @@ import { environment, workspaceEnvironment } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { decryptSecret } from '@/lib/core/security/encryption'
|
||||
import { REFERENCE } from '@/executor/constants'
|
||||
import { createEnvVarPattern } from '@/executor/utils/reference-validation'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('EnvironmentUtils')
|
||||
|
||||
@@ -51,7 +54,6 @@ export async function getPersonalAndWorkspaceEnv(
|
||||
personalDecrypted: Record<string, string>
|
||||
workspaceDecrypted: Record<string, string>
|
||||
conflicts: string[]
|
||||
decryptionFailures: string[]
|
||||
}> {
|
||||
const [personalRows, workspaceRows] = await Promise.all([
|
||||
db.select().from(environment).where(eq(environment.userId, userId)).limit(1),
|
||||
@@ -67,23 +69,14 @@ export async function getPersonalAndWorkspaceEnv(
|
||||
const personalEncrypted: Record<string, string> = (personalRows[0]?.variables as any) || {}
|
||||
const workspaceEncrypted: Record<string, string> = (workspaceRows[0]?.variables as any) || {}
|
||||
|
||||
const decryptionFailures: string[] = []
|
||||
|
||||
const decryptAll = async (src: Record<string, string>, source: 'personal' | 'workspace') => {
|
||||
const decryptAll = async (src: Record<string, string>) => {
|
||||
const entries = Object.entries(src)
|
||||
const results = await Promise.all(
|
||||
entries.map(async ([k, v]) => {
|
||||
try {
|
||||
const { decrypted } = await decryptSecret(v)
|
||||
return [k, decrypted] as const
|
||||
} catch (error) {
|
||||
logger.error(`Failed to decrypt ${source} environment variable "${k}"`, {
|
||||
userId,
|
||||
workspaceId,
|
||||
source,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
decryptionFailures.push(k)
|
||||
} catch {
|
||||
return [k, ''] as const
|
||||
}
|
||||
})
|
||||
@@ -92,28 +85,18 @@ export async function getPersonalAndWorkspaceEnv(
|
||||
}
|
||||
|
||||
const [personalDecrypted, workspaceDecrypted] = await Promise.all([
|
||||
decryptAll(personalEncrypted, 'personal'),
|
||||
decryptAll(workspaceEncrypted, 'workspace'),
|
||||
decryptAll(personalEncrypted),
|
||||
decryptAll(workspaceEncrypted),
|
||||
])
|
||||
|
||||
const conflicts = Object.keys(personalEncrypted).filter((k) => k in workspaceEncrypted)
|
||||
|
||||
if (decryptionFailures.length > 0) {
|
||||
logger.warn('Some environment variables failed to decrypt', {
|
||||
userId,
|
||||
workspaceId,
|
||||
failedKeys: decryptionFailures,
|
||||
failedCount: decryptionFailures.length,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
personalEncrypted,
|
||||
workspaceEncrypted,
|
||||
personalDecrypted,
|
||||
workspaceDecrypted,
|
||||
conflicts,
|
||||
decryptionFailures,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,3 +110,86 @@ export async function getEffectiveDecryptedEnv(
|
||||
)
|
||||
return { ...personalDecrypted, ...workspaceDecrypted }
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure all environment variables can be decrypted.
|
||||
*/
|
||||
export async function ensureEnvVarsDecryptable(
|
||||
variables: Record<string, string>,
|
||||
options: { requestId?: string } = {}
|
||||
): Promise<void> {
|
||||
const requestId = options.requestId
|
||||
for (const [key, encryptedValue] of Object.entries(variables)) {
|
||||
try {
|
||||
await decryptSecret(encryptedValue)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
if (requestId) {
|
||||
logger.error(`[${requestId}] Failed to decrypt environment variable "${key}"`, error)
|
||||
} else {
|
||||
logger.error(`Failed to decrypt environment variable "${key}"`, error)
|
||||
}
|
||||
throw new Error(`Failed to decrypt environment variable "${key}": ${message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure all {{ENV_VAR}} references in block subblocks resolve to decryptable values.
|
||||
*/
|
||||
export async function ensureBlockEnvVarsResolvable(
|
||||
blocks: Record<string, BlockState>,
|
||||
variables: Record<string, string>,
|
||||
options: { requestId?: string } = {}
|
||||
): Promise<void> {
|
||||
const requestId = options.requestId
|
||||
const envVarPattern = createEnvVarPattern()
|
||||
await Promise.all(
|
||||
Object.values(blocks).map(async (block) => {
|
||||
const subBlocks = block.subBlocks ?? {}
|
||||
await Promise.all(
|
||||
Object.values(subBlocks).map(async (subBlock) => {
|
||||
const value = subBlock.value
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!value.includes(REFERENCE.ENV_VAR_START) ||
|
||||
!value.includes(REFERENCE.ENV_VAR_END)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const matches = value.match(envVarPattern)
|
||||
if (!matches) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const match of matches) {
|
||||
const varName = match.slice(
|
||||
REFERENCE.ENV_VAR_START.length,
|
||||
-REFERENCE.ENV_VAR_END.length
|
||||
)
|
||||
const encryptedValue = variables[varName]
|
||||
if (!encryptedValue) {
|
||||
throw new Error(`Environment variable "${varName}" was not found`)
|
||||
}
|
||||
|
||||
try {
|
||||
await decryptSecret(encryptedValue)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
if (requestId) {
|
||||
logger.error(
|
||||
`[${requestId}] Error decrypting value for variable "${varName}"`,
|
||||
error
|
||||
)
|
||||
} else {
|
||||
logger.error(`Error decrypting value for variable "${varName}"`, error)
|
||||
}
|
||||
throw new Error(`Failed to decrypt environment variable "${varName}": ${message}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { checkServerSideUsageLimits } from '@/lib/billing/calculations/usage-mon
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter/rate-limiter'
|
||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
import { preflightWorkflowEnvVars } from '@/lib/workflows/executor/preflight'
|
||||
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
|
||||
import type { CoreTriggerType } from '@/stores/logs/filters/types'
|
||||
|
||||
@@ -117,13 +118,15 @@ export interface PreprocessExecutionOptions {
|
||||
checkRateLimit?: boolean // Default: false for manual/chat, true for others
|
||||
checkDeployment?: boolean // Default: true for non-manual triggers
|
||||
skipUsageLimits?: boolean // Default: false (only use for test mode)
|
||||
preflightEnvVars?: boolean // Default: false
|
||||
|
||||
// Context information
|
||||
workspaceId?: string // If known, used for billing resolution
|
||||
loggingSession?: LoggingSession // If provided, will be used for error logging
|
||||
isResumeContext?: boolean // If true, allows fallback billing on resolution failure (for paused workflow resumes)
|
||||
/** @deprecated No longer used - background/async executions always use deployed state */
|
||||
/** @deprecated No longer used - preflight always uses deployed state */
|
||||
useDraftState?: boolean
|
||||
envUserId?: string // Optional override for env var resolution user
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,9 +164,11 @@ export async function preprocessExecution(
|
||||
checkRateLimit = triggerType !== 'manual' && triggerType !== 'chat',
|
||||
checkDeployment = triggerType !== 'manual',
|
||||
skipUsageLimits = false,
|
||||
preflightEnvVars = false,
|
||||
workspaceId: providedWorkspaceId,
|
||||
loggingSession: providedLoggingSession,
|
||||
isResumeContext = false,
|
||||
envUserId,
|
||||
} = options
|
||||
|
||||
logger.info(`[${requestId}] Starting execution preprocessing`, {
|
||||
@@ -478,6 +483,44 @@ export async function preprocessExecution(
|
||||
}
|
||||
|
||||
// ========== SUCCESS: All Checks Passed ==========
|
||||
if (preflightEnvVars) {
|
||||
try {
|
||||
const resolvedEnvUserId = envUserId || workflowRecord.userId || userId
|
||||
await preflightWorkflowEnvVars({
|
||||
workflowId,
|
||||
workspaceId,
|
||||
envUserId: resolvedEnvUserId,
|
||||
requestId,
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Env var preflight failed'
|
||||
logger.warn(`[${requestId}] Env var preflight failed`, {
|
||||
workflowId,
|
||||
message,
|
||||
})
|
||||
|
||||
await logPreprocessingError({
|
||||
workflowId,
|
||||
executionId,
|
||||
triggerType,
|
||||
requestId,
|
||||
userId: actorUserId,
|
||||
workspaceId,
|
||||
errorMessage: message,
|
||||
loggingSession: providedLoggingSession,
|
||||
})
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
message,
|
||||
statusCode: 400,
|
||||
logCreated: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] All preprocessing checks passed`, {
|
||||
workflowId,
|
||||
actorUserId,
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* Server-only MCP config resolution utilities.
|
||||
* This file contains functions that require server-side dependencies (database access).
|
||||
* Do NOT import this file in client components.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
|
||||
import type { McpServerConfig } from '@/lib/mcp/types'
|
||||
import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
|
||||
|
||||
const logger = createLogger('McpResolveConfig')
|
||||
|
||||
export interface ResolveMcpConfigOptions {
|
||||
/** If true, throws an error when env vars are missing. Default: true */
|
||||
strict?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve environment variables in MCP server config (url, headers).
|
||||
* Shared utility used by both MCP service and test-connection endpoint.
|
||||
*
|
||||
* @param config - MCP server config with potential {{ENV_VAR}} patterns
|
||||
* @param userId - User ID to fetch environment variables for
|
||||
* @param workspaceId - Workspace ID for workspace-specific env vars
|
||||
* @param options - Resolution options (strict mode throws on missing vars)
|
||||
* @returns Resolved config with env vars replaced
|
||||
*/
|
||||
export async function resolveMcpConfigEnvVars(
|
||||
config: McpServerConfig,
|
||||
userId: string,
|
||||
workspaceId?: string,
|
||||
options: ResolveMcpConfigOptions = {}
|
||||
): Promise<{ config: McpServerConfig; missingVars: string[] }> {
|
||||
const { strict = true } = options
|
||||
const allMissingVars: string[] = []
|
||||
|
||||
let envVars: Record<string, string> = {}
|
||||
try {
|
||||
envVars = await getEffectiveDecryptedEnv(userId, workspaceId)
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch environment variables for MCP config:', error)
|
||||
return { config, missingVars: [] }
|
||||
}
|
||||
|
||||
const resolveValue = (value: string): string => {
|
||||
const missingVars: string[] = []
|
||||
const resolved = resolveEnvVarReferences(value, envVars, {
|
||||
missingKeys: missingVars,
|
||||
}) as string
|
||||
allMissingVars.push(...missingVars)
|
||||
return resolved
|
||||
}
|
||||
|
||||
const resolvedConfig = { ...config }
|
||||
|
||||
if (resolvedConfig.url) {
|
||||
resolvedConfig.url = resolveValue(resolvedConfig.url)
|
||||
}
|
||||
|
||||
if (resolvedConfig.headers) {
|
||||
const resolvedHeaders: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(resolvedConfig.headers)) {
|
||||
resolvedHeaders[key] = resolveValue(value)
|
||||
}
|
||||
resolvedConfig.headers = resolvedHeaders
|
||||
}
|
||||
|
||||
// Handle missing vars based on strict mode
|
||||
if (allMissingVars.length > 0) {
|
||||
const uniqueMissing = Array.from(new Set(allMissingVars))
|
||||
|
||||
if (strict) {
|
||||
throw new Error(
|
||||
`Missing required environment variable${uniqueMissing.length > 1 ? 's' : ''}: ${uniqueMissing.join(', ')}. ` +
|
||||
`Please set ${uniqueMissing.length > 1 ? 'these variables' : 'this variable'} in your workspace or personal environment settings.`
|
||||
)
|
||||
}
|
||||
uniqueMissing.forEach((envKey) => {
|
||||
logger.warn(`Environment variable "${envKey}" not found in MCP config`)
|
||||
})
|
||||
}
|
||||
|
||||
return { config: resolvedConfig, missingVars: allMissingVars }
|
||||
}
|
||||
@@ -8,8 +8,8 @@ import { createLogger } from '@sim/logger'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { isTest } from '@/lib/core/config/feature-flags'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
|
||||
import { McpClient } from '@/lib/mcp/client'
|
||||
import { resolveMcpConfigEnvVars } from '@/lib/mcp/resolve-config'
|
||||
import {
|
||||
createMcpCacheAdapter,
|
||||
getMcpCacheType,
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
McpTransport,
|
||||
} from '@/lib/mcp/types'
|
||||
import { MCP_CONSTANTS } from '@/lib/mcp/utils'
|
||||
import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
|
||||
|
||||
const logger = createLogger('McpService')
|
||||
|
||||
@@ -46,18 +47,60 @@ class McpService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve environment variables in server config.
|
||||
* Uses shared utility with strict mode (throws on missing vars).
|
||||
* Resolve environment variables in strings
|
||||
*/
|
||||
private resolveEnvVars(value: string, envVars: Record<string, string>): string {
|
||||
const missingVars: string[] = []
|
||||
const resolvedValue = resolveEnvVarReferences(value, envVars, {
|
||||
allowEmbedded: true,
|
||||
resolveExactMatch: true,
|
||||
trimKeys: true,
|
||||
onMissing: 'keep',
|
||||
deep: false,
|
||||
missingKeys: missingVars,
|
||||
}) as string
|
||||
|
||||
if (missingVars.length > 0) {
|
||||
const uniqueMissing = Array.from(new Set(missingVars))
|
||||
throw new Error(
|
||||
`Missing required environment variable${uniqueMissing.length > 1 ? 's' : ''}: ${uniqueMissing.join(', ')}. ` +
|
||||
`Please set ${uniqueMissing.length > 1 ? 'these variables' : 'this variable'} in your workspace or personal environment settings.`
|
||||
)
|
||||
}
|
||||
|
||||
return resolvedValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve environment variables in server config
|
||||
*/
|
||||
private async resolveConfigEnvVars(
|
||||
config: McpServerConfig,
|
||||
userId: string,
|
||||
workspaceId?: string
|
||||
): Promise<McpServerConfig> {
|
||||
const { config: resolvedConfig } = await resolveMcpConfigEnvVars(config, userId, workspaceId, {
|
||||
strict: true,
|
||||
})
|
||||
return resolvedConfig
|
||||
try {
|
||||
const envVars = await getEffectiveDecryptedEnv(userId, workspaceId)
|
||||
|
||||
const resolvedConfig = { ...config }
|
||||
|
||||
if (resolvedConfig.url) {
|
||||
resolvedConfig.url = this.resolveEnvVars(resolvedConfig.url, envVars)
|
||||
}
|
||||
|
||||
if (resolvedConfig.headers) {
|
||||
const resolvedHeaders: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(resolvedConfig.headers)) {
|
||||
resolvedHeaders[key] = this.resolveEnvVars(value, envVars)
|
||||
}
|
||||
resolvedConfig.headers = resolvedHeaders
|
||||
}
|
||||
|
||||
return resolvedConfig
|
||||
} catch (error) {
|
||||
logger.error('Failed to resolve environment variables for MCP server config:', error)
|
||||
return config
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* Shared MCP utilities - safe for both client and server.
|
||||
* No server-side dependencies (database, fs, etc.) should be imported here.
|
||||
*/
|
||||
|
||||
import { isMcpTool, MCP } from '@/executor/constants'
|
||||
|
||||
/**
|
||||
* Sanitizes a string by removing invisible Unicode characters that cause HTTP header errors.
|
||||
* Handles characters like U+2028 (Line Separator) that can be introduced via copy-paste.
|
||||
*/
|
||||
export function sanitizeForHttp(value: string): string {
|
||||
return value
|
||||
.replace(/[\u2028\u2029\u200B-\u200D\uFEFF]/g, '')
|
||||
.replace(/[\x00-\x1F\x7F]/g, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes all header key-value pairs for HTTP usage.
|
||||
*/
|
||||
export function sanitizeHeaders(
|
||||
headers: Record<string, string> | undefined
|
||||
): Record<string, string> | undefined {
|
||||
if (!headers) return headers
|
||||
return Object.fromEntries(
|
||||
Object.entries(headers)
|
||||
.map(([key, value]) => [sanitizeForHttp(key), sanitizeForHttp(value)])
|
||||
.filter(([key, value]) => key !== '' && value !== '')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-safe MCP constants
|
||||
*/
|
||||
export const MCP_CLIENT_CONSTANTS = {
|
||||
CLIENT_TIMEOUT: 60000,
|
||||
MAX_RETRIES: 3,
|
||||
RECONNECT_DELAY: 1000,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Create standardized MCP tool ID from server ID and tool name
|
||||
*/
|
||||
export function createMcpToolId(serverId: string, toolName: string): string {
|
||||
const normalizedServerId = isMcpTool(serverId) ? serverId : `${MCP.TOOL_PREFIX}${serverId}`
|
||||
return `${normalizedServerId}-${toolName}`
|
||||
}
|
||||
@@ -1,22 +1,72 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
|
||||
import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
|
||||
import { extractEnvVarName, isEnvVarReference } from '@/executor/constants'
|
||||
|
||||
const logger = createLogger('EnvResolver')
|
||||
|
||||
/**
|
||||
* Recursively resolves all environment variable references in a configuration object.
|
||||
* Supports both exact matches (`{{VAR_NAME}}`) and embedded patterns (`https://{{HOST}}/path`).
|
||||
* Resolves environment variable references in a string value
|
||||
* Uses the same helper functions as the executor's EnvResolver
|
||||
*
|
||||
* Uses `deep: true` because webhook configs have nested structures that need full resolution.
|
||||
* @param value - The string that may contain env var references
|
||||
* @param envVars - Object containing environment variable key-value pairs
|
||||
* @returns The resolved string with env vars replaced
|
||||
*/
|
||||
function resolveEnvVarInString(value: string, envVars: Record<string, string>): string {
|
||||
if (!isEnvVarReference(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
const varName = extractEnvVarName(value)
|
||||
const resolvedValue = envVars[varName]
|
||||
|
||||
if (resolvedValue === undefined) {
|
||||
logger.warn(`Environment variable not found: ${varName}`)
|
||||
return value // Return original if not found
|
||||
}
|
||||
|
||||
logger.debug(`Resolved environment variable: ${varName}`)
|
||||
return resolvedValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively resolves all environment variable references in a configuration object
|
||||
* Supports the pattern: {{VAR_NAME}}
|
||||
*
|
||||
* @param config - Configuration object that may contain env var references
|
||||
* @param userId - User ID to fetch environment variables for
|
||||
* @param workspaceId - Optional workspace ID for workspace-specific env vars
|
||||
* @returns A new object with all env var references resolved
|
||||
*/
|
||||
export async function resolveEnvVarsInObject<T extends Record<string, unknown>>(
|
||||
config: T,
|
||||
export async function resolveEnvVarsInObject(
|
||||
config: Record<string, any>,
|
||||
userId: string,
|
||||
workspaceId?: string
|
||||
): Promise<T> {
|
||||
): Promise<Record<string, any>> {
|
||||
const envVars = await getEffectiveDecryptedEnv(userId, workspaceId)
|
||||
return resolveEnvVarReferences(config, envVars, { deep: true }) as T
|
||||
|
||||
const resolved = { ...config }
|
||||
|
||||
function resolveValue(value: any): any {
|
||||
if (typeof value === 'string') {
|
||||
return resolveEnvVarInString(value, envVars)
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(resolveValue)
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const resolvedObj: Record<string, any> = {}
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
resolvedObj[key] = resolveValue(val)
|
||||
}
|
||||
return resolvedObj
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(resolved)) {
|
||||
resolved[key] = resolveValue(value)
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
@@ -413,7 +413,13 @@ export async function findAllWebhooksForPath(
|
||||
* @returns String with all {{VARIABLE}} references replaced
|
||||
*/
|
||||
function resolveEnvVars(value: string, envVars: Record<string, string>): string {
|
||||
return resolveEnvVarReferences(value, envVars) as string
|
||||
return resolveEnvVarReferences(value, envVars, {
|
||||
allowEmbedded: true,
|
||||
resolveExactMatch: true,
|
||||
trimKeys: true,
|
||||
onMissing: 'keep',
|
||||
deep: false,
|
||||
}) as string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -798,6 +804,7 @@ export async function checkWebhookPreprocessing(
|
||||
checkRateLimit: true,
|
||||
checkDeployment: true,
|
||||
workspaceId: foundWorkflow.workspaceId,
|
||||
preflightEnvVars: isTriggerDevEnabled,
|
||||
})
|
||||
|
||||
if (!preprocessResult.success) {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { Edge } from 'reactflow'
|
||||
import { z } from 'zod'
|
||||
import { parseResponseFormatSafely } from '@/lib/core/utils/response-format'
|
||||
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
|
||||
import { clearExecutionCancellation } from '@/lib/execution/cancellation'
|
||||
import type { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
@@ -24,6 +25,7 @@ import type {
|
||||
IterationContext,
|
||||
} from '@/executor/execution/types'
|
||||
import type { ExecutionResult, NormalizedBlockOutput } from '@/executor/types'
|
||||
import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
|
||||
import { Serializer } from '@/serializer'
|
||||
import { mergeSubblockState } from '@/stores/workflows/server-utils'
|
||||
|
||||
@@ -200,6 +202,50 @@ export async function executeWorkflowCore(
|
||||
deploymentVersionId,
|
||||
})
|
||||
|
||||
// Process block states with env var substitution using pre-decrypted values
|
||||
const currentBlockStates = Object.entries(mergedStates).reduce(
|
||||
(acc, [id, block]) => {
|
||||
acc[id] = Object.entries(block.subBlocks).reduce(
|
||||
(subAcc, [key, subBlock]) => {
|
||||
let value = subBlock.value
|
||||
|
||||
if (typeof value === 'string') {
|
||||
value = resolveEnvVarReferences(value, decryptedEnvVars, {
|
||||
resolveExactMatch: false,
|
||||
trimKeys: false,
|
||||
onMissing: 'keep',
|
||||
deep: false,
|
||||
}) as string
|
||||
}
|
||||
|
||||
subAcc[key] = value
|
||||
return subAcc
|
||||
},
|
||||
{} as Record<string, any>
|
||||
)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, Record<string, any>>
|
||||
)
|
||||
|
||||
// Process response format
|
||||
const processedBlockStates = Object.entries(currentBlockStates).reduce(
|
||||
(acc, [blockId, blockState]) => {
|
||||
const responseFormatValue = blockState.responseFormat
|
||||
if (responseFormatValue === undefined || responseFormatValue === null) {
|
||||
acc[blockId] = blockState
|
||||
return acc
|
||||
}
|
||||
|
||||
const responseFormat = parseResponseFormatSafely(responseFormatValue, blockId, {
|
||||
allowReferences: true,
|
||||
})
|
||||
acc[blockId] = { ...blockState, responseFormat: responseFormat ?? undefined }
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, Record<string, any>>
|
||||
)
|
||||
|
||||
// Use edges directly - trigger-to-trigger edges are prevented at creation time
|
||||
const filteredEdges = edges
|
||||
|
||||
@@ -300,6 +346,7 @@ export async function executeWorkflowCore(
|
||||
|
||||
const executorInstance = new Executor({
|
||||
workflow: serializedWorkflow,
|
||||
currentBlockStates: processedBlockStates,
|
||||
envVarValues: decryptedEnvVars,
|
||||
workflowInput: processedInput,
|
||||
workflowVariables,
|
||||
|
||||
51
apps/sim/lib/workflows/executor/preflight.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import {
|
||||
ensureBlockEnvVarsResolvable,
|
||||
ensureEnvVarsDecryptable,
|
||||
getPersonalAndWorkspaceEnv,
|
||||
} from '@/lib/environment/utils'
|
||||
import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils'
|
||||
import { mergeSubblockState } from '@/stores/workflows/server-utils'
|
||||
|
||||
const logger = createLogger('ExecutionPreflight')
|
||||
|
||||
export interface EnvVarPreflightOptions {
|
||||
workflowId: string
|
||||
workspaceId: string
|
||||
envUserId: string
|
||||
requestId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Preflight env var checks to avoid scheduling executions that will fail.
|
||||
* Always uses deployed workflow state since preflight is only done for async
|
||||
* executions which always run on deployed state.
|
||||
*/
|
||||
export async function preflightWorkflowEnvVars({
|
||||
workflowId,
|
||||
workspaceId,
|
||||
envUserId,
|
||||
requestId,
|
||||
}: EnvVarPreflightOptions): Promise<void> {
|
||||
const workflowData = await loadDeployedWorkflowState(workflowId)
|
||||
|
||||
if (!workflowData) {
|
||||
throw new Error('Workflow state not found')
|
||||
}
|
||||
|
||||
const mergedStates = mergeSubblockState(workflowData.blocks)
|
||||
const { personalEncrypted, workspaceEncrypted } = await getPersonalAndWorkspaceEnv(
|
||||
envUserId,
|
||||
workspaceId
|
||||
)
|
||||
const variables = { ...personalEncrypted, ...workspaceEncrypted }
|
||||
|
||||
await ensureBlockEnvVarsResolvable(mergedStates, variables, { requestId })
|
||||
await ensureEnvVarsDecryptable(variables, { requestId })
|
||||
|
||||
if (requestId) {
|
||||
logger.debug(`[${requestId}] Env var preflight passed`, { workflowId })
|
||||
} else {
|
||||
logger.debug('Env var preflight passed', { workflowId })
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,6 @@
|
||||
"jose": "6.0.11",
|
||||
"js-tiktoken": "1.0.21",
|
||||
"js-yaml": "4.1.0",
|
||||
"json5": "2.2.3",
|
||||
"jszip": "3.10.1",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lodash": "4.17.21",
|
||||
@@ -138,6 +137,7 @@
|
||||
"posthog-node": "5.9.2",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "19.2.1",
|
||||
"react-colorful": "5.6.1",
|
||||
"react-dom": "19.2.1",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
||||
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 222 KiB |
|
Before Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 50 KiB |
@@ -132,6 +132,7 @@ describe('Serializer', () => {
|
||||
expect(agentBlock?.metadata?.id).toBe('agent')
|
||||
expect(agentBlock?.config.tool).toBe('openai')
|
||||
expect(agentBlock?.config.params.model).toBe('gpt-4o')
|
||||
expect(agentBlock?.outputs.responseFormat).toBeDefined()
|
||||
})
|
||||
|
||||
it.concurrent('should serialize agent block with custom tools correctly', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { Edge } from 'reactflow'
|
||||
import { parseResponseFormatSafely } from '@/lib/core/utils/response-format'
|
||||
import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator'
|
||||
import {
|
||||
buildCanonicalIndex,
|
||||
@@ -274,6 +275,15 @@ export class Serializer {
|
||||
inputs,
|
||||
outputs: {
|
||||
...block.outputs,
|
||||
// Include response format fields if available
|
||||
...(params.responseFormat
|
||||
? {
|
||||
responseFormat:
|
||||
parseResponseFormatSafely(params.responseFormat, block.id, {
|
||||
allowReferences: true,
|
||||
}) ?? undefined,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
metadata: {
|
||||
id: block.type,
|
||||
|
||||
@@ -257,6 +257,148 @@ describe('Serializer Extended Tests', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseResponseFormatSafely edge cases', () => {
|
||||
it('should handle null responseFormat', () => {
|
||||
const serializer = new Serializer()
|
||||
const block: BlockState = {
|
||||
id: 'agent-1',
|
||||
type: 'agent',
|
||||
name: 'Agent',
|
||||
position: { x: 0, y: 0 },
|
||||
subBlocks: {
|
||||
model: { id: 'model', type: 'dropdown', value: 'gpt-4o' },
|
||||
prompt: { id: 'prompt', type: 'long-input', value: 'Test' },
|
||||
responseFormat: { id: 'responseFormat', type: 'code', value: null },
|
||||
},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const serialized = serializer.serializeWorkflow({ 'agent-1': block }, [], {})
|
||||
const agentBlock = serialized.blocks.find((b) => b.id === 'agent-1')
|
||||
|
||||
expect(agentBlock?.outputs.responseFormat).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should handle empty string responseFormat', () => {
|
||||
const serializer = new Serializer()
|
||||
const block: BlockState = {
|
||||
id: 'agent-1',
|
||||
type: 'agent',
|
||||
name: 'Agent',
|
||||
position: { x: 0, y: 0 },
|
||||
subBlocks: {
|
||||
model: { id: 'model', type: 'dropdown', value: 'gpt-4o' },
|
||||
prompt: { id: 'prompt', type: 'long-input', value: 'Test' },
|
||||
responseFormat: { id: 'responseFormat', type: 'code', value: ' ' },
|
||||
},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const serialized = serializer.serializeWorkflow({ 'agent-1': block }, [], {})
|
||||
const agentBlock = serialized.blocks.find((b) => b.id === 'agent-1')
|
||||
|
||||
expect(agentBlock?.outputs.responseFormat).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should handle variable reference in responseFormat', () => {
|
||||
const serializer = new Serializer()
|
||||
const block: BlockState = {
|
||||
id: 'agent-1',
|
||||
type: 'agent',
|
||||
name: 'Agent',
|
||||
position: { x: 0, y: 0 },
|
||||
subBlocks: {
|
||||
model: { id: 'model', type: 'dropdown', value: 'gpt-4o' },
|
||||
prompt: { id: 'prompt', type: 'long-input', value: 'Test' },
|
||||
responseFormat: { id: 'responseFormat', type: 'code', value: '<start.schema>' },
|
||||
},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const serialized = serializer.serializeWorkflow({ 'agent-1': block }, [], {})
|
||||
const agentBlock = serialized.blocks.find((b) => b.id === 'agent-1')
|
||||
|
||||
expect(agentBlock?.outputs.responseFormat).toBe('<start.schema>')
|
||||
})
|
||||
|
||||
it('should handle object responseFormat', () => {
|
||||
const serializer = new Serializer()
|
||||
const schemaObject = { type: 'object', properties: { name: { type: 'string' } } }
|
||||
const block: BlockState = {
|
||||
id: 'agent-1',
|
||||
type: 'agent',
|
||||
name: 'Agent',
|
||||
position: { x: 0, y: 0 },
|
||||
subBlocks: {
|
||||
model: { id: 'model', type: 'dropdown', value: 'gpt-4o' },
|
||||
prompt: { id: 'prompt', type: 'long-input', value: 'Test' },
|
||||
responseFormat: { id: 'responseFormat', type: 'code', value: schemaObject as any },
|
||||
},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const serialized = serializer.serializeWorkflow({ 'agent-1': block }, [], {})
|
||||
const agentBlock = serialized.blocks.find((b) => b.id === 'agent-1')
|
||||
|
||||
expect(agentBlock?.outputs.responseFormat).toEqual(schemaObject)
|
||||
})
|
||||
|
||||
it('should handle invalid JSON responseFormat gracefully', () => {
|
||||
const serializer = new Serializer()
|
||||
const block: BlockState = {
|
||||
id: 'agent-1',
|
||||
type: 'agent',
|
||||
name: 'Agent',
|
||||
position: { x: 0, y: 0 },
|
||||
subBlocks: {
|
||||
model: { id: 'model', type: 'dropdown', value: 'gpt-4o' },
|
||||
prompt: { id: 'prompt', type: 'long-input', value: 'Test' },
|
||||
responseFormat: { id: 'responseFormat', type: 'code', value: '{invalid json}' },
|
||||
},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const serialized = serializer.serializeWorkflow({ 'agent-1': block }, [], {})
|
||||
const agentBlock = serialized.blocks.find((b) => b.id === 'agent-1')
|
||||
|
||||
expect(agentBlock?.outputs.responseFormat).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should parse valid JSON responseFormat', () => {
|
||||
const serializer = new Serializer()
|
||||
const block: BlockState = {
|
||||
id: 'agent-1',
|
||||
type: 'agent',
|
||||
name: 'Agent',
|
||||
position: { x: 0, y: 0 },
|
||||
subBlocks: {
|
||||
model: { id: 'model', type: 'dropdown', value: 'gpt-4o' },
|
||||
prompt: { id: 'prompt', type: 'long-input', value: 'Test' },
|
||||
responseFormat: {
|
||||
id: 'responseFormat',
|
||||
type: 'code',
|
||||
value: '{"type":"object","properties":{"result":{"type":"string"}}}',
|
||||
},
|
||||
},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const serialized = serializer.serializeWorkflow({ 'agent-1': block }, [], {})
|
||||
const agentBlock = serialized.blocks.find((b) => b.id === 'agent-1')
|
||||
|
||||
expect(agentBlock?.outputs.responseFormat).toEqual({
|
||||
type: 'object',
|
||||
properties: { result: { type: 'string' } },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('subflow block serialization', () => {
|
||||
it('should serialize loop blocks correctly', () => {
|
||||
const serializer = new Serializer()
|
||||
|
||||
@@ -2737,16 +2737,11 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
}))
|
||||
}
|
||||
|
||||
get()
|
||||
.loadSensitiveCredentialIds()
|
||||
.catch((err) => {
|
||||
logger.warn('[Copilot] Failed to load sensitive credential IDs', err)
|
||||
})
|
||||
get()
|
||||
.loadAutoAllowedTools()
|
||||
.catch((err) => {
|
||||
logger.warn('[Copilot] Failed to load auto-allowed tools', err)
|
||||
})
|
||||
// Load sensitive credential IDs for masking before streaming starts
|
||||
await get().loadSensitiveCredentialIds()
|
||||
|
||||
// Ensure auto-allowed tools are loaded before tool calls arrive
|
||||
await get().loadAutoAllowedTools()
|
||||
|
||||
let newMessages: CopilotMessage[]
|
||||
if (revertState) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import JSON5 from 'json5'
|
||||
import { create } from 'zustand'
|
||||
import { devtools } from 'zustand/middleware'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
@@ -31,7 +30,7 @@ function validateVariable(variable: Variable): string | undefined {
|
||||
return 'Not a valid object format'
|
||||
}
|
||||
|
||||
const parsed = JSON5.parse(valueToEvaluate)
|
||||
const parsed = new Function(`return ${valueToEvaluate}`)()
|
||||
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return 'Not a valid object'
|
||||
@@ -44,12 +43,12 @@ function validateVariable(variable: Variable): string | undefined {
|
||||
}
|
||||
case 'array':
|
||||
try {
|
||||
const parsed = JSON5.parse(String(variable.value))
|
||||
const parsed = JSON.parse(String(variable.value))
|
||||
if (!Array.isArray(parsed)) {
|
||||
return 'Not a valid array'
|
||||
return 'Not a valid JSON array'
|
||||
}
|
||||
} catch {
|
||||
return 'Invalid array syntax'
|
||||
return 'Invalid JSON array syntax'
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||