diff --git a/apps/sim/app/api/copilot/auto-allowed-tools/route.ts b/apps/sim/app/api/copilot/auto-allowed-tools/route.ts index ecf6aa7f7..a97bede5d 100644 --- a/apps/sim/app/api/copilot/auto-allowed-tools/route.ts +++ b/apps/sim/app/api/copilot/auto-allowed-tools/route.ts @@ -1,145 +1,81 @@ -import { db } from '@sim/db' -import { settings } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' +import { SIM_AGENT_API_URL } from '@/lib/copilot/constants' +import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request-helpers' +import { env } from '@/lib/core/config/env' const logger = createLogger('CopilotAutoAllowedToolsAPI') -/** - * GET - Fetch user's auto-allowed integration tools - */ -export async function GET() { - try { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const userId = session.user.id - - const [userSettings] = await db - .select() - .from(settings) - .where(eq(settings.userId, userId)) - .limit(1) - - if (userSettings) { - const autoAllowedTools = (userSettings.copilotAutoAllowedTools as string[]) || [] - return NextResponse.json({ autoAllowedTools }) - } - - await db.insert(settings).values({ - id: userId, - userId, - copilotAutoAllowedTools: [], - }) - - return NextResponse.json({ autoAllowedTools: [] }) - } catch (error) { - logger.error('Failed to fetch auto-allowed tools', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) +function copilotHeaders(): HeadersInit { + const headers: Record = { + 'Content-Type': 'application/json', } + if (env.COPILOT_API_KEY) { + headers['x-api-key'] = env.COPILOT_API_KEY + } + return headers } -/** - * POST - Add a tool to the auto-allowed list - */ -export async function POST(request: NextRequest) { - try { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const userId = session.user.id - const body = await request.json() - - if (!body.toolId || typeof body.toolId !== 'string') { - return NextResponse.json({ error: 'toolId must be a string' }, { status: 400 }) - } - - const toolId = body.toolId - - const [existing] = await db.select().from(settings).where(eq(settings.userId, userId)).limit(1) - - if (existing) { - const currentTools = (existing.copilotAutoAllowedTools as string[]) || [] - - if (!currentTools.includes(toolId)) { - const updatedTools = [...currentTools, toolId] - await db - .update(settings) - .set({ - copilotAutoAllowedTools: updatedTools, - updatedAt: new Date(), - }) - .where(eq(settings.userId, userId)) - - logger.info('Added tool to auto-allowed list', { userId, toolId }) - return NextResponse.json({ success: true, autoAllowedTools: updatedTools }) - } - - return NextResponse.json({ success: true, autoAllowedTools: currentTools }) - } - - await db.insert(settings).values({ - id: userId, - userId, - copilotAutoAllowedTools: [toolId], - }) - - logger.info('Created settings and added tool to auto-allowed list', { userId, toolId }) - return NextResponse.json({ success: true, autoAllowedTools: [toolId] }) - } catch (error) { - logger.error('Failed to add auto-allowed tool', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} - -/** - * DELETE - Remove a tool from the auto-allowed list - */ export async function DELETE(request: NextRequest) { + const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() + if (!isAuthenticated || !userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const toolIdFromQuery = new URL(request.url).searchParams.get('toolId') || undefined + const toolIdFromBody = await request + .json() + .then((body) => (typeof body?.toolId === 'string' ? body.toolId : undefined)) + .catch(() => undefined) + const toolId = toolIdFromBody || toolIdFromQuery + if (!toolId) { + return NextResponse.json({ error: 'toolId is required' }, { status: 400 }) + } + try { - const session = await getSession() + const res = await fetch(`${SIM_AGENT_API_URL}/api/tool-preferences/auto-allowed`, { + method: 'DELETE', + headers: copilotHeaders(), + body: JSON.stringify({ + userId, + toolId, + }), + }) - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const payload = await res.json().catch(() => ({})) + if (!res.ok) { + logger.warn('Failed to remove auto-allowed tool via copilot backend', { + status: res.status, + userId, + toolId, + }) + return NextResponse.json( + { + success: false, + error: payload?.error || 'Failed to remove auto-allowed tool', + autoAllowedTools: [], + }, + { status: res.status } + ) } - const userId = session.user.id - const { searchParams } = new URL(request.url) - const toolId = searchParams.get('toolId') - - if (!toolId) { - return NextResponse.json({ error: 'toolId query parameter is required' }, { status: 400 }) - } - - const [existing] = await db.select().from(settings).where(eq(settings.userId, userId)).limit(1) - - if (existing) { - const currentTools = (existing.copilotAutoAllowedTools as string[]) || [] - const updatedTools = currentTools.filter((t) => t !== toolId) - - await db - .update(settings) - .set({ - copilotAutoAllowedTools: updatedTools, - updatedAt: new Date(), - }) - .where(eq(settings.userId, userId)) - - logger.info('Removed tool from auto-allowed list', { userId, toolId }) - return NextResponse.json({ success: true, autoAllowedTools: updatedTools }) - } - - return NextResponse.json({ success: true, autoAllowedTools: [] }) + return NextResponse.json({ + success: true, + autoAllowedTools: Array.isArray(payload?.autoAllowedTools) ? payload.autoAllowedTools : [], + }) } catch (error) { - logger.error('Failed to remove auto-allowed tool', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + logger.error('Error removing auto-allowed tool', { + userId, + toolId, + error: error instanceof Error ? error.message : String(error), + }) + return NextResponse.json( + { + success: false, + error: 'Failed to remove auto-allowed tool', + autoAllowedTools: [], + }, + { status: 500 } + ) } } diff --git a/apps/sim/app/api/copilot/confirm/route.ts b/apps/sim/app/api/copilot/confirm/route.ts index eb63b7524..14562e6a1 100644 --- a/apps/sim/app/api/copilot/confirm/route.ts +++ b/apps/sim/app/api/copilot/confirm/route.ts @@ -1,7 +1,11 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { REDIS_TOOL_CALL_PREFIX, REDIS_TOOL_CALL_TTL_SECONDS } from '@/lib/copilot/constants' +import { + REDIS_TOOL_CALL_PREFIX, + REDIS_TOOL_CALL_TTL_SECONDS, + SIM_AGENT_API_URL, +} from '@/lib/copilot/constants' import { authenticateCopilotRequestSessionOnly, createBadRequestResponse, @@ -10,6 +14,7 @@ import { createUnauthorizedResponse, type NotificationStatus, } from '@/lib/copilot/request-helpers' +import { env } from '@/lib/core/config/env' import { getRedisClient } from '@/lib/core/config/redis' const logger = createLogger('CopilotConfirmAPI') @@ -21,6 +26,8 @@ const ConfirmationSchema = z.object({ errorMap: () => ({ message: 'Invalid notification status' }), }), message: z.string().optional(), // Optional message for background moves or additional context + toolName: z.string().optional(), + remember: z.boolean().optional(), }) /** @@ -57,6 +64,44 @@ async function updateToolCallStatus( } } +async function saveAutoAllowedToolPreference(userId: string, toolName: string): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + } + if (env.COPILOT_API_KEY) { + headers['x-api-key'] = env.COPILOT_API_KEY + } + + try { + const response = await fetch(`${SIM_AGENT_API_URL}/api/tool-preferences/auto-allowed`, { + method: 'POST', + headers, + body: JSON.stringify({ + userId, + toolId: toolName, + }), + }) + + if (!response.ok) { + logger.warn('Failed to persist auto-allowed tool preference', { + userId, + toolName, + status: response.status, + }) + return false + } + + return true + } catch (error) { + logger.error('Error persisting auto-allowed tool preference', { + userId, + toolName, + error: error instanceof Error ? error.message : String(error), + }) + return false + } +} + /** * POST /api/copilot/confirm * Update tool call status (Accept/Reject) @@ -74,7 +119,7 @@ export async function POST(req: NextRequest) { } const body = await req.json() - const { toolCallId, status, message } = ConfirmationSchema.parse(body) + const { toolCallId, status, message, toolName, remember } = ConfirmationSchema.parse(body) // Update the tool call status in Redis const updated = await updateToolCallStatus(toolCallId, status, message) @@ -90,14 +135,22 @@ export async function POST(req: NextRequest) { return createBadRequestResponse('Failed to update tool call status or tool call not found') } - const duration = tracker.getDuration() + let rememberSaved = false + if (status === 'accepted' && remember === true && toolName && authenticatedUserId) { + rememberSaved = await saveAutoAllowedToolPreference(authenticatedUserId, toolName) + } - return NextResponse.json({ + const response: Record = { success: true, message: message || `Tool call ${toolCallId} has been ${status.toLowerCase()}`, toolCallId, status, - }) + } + if (remember === true) { + response.rememberSaved = rememberSaved + } + + return NextResponse.json(response) } catch (error) { const duration = tracker.getDuration() diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx index e4dcc3fca..9587639e3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx @@ -1243,11 +1243,6 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({ ) }) -/** Checks if a tool is server-side executed (not a client tool) */ -function isIntegrationTool(toolName: string): boolean { - return !TOOL_DISPLAY_REGISTRY[toolName] -} - function shouldShowRunSkipButtons(toolCall: CopilotToolCall): boolean { if (!toolCall.name || toolCall.name === 'unknown_tool') { return false @@ -1257,59 +1252,96 @@ function shouldShowRunSkipButtons(toolCall: CopilotToolCall): boolean { return false } - // Never show buttons for tools the user has marked as always-allowed - if (useCopilotStore.getState().isToolAutoAllowed(toolCall.name)) { + if (toolCall.ui?.showInterrupt !== true) { return false } - const hasInterrupt = !!TOOL_DISPLAY_REGISTRY[toolCall.name]?.uiConfig?.interrupt - if (hasInterrupt) { - return true - } - - // Integration tools (user-installed) always require approval - if (isIntegrationTool(toolCall.name)) { - return true - } - - return false + return true } const toolCallLogger = createLogger('CopilotToolCall') async function sendToolDecision( toolCallId: string, - status: 'accepted' | 'rejected' | 'background' + status: 'accepted' | 'rejected' | 'background', + options?: { + toolName?: string + remember?: boolean + } ) { try { await fetch('/api/copilot/confirm', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ toolCallId, status }), + body: JSON.stringify({ + toolCallId, + status, + ...(options?.toolName ? { toolName: options.toolName } : {}), + ...(options?.remember ? { remember: true } : {}), + }), }) } catch (error) { toolCallLogger.warn('Failed to send tool decision', { toolCallId, status, + remember: options?.remember === true, + toolName: options?.toolName, error: error instanceof Error ? error.message : String(error), }) } } +async function removeAutoAllowedToolPreference(toolName: string): Promise { + try { + const response = await fetch(`/api/copilot/auto-allowed-tools?toolId=${encodeURIComponent(toolName)}`, { + method: 'DELETE', + }) + return response.ok + } catch (error) { + toolCallLogger.warn('Failed to remove auto-allowed tool preference', { + toolName, + error: error instanceof Error ? error.message : String(error), + }) + return false + } +} + +type ToolUiAction = NonNullable['actions']>[number] + +function actionDecision(action: ToolUiAction): 'accepted' | 'rejected' | 'background' { + const id = action.id.toLowerCase() + if (id.includes('background')) return 'background' + if (action.kind === 'reject') return 'rejected' + return 'accepted' +} + +function isClientRunCapability(toolCall: CopilotToolCall): boolean { + if (toolCall.execution?.target === 'sim_client_capability') { + return toolCall.execution.capabilityId === 'workflow.run' || !toolCall.execution.capabilityId + } + return CLIENT_EXECUTABLE_RUN_TOOLS.has(toolCall.name) +} + async function handleRun( toolCall: CopilotToolCall, setToolCallState: any, onStateChange?: any, - editedParams?: any + editedParams?: any, + options?: { + remember?: boolean + } ) { setToolCallState(toolCall, 'executing', editedParams ? { params: editedParams } : undefined) onStateChange?.('executing') - await sendToolDecision(toolCall.id, 'accepted') + await sendToolDecision(toolCall.id, 'accepted', { + toolName: toolCall.name, + remember: options?.remember === true, + }) // Client-executable run tools: execute on the client for real-time feedback // (block pulsing, console logs, stop button). The server defers execution // for these tools; the client reports back via mark-complete. - if (CLIENT_EXECUTABLE_RUN_TOOLS.has(toolCall.name)) { + if (isClientRunCapability(toolCall)) { const params = editedParams || toolCall.params || {} executeRunToolOnClient(toolCall.id, toolCall.name, params) } @@ -1322,6 +1354,9 @@ async function handleSkip(toolCall: CopilotToolCall, setToolCallState: any, onSt } function getDisplayName(toolCall: CopilotToolCall): string { + if (toolCall.ui?.phaseLabel) return toolCall.ui.phaseLabel + if (toolCall.ui?.title) return `${getStateVerb(toolCall.state)} ${toolCall.ui.title}` + const fromStore = (toolCall as any).display?.text if (fromStore) return fromStore const registryEntry = TOOL_DISPLAY_REGISTRY[toolCall.name] @@ -1366,53 +1401,37 @@ function RunSkipButtons({ toolCall, onStateChange, editedParams, + actions, }: { toolCall: CopilotToolCall onStateChange?: (state: any) => void editedParams?: any + actions: ToolUiAction[] }) { const [isProcessing, setIsProcessing] = useState(false) const [buttonsHidden, setButtonsHidden] = useState(false) const actionInProgressRef = useRef(false) - const { setToolCallState, addAutoAllowedTool } = useCopilotStore() + const { setToolCallState } = useCopilotStore() - const onRun = async () => { + const onAction = async (action: ToolUiAction) => { // Prevent race condition - check ref synchronously if (actionInProgressRef.current) return actionInProgressRef.current = true setIsProcessing(true) setButtonsHidden(true) try { - await handleRun(toolCall, setToolCallState, onStateChange, editedParams) - } finally { - setIsProcessing(false) - actionInProgressRef.current = false - } - } - - const onAlwaysAllow = async () => { - // Prevent race condition - check ref synchronously - if (actionInProgressRef.current) return - actionInProgressRef.current = true - setIsProcessing(true) - setButtonsHidden(true) - try { - await addAutoAllowedTool(toolCall.name) - await handleRun(toolCall, setToolCallState, onStateChange, editedParams) - } finally { - setIsProcessing(false) - actionInProgressRef.current = false - } - } - - const onSkip = async () => { - // Prevent race condition - check ref synchronously - if (actionInProgressRef.current) return - actionInProgressRef.current = true - setIsProcessing(true) - setButtonsHidden(true) - try { - await handleSkip(toolCall, setToolCallState, onStateChange) + const decision = actionDecision(action) + if (decision === 'accepted') { + await handleRun(toolCall, setToolCallState, onStateChange, editedParams, { + remember: action.remember === true, + }) + } else if (decision === 'rejected') { + await handleSkip(toolCall, setToolCallState, onStateChange) + } else { + setToolCallState(toolCall, ClientToolCallState.background) + onStateChange?.('background') + await sendToolDecision(toolCall.id, 'background') + } } finally { setIsProcessing(false) actionInProgressRef.current = false @@ -1421,23 +1440,22 @@ function RunSkipButtons({ if (buttonsHidden) return null - // Show "Always Allow" for all tools that require confirmation - const showAlwaysAllow = true - - // Standardized buttons for all interrupt tools: Allow, Always Allow, Skip return (
- - {showAlwaysAllow && ( - - )} - + {actions.map((action, index) => { + const variant = + action.kind === 'reject' ? 'default' : action.remember ? 'default' : 'tertiary' + return ( + + ) + })}
) } @@ -1454,10 +1472,16 @@ export function ToolCall({ const liveToolCall = useCopilotStore((s) => effectiveId ? s.toolCallsById[effectiveId] : undefined ) - const toolCall = liveToolCall || toolCallProp - - // Guard: nothing to render without a toolCall - if (!toolCall) return null + const rawToolCall = liveToolCall || toolCallProp + const hasRealToolCall = !!rawToolCall + const toolCall: CopilotToolCall = + rawToolCall || + ({ + id: effectiveId || '', + name: '', + state: ClientToolCallState.generating, + params: {}, + } as CopilotToolCall) const isExpandablePending = toolCall?.state === 'pending' && @@ -1465,17 +1489,15 @@ export function ToolCall({ const [expanded, setExpanded] = useState(isExpandablePending) const [showRemoveAutoAllow, setShowRemoveAutoAllow] = useState(false) + const [autoAllowRemovedForCall, setAutoAllowRemovedForCall] = useState(false) // State for editable parameters const params = (toolCall as any).parameters || (toolCall as any).input || toolCall.params || {} const [editedParams, setEditedParams] = useState(params) const paramsRef = useRef(params) - // Check if this integration tool is auto-allowed - const { removeAutoAllowedTool, setToolCallState } = useCopilotStore() - const isAutoAllowed = useCopilotStore( - (s) => isIntegrationTool(toolCall.name) && s.isToolAutoAllowed(toolCall.name) - ) + const { setToolCallState } = useCopilotStore() + const isAutoAllowed = toolCall.ui?.autoAllowed === true && !autoAllowRemovedForCall // Update edited params when toolCall params change (deep comparison to avoid resetting user edits on ref change) useEffect(() => { @@ -1485,6 +1507,14 @@ export function ToolCall({ } }, [params]) + useEffect(() => { + setAutoAllowRemovedForCall(false) + setShowRemoveAutoAllow(false) + }, [toolCall.id]) + + // Guard: nothing to render without a toolCall + if (!hasRealToolCall) return null + // Skip rendering some internal tools if ( toolCall.name === 'checkoff_todo' || @@ -1496,7 +1526,9 @@ export function ToolCall({ return null // Special rendering for subagent tools - show as thinking text with tool calls at top level - const isSubagentTool = TOOL_DISPLAY_REGISTRY[toolCall.name]?.uiConfig?.subagent === true + const isSubagentTool = + toolCall.execution?.target === 'go_subagent' || + TOOL_DISPLAY_REGISTRY[toolCall.name]?.uiConfig?.subagent === true // For ALL subagent tools, don't show anything until we have blocks with content if (isSubagentTool) { @@ -1523,28 +1555,6 @@ export function ToolCall({ ) } - // Get current mode from store to determine if we should render integration tools - const mode = useCopilotStore.getState().mode - - // Check if this is a completed/historical tool call (not pending/executing) - // Use string comparison to handle both enum values and string values from DB - const stateStr = String(toolCall.state) - const isCompletedToolCall = - stateStr === 'success' || - stateStr === 'error' || - stateStr === 'rejected' || - stateStr === 'aborted' - - // Allow rendering if: - // 1. Tool is in TOOL_DISPLAY_REGISTRY (client tools), OR - // 2. We're in build mode (integration tools are executed server-side), OR - // 3. Tool call is already completed (historical - should always render) - const isClientTool = !!TOOL_DISPLAY_REGISTRY[toolCall.name] - const isIntegrationToolInBuildMode = mode === 'build' && !isClientTool - - if (!isClientTool && !isIntegrationToolInBuildMode && !isCompletedToolCall) { - return null - } const toolUIConfig = TOOL_DISPLAY_REGISTRY[toolCall.name]?.uiConfig // Check if tool has params table config (meaning it's expandable) const hasParamsTable = !!toolUIConfig?.paramsTable @@ -1554,6 +1564,14 @@ export function ToolCall({ toolCall.name === 'make_api_request' || toolCall.name === 'set_global_workflow_variables' + const interruptActions = + (toolCall.ui?.actions && toolCall.ui.actions.length > 0 + ? toolCall.ui.actions + : [ + { id: 'allow_once', label: 'Allow', kind: 'accept' as const }, + { id: 'allow_always', label: 'Always Allow', kind: 'accept' as const, remember: true }, + { id: 'reject', label: 'Skip', kind: 'reject' as const }, + ]) as ToolUiAction[] const showButtons = isCurrentMessage && shouldShowRunSkipButtons(toolCall) // Check UI config for secondary action - only show for current message tool calls @@ -2011,9 +2029,12 @@ export function ToolCall({