Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
146 changes: 146 additions & 0 deletions apps/sim/app/api/tools/managed-agent/list/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import {
listManagedAgentOptionsContract,
type ManagedAgentOption,
type ManagedAgentResource,
} from '@/lib/api/contracts/managed-agents'
import { parseRequest } from '@/lib/api/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { CLAUDE_PLATFORM_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/credentials/token-service-accounts/descriptors'
import { AGENT_MEMORY_BETA, managedAgentsList } from '@/lib/managed-agents/session-client'
import { captureServerEvent } from '@/lib/posthog/server'
import { resolveOAuthAccountId, resolveServiceAccountToken } from '@/app/api/auth/oauth/utils'

export const dynamic = 'force-dynamic'

const logger = createLogger('ManagedAgentListAPI')

interface AnthropicListRow {
id?: string
name?: string | null
config?: { type?: 'cloud' | 'self_hosted' }
}

/**
* Anthropic list path + the beta header it requires. Memory-store endpoints
* use `agent-memory-2026-07-22`; combining it with the managed-agents beta is
* a documented 400, so each resource declares exactly one.
*/
const RESOURCE_ENDPOINTS: Record<ManagedAgentResource, { path: string; beta?: string }> = {
agents: { path: '/v1/agents' },
environments: { path: '/v1/environments' },
vaults: { path: '/v1/vaults' },
'memory-stores': { path: '/v1/memory_stores', beta: AGENT_MEMORY_BETA },
}

function toOption(
resource: ManagedAgentResource,
row: AnthropicListRow
): ManagedAgentOption | null {
if (!row.id) return null
const name = row.name?.trim()
if (resource === 'environments') {
const type = row.config?.type
const suffix = type ? ` (${type})` : ''
return { id: row.id, label: `${name || row.id}${suffix}`, ...(type ? { type } : {}) }
}
if (resource === 'vaults') {
return { id: row.id, label: name || row.id }
}
return { id: row.id, label: name ? `${name} (${row.id})` : row.id }
}

/**
* Resolves Managed Agent dropdown options (agents / environments / vaults /
* memory stores) for the block editor against a selected Claude Platform
* credential. The credential's API key is decrypted server-side and never
* crosses the client boundary — the browser only ever receives `{ id, label }`
* options.
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
const parsed = await parseRequest(listManagedAgentOptionsContract, request, {})
if (!parsed.success) return parsed.response
const { credentialId, resource } = parsed.data.query

// Authenticates the caller AND verifies they may use this credential.
const authz = await authorizeCredentialUse(request, {
credentialId,
requireWorkflowIdForInternal: false,
})
if (!authz.ok) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}

const resolved = await resolveOAuthAccountId(credentialId)
if (
resolved?.credentialType !== 'service_account' ||
resolved.providerId !== CLAUDE_PLATFORM_SERVICE_ACCOUNT_PROVIDER_ID
) {
return NextResponse.json({ error: 'Not a Claude Platform credential' }, { status: 400 })
}

let apiKey: string
try {
const token = await resolveServiceAccountToken(
credentialId,
CLAUDE_PLATFORM_SERVICE_ACCOUNT_PROVIDER_ID
)
apiKey = token.accessToken
} catch (error) {
logger.warn('Failed to resolve Claude Platform credential', { error: getErrorMessage(error) })
return NextResponse.json({ options: [] })
}

// Decrypting and using the credential's key is a credential access — record
// it, mirroring the OAuth token route's service-account audit trail.
const actorId = authz.requesterUserId
const workspaceId = resolved.workspaceId ?? authz.workspaceId ?? null
if (actorId) {
recordAudit({
workspaceId,
actorId,
action: AuditAction.CREDENTIAL_ACCESSED,
resourceType: AuditResourceType.CREDENTIAL,
resourceId: credentialId,
description: 'Accessed Claude Platform credential to list Managed Agent resources',
metadata: {
provider: CLAUDE_PLATFORM_SERVICE_ACCOUNT_PROVIDER_ID,
credentialType: 'service_account',
},
request,
})
captureServerEvent(
actorId,
'credential_used',
{
credential_type: 'service_account',
provider_id: CLAUDE_PLATFORM_SERVICE_ACCOUNT_PROVIDER_ID,
...(workspaceId ? { workspace_id: workspaceId } : {}),
},
workspaceId ? { groups: { workspace: workspaceId } } : undefined
)
}

try {
const endpoint = RESOURCE_ENDPOINTS[resource]
const rows = await managedAgentsList<AnthropicListRow>({
apiKey,
path: endpoint.path,
beta: endpoint.beta,
signal: request.signal,
})
const options = rows
.map((row) => toOption(resource, row))
.filter((option): option is ManagedAgentOption => option !== null)
return NextResponse.json({ options })
} catch (error) {
// Some beta workspaces may not expose every resource (e.g. vaults). Log
// and degrade to an empty list rather than breaking the editor.
logger.warn('Managed agent list proxy failed', { resource, error: getErrorMessage(error) })
return NextResponse.json({ options: [] })
}
})
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ interface ConnectServiceAccountModalProps {
credentialDisplayName?: string
/** Existing description, used to seed reconnect-capable modals. */
credentialDescription?: string
/** Called with the new credential id after a successful create (token-paste providers). */
onCreated?: (credentialId: string) => void
}

/**
Expand All @@ -132,6 +134,7 @@ export function ConnectServiceAccountModal({
credentialId,
credentialDisplayName,
credentialDescription,
onCreated,
}: ConnectServiceAccountModalProps) {
const clientCredentialDescriptor = getClientCredentialAccountDescriptor(serviceAccountProviderId)
if (clientCredentialDescriptor) {
Expand Down Expand Up @@ -162,6 +165,7 @@ export function ConnectServiceAccountModal({
credentialId={credentialId}
initialDisplayName={credentialDisplayName}
initialDescription={credentialDescription}
onCreated={onCreated}
/>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ interface TokenServiceAccountModalProps {
credentialId?: string
initialDisplayName?: string
initialDescription?: string
/** Called with the new credential id after a successful create (not reconnect). */
onCreated?: (credentialId: string) => void
}

/**
Expand All @@ -91,6 +93,7 @@ export function TokenServiceAccountModal({
credentialId,
initialDisplayName,
initialDescription,
onCreated,
}: TokenServiceAccountModalProps) {
const [apiToken, setApiToken] = useState('')
const [domain, setDomain] = useState('')
Expand Down Expand Up @@ -139,14 +142,15 @@ export function TokenServiceAccountModal({
description: description.trim() || undefined,
})
} else {
await createCredential.mutateAsync({
const created = await createCredential.mutateAsync({
workspaceId,
type: 'service_account',
providerId: descriptor.providerId,
...secretFields,
displayName: displayName.trim() || undefined,
description: description.trim() || undefined,
})
onCreated?.(created.credential.id)
}
onOpenChange(false)
} catch (err: unknown) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,12 @@ import {
type OAuthProvider,
parseProvider,
} from '@/lib/oauth'
import { getMissingRequiredScopes } from '@/lib/oauth/utils'
import { getMissingRequiredScopes, getServiceConfigByServiceId } from '@/lib/oauth/utils'
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
import {
ConnectServiceAccountModal,
type ServiceAccountProviderId,
} from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal'
import { ConnectSlackBotModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal'
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'
Expand Down Expand Up @@ -50,6 +54,7 @@ export function CredentialSelector({
const [showConnectModal, setShowConnectModal] = useState(false)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [showSlackBotModal, setShowSlackBotModal] = useState(false)
const [showServiceAccountModal, setShowServiceAccountModal] = useState(false)
const [editingValue, setEditingValue] = useState('')
const [isEditing, setIsEditing] = useState(false)
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
Expand Down Expand Up @@ -101,16 +106,24 @@ export function CredentialSelector({
const credentialKind = subBlock.credentialKind

const credentials = useMemo(() => {
// A custom-bot picker lists only the reusable Slack bot credentials
// (service-account type), including in trigger mode.
if (credentialKind === 'custom-bot') {
// A custom-bot or service-account picker lists only the reusable
// service-account credentials, including in trigger mode.
if (credentialKind === 'custom-bot' || credentialKind === 'service-account') {
return rawCredentials.filter((cred) => cred.type === 'service_account')
}
return isTriggerMode && !subBlock.allowServiceAccounts
? rawCredentials.filter((cred) => cred.type !== 'service_account')
: rawCredentials
}, [rawCredentials, isTriggerMode, credentialKind, subBlock.allowServiceAccounts])

// Resolved service-account provider metadata for the token-paste connect
// modal. Gated on `credentialKind` and using the non-throwing lookup so it
// never runs (or throws) for the OAuth / custom-bot pickers.
const serviceAccountService = useMemo(
() => (credentialKind === 'service-account' ? getServiceConfigByServiceId(serviceId) : null),
[credentialKind, serviceId]
)

const selectedCredential = useMemo(
() => credentials.find((cred) => cred.id === selectedId),
[credentials, selectedId]
Expand Down Expand Up @@ -189,6 +202,10 @@ export function CredentialSelector({
setShowSlackBotModal(true)
return
}
if (credentialKind === 'service-account') {
setShowServiceAccountModal(true)
return
}
setShowConnectModal(true)
}, [credentialKind])

Expand Down Expand Up @@ -235,9 +252,13 @@ export function CredentialSelector({
? credentials.length > 0
? 'Connect another custom bot'
: 'Set up a custom bot'
: credentials.length > 0
? `Connect another ${getProviderName(provider)} account`
: `Connect ${getProviderName(provider)} account`,
: credentialKind === 'service-account'
? credentials.length > 0
? `Add another ${getProviderName(provider)} key`
: `Add ${getProviderName(provider)} key`
: credentials.length > 0
? `Connect another ${getProviderName(provider)} account`
: `Connect ${getProviderName(provider)} account`,
value: '__connect_account__',
iconElement: <ExternalLink className='size-3' />,
})
Expand Down Expand Up @@ -407,6 +428,23 @@ export function CredentialSelector({
}}
/>
)}

{showServiceAccountModal && serviceAccountService?.serviceAccountProviderId && (
<ConnectServiceAccountModal
open={showServiceAccountModal}
onOpenChange={setShowServiceAccountModal}
workspaceId={workspaceId}
serviceAccountProviderId={
serviceAccountService.serviceAccountProviderId as ServiceAccountProviderId
}
serviceName={serviceAccountService.name}
serviceIcon={serviceAccountService.icon}
onCreated={(newCredentialId) => {
setStoreValue(newCredentialId)
refetchCredentials()
}}
/>
)}
Comment thread
waleedlatif1 marked this conversation as resolved.
</div>
)
}
Loading
Loading