From 33caa35985820fb44eda2eb95dee081dc2fb24bc Mon Sep 17 00:00:00 2001 From: Robin Braumann <50590409+bjorger@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:21:43 +0200 Subject: [PATCH] fix(editor): Create agent-builder credentials in the agent's project (#35286) Co-authored-by: Cursor --- .../__tests__/AgentModelSelector.spec.ts | 25 +++++++-- .../agents/components/AgentInfoPanel.vue | 1 + .../agents/components/AgentMemoryPanel.vue | 26 +++++++++- .../agents/components/AgentModelSelector.vue | 11 +++- .../agents/components/AgentSubAgentsPanel.vue | 5 ++ .../settings/AgentBuilderModelSection.vue | 6 +++ .../CredentialEdit/CredentialEdit.test.ts | 52 ++++++++++++++++++- .../CredentialEdit/CredentialEdit.vue | 17 +++--- 8 files changed, 127 insertions(+), 16 deletions(-) diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentModelSelector.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentModelSelector.spec.ts index a5b51a3df3b..fb475c8d14a 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentModelSelector.spec.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentModelSelector.spec.ts @@ -172,7 +172,11 @@ const modelsByProvider: AgentModelsByProvider = { async function mountSelector( credentials: Record, - extraProps: { isManagedCredential?: boolean; credentialModalAppendToBody?: boolean } = {}, + extraProps: { + isManagedCredential?: boolean; + credentialModalAppendToBody?: boolean; + boundCredentialId?: string | null; + } = {}, ) { const { default: AgentModelSelector } = await import('../components/AgentModelSelector.vue'); return mount(AgentModelSelector, { @@ -386,7 +390,10 @@ describe('AgentModelSelector', () => { }); it('surfaces a stale selected credential as missing', async () => { - const wrapper = await mountSelector({ anthropic: 'deleted-credential' }); + const wrapper = await mountSelector( + { anthropic: 'deleted-credential' }, + { boundCredentialId: 'deleted-credential' }, + ); const dropdown = getDropdown(wrapper); const anthropicItem = getProviderItem(wrapper, 'anthropic'); @@ -395,8 +402,20 @@ describe('AgentModelSelector', () => { expect(JSON.stringify(anthropicItem?.children ?? [])).not.toContain('Claude Sonnet 4.5'); }); + it('reports missing credentials when the picker resolves one the config has not bound', async () => { + const wrapper = await mountSelector( + { anthropic: 'anthropic-cred' }, + { boundCredentialId: null }, + ); + + expect(getDropdown(wrapper).props('credentialsMissing')).toBe(true); + }); + it('uses an available selected credential', async () => { - const wrapper = await mountSelector({ anthropic: 'anthropic-cred' }); + const wrapper = await mountSelector( + { anthropic: 'anthropic-cred' }, + { boundCredentialId: 'anthropic-cred' }, + ); const dropdown = getDropdown(wrapper); const anthropicItem = getProviderItem(wrapper, 'anthropic'); diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentInfoPanel.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentInfoPanel.vue index f888c5c2f96..81b19adc69e 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentInfoPanel.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentInfoPanel.vue @@ -218,6 +218,7 @@ function onInstructionsInput(value: string) { :is-loading="isLoading" :project-id="projectId" :warn-missing-credentials="true" + :bound-credential-id="props.config?.credential ?? null" :is-managed-credential="isManagedCredential" data-testid="agent-model-selector" @change="onModelChange" diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentMemoryPanel.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentMemoryPanel.vue index 1b39e77ec8a..f7f0c1e1a2a 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentMemoryPanel.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentMemoryPanel.vue @@ -10,7 +10,7 @@ import { N8nSwitch, } from '@n8n/design-system'; import { useI18n, type BaseTextKey } from '@n8n/i18n'; -import { MANAGED_CREDENTIAL_TOKEN } from '@n8n/api-types'; +import { AI_GATEWAY_MANAGED_TAG, MANAGED_CREDENTIAL_TOKEN } from '@n8n/api-types'; import { useSettingsStore } from '@/app/stores/settings.store'; import { useUsersStore } from '@/features/settings/users/users.store'; import CredentialPicker from '@/features/credentials/components/CredentialPicker/CredentialPicker.vue'; @@ -77,6 +77,28 @@ const configuredMemoryModel = computed(() => { }); const selectedMemoryModel = ref(configuredMemoryModel.value); +// Follows the same precedence as `configuredMemoryModel`, down to falling back +// to the agent's own credential when no memory-specific model is configured. +const configuredMemoryCredential = computed(() => { + const episodicCredential = + episodicMemory.value?.enabled === true + ? (episodicMemory.value.reflectorModel?.credential ?? + episodicMemory.value.extractorModel?.credential) + : null; + + return ( + episodicCredential ?? + props.config?.memory?.observationalMemory?.reflectorModel?.credential ?? + props.config?.memory?.observationalMemory?.observerModel?.credential ?? + props.config?.credential ?? + null + ); +}); + +const isManagedMemoryCredential = computed( + () => configuredMemoryCredential.value === AI_GATEWAY_MANAGED_TAG, +); + watch( projectId, (id) => { @@ -269,6 +291,8 @@ function onEpisodicMemoryToggle(enabled: boolean) { :is-loading="isLoading" :project-id="projectId" :warn-missing-credentials="true" + :bound-credential-id="configuredMemoryCredential" + :is-managed-credential="isManagedMemoryCredential" credential-modal-append-to-body data-testid="agent-memory-recall-model-selector" @change="onMemoryRecallModelChange" diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentModelSelector.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentModelSelector.vue index 9fc9bc12344..a71a168138a 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentModelSelector.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentModelSelector.vue @@ -48,6 +48,7 @@ const { isLoading, projectId, warnMissingCredentials = false, + boundCredentialId = null, disabled = false, isManagedCredential = false, credentialModalAppendToBody = false, @@ -58,6 +59,12 @@ const { isLoading: boolean; projectId: string; warnMissingCredentials?: boolean; + /** + * The credential the host has actually persisted for this model. The picker + * falls back to any credential of the provider, so only this tells us + * whether the saved config can run. + */ + boundCredentialId?: string | null; disabled?: boolean; /** The selected model uses the n8n Connect (AI Gateway) managed credential. */ isManagedCredential?: boolean; @@ -124,8 +131,8 @@ const isCredentialsMissing = computed( () => !isManagedCredential && warnMissingCredentials && - selectedModel?.provider && - !selectedCredential.value, + Boolean(selectedModel?.provider) && + !(boundCredentialId && credentialsStore.getCredentialById(boundCredentialId)), ); const selectedLabel = computed( diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentSubAgentsPanel.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentSubAgentsPanel.vue index 9166c6a64c9..7ff0d473968 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentSubAgentsPanel.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentSubAgentsPanel.vue @@ -202,6 +202,10 @@ function hasDifficultyMapping(difficulty: SubAgentTaskDifficulty): boolean { return Boolean(props.config?.subAgents?.modelsByDifficulty?.[difficulty]); } +function boundCredentialForDifficulty(difficulty: SubAgentTaskDifficulty): string | null { + return props.config?.subAgents?.modelsByDifficulty?.[difficulty]?.credential ?? null; +} + const hasAnyDifficultyMapping = computed(() => SUB_AGENT_TASK_DIFFICULTIES.some((difficulty) => hasDifficultyMapping(difficulty)), ); @@ -392,6 +396,7 @@ function clearDifficultyMapping(difficulty: SubAgentTaskDifficulty) { :is-loading="isLoading" :project-id="projectId" :warn-missing-credentials="true" + :bound-credential-id="boundCredentialForDifficulty(difficulty)" :disabled="disabled" :is-managed-credential="isManagedCredentialForDifficulty(difficulty)" :data-testid="`agent-sub-agents-difficulty-${difficulty}-model`" diff --git a/packages/frontend/editor-ui/src/features/agents/components/settings/AgentBuilderModelSection.vue b/packages/frontend/editor-ui/src/features/agents/components/settings/AgentBuilderModelSection.vue index 45f244556d3..8c6cf6b4925 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/settings/AgentBuilderModelSection.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/settings/AgentBuilderModelSection.vue @@ -61,6 +61,11 @@ const filteredAgents = computed(() => getModelsForPicker(credentialsByProvider.value), ); +const boundCredentialId = computed(() => { + const settings = store.effectiveSettings; + return settings.mode === 'custom' ? settings.credentialId : null; +}); + const selectedAgent = computed(() => { const settings = store.effectiveSettings; if (settings.mode !== 'custom') return null; @@ -206,6 +211,7 @@ function onCancel() { :is-loading="isLoading" :project-id="projectId" :warn-missing-credentials="true" + :bound-credential-id="boundCredentialId" @change="onModelChange" @select-credential="onSelectCredential" /> diff --git a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.test.ts b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.test.ts index abfc77a9044..b353699d00f 100644 --- a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.test.ts +++ b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.test.ts @@ -1006,7 +1006,10 @@ describe('CredentialEdit', () => { }); describe('saving credentials', () => { - const createPiniaForSaveTest = (credentialModalState: Partial = {}) => + const createPiniaForSaveTest = ( + credentialModalState: Partial = {}, + projectsState: Record = {}, + ) => createTestingPinia({ initialState: { [STORES.UI]: { @@ -1035,6 +1038,7 @@ describe('CredentialEdit', () => { type: 'personal', scopes: ['credential:create', 'credential:read', 'credential:update'], }, + ...projectsState, }, }, }); @@ -1042,8 +1046,9 @@ describe('CredentialEdit', () => { const setupNewCredential = ( credentialType: ICredentialType, credentialModalState: Partial = {}, + projectsState: Record = {}, ) => { - const pinia = createPiniaForSaveTest(credentialModalState); + const pinia = createPiniaForSaveTest(credentialModalState, projectsState); const credentialsStore = mockedStore(useCredentialsStore); credentialsStore.state.credentialTypes = { [credentialType.name]: credentialType, @@ -1158,6 +1163,49 @@ describe('CredentialEdit', () => { expect(credentialsStore.testCredential).not.toHaveBeenCalled(); }); + test('creates the credential in the project the modal was opened for', async () => { + const credentialType = { + name: 'testApi', + displayName: 'Test API', + properties: [], + } as ICredentialType; + const { credentialsStore, pinia } = setupNewCredential( + credentialType, + { projectId: 'team-project' }, + { + currentProject: null, + myProjects: [ + { + id: 'team-project', + name: 'Team project', + type: 'team', + scopes: ['credential:create', 'credential:read', 'credential:update'], + }, + ], + }, + ); + + const { getByTestId } = renderComponent({ + props: { + activeId: credentialType.name, + modalName: CREDENTIAL_EDIT_MODAL_KEY, + mode: 'new', + }, + pinia, + }); + + await waitFor(() => expect(credentialsStore.getNewCredentialName).toHaveBeenCalled()); + await userEvent.click(within(getByTestId('credential-save-button')).getByRole('button')); + + await waitFor(() => + expect(credentialsStore.createNewCredential).toHaveBeenCalledWith( + expect.objectContaining({ type: 'testApi' }), + 'team-project', + undefined, + ), + ); + }); + test('keeps the modal open after saving credentials by default', async () => { const credentialType = { name: 'testApi', diff --git a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.vue b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.vue index 38812b51e7d..2be9428f9a3 100644 --- a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.vue +++ b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.vue @@ -31,7 +31,7 @@ import { useNDVStore } from '@/features/ndv/shared/ndv.store'; import { useSettingsStore } from '@/app/stores/settings.store'; import { useUIStore } from '@/app/stores/ui.store'; import { provideWorkflowDocumentStore } from '@/app/stores/workflowDocument.store'; -import type { Project, ProjectSharingData } from '@/features/collaboration/projects/projects.types'; +import type { ProjectSharingData } from '@/features/collaboration/projects/projects.types'; import { assert } from '@n8n/utils/assert'; import { createEventBus } from '@n8n/utils/event-bus'; @@ -71,6 +71,9 @@ type Props = { mode?: 'new' | 'edit'; }; +/** All a new credential needs of its owning project: where to save it, and what to call it in the toast. */ +type CredentialHomeProject = { id: string; name?: string | null }; + const props = withDefaults(defineProps(), { mode: 'new', activeId: undefined }); const credentialsStore = useCredentialsStore(); @@ -230,6 +233,7 @@ const { isCredentialTestable, credentialPermissions, usesExternalSecrets, + homeProject, setCredentialPropertyDefaults, resetCredentialData, testCredential, @@ -667,7 +671,7 @@ async function saveCredential(): Promise { if (presetUsageScope.value) { credentialDetails.usageScope = presetUsageScope.value; } - credential = await createCredential(credentialDetails, projectsStore.currentProject); + credential = await createCredential(credentialDetails, homeProject.value); } else { if (settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Sharing]) { credentialDetails.sharedWithProjects = credentialData.value @@ -797,14 +801,11 @@ async function handleDynamicNotification(isValid: boolean) { } } -const createToastMessagingForNewCredentials = (project?: Project | null) => { +const createToastMessagingForNewCredentials = (project?: CredentialHomeProject | null) => { let toastTitle = i18n.baseText('credentials.create.personal.toast.title'); let toastText = ''; - if ( - projectsStore.currentProject && - projectsStore.currentProject.id !== projectsStore.personalProject?.id - ) { + if (project && project.id !== projectsStore.personalProject?.id) { toastTitle = i18n.baseText('credentials.create.project.toast.title', { interpolate: { projectName: project?.name ?? '' }, }); @@ -822,7 +823,7 @@ const createToastMessagingForNewCredentials = (project?: Project | null) => { async function createCredential( credentialDetails: ICredentialsDecrypted, - project?: Project | null, + project?: CredentialHomeProject | null, ): Promise { let credential;