Compare commits

..

4 Commits

Author SHA1 Message Date
Siddharth Ganesan
528d8e7729 Autoload 2026-01-22 13:01:45 -08:00
Siddharth Ganesan
04a6f9d0a4 Credential masking 2026-01-22 12:30:53 -08:00
Siddharth Ganesan
76dd4a0c95 Fix always allow, credential validation 2026-01-22 11:57:57 -08:00
Waleed
66dfe2c6b2 improvement(workflow-item): stabilize avatar layout and fix name truncation (#2939)
* improvement(workflow-item): stabilize avatar layout and fix name truncation

* fix(avatars): revert overflow bg to hardcoded color for contrast
2026-01-22 11:26:47 -08:00
9 changed files with 308 additions and 41 deletions

View File

@@ -78,6 +78,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
mode,
setMode,
isAborting,
maskCredentialValue,
} = useCopilotStore()
const messageCheckpoints = isUser ? allMessageCheckpoints[message.id] || [] : []
@@ -210,7 +211,10 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
const isLastTextBlock =
index === message.contentBlocks!.length - 1 && block.type === 'text'
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
@@ -238,7 +242,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
return (
<div key={blockKey} className='w-full'>
<ThinkingBlock
content={block.content}
content={maskCredentialValue(block.content)}
isStreaming={isActivelyStreaming}
hasFollowingContent={hasFollowingContent}
hasSpecialTags={hasSpecialTags}
@@ -261,7 +265,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
}
return null
})
}, [message.contentBlocks, isActivelyStreaming, parsedTags, isLastMessage])
}, [message.contentBlocks, isActivelyStreaming, parsedTags, isLastMessage, maskCredentialValue])
if (isUser) {
return (

View File

@@ -782,6 +782,7 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
const [isExpanded, setIsExpanded] = useState(true)
const [duration, setDuration] = useState(0)
const startTimeRef = useRef<number>(Date.now())
const maskCredentialValue = useCopilotStore((s) => s.maskCredentialValue)
const wasStreamingRef = useRef(false)
// Only show streaming animations for current message
@@ -816,14 +817,16 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
currentText += parsed.cleanContent
} else if (block.type === 'subagent_tool_call' && block.toolCall) {
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 = ''
}
segments.push({ type: 'tool', block })
}
}
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)
@@ -952,6 +955,7 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
toolCall: CopilotToolCall
}) {
const blocks = useWorkflowStore((s) => s.blocks)
const maskCredentialValue = useCopilotStore((s) => s.maskCredentialValue)
const cachedBlockInfoRef = useRef<Record<string, { name: string; type: string }>>({})
@@ -983,6 +987,7 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
title: string
value: any
isPassword?: boolean
isCredential?: boolean
}
interface BlockChange {
@@ -1091,6 +1096,7 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
title: subBlockConfig.title ?? subBlockConfig.id,
value,
isPassword: subBlockConfig.password === true,
isCredential: subBlockConfig.type === 'oauth-input',
})
}
}
@@ -1172,8 +1178,15 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
{subBlocksToShow && subBlocksToShow.length > 0 && (
<div className='border-[var(--border-1)] border-t px-2.5 py-1.5'>
{subBlocksToShow.map((sb) => {
// Mask password fields like the canvas does
const displayValue = sb.isPassword ? '•••' : getDisplayValue(sb.value)
// Mask password fields and credential IDs
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 (
<div key={sb.id} className='flex items-start gap-1.5 py-0.5 text-[11px]'>
<span
@@ -1412,10 +1425,13 @@ function RunSkipButtons({
setIsProcessing(true)
setButtonsHidden(true)
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)
// Then execute
await handleRun(toolCall, setToolCallState, onStateChange, editedParams)
// For client tools with interrupts (not integration tools), we still need to call handleRun
// since executeIntegrationTool only works for server-side tools
if (!isIntegrationTool(toolCall.name)) {
await handleRun(toolCall, setToolCallState, onStateChange, editedParams)
}
} finally {
setIsProcessing(false)
actionInProgressRef.current = false
@@ -1438,10 +1454,10 @@ function RunSkipButtons({
if (buttonsHidden) return null
// Hide "Always Allow" for integration tools (only show for client tools with interrupts)
const showAlwaysAllow = !isIntegrationTool(toolCall.name)
// Show "Always Allow" for all tools that require confirmation
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 (
<div className='mt-[10px] flex gap-[6px]'>
<Button onClick={onRun} disabled={isProcessing} variant='tertiary'>

View File

@@ -105,10 +105,10 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
isSendingMessage,
])
/** Load auto-allowed tools once on mount */
/** Load auto-allowed tools once on mount - runs immediately, independent of workflow */
const hasLoadedAutoAllowedToolsRef = useRef(false)
useEffect(() => {
if (hasMountedRef.current && !hasLoadedAutoAllowedToolsRef.current) {
if (!hasLoadedAutoAllowedToolsRef.current) {
hasLoadedAutoAllowedToolsRef.current = true
loadAutoAllowedTools().catch((err) => {
logger.warn('[Copilot] Failed to load auto-allowed tools', err)

View File

@@ -11,7 +11,7 @@ import { useSidebarStore } from '@/stores/sidebar/store'
* Avatar display configuration for responsive layout.
*/
const AVATAR_CONFIG = {
MIN_COUNT: 3,
MIN_COUNT: 4,
MAX_COUNT: 12,
WIDTH_PER_AVATAR: 20,
} as const
@@ -106,7 +106,9 @@ export function Avatars({ workflowId }: AvatarsProps) {
}, [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(() => {
if (workflowUsers.length === 0) {
@@ -116,7 +118,8 @@ export function Avatars({ workflowId }: AvatarsProps) {
const visible = workflowUsers.slice(0, 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])
if (visibleUsers.length === 0) {
@@ -139,9 +142,8 @@ export function Avatars({ workflowId }: AvatarsProps) {
</Tooltip.Content>
</Tooltip.Root>
)}
{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>
)

View File

@@ -347,7 +347,7 @@ export function WorkflowItem({
) : (
<div
className={clsx(
'min-w-0 flex-1 truncate font-medium',
'min-w-0 truncate font-medium',
active
? 'text-[var(--text-primary)]'
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'

View File

@@ -2468,16 +2468,17 @@ async function validateWorkflowSelectorIds(
const result = await validateSelectorIds(selector.selectorType, selector.value, context)
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({
blockId: selector.blockId,
blockType: selector.blockType,
field: selector.fieldName,
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}`,
})
}
if (result.warning) {
} else if (result.warning) {
// Log warnings that don't have errors (shouldn't happen for credentials but may for other selectors)
logger.warn(result.warning, {
blockId: selector.blockId,
fieldName: selector.fieldName,

View File

@@ -39,6 +39,31 @@ export async function validateSelectorIds(
.from(account)
.where(and(inArray(account.id, idsArray), eq(account.userId, context.userId)))
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
}

View File

@@ -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.
* Deep clones all fields to ensure proper JSON serialization.
* Masks sensitive credential IDs before persisting.
* This ensures they render identically when loaded back.
*/
function serializeMessagesForDB(messages: CopilotMessage[]): any[] {
// Get credential IDs to mask
const credentialIds = useCopilotStore.getState().sensitiveCredentialIds
const result = messages
.map((msg) => {
// 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
}
return serialized
// Mask credential IDs in the serialized message before persisting
return maskCredentialIdsInValue(serialized, credentialIds)
})
.filter((msg) => {
// Filter out empty assistant messages
@@ -1320,7 +1359,16 @@ const sseHandlers: Record<string, SSEHandler> = {
typeof def.hasInterrupt === 'function'
? !!def.hasInterrupt(args || {})
: !!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' })
// Defer executing transition by a tick to let pending render
setTimeout(() => {
@@ -1426,11 +1474,23 @@ const sseHandlers: Record<string, SSEHandler> = {
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 {
const inst = getClientTool(id) as any
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(() => {
// Guard against duplicate execution - check if already executing or terminal
const currentState = get().toolCallsById[id]?.state
@@ -1449,7 +1509,12 @@ const sseHandlers: Record<string, SSEHandler> = {
Promise.resolve()
.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
})
.catch(() => {
@@ -1474,20 +1539,35 @@ const sseHandlers: Record<string, SSEHandler> = {
}
} 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
// Only relevant if mode is 'build' (agent)
const { mode, workflowId } = get()
const { mode, workflowId, autoAllowedTools, executeIntegrationTool } = get()
if (mode === 'build' && workflowId) {
// Check if tool was NOT found in client registry
const def = name ? getTool(name) : undefined
const inst = getClientTool(id) as any
if (!def && !inst && name) {
// Integration tools stay in pending state until user confirms
logger.info('[build mode] Integration tool awaiting user confirmation', {
id,
name,
})
// Check if this integration tool is auto-allowed - if so, execute it immediately
if (autoAllowedTools.includes(name)) {
logger.info('[build mode] Auto-executing integration tool (auto-allowed)', { id, 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
// Check if tool is auto-allowed
const { autoAllowedTools: subAgentAutoAllowed } = get()
const isSubAgentAutoAllowed = name ? subAgentAutoAllowed.includes(name) : false
try {
const def = getTool(name)
if (def) {
@@ -1983,8 +2067,15 @@ const subAgentSSEHandlers: Record<string, SSEHandler> = {
typeof def.hasInterrupt === 'function'
? !!def.hasInterrupt(args || {})
: !!def.hasInterrupt
if (!hasInterrupt) {
// Auto-execute tools without interrupts - non-blocking
// Auto-execute if no interrupt OR if auto-allowed
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 })
Promise.resolve()
.then(() => def.execute(ctx, args || {}))
@@ -2001,9 +2092,22 @@ const subAgentSSEHandlers: Record<string, SSEHandler> = {
const instance = getClientTool(id)
if (instance) {
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()
.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) => {
logger.error('[SubAgent] Class tool execution failed', {
id,
@@ -2232,6 +2336,7 @@ const initialState = {
autoAllowedTools: [] as string[],
messageQueue: [] as import('./types').QueuedMessage[],
suppressAbortContinueOption: false,
sensitiveCredentialIds: new Set<string>(),
}
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[]
if (revertState) {
const currentMessages = get().messages
@@ -3676,6 +3787,16 @@ export const useCopilotStore = create<CopilotStore>()(
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
const executingMap = { ...get().toolCallsById }
executingMap[id] = {
@@ -3824,6 +3945,46 @@ export const useCopilotStore = create<CopilotStore>()(
const data = await res.json()
set({ autoAllowedTools: data.autoAllowedTools || [] })
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) {
logger.error('[AutoAllowedTools] Failed to add tool', { toolId, error: err })
@@ -3853,6 +4014,57 @@ export const useCopilotStore = create<CopilotStore>()(
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
addToQueue: (message, options) => {
const queuedMessage: import('./types').QueuedMessage = {

View File

@@ -156,6 +156,9 @@ export interface CopilotState {
// Message queue for messages sent while another is in progress
messageQueue: QueuedMessage[]
// Credential IDs to mask in UI (for sensitive data protection)
sensitiveCredentialIds: Set<string>
}
export interface CopilotActions {
@@ -235,6 +238,10 @@ export interface CopilotActions {
removeAutoAllowedTool: (toolId: string) => Promise<void>
isToolAutoAllowed: (toolId: string) => boolean
// Credential masking
loadSensitiveCredentialIds: () => Promise<void>
maskCredentialValue: (value: string) => string
// Message queue actions
addToQueue: (
message: string,