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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/
import type { SubBlockConfig } from '@/blocks/types'
import { getDependsOnFields } from '@/blocks/utils'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { getProviderFromModel } from '@/providers/utils'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'

/**
Expand Down Expand Up @@ -108,30 +107,18 @@ export const ComboBox = memo(function ComboBox({
const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue

// Permission-based filtering for model dropdowns
const {
isProviderAllowed,
isModelAllowed,
isLoading: isPermissionLoading,
} = usePermissionConfig()
const { isModelUsable, isLoading: isPermissionLoading } = usePermissionConfig()

// Evaluate static options if provided as a function
const staticOptions = useMemo(() => {
const opts = typeof options === 'function' ? options() : options

if (subBlockId === 'model') {
return opts.filter((opt) => {
const modelId = typeof opt === 'string' ? opt : opt.id
if (!isModelAllowed(modelId)) return false
try {
return isProviderAllowed(getProviderFromModel(modelId))
} catch {
return true
}
})
return opts.filter((opt) => isModelUsable(typeof opt === 'string' ? opt : opt.id))
}

return opts
}, [options, subBlockId, isProviderAllowed, isModelAllowed])
}, [options, subBlockId, isModelUsable])

const {
fetchedOptions,
Expand Down Expand Up @@ -210,15 +197,7 @@ export const ComboBox = memo(function ComboBox({
fetchOptions && normalizedFetchedOptions.length > 0 ? normalizedFetchedOptions : staticOptions

if (subBlockId === 'model' && fetchOptions && normalizedFetchedOptions.length > 0) {
opts = opts.filter((opt) => {
const modelId = typeof opt === 'string' ? opt : opt.id
if (!isModelAllowed(modelId)) return false
try {
return isProviderAllowed(getProviderFromModel(modelId))
} catch {
return true
}
})
opts = opts.filter((opt) => isModelUsable(typeof opt === 'string' ? opt : opt.id))
}

// Merge hydrated option if not already present
Expand Down Expand Up @@ -251,8 +230,7 @@ export const ComboBox = memo(function ComboBox({
hydratedOption,
createdOption,
subBlockId,
isProviderAllowed,
isModelAllowed,
Comment thread
cursor[bot] marked this conversation as resolved.
isModelUsable,
])

// Convert options to Combobox format
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
import { ChipTag, Combobox, type ComboboxOption } from '@sim/emcn'
import { generateId } from '@sim/utils/id'
import { isRecordLike } from '@sim/utils/object'
import {
NO_DENIED_OPERATIONS,
OPERATION_SUBBLOCK_ID,
} from '@/lib/permission-groups/operation-access'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight'
import { useFetchedOptions } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options'
Expand All @@ -11,7 +15,7 @@ import { getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
import { getDependsOnFields } from '@/blocks/utils'
import { ResponseBlockHandler } from '@/executor/handlers/response/response-handler'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useOperationAccess } from '@/hooks/use-operation-access'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'

/** Selected-value badges shown before folding the rest into a "+N" badge. */
Expand Down Expand Up @@ -97,7 +101,7 @@ export const Dropdown = memo(function Dropdown({
preserveLabelCase = false,
}: DropdownProps) {
const activeSearchTarget = useActiveSearchTarget()
const { isToolAllowed } = usePermissionConfig()
const { getDeniedOperations, resolveDefaultOperation, isPermissionLoading } = useOperationAccess()
const [storeValue, setStoreValue] = useSubBlockValue<string | string[]>(blockId, subBlockId) as [
string | string[] | null | undefined,
(value: string | string[]) => void,
Expand Down Expand Up @@ -189,26 +193,17 @@ export const Dropdown = memo(function Dropdown({

/**
* Operation IDs whose resolved tool is denied by the caller's permission
* group. Only the `operation` selector of a block with a tool selector is
* gated. Denied operations are hidden from the picker (still resolvable for
* label display); the server is the authoritative gate regardless.
* group. Only the `operation` selector is gated. Denied operations are hidden
* from the picker (still resolvable for label display); the server is the
* authoritative gate regardless.
*/
const deniedOperationIds = useMemo(() => {
const denied = new Set<string>()
if (subBlockId !== 'operation') return denied
const selectTool = blockConfig?.tools?.config?.tool
if (!selectTool) return denied
for (const opt of allOptions) {
const optionId = typeof opt === 'string' ? opt : opt.id
try {
const toolId = selectTool({ operation: optionId })
if (toolId && !isToolAllowed(toolId)) denied.add(optionId)
} catch {
// Unresolvable from the operation alone — leave it visible; the server still enforces.
}
}
return denied
}, [subBlockId, blockConfig, allOptions, isToolAllowed])
if (subBlockId !== OPERATION_SUBBLOCK_ID) return NO_DENIED_OPERATIONS
return getDeniedOperations(
blockConfig,
allOptions.map((opt) => (typeof opt === 'string' ? opt : opt.id))
)
}, [subBlockId, blockConfig, allOptions, getDeniedOperations])

const comboboxOptions = useMemo((): ComboboxOption[] => {
const toLabel = (raw: string) => (preserveLabelCase ? raw : raw.toLowerCase())
Expand All @@ -232,17 +227,22 @@ export const Dropdown = memo(function Dropdown({
const defaultOptionValue = useMemo(() => {
if (multiSelect) return undefined

const firstSelectable = comboboxOptions.find((opt) => !opt.hidden)
if (defaultValue !== undefined) {
// Don't seed a denied operation as the default; use the first allowed option.
if (deniedOperationIds.has(defaultValue)) {
return firstSelectable?.value
}
return defaultValue
/**
* The operation field defaults through the permission gate, which withholds
* a value until the group config has loaded. Seeding the static first
* option in that window would persist an operation the group denies —
* nothing revisits a field that already holds a value, so the correction
* that arrives with the config would never apply.
*/
if (subBlockId === OPERATION_SUBBLOCK_ID) {
const selectableIds = comboboxOptions.filter((opt) => !opt.hidden).map((opt) => opt.value)
return resolveDefaultOperation(blockConfig, selectableIds, defaultValue)
}

return firstSelectable?.value
}, [defaultValue, comboboxOptions, deniedOperationIds, multiSelect])
if (defaultValue !== undefined) return defaultValue

return comboboxOptions.find((opt) => !opt.hidden)?.value
}, [defaultValue, comboboxOptions, multiSelect, subBlockId, blockConfig, resolveDefaultOperation])

useEffect(() => {
if (multiSelect || defaultOptionValue === undefined) {
Expand Down Expand Up @@ -437,7 +437,9 @@ export const Dropdown = memo(function Dropdown({
onChange={handleChange}
onMultiSelectChange={handleMultiSelectChange}
placeholder={placeholder}
disabled={disabled}
/* The operation list only drops denied entries once the config resolves,
and a pick here persists — matching the agent tool selector. */
disabled={disabled || (subBlockId === OPERATION_SUBBLOCK_ID && isPermissionLoading)}
editable={false}
onOpenChange={handleOpenChange}
overlayContent={multiSelectOverlay ?? singleSelectOverlay}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ import {
} from '@/lib/mcp/tool-validation'
import type { McpToolSchema } from '@/lib/mcp/types'
import { getProviderIdFromServiceId, type OAuthProvider, type OAuthService } from '@/lib/oauth'
import {
NO_DENIED_OPERATIONS,
OPERATION_SUBBLOCK_ID,
} from '@/lib/permission-groups/operation-access'
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
import { resolveStoredToolName } from '@/lib/workflows/subblocks/display'
import { buildToolSubBlockId } from '@/lib/workflows/tool-input/synthetic-subblocks'
Expand Down Expand Up @@ -65,7 +69,7 @@ import { getAllBlocks, getBlock } from '@/blocks'
import { isCustomBlockType } from '@/blocks/custom/build-config'
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
import { getTileIconColorClass } from '@/blocks/icon-color'
import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
import type { BlockConfig, SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
import { BUILT_IN_TOOL_TYPES } from '@/blocks/utils'
import { useMcpOauthPopup } from '@/hooks/mcp/use-mcp-oauth-popup'
import { useMcpTools } from '@/hooks/mcp/use-mcp-tools'
Expand All @@ -85,6 +89,7 @@ import {
import { useWorkflowState, useWorkflows } from '@/hooks/queries/workflows'
import { useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useOperationAccess } from '@/hooks/use-operation-access'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { getProviderFromModel, supportsToolUsageControl } from '@/providers/utils'
Expand Down Expand Up @@ -355,25 +360,23 @@ function resolveCustomToolFromReference(
/**
* Checks if a block supports multiple operations.
*
* @param blockType - The block type to check
* @param block - The block config to check
* @returns `true` if the block has more than one tool operation available
*/
function hasMultipleOperations(blockType: string): boolean {
const block = getAllBlocks().find((b) => b.type === blockType)
function hasMultipleOperations(block: BlockConfig | undefined): boolean {
return (block?.tools?.access?.length || 0) > 1
}

/**
* Gets the available operation options for a multi-operation tool.
*
* @param blockType - The block type to get operations for
* @param block - The block config to get operations for
* @returns Array of operation options with label and id properties
*/
function getOperationOptions(blockType: string): { label: string; id: string }[] {
const block = getAllBlocks().find((b) => b.type === blockType)
function getOperationOptions(block: BlockConfig | undefined): { label: string; id: string }[] {
if (!block || !block.tools?.access) return []

const operationSubBlock = block.subBlocks.find((sb) => sb.id === 'operation')
const operationSubBlock = block.subBlocks.find((sb) => sb.id === OPERATION_SUBBLOCK_ID)
if (
operationSubBlock &&
operationSubBlock.type === 'dropdown' &&
Expand Down Expand Up @@ -662,13 +665,47 @@ export const ToolInput = memo(function ToolInput({
const provider = model ? getProviderFromModel(model) : ''
const supportsToolControl = provider ? supportsToolUsageControl(provider) : false

const { filterBlocks, config: permissionConfig } = usePermissionConfig()
const {
filterBlocks,
config: permissionConfig,
isLoading: isPermissionLoading,
} = usePermissionConfig()
const { getDeniedOperations } = useOperationAccess()

/**
* A tool block's selectable operations paired with the ones the caller's
* permission group denies.
*
* Both callers derive from this single result so they cannot drift: the
* picker *removes* denied operations (it must never offer or default to one),
* while the editor's selector *hides* them (a tool already saved on one keeps
* showing its name).
*/
const getOperationChoices = useCallback(
(block: BlockConfig | undefined) => {
const options = getOperationOptions(block).filter((option) => option.id !== '')
return {
options,
denied: getDeniedOperations(
block,
options.map((option) => option.id)
),
}
},
[getDeniedOperations]
)

const customBlockOverlayVersion = useCustomBlockOverlayVersion()
const toolBlocks = useMemo(() => {
const allToolBlocks = getAllBlocks().filter(isAgentToolBlock)
return filterBlocks(allToolBlocks)
}, [filterBlocks, customBlockOverlayVersion])
/* An empty option list means the block declares no selectable operation, so
there is nothing to gate — only a wholly denied one leaves the picker. */
return filterBlocks(allToolBlocks).filter((block) => {
if (!hasMultipleOperations(block)) return true
const { options, denied } = getOperationChoices(block)
return options.length === 0 || options.some((option) => !denied.has(option.id))
})
Comment thread
cursor[bot] marked this conversation as resolved.
}, [filterBlocks, customBlockOverlayVersion, getOperationChoices])

const hasBackfilledRef = useRef(false)
useEffect(() => {
Expand Down Expand Up @@ -744,7 +781,7 @@ export const ToolInput = memo(function ToolInput({
* @returns `true` if tool is already selected (for single-operation tools only)
*/
const isToolAlreadySelected = (toolId: string, blockType: string) => {
if (hasMultipleOperations(blockType)) {
if (hasMultipleOperations(getBlock(blockType))) {
return false
}
// Custom blocks all share toolId `workflow_executor`, so dedup-by-toolId would
Expand Down Expand Up @@ -783,9 +820,10 @@ export const ToolInput = memo(function ToolInput({
(toolBlock: (typeof toolBlocks)[0]) => {
if (isPreview || disabled) return

const hasOperations = hasMultipleOperations(toolBlock.type)
const operationOptions = hasOperations ? getOperationOptions(toolBlock.type) : []
const defaultOperation = operationOptions.length > 0 ? operationOptions[0].id : undefined
const { options, denied } = hasMultipleOperations(toolBlock)
? getOperationChoices(toolBlock)
: { options: [], denied: NO_DENIED_OPERATIONS }
const defaultOperation = options.find((option) => !denied.has(option.id))?.id

const toolId = getToolIdForOperation(toolBlock.type, defaultOperation, toolBlock)
if (!toolId) return
Expand Down Expand Up @@ -821,7 +859,7 @@ export const ToolInput = memo(function ToolInput({

setOpen(false)
},
[isPreview, disabled, isToolAlreadySelected, selectedTools, setStoreValue]
Comment thread
waleedlatif1 marked this conversation as resolved.
[isPreview, disabled, isToolAlreadySelected, selectedTools, setStoreValue, getOperationChoices]
)

const handleAddCustomTool = useCallback(
Expand Down Expand Up @@ -1670,7 +1708,11 @@ export const ToolInput = memo(function ToolInput({
options={[]}
groups={toolGroups}
placeholder='Add tool...'
disabled={disabled}
/* Every list this picker offers — blocks, operations, MCP and custom
tools — reads as unrestricted until the permission config resolves,
and adding a tool is a one-shot write that nothing revisits. Closed
rather than optimistic for that beat. */
disabled={disabled || isPermissionLoading}
searchable
searchPlaceholder='Search tools...'
maxHeight={240}
Expand Down Expand Up @@ -1799,7 +1841,8 @@ export const ToolInput = memo(function ToolInput({
)
: []

const hasOperations = !isCustomTool && !isMcpTool && hasMultipleOperations(tool.type)
const hasOperations =
!isCustomTool && !isMcpTool && hasMultipleOperations(toolBlock ?? undefined)
const hasParams = useSubBlocks
? displaySubBlocks.length > 0
: displayParams.filter((param) => evaluateParameterCondition(param, tool)).length > 0
Expand Down Expand Up @@ -2059,26 +2102,33 @@ export const ToolInput = memo(function ToolInput({
<div className='flex flex-col gap-2.5 overflow-visible rounded-b-[4px] border-[var(--border-1)] border-t bg-[var(--surface-2)] p-2'>
{/* Operation dropdown for tools with multiple operations */}
{(() => {
const hasOperations = hasMultipleOperations(tool.type)
const operationOptions = hasOperations ? getOperationOptions(tool.type) : []
if (!hasOperations) return null
const { options: operationOptions, denied } = getOperationChoices(
toolBlock ?? undefined
)
if (operationOptions.length === 0) return null

return hasOperations && operationOptions.length > 0 ? (
return (
<div className='relative space-y-1.5'>
<div className='text-[var(--text-primary)] text-small'>Operation</div>
<Combobox
options={operationOptions
.filter((option) => option.id !== '')
.map((option) => ({
label: option.label,
value: option.id,
}))}
value={tool.operation || operationOptions[0].id}
options={operationOptions.map((option) => ({
label: option.label,
value: option.id,
hidden: denied.has(option.id),
}))}
value={
tool.operation ||
operationOptions.find((option) => !denied.has(option.id))?.id
}
onChange={(value) => handleOperationChange(toolIndex, value)}
placeholder='Select operation'
disabled={disabled}
/* Denied operations only drop out once the config
resolves, and picking one rewrites the stored tool. */
disabled={disabled || isPermissionLoading}
/>
</div>
) : null
)
})()}

{(() => {
Expand Down
Loading
Loading