fix(core): Scope AI Assistant credential setup cards to the thread's project (#36425)

This commit is contained in:
oleg 2026-08-17 11:44:02 +00:00 committed by GitHub
parent df924f8eb3
commit b50ea9cd42
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 80 additions and 10 deletions

View File

@ -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<typeof credentialSuspendPayloadSchema>;

View File

@ -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([
{

View File

@ -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 } : {}),
});
}

View File

@ -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,

View File

@ -27,13 +27,15 @@ function makeProvider(creds: CredentialListItem[]): CredentialProvider {
let track: Mock;
function askCredentialTool(deps: Omit<AskCredentialToolDeps, 'track'>) {
const merged: AskCredentialToolDeps = { ...deps, track };
function askCredentialTool(deps: Omit<AskCredentialToolDeps, 'track' | 'projectId'>) {
const merged: AskCredentialToolDeps = { projectId: 'project-1', ...deps, track };
return buildAskCredentialTool(merged);
}
function askEmbeddingCredentialTool(deps: Omit<AskEmbeddingCredentialToolDeps, 'track'>) {
const merged: AskEmbeddingCredentialToolDeps = { ...deps, track };
function askEmbeddingCredentialTool(
deps: Omit<AskEmbeddingCredentialToolDeps, 'track' | 'projectId'>,
) {
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, {

View File

@ -73,6 +73,7 @@ describe('finish_setup tool', () => {
],
},
]);
expect(payload.projectId).toBe('project-1');
expect(
(payload.finishSetupChain as { collected: { credentials: unknown } }).collected.credentials,
).toEqual({

View File

@ -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,
});
}

View File

@ -374,6 +374,7 @@ async function suspendForPhase(params: {
severity: 'info' as const,
credentialRequests,
credentialFlow: { stage: 'generic' as const },
projectId: deps.projectId,
finishSetupChain,
});
}

View File

@ -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, () => []);

View File

@ -431,12 +431,14 @@ function handlePlanDeny(conf: InstanceAiConfirmation, numTasks: number) {
<!-- ============ Standalone items (no approval wrapper) ============ -->
<template v-if="chunk.type === 'standalone'">
<!-- Workflow setup -->
<!-- Threads are project-bound: fall back to the thread's project so a
payload without projectId never degrades to the personal project. -->
<InstanceAiWorkflowSetup
v-if="chunk.item.toolCall.confirmation.setupRequests?.length"
:key="'setup-' + chunk.item.toolCall.confirmation.requestId"
:request-id="chunk.item.toolCall.confirmation.requestId"
:setup-requests="chunk.item.toolCall.confirmation.setupRequests!"
:project-id="chunk.item.toolCall.confirmation.projectId"
:project-id="chunk.item.toolCall.confirmation.projectId ?? thread.projectId"
:credential-flow="chunk.item.toolCall.confirmation.credentialFlow"
:workflow-id="chunk.item.toolCall.confirmation.workflowId"
/>
@ -448,7 +450,7 @@ function handlePlanDeny(conf: InstanceAiConfirmation, numTasks: number) {
:request-id="chunk.item.toolCall.confirmation.requestId"
:credential-requests="chunk.item.toolCall.confirmation.credentialRequests!"
:message="chunk.item.toolCall.confirmation.message"
:project-id="chunk.item.toolCall.confirmation.projectId"
:project-id="chunk.item.toolCall.confirmation.projectId ?? thread.projectId"
:credential-flow="chunk.item.toolCall.confirmation.credentialFlow"
/>

View File

@ -226,10 +226,14 @@ onMounted(async () => {
// Ensure the credentials store is populated so NodeCredentials can show
// existing credentials in the dropdown. The Instance AI page may not have
// fetched them yet.
// fetched them yet. Scope to the thread's project — an unscoped fetch fills
// the store with credentials from other projects (e.g. personal) that the
// picker would then offer.
try {
await Promise.all([
credentialsStore.fetchAllCredentials(),
props.projectId
? credentialsStore.fetchAllCredentialsForWorkflow({ projectId: props.projectId })
: credentialsStore.fetchAllCredentials(),
credentialsStore.fetchCredentialTypes(false),
]);
} catch (error) {