mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-23 05:47:59 -05:00
Compare commits
4 Commits
v0.5.67
...
fix/copilo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
528d8e7729 | ||
|
|
04a6f9d0a4 | ||
|
|
76dd4a0c95 | ||
|
|
66dfe2c6b2 |
@@ -78,6 +78,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
mode,
|
mode,
|
||||||
setMode,
|
setMode,
|
||||||
isAborting,
|
isAborting,
|
||||||
|
maskCredentialValue,
|
||||||
} = useCopilotStore()
|
} = useCopilotStore()
|
||||||
|
|
||||||
const messageCheckpoints = isUser ? allMessageCheckpoints[message.id] || [] : []
|
const messageCheckpoints = isUser ? allMessageCheckpoints[message.id] || [] : []
|
||||||
@@ -210,7 +211,10 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
const isLastTextBlock =
|
const isLastTextBlock =
|
||||||
index === message.contentBlocks!.length - 1 && block.type === 'text'
|
index === message.contentBlocks!.length - 1 && block.type === 'text'
|
||||||
const parsed = parseSpecialTags(block.content)
|
const parsed = parseSpecialTags(block.content)
|
||||||
const cleanBlockContent = parsed.cleanContent.replace(/\n{3,}/g, '\n\n')
|
// Mask credential IDs in the displayed content
|
||||||
|
const cleanBlockContent = maskCredentialValue(
|
||||||
|
parsed.cleanContent.replace(/\n{3,}/g, '\n\n')
|
||||||
|
)
|
||||||
|
|
||||||
if (!cleanBlockContent.trim()) return null
|
if (!cleanBlockContent.trim()) return null
|
||||||
|
|
||||||
@@ -238,7 +242,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
return (
|
return (
|
||||||
<div key={blockKey} className='w-full'>
|
<div key={blockKey} className='w-full'>
|
||||||
<ThinkingBlock
|
<ThinkingBlock
|
||||||
content={block.content}
|
content={maskCredentialValue(block.content)}
|
||||||
isStreaming={isActivelyStreaming}
|
isStreaming={isActivelyStreaming}
|
||||||
hasFollowingContent={hasFollowingContent}
|
hasFollowingContent={hasFollowingContent}
|
||||||
hasSpecialTags={hasSpecialTags}
|
hasSpecialTags={hasSpecialTags}
|
||||||
@@ -261,7 +265,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
}, [message.contentBlocks, isActivelyStreaming, parsedTags, isLastMessage])
|
}, [message.contentBlocks, isActivelyStreaming, parsedTags, isLastMessage, maskCredentialValue])
|
||||||
|
|
||||||
if (isUser) {
|
if (isUser) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -782,6 +782,7 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
|
|||||||
const [isExpanded, setIsExpanded] = useState(true)
|
const [isExpanded, setIsExpanded] = useState(true)
|
||||||
const [duration, setDuration] = useState(0)
|
const [duration, setDuration] = useState(0)
|
||||||
const startTimeRef = useRef<number>(Date.now())
|
const startTimeRef = useRef<number>(Date.now())
|
||||||
|
const maskCredentialValue = useCopilotStore((s) => s.maskCredentialValue)
|
||||||
const wasStreamingRef = useRef(false)
|
const wasStreamingRef = useRef(false)
|
||||||
|
|
||||||
// Only show streaming animations for current message
|
// Only show streaming animations for current message
|
||||||
@@ -816,14 +817,16 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
|
|||||||
currentText += parsed.cleanContent
|
currentText += parsed.cleanContent
|
||||||
} else if (block.type === 'subagent_tool_call' && block.toolCall) {
|
} else if (block.type === 'subagent_tool_call' && block.toolCall) {
|
||||||
if (currentText.trim()) {
|
if (currentText.trim()) {
|
||||||
segments.push({ type: 'text', content: currentText })
|
// Mask any credential IDs in the accumulated text before displaying
|
||||||
|
segments.push({ type: 'text', content: maskCredentialValue(currentText) })
|
||||||
currentText = ''
|
currentText = ''
|
||||||
}
|
}
|
||||||
segments.push({ type: 'tool', block })
|
segments.push({ type: 'tool', block })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (currentText.trim()) {
|
if (currentText.trim()) {
|
||||||
segments.push({ type: 'text', content: currentText })
|
// Mask any credential IDs in the accumulated text before displaying
|
||||||
|
segments.push({ type: 'text', content: maskCredentialValue(currentText) })
|
||||||
}
|
}
|
||||||
|
|
||||||
const allParsed = parseSpecialTags(allRawText)
|
const allParsed = parseSpecialTags(allRawText)
|
||||||
@@ -952,6 +955,7 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
|
|||||||
toolCall: CopilotToolCall
|
toolCall: CopilotToolCall
|
||||||
}) {
|
}) {
|
||||||
const blocks = useWorkflowStore((s) => s.blocks)
|
const blocks = useWorkflowStore((s) => s.blocks)
|
||||||
|
const maskCredentialValue = useCopilotStore((s) => s.maskCredentialValue)
|
||||||
|
|
||||||
const cachedBlockInfoRef = useRef<Record<string, { name: string; type: string }>>({})
|
const cachedBlockInfoRef = useRef<Record<string, { name: string; type: string }>>({})
|
||||||
|
|
||||||
@@ -983,6 +987,7 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
|
|||||||
title: string
|
title: string
|
||||||
value: any
|
value: any
|
||||||
isPassword?: boolean
|
isPassword?: boolean
|
||||||
|
isCredential?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BlockChange {
|
interface BlockChange {
|
||||||
@@ -1091,6 +1096,7 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
|
|||||||
title: subBlockConfig.title ?? subBlockConfig.id,
|
title: subBlockConfig.title ?? subBlockConfig.id,
|
||||||
value,
|
value,
|
||||||
isPassword: subBlockConfig.password === true,
|
isPassword: subBlockConfig.password === true,
|
||||||
|
isCredential: subBlockConfig.type === 'oauth-input',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1172,8 +1178,15 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
|
|||||||
{subBlocksToShow && subBlocksToShow.length > 0 && (
|
{subBlocksToShow && subBlocksToShow.length > 0 && (
|
||||||
<div className='border-[var(--border-1)] border-t px-2.5 py-1.5'>
|
<div className='border-[var(--border-1)] border-t px-2.5 py-1.5'>
|
||||||
{subBlocksToShow.map((sb) => {
|
{subBlocksToShow.map((sb) => {
|
||||||
// Mask password fields like the canvas does
|
// Mask password fields and credential IDs
|
||||||
const displayValue = sb.isPassword ? '•••' : getDisplayValue(sb.value)
|
let displayValue: string
|
||||||
|
if (sb.isPassword) {
|
||||||
|
displayValue = '•••'
|
||||||
|
} else {
|
||||||
|
// Get display value first, then mask any credential IDs that might be in it
|
||||||
|
const rawValue = getDisplayValue(sb.value)
|
||||||
|
displayValue = maskCredentialValue(rawValue)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div key={sb.id} className='flex items-start gap-1.5 py-0.5 text-[11px]'>
|
<div key={sb.id} className='flex items-start gap-1.5 py-0.5 text-[11px]'>
|
||||||
<span
|
<span
|
||||||
@@ -1412,10 +1425,13 @@ function RunSkipButtons({
|
|||||||
setIsProcessing(true)
|
setIsProcessing(true)
|
||||||
setButtonsHidden(true)
|
setButtonsHidden(true)
|
||||||
try {
|
try {
|
||||||
// Add to auto-allowed list first
|
// Add to auto-allowed list - this also executes all pending integration tools of this type
|
||||||
await addAutoAllowedTool(toolCall.name)
|
await addAutoAllowedTool(toolCall.name)
|
||||||
// Then execute
|
// For client tools with interrupts (not integration tools), we still need to call handleRun
|
||||||
await handleRun(toolCall, setToolCallState, onStateChange, editedParams)
|
// since executeIntegrationTool only works for server-side tools
|
||||||
|
if (!isIntegrationTool(toolCall.name)) {
|
||||||
|
await handleRun(toolCall, setToolCallState, onStateChange, editedParams)
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false)
|
setIsProcessing(false)
|
||||||
actionInProgressRef.current = false
|
actionInProgressRef.current = false
|
||||||
@@ -1438,10 +1454,10 @@ function RunSkipButtons({
|
|||||||
|
|
||||||
if (buttonsHidden) return null
|
if (buttonsHidden) return null
|
||||||
|
|
||||||
// Hide "Always Allow" for integration tools (only show for client tools with interrupts)
|
// Show "Always Allow" for all tools that require confirmation
|
||||||
const showAlwaysAllow = !isIntegrationTool(toolCall.name)
|
const showAlwaysAllow = true
|
||||||
|
|
||||||
// Standardized buttons for all interrupt tools: Allow, (Always Allow for client tools only), Skip
|
// Standardized buttons for all interrupt tools: Allow, Always Allow, Skip
|
||||||
return (
|
return (
|
||||||
<div className='mt-[10px] flex gap-[6px]'>
|
<div className='mt-[10px] flex gap-[6px]'>
|
||||||
<Button onClick={onRun} disabled={isProcessing} variant='tertiary'>
|
<Button onClick={onRun} disabled={isProcessing} variant='tertiary'>
|
||||||
|
|||||||
@@ -105,10 +105,10 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
|
|||||||
isSendingMessage,
|
isSendingMessage,
|
||||||
])
|
])
|
||||||
|
|
||||||
/** Load auto-allowed tools once on mount */
|
/** Load auto-allowed tools once on mount - runs immediately, independent of workflow */
|
||||||
const hasLoadedAutoAllowedToolsRef = useRef(false)
|
const hasLoadedAutoAllowedToolsRef = useRef(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasMountedRef.current && !hasLoadedAutoAllowedToolsRef.current) {
|
if (!hasLoadedAutoAllowedToolsRef.current) {
|
||||||
hasLoadedAutoAllowedToolsRef.current = true
|
hasLoadedAutoAllowedToolsRef.current = true
|
||||||
loadAutoAllowedTools().catch((err) => {
|
loadAutoAllowedTools().catch((err) => {
|
||||||
logger.warn('[Copilot] Failed to load auto-allowed tools', err)
|
logger.warn('[Copilot] Failed to load auto-allowed tools', err)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useSidebarStore } from '@/stores/sidebar/store'
|
|||||||
* Avatar display configuration for responsive layout.
|
* Avatar display configuration for responsive layout.
|
||||||
*/
|
*/
|
||||||
const AVATAR_CONFIG = {
|
const AVATAR_CONFIG = {
|
||||||
MIN_COUNT: 3,
|
MIN_COUNT: 4,
|
||||||
MAX_COUNT: 12,
|
MAX_COUNT: 12,
|
||||||
WIDTH_PER_AVATAR: 20,
|
WIDTH_PER_AVATAR: 20,
|
||||||
} as const
|
} as const
|
||||||
@@ -106,7 +106,9 @@ export function Avatars({ workflowId }: AvatarsProps) {
|
|||||||
}, [presenceUsers, currentWorkflowId, workflowId, currentSocketId])
|
}, [presenceUsers, currentWorkflowId, workflowId, currentSocketId])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate visible users and overflow count
|
* Calculate visible users and overflow count.
|
||||||
|
* Shows up to maxVisible avatars, with overflow indicator for any remaining.
|
||||||
|
* Users are reversed so new avatars appear on the left (keeping right side stable).
|
||||||
*/
|
*/
|
||||||
const { visibleUsers, overflowCount } = useMemo(() => {
|
const { visibleUsers, overflowCount } = useMemo(() => {
|
||||||
if (workflowUsers.length === 0) {
|
if (workflowUsers.length === 0) {
|
||||||
@@ -116,7 +118,8 @@ export function Avatars({ workflowId }: AvatarsProps) {
|
|||||||
const visible = workflowUsers.slice(0, maxVisible)
|
const visible = workflowUsers.slice(0, maxVisible)
|
||||||
const overflow = Math.max(0, workflowUsers.length - maxVisible)
|
const overflow = Math.max(0, workflowUsers.length - maxVisible)
|
||||||
|
|
||||||
return { visibleUsers: visible, overflowCount: overflow }
|
// Reverse so rightmost avatars stay stable as new ones are revealed on the left
|
||||||
|
return { visibleUsers: [...visible].reverse(), overflowCount: overflow }
|
||||||
}, [workflowUsers, maxVisible])
|
}, [workflowUsers, maxVisible])
|
||||||
|
|
||||||
if (visibleUsers.length === 0) {
|
if (visibleUsers.length === 0) {
|
||||||
@@ -139,9 +142,8 @@ export function Avatars({ workflowId }: AvatarsProps) {
|
|||||||
</Tooltip.Content>
|
</Tooltip.Content>
|
||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{visibleUsers.map((user, index) => (
|
{visibleUsers.map((user, index) => (
|
||||||
<UserAvatar key={user.socketId} user={user} index={overflowCount > 0 ? index + 1 : index} />
|
<UserAvatar key={user.socketId} user={user} index={index} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -347,7 +347,7 @@ export function WorkflowItem({
|
|||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'min-w-0 flex-1 truncate font-medium',
|
'min-w-0 truncate font-medium',
|
||||||
active
|
active
|
||||||
? 'text-[var(--text-primary)]'
|
? 'text-[var(--text-primary)]'
|
||||||
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
|
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
|
||||||
|
|||||||
@@ -2468,16 +2468,17 @@ async function validateWorkflowSelectorIds(
|
|||||||
const result = await validateSelectorIds(selector.selectorType, selector.value, context)
|
const result = await validateSelectorIds(selector.selectorType, selector.value, context)
|
||||||
|
|
||||||
if (result.invalid.length > 0) {
|
if (result.invalid.length > 0) {
|
||||||
|
// Include warning info (like available credentials) in the error message for better LLM feedback
|
||||||
|
const warningInfo = result.warning ? `. ${result.warning}` : ''
|
||||||
errors.push({
|
errors.push({
|
||||||
blockId: selector.blockId,
|
blockId: selector.blockId,
|
||||||
blockType: selector.blockType,
|
blockType: selector.blockType,
|
||||||
field: selector.fieldName,
|
field: selector.fieldName,
|
||||||
value: selector.value,
|
value: selector.value,
|
||||||
error: `Invalid ${selector.selectorType} ID(s): ${result.invalid.join(', ')} - ID(s) do not exist`,
|
error: `Invalid ${selector.selectorType} ID(s): ${result.invalid.join(', ')} - ID(s) do not exist or user doesn't have access${warningInfo}`,
|
||||||
})
|
})
|
||||||
}
|
} else if (result.warning) {
|
||||||
|
// Log warnings that don't have errors (shouldn't happen for credentials but may for other selectors)
|
||||||
if (result.warning) {
|
|
||||||
logger.warn(result.warning, {
|
logger.warn(result.warning, {
|
||||||
blockId: selector.blockId,
|
blockId: selector.blockId,
|
||||||
fieldName: selector.fieldName,
|
fieldName: selector.fieldName,
|
||||||
|
|||||||
@@ -39,6 +39,31 @@ export async function validateSelectorIds(
|
|||||||
.from(account)
|
.from(account)
|
||||||
.where(and(inArray(account.id, idsArray), eq(account.userId, context.userId)))
|
.where(and(inArray(account.id, idsArray), eq(account.userId, context.userId)))
|
||||||
existingIds = results.map((r) => r.id)
|
existingIds = results.map((r) => r.id)
|
||||||
|
|
||||||
|
// If any IDs are invalid, fetch user's available credentials to include in error message
|
||||||
|
const existingSet = new Set(existingIds)
|
||||||
|
const invalidIds = idsArray.filter((id) => !existingSet.has(id))
|
||||||
|
if (invalidIds.length > 0) {
|
||||||
|
// Fetch all of the user's credentials to provide helpful feedback
|
||||||
|
const allUserCredentials = await db
|
||||||
|
.select({ id: account.id, providerId: account.providerId })
|
||||||
|
.from(account)
|
||||||
|
.where(eq(account.userId, context.userId))
|
||||||
|
|
||||||
|
const availableCredentials = allUserCredentials
|
||||||
|
.map((c) => `${c.id} (${c.providerId})`)
|
||||||
|
.join(', ')
|
||||||
|
const noCredentialsMessage = 'User has no credentials configured.'
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: existingIds,
|
||||||
|
invalid: invalidIds,
|
||||||
|
warning:
|
||||||
|
allUserCredentials.length > 0
|
||||||
|
? `Available credentials for this user: ${availableCredentials}`
|
||||||
|
: noCredentialsMessage,
|
||||||
|
}
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -771,12 +771,50 @@ function deepClone<T>(obj: T): T {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively masks credential IDs in any value (string, object, or array).
|
||||||
|
* Used during serialization to ensure sensitive IDs are never persisted.
|
||||||
|
*/
|
||||||
|
function maskCredentialIdsInValue(value: any, credentialIds: Set<string>): any {
|
||||||
|
if (!value || credentialIds.size === 0) return value
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
let masked = value
|
||||||
|
// Sort by length descending to mask longer IDs first
|
||||||
|
const sortedIds = Array.from(credentialIds).sort((a, b) => b.length - a.length)
|
||||||
|
for (const id of sortedIds) {
|
||||||
|
if (id && masked.includes(id)) {
|
||||||
|
masked = masked.split(id).join('••••••••')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return masked
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => maskCredentialIdsInValue(item, credentialIds))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
const masked: any = {}
|
||||||
|
for (const key of Object.keys(value)) {
|
||||||
|
masked[key] = maskCredentialIdsInValue(value[key], credentialIds)
|
||||||
|
}
|
||||||
|
return masked
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serializes messages for database storage.
|
* Serializes messages for database storage.
|
||||||
* Deep clones all fields to ensure proper JSON serialization.
|
* Deep clones all fields to ensure proper JSON serialization.
|
||||||
|
* Masks sensitive credential IDs before persisting.
|
||||||
* This ensures they render identically when loaded back.
|
* This ensures they render identically when loaded back.
|
||||||
*/
|
*/
|
||||||
function serializeMessagesForDB(messages: CopilotMessage[]): any[] {
|
function serializeMessagesForDB(messages: CopilotMessage[]): any[] {
|
||||||
|
// Get credential IDs to mask
|
||||||
|
const credentialIds = useCopilotStore.getState().sensitiveCredentialIds
|
||||||
|
|
||||||
const result = messages
|
const result = messages
|
||||||
.map((msg) => {
|
.map((msg) => {
|
||||||
// Deep clone the entire message to ensure all nested data is serializable
|
// Deep clone the entire message to ensure all nested data is serializable
|
||||||
@@ -824,7 +862,8 @@ function serializeMessagesForDB(messages: CopilotMessage[]): any[] {
|
|||||||
serialized.errorType = msg.errorType
|
serialized.errorType = msg.errorType
|
||||||
}
|
}
|
||||||
|
|
||||||
return serialized
|
// Mask credential IDs in the serialized message before persisting
|
||||||
|
return maskCredentialIdsInValue(serialized, credentialIds)
|
||||||
})
|
})
|
||||||
.filter((msg) => {
|
.filter((msg) => {
|
||||||
// Filter out empty assistant messages
|
// Filter out empty assistant messages
|
||||||
@@ -1320,7 +1359,16 @@ const sseHandlers: Record<string, SSEHandler> = {
|
|||||||
typeof def.hasInterrupt === 'function'
|
typeof def.hasInterrupt === 'function'
|
||||||
? !!def.hasInterrupt(args || {})
|
? !!def.hasInterrupt(args || {})
|
||||||
: !!def.hasInterrupt
|
: !!def.hasInterrupt
|
||||||
if (!hasInterrupt && typeof def.execute === 'function') {
|
// Check if tool is auto-allowed - if so, execute even if it has an interrupt
|
||||||
|
const { autoAllowedTools } = get()
|
||||||
|
const isAutoAllowed = name ? autoAllowedTools.includes(name) : false
|
||||||
|
if ((!hasInterrupt || isAutoAllowed) && typeof def.execute === 'function') {
|
||||||
|
if (isAutoAllowed && hasInterrupt) {
|
||||||
|
logger.info('[toolCallsById] Auto-executing tool with interrupt (auto-allowed)', {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
}
|
||||||
const ctx = createExecutionContext({ toolCallId: id, toolName: name || 'unknown_tool' })
|
const ctx = createExecutionContext({ toolCallId: id, toolName: name || 'unknown_tool' })
|
||||||
// Defer executing transition by a tick to let pending render
|
// Defer executing transition by a tick to let pending render
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -1426,11 +1474,23 @@ const sseHandlers: Record<string, SSEHandler> = {
|
|||||||
logger.warn('tool_call registry auto-exec check failed', { id, name, error: e })
|
logger.warn('tool_call registry auto-exec check failed', { id, name, error: e })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Class-based auto-exec for non-interrupt tools
|
// Class-based auto-exec for non-interrupt tools or auto-allowed tools
|
||||||
try {
|
try {
|
||||||
const inst = getClientTool(id) as any
|
const inst = getClientTool(id) as any
|
||||||
const hasInterrupt = !!inst?.getInterruptDisplays?.()
|
const hasInterrupt = !!inst?.getInterruptDisplays?.()
|
||||||
if (!hasInterrupt && typeof inst?.execute === 'function') {
|
// Check if tool is auto-allowed - if so, execute even if it has an interrupt
|
||||||
|
const { autoAllowedTools: classAutoAllowed } = get()
|
||||||
|
const isClassAutoAllowed = name ? classAutoAllowed.includes(name) : false
|
||||||
|
if (
|
||||||
|
(!hasInterrupt || isClassAutoAllowed) &&
|
||||||
|
(typeof inst?.execute === 'function' || typeof inst?.handleAccept === 'function')
|
||||||
|
) {
|
||||||
|
if (isClassAutoAllowed && hasInterrupt) {
|
||||||
|
logger.info('[toolCallsById] Auto-executing class tool with interrupt (auto-allowed)', {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// Guard against duplicate execution - check if already executing or terminal
|
// Guard against duplicate execution - check if already executing or terminal
|
||||||
const currentState = get().toolCallsById[id]?.state
|
const currentState = get().toolCallsById[id]?.state
|
||||||
@@ -1449,7 +1509,12 @@ const sseHandlers: Record<string, SSEHandler> = {
|
|||||||
|
|
||||||
Promise.resolve()
|
Promise.resolve()
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
await inst.execute(args || {})
|
// Use handleAccept for tools with interrupts, execute for others
|
||||||
|
if (hasInterrupt && typeof inst?.handleAccept === 'function') {
|
||||||
|
await inst.handleAccept(args || {})
|
||||||
|
} else {
|
||||||
|
await inst.execute(args || {})
|
||||||
|
}
|
||||||
// Success/error will be synced via registerToolStateSync
|
// Success/error will be synced via registerToolStateSync
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -1474,20 +1539,35 @@ const sseHandlers: Record<string, SSEHandler> = {
|
|||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
// Integration tools: Stay in pending state until user confirms via buttons
|
// Integration tools: Check auto-allowed or stay in pending state until user confirms
|
||||||
// This handles tools like google_calendar_*, exa_*, gmail_read, etc. that aren't in the client registry
|
// This handles tools like google_calendar_*, exa_*, gmail_read, etc. that aren't in the client registry
|
||||||
// Only relevant if mode is 'build' (agent)
|
// Only relevant if mode is 'build' (agent)
|
||||||
const { mode, workflowId } = get()
|
const { mode, workflowId, autoAllowedTools, executeIntegrationTool } = get()
|
||||||
if (mode === 'build' && workflowId) {
|
if (mode === 'build' && workflowId) {
|
||||||
// Check if tool was NOT found in client registry
|
// Check if tool was NOT found in client registry
|
||||||
const def = name ? getTool(name) : undefined
|
const def = name ? getTool(name) : undefined
|
||||||
const inst = getClientTool(id) as any
|
const inst = getClientTool(id) as any
|
||||||
if (!def && !inst && name) {
|
if (!def && !inst && name) {
|
||||||
// Integration tools stay in pending state until user confirms
|
// Check if this integration tool is auto-allowed - if so, execute it immediately
|
||||||
logger.info('[build mode] Integration tool awaiting user confirmation', {
|
if (autoAllowedTools.includes(name)) {
|
||||||
id,
|
logger.info('[build mode] Auto-executing integration tool (auto-allowed)', { id, name })
|
||||||
name,
|
// Defer to allow pending state to render briefly
|
||||||
})
|
setTimeout(() => {
|
||||||
|
executeIntegrationTool(id).catch((err) => {
|
||||||
|
logger.error('[build mode] Auto-execute integration tool failed', {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
error: err,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, 0)
|
||||||
|
} else {
|
||||||
|
// Integration tools stay in pending state until user confirms
|
||||||
|
logger.info('[build mode] Integration tool awaiting user confirmation', {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1976,6 +2056,10 @@ const subAgentSSEHandlers: Record<string, SSEHandler> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Execute client tools in parallel (non-blocking) - same pattern as main tool_call handler
|
// Execute client tools in parallel (non-blocking) - same pattern as main tool_call handler
|
||||||
|
// Check if tool is auto-allowed
|
||||||
|
const { autoAllowedTools: subAgentAutoAllowed } = get()
|
||||||
|
const isSubAgentAutoAllowed = name ? subAgentAutoAllowed.includes(name) : false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const def = getTool(name)
|
const def = getTool(name)
|
||||||
if (def) {
|
if (def) {
|
||||||
@@ -1983,8 +2067,15 @@ const subAgentSSEHandlers: Record<string, SSEHandler> = {
|
|||||||
typeof def.hasInterrupt === 'function'
|
typeof def.hasInterrupt === 'function'
|
||||||
? !!def.hasInterrupt(args || {})
|
? !!def.hasInterrupt(args || {})
|
||||||
: !!def.hasInterrupt
|
: !!def.hasInterrupt
|
||||||
if (!hasInterrupt) {
|
// Auto-execute if no interrupt OR if auto-allowed
|
||||||
// Auto-execute tools without interrupts - non-blocking
|
if (!hasInterrupt || isSubAgentAutoAllowed) {
|
||||||
|
if (isSubAgentAutoAllowed && hasInterrupt) {
|
||||||
|
logger.info('[SubAgent] Auto-executing tool with interrupt (auto-allowed)', {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Auto-execute tools - non-blocking
|
||||||
const ctx = createExecutionContext({ toolCallId: id, toolName: name })
|
const ctx = createExecutionContext({ toolCallId: id, toolName: name })
|
||||||
Promise.resolve()
|
Promise.resolve()
|
||||||
.then(() => def.execute(ctx, args || {}))
|
.then(() => def.execute(ctx, args || {}))
|
||||||
@@ -2001,9 +2092,22 @@ const subAgentSSEHandlers: Record<string, SSEHandler> = {
|
|||||||
const instance = getClientTool(id)
|
const instance = getClientTool(id)
|
||||||
if (instance) {
|
if (instance) {
|
||||||
const hasInterruptDisplays = !!instance.getInterruptDisplays?.()
|
const hasInterruptDisplays = !!instance.getInterruptDisplays?.()
|
||||||
if (!hasInterruptDisplays) {
|
// Auto-execute if no interrupt OR if auto-allowed
|
||||||
|
if (!hasInterruptDisplays || isSubAgentAutoAllowed) {
|
||||||
|
if (isSubAgentAutoAllowed && hasInterruptDisplays) {
|
||||||
|
logger.info('[SubAgent] Auto-executing class tool with interrupt (auto-allowed)', {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
}
|
||||||
Promise.resolve()
|
Promise.resolve()
|
||||||
.then(() => instance.execute(args || {}))
|
.then(() => {
|
||||||
|
// Use handleAccept for tools with interrupts, execute for others
|
||||||
|
if (hasInterruptDisplays && typeof instance.handleAccept === 'function') {
|
||||||
|
return instance.handleAccept(args || {})
|
||||||
|
}
|
||||||
|
return instance.execute(args || {})
|
||||||
|
})
|
||||||
.catch((execErr: any) => {
|
.catch((execErr: any) => {
|
||||||
logger.error('[SubAgent] Class tool execution failed', {
|
logger.error('[SubAgent] Class tool execution failed', {
|
||||||
id,
|
id,
|
||||||
@@ -2232,6 +2336,7 @@ const initialState = {
|
|||||||
autoAllowedTools: [] as string[],
|
autoAllowedTools: [] as string[],
|
||||||
messageQueue: [] as import('./types').QueuedMessage[],
|
messageQueue: [] as import('./types').QueuedMessage[],
|
||||||
suppressAbortContinueOption: false,
|
suppressAbortContinueOption: false,
|
||||||
|
sensitiveCredentialIds: new Set<string>(),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useCopilotStore = create<CopilotStore>()(
|
export const useCopilotStore = create<CopilotStore>()(
|
||||||
@@ -2614,6 +2719,12 @@ export const useCopilotStore = create<CopilotStore>()(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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[]
|
let newMessages: CopilotMessage[]
|
||||||
if (revertState) {
|
if (revertState) {
|
||||||
const currentMessages = get().messages
|
const currentMessages = get().messages
|
||||||
@@ -3676,6 +3787,16 @@ export const useCopilotStore = create<CopilotStore>()(
|
|||||||
|
|
||||||
const { id, name, params } = toolCall
|
const { id, name, params } = toolCall
|
||||||
|
|
||||||
|
// Guard against double execution - skip if already executing or in terminal state
|
||||||
|
if (toolCall.state === ClientToolCallState.executing || isTerminalState(toolCall.state)) {
|
||||||
|
logger.info('[executeIntegrationTool] Skipping - already executing or terminal', {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
state: toolCall.state,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Set to executing state
|
// Set to executing state
|
||||||
const executingMap = { ...get().toolCallsById }
|
const executingMap = { ...get().toolCallsById }
|
||||||
executingMap[id] = {
|
executingMap[id] = {
|
||||||
@@ -3824,6 +3945,46 @@ export const useCopilotStore = create<CopilotStore>()(
|
|||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
set({ autoAllowedTools: data.autoAllowedTools || [] })
|
set({ autoAllowedTools: data.autoAllowedTools || [] })
|
||||||
logger.info('[AutoAllowedTools] Added tool', { toolId })
|
logger.info('[AutoAllowedTools] Added tool', { toolId })
|
||||||
|
|
||||||
|
// Auto-execute all pending tools of the same type
|
||||||
|
const { toolCallsById, executeIntegrationTool } = get()
|
||||||
|
const pendingToolCalls = Object.values(toolCallsById).filter(
|
||||||
|
(tc) => tc.name === toolId && tc.state === ClientToolCallState.pending
|
||||||
|
)
|
||||||
|
if (pendingToolCalls.length > 0) {
|
||||||
|
const isIntegrationTool = !CLASS_TOOL_METADATA[toolId]
|
||||||
|
logger.info('[AutoAllowedTools] Auto-executing pending tools', {
|
||||||
|
toolId,
|
||||||
|
count: pendingToolCalls.length,
|
||||||
|
isIntegrationTool,
|
||||||
|
})
|
||||||
|
for (const tc of pendingToolCalls) {
|
||||||
|
if (isIntegrationTool) {
|
||||||
|
// Integration tools use executeIntegrationTool
|
||||||
|
executeIntegrationTool(tc.id).catch((err) => {
|
||||||
|
logger.error('[AutoAllowedTools] Auto-execute pending integration tool failed', {
|
||||||
|
toolCallId: tc.id,
|
||||||
|
toolId,
|
||||||
|
error: err,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Client tools with interrupts use handleAccept
|
||||||
|
const inst = getClientTool(tc.id) as any
|
||||||
|
if (inst && typeof inst.handleAccept === 'function') {
|
||||||
|
Promise.resolve()
|
||||||
|
.then(() => inst.handleAccept(tc.params || {}))
|
||||||
|
.catch((err: any) => {
|
||||||
|
logger.error('[AutoAllowedTools] Auto-execute pending client tool failed', {
|
||||||
|
toolCallId: tc.id,
|
||||||
|
toolId,
|
||||||
|
error: err,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('[AutoAllowedTools] Failed to add tool', { toolId, error: err })
|
logger.error('[AutoAllowedTools] Failed to add tool', { toolId, error: err })
|
||||||
@@ -3853,6 +4014,57 @@ export const useCopilotStore = create<CopilotStore>()(
|
|||||||
return autoAllowedTools.includes(toolId)
|
return autoAllowedTools.includes(toolId)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Credential masking
|
||||||
|
loadSensitiveCredentialIds: async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ toolName: 'get_credentials', payload: {} }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
logger.warn('[loadSensitiveCredentialIds] Failed to fetch credentials', {
|
||||||
|
status: res.status,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const json = await res.json()
|
||||||
|
// Credentials are at result.oauth.connected.credentials
|
||||||
|
const credentials = json?.result?.oauth?.connected?.credentials || []
|
||||||
|
logger.info('[loadSensitiveCredentialIds] Response', {
|
||||||
|
hasResult: !!json?.result,
|
||||||
|
credentialCount: credentials.length,
|
||||||
|
})
|
||||||
|
const ids = new Set<string>()
|
||||||
|
for (const cred of credentials) {
|
||||||
|
if (cred?.id) {
|
||||||
|
ids.add(cred.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set({ sensitiveCredentialIds: ids })
|
||||||
|
logger.info('[loadSensitiveCredentialIds] Loaded credential IDs', {
|
||||||
|
count: ids.size,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('[loadSensitiveCredentialIds] Error loading credentials', err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
maskCredentialValue: (value: string) => {
|
||||||
|
const { sensitiveCredentialIds } = get()
|
||||||
|
if (!value || sensitiveCredentialIds.size === 0) return value
|
||||||
|
|
||||||
|
let masked = value
|
||||||
|
// Sort by length descending to mask longer IDs first
|
||||||
|
const sortedIds = Array.from(sensitiveCredentialIds).sort((a, b) => b.length - a.length)
|
||||||
|
for (const id of sortedIds) {
|
||||||
|
if (id && masked.includes(id)) {
|
||||||
|
masked = masked.split(id).join('••••••••')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return masked
|
||||||
|
},
|
||||||
|
|
||||||
// Message queue actions
|
// Message queue actions
|
||||||
addToQueue: (message, options) => {
|
addToQueue: (message, options) => {
|
||||||
const queuedMessage: import('./types').QueuedMessage = {
|
const queuedMessage: import('./types').QueuedMessage = {
|
||||||
|
|||||||
@@ -156,6 +156,9 @@ export interface CopilotState {
|
|||||||
|
|
||||||
// Message queue for messages sent while another is in progress
|
// Message queue for messages sent while another is in progress
|
||||||
messageQueue: QueuedMessage[]
|
messageQueue: QueuedMessage[]
|
||||||
|
|
||||||
|
// Credential IDs to mask in UI (for sensitive data protection)
|
||||||
|
sensitiveCredentialIds: Set<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CopilotActions {
|
export interface CopilotActions {
|
||||||
@@ -235,6 +238,10 @@ export interface CopilotActions {
|
|||||||
removeAutoAllowedTool: (toolId: string) => Promise<void>
|
removeAutoAllowedTool: (toolId: string) => Promise<void>
|
||||||
isToolAutoAllowed: (toolId: string) => boolean
|
isToolAutoAllowed: (toolId: string) => boolean
|
||||||
|
|
||||||
|
// Credential masking
|
||||||
|
loadSensitiveCredentialIds: () => Promise<void>
|
||||||
|
maskCredentialValue: (value: string) => string
|
||||||
|
|
||||||
// Message queue actions
|
// Message queue actions
|
||||||
addToQueue: (
|
addToQueue: (
|
||||||
message: string,
|
message: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user