From b50ea9cd42e1d3dc1741092d7685e3cdbd1a0873 Mon Sep 17 00:00:00 2001 From: oleg Date: Mon, 17 Aug 2026 11:44:02 +0000 Subject: [PATCH] fix(core): Scope AI Assistant credential setup cards to the thread's project (#36425) --- .../src/agents/agent-interaction.schema.ts | 3 +++ .../tools/__tests__/workflows.tool.test.ts | 21 +++++++++++++++++++ .../instance-ai/src/tools/workflows.tool.ts | 14 +++++++++++-- .../builder/agents-builder-tools.service.ts | 2 ++ .../__tests__/ask-credential.tool.test.ts | 11 ++++++---- .../__tests__/finish-setup.tool.test.ts | 1 + .../interactive/ask-credential.tool.ts | 3 +++ .../builder/interactive/finish-setup.tool.ts | 1 + .../InstanceAiCredentialSetup.test.ts | 20 ++++++++++++++++++ .../InstanceAiConfirmationPanel.vue | 6 ++++-- .../components/InstanceAiCredentialSetup.vue | 8 +++++-- 11 files changed, 80 insertions(+), 10 deletions(-) diff --git a/packages/@n8n/api-types/src/agents/agent-interaction.schema.ts b/packages/@n8n/api-types/src/agents/agent-interaction.schema.ts index 88b08d7c58b..b76f39c3759 100644 --- a/packages/@n8n/api-types/src/agents/agent-interaction.schema.ts +++ b/packages/@n8n/api-types/src/agents/agent-interaction.schema.ts @@ -101,6 +101,9 @@ export const credentialSuspendPayloadSchema = z.object({ severity: z.literal('info'), credentialRequests: z.array(credentialRequestSchema).min(1), credentialFlow: z.object({ stage: z.literal('generic') }), + // Scopes the FE credential picker to the builder's project — without it the + // picker falls back to the user's personal project. + projectId: z.string().optional(), }); export type CredentialSuspendPayload = z.infer; diff --git a/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.test.ts b/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.test.ts index 885fff43f5d..db314ebf425 100644 --- a/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.test.ts @@ -1616,6 +1616,27 @@ describe('workflows tool', () => { }); }); + it('should scope the setup suspend to the thread-bound project even when the model omits projectId', async () => { + (analyzeWorkflow as Mock).mockResolvedValue([ + { + node: { name: 'Slack', type: 'n8n-nodes-base.slack' }, + credentialType: 'slackApi', + needsAction: true, + }, + ]); + + const context = createMockContext({ projectId: 'project-team-1' }); + const suspend = vi.fn(); + + const tool = createWorkflowsTool(context, 'full'); + await executeTool(tool, { action: 'setup', workflowId: 'wf1' }, { + suspend, + resumeData: undefined, + } as never); + + expect(suspend.mock.calls[0][0]).toMatchObject({ projectId: 'project-team-1' }); + }); + it('should scope setup requests to nodes changed by the latest build', async () => { (analyzeWorkflow as Mock).mockResolvedValue([ { diff --git a/packages/@n8n/instance-ai/src/tools/workflows.tool.ts b/packages/@n8n/instance-ai/src/tools/workflows.tool.ts index d96228df3f5..7d445d04aa2 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows.tool.ts @@ -669,13 +669,18 @@ async function handleSetupTestTrigger( // as already-resolved from the previous suspend cycle state.currentRequestId = nanoid(); + // The thread-bound project is authoritative for credential scoping; without + // it the frontend falls back to the user's personal project and offers + // credentials from outside the conversation's project. + const projectId = context.projectId ?? input.projectId; + return await ctx.suspend({ requestId: state.currentRequestId, message: 'Configure credentials for your workflow', severity: 'info' as const, setupRequests: refreshedRequests, workflowId: input.workflowId, - ...(input.projectId ? { projectId: input.projectId } : {}), + ...(projectId ? { projectId } : {}), }); } @@ -898,13 +903,18 @@ async function handleSetup( state.currentRequestId = nanoid(); + // The thread-bound project is authoritative for credential scoping; without + // it the frontend falls back to the user's personal project and offers + // credentials from outside the conversation's project. + const projectId = context.projectId ?? input.projectId; + return await ctx.suspend({ requestId: state.currentRequestId, message: 'Configure credentials for your workflow', severity: 'info' as const, setupRequests, workflowId: input.workflowId, - ...(input.projectId ? { projectId: input.projectId } : {}), + ...(projectId ? { projectId } : {}), }); } diff --git a/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts b/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts index 7752f33576c..cf2666efbe7 100644 --- a/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts +++ b/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts @@ -853,6 +853,7 @@ export class AgentsBuilderToolsService { }), buildAskCredentialTool({ credentialProvider, + projectId, isCredentialTypeKnown: (credentialType) => this.credentialTypes.recognizes(credentialType), listIntegrationCredentialIds: async () => { const agent = await this.agentsService.findById(agentId, projectId); @@ -864,6 +865,7 @@ export class AgentsBuilderToolsService { }), buildAskEmbeddingCredentialTool({ credentialProvider, + projectId, isCredentialTypeKnown: (credentialType) => this.credentialTypes.recognizes(credentialType), isAssistantProxyEnabled: () => this.aiService.isProxyEnabled(), track, diff --git a/packages/cli/src/modules/agents/builder/interactive/__tests__/ask-credential.tool.test.ts b/packages/cli/src/modules/agents/builder/interactive/__tests__/ask-credential.tool.test.ts index ea8b488ecaf..358237febbf 100644 --- a/packages/cli/src/modules/agents/builder/interactive/__tests__/ask-credential.tool.test.ts +++ b/packages/cli/src/modules/agents/builder/interactive/__tests__/ask-credential.tool.test.ts @@ -27,13 +27,15 @@ function makeProvider(creds: CredentialListItem[]): CredentialProvider { let track: Mock; -function askCredentialTool(deps: Omit) { - const merged: AskCredentialToolDeps = { ...deps, track }; +function askCredentialTool(deps: Omit) { + const merged: AskCredentialToolDeps = { projectId: 'project-1', ...deps, track }; return buildAskCredentialTool(merged); } -function askEmbeddingCredentialTool(deps: Omit) { - const merged: AskEmbeddingCredentialToolDeps = { ...deps, track }; +function askEmbeddingCredentialTool( + deps: Omit, +) { + const merged: AskEmbeddingCredentialToolDeps = { projectId: 'project-1', ...deps, track }; return buildAskEmbeddingCredentialTool(merged); } @@ -189,6 +191,7 @@ describe('ask_credential tool', () => { }, ], credentialFlow: { stage: 'generic' }, + projectId: 'project-1', }), ); expect(track).toHaveBeenCalledWith(TELEMETRY_EVENT.AGENTS.BUILDER_REQUESTED_CREDENTIAL, { diff --git a/packages/cli/src/modules/agents/builder/interactive/__tests__/finish-setup.tool.test.ts b/packages/cli/src/modules/agents/builder/interactive/__tests__/finish-setup.tool.test.ts index 177fd3c2cc4..799d10f7651 100644 --- a/packages/cli/src/modules/agents/builder/interactive/__tests__/finish-setup.tool.test.ts +++ b/packages/cli/src/modules/agents/builder/interactive/__tests__/finish-setup.tool.test.ts @@ -73,6 +73,7 @@ describe('finish_setup tool', () => { ], }, ]); + expect(payload.projectId).toBe('project-1'); expect( (payload.finishSetupChain as { collected: { credentials: unknown } }).collected.credentials, ).toEqual({ diff --git a/packages/cli/src/modules/agents/builder/interactive/ask-credential.tool.ts b/packages/cli/src/modules/agents/builder/interactive/ask-credential.tool.ts index db855787551..e9c519d94e6 100644 --- a/packages/cli/src/modules/agents/builder/interactive/ask-credential.tool.ts +++ b/packages/cli/src/modules/agents/builder/interactive/ask-credential.tool.ts @@ -19,6 +19,8 @@ import type { BuilderTrackFn } from '../builder-config-telemetry'; export interface AskCredentialToolDeps { credentialProvider: CredentialProvider; + /** Project the agent lives in — scopes the FE credential picker. */ + projectId: string; isCredentialTypeKnown?: (credentialType: string) => boolean; /** * Credential ids of the agent's configured chat channel integrations. When @@ -159,6 +161,7 @@ async function resolveCredentialSelection( }, ], credentialFlow: { stage: 'generic' as const }, + projectId: deps.projectId, }); } diff --git a/packages/cli/src/modules/agents/builder/interactive/finish-setup.tool.ts b/packages/cli/src/modules/agents/builder/interactive/finish-setup.tool.ts index 4b418166deb..55128eb6e75 100644 --- a/packages/cli/src/modules/agents/builder/interactive/finish-setup.tool.ts +++ b/packages/cli/src/modules/agents/builder/interactive/finish-setup.tool.ts @@ -374,6 +374,7 @@ async function suspendForPhase(params: { severity: 'info' as const, credentialRequests, credentialFlow: { stage: 'generic' as const }, + projectId: deps.projectId, finishSetupChain, }); } diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiCredentialSetup.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiCredentialSetup.test.ts index 5ffaeb1de5c..826e3b15cd4 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiCredentialSetup.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiCredentialSetup.test.ts @@ -184,6 +184,7 @@ describe('InstanceAiCredentialSetup', () => { const credentialsStore = useCredentialsStore(); vi.spyOn(credentialsStore, 'fetchAllCredentials').mockResolvedValue([]); + vi.spyOn(credentialsStore, 'fetchAllCredentialsForWorkflow').mockResolvedValue([]); vi.spyOn(credentialsStore, 'fetchCredentialTypes').mockResolvedValue(undefined); // The card renders the NodeCredentials picker when the store has a usable // credential of the type; default to one so the picker-based tests render it. @@ -234,6 +235,25 @@ describe('InstanceAiCredentialSetup', () => { expect(getAllByTestId('credential-picker')).toHaveLength(1); }); + it('preloads only project-scoped credentials when a projectId is provided', async () => { + const credentialsStore = useCredentialsStore(); + const requests = makeCredentialRequestsWithExisting(1); + renderComponent({ + props: { + requestId: 'req-1', + credentialRequests: requests, + message: 'Set up credentials', + projectId: 'project-team-1', + }, + }); + await nextTick(); + + expect(credentialsStore.fetchAllCredentialsForWorkflow).toHaveBeenCalledWith({ + projectId: 'project-team-1', + }); + expect(credentialsStore.fetchAllCredentials).not.toHaveBeenCalled(); + }); + it('renders the setup button (modal path) for a plain type with no usable credentials', () => { const credentialsStore = useCredentialsStore(); stubUsableCredentials(credentialsStore, () => []); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiConfirmationPanel.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiConfirmationPanel.vue index 2c6955d7fde..93fe147f151 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiConfirmationPanel.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiConfirmationPanel.vue @@ -431,12 +431,14 @@ function handlePlanDeny(conf: InstanceAiConfirmation, numTasks: number) {