fix: Preserve explicit credential selection (#36500)

This commit is contained in:
Predrag Ristic
2026-08-18 14:36:39 +00:00
committed by GitHub
parent 38ec2ec6d0
commit 7b94975c55
13 changed files with 268 additions and 8 deletions
@@ -781,6 +781,33 @@ describe('agent-run-reducer', () => {
projectId: 'proj-456',
});
});
it('confirmation-request preserves explicit credential selection on replay', () => {
const state = stateWithRun('run-1', 'root');
reduceEvent(state, makeToolCall('run-1', 'root', 'tc-1', 'setup-credentials'));
reduceEvent(state, {
type: 'confirmation-request',
runId: 'run-1',
agentId: 'root',
payload: {
requestId: 'req-2',
toolCallId: 'tc-1',
toolName: 'setup-credentials',
args: {},
severity: 'info',
message: 'Select credentials',
requireUserSelection: true,
},
});
const tc = state.toolCallsById['tc-1'];
expect(tc.confirmation).toEqual({
requestId: 'req-2',
severity: 'info',
message: 'Select credentials',
requireUserSelection: true,
});
});
});
describe('tasks-update', () => {
@@ -15,6 +15,7 @@ import {
buildSetupSkipGrantKey,
parseSetupSkipGrants,
buildFetchUrlGrantKey,
confirmationRequestPayloadSchema,
DEFAULT_INSTANCE_AI_PERMISSIONS,
errorPayloadSchema,
FETCH_URL_ALLOW_ALL_GRANT_KEY,
@@ -226,6 +227,14 @@ function makeConfirmation(
};
}
describe('confirmationRequestPayloadSchema', () => {
it('preserves an explicit credential selection requirement', () => {
const payload = makeConfirmation({ requireUserSelection: true });
expect(confirmationRequestPayloadSchema.parse(payload)).toEqual(payload);
});
});
describe('isDisplayableConfirmationRequest', () => {
it('treats approval and text messages as displayable', () => {
expect(isDisplayableConfirmationRequest(makeConfirmation({ inputType: 'approval' }))).toBe(
@@ -477,6 +477,7 @@ export function reduceEvent(state: AgentRunState, event: InstanceAiEvent): Agent
message: event.payload.message,
targetApproval: event.payload.targetApproval,
credentialRequests: event.payload.credentialRequests,
requireUserSelection: event.payload.requireUserSelection,
projectId: event.payload.projectId,
inputType: event.payload.inputType,
domainAccess: event.payload.domainAccess,
@@ -660,6 +660,12 @@ export const confirmationRequestPayloadSchema = z.object({
.optional()
.describe('Target-agent tool approval details rendered instead of the outer tool call'),
credentialRequests: z.array(credentialRequestSchema).optional(),
requireUserSelection: z
.boolean()
.optional()
.describe(
'When true, the credential setup card must wait for an explicit user choice instead of automatically submitting a preselected existing credential',
),
projectId: z
.string()
.optional()
@@ -1354,6 +1360,7 @@ export interface InstanceAiConfirmation {
message: string;
targetApproval?: InstanceAiTargetApproval;
credentialRequests?: InstanceAiCredentialRequest[];
requireUserSelection?: boolean;
projectId?: string;
inputType?: 'approval' | 'text' | 'questions' | 'plan-review' | 'resource-decision' | 'continue';
domainAccess?: DomainAccessMeta;
@@ -175,6 +175,7 @@ Don't fabricate provider setup mechanics (credential field names, secret values,
## Safety
- **Standalone credential setup intent** — When using \`credentials(action="setup")\` outside workflow context, set \`requireUserSelection=true\` only when the user explicitly asks for a new, separate, or different credential, or asks to see the setup card or choose a credential even if one already exists. Omit it for ordinary setup requests so a sole existing service-scoped credential can still be selected automatically.
- **Destructive operations** show a confirmation UI automatically — don't ask via text.
- **Credential setup** uses \`workflows(action="setup")\` when a workflowId is available — it opens the inline setup card in the AI Assistant panel and handles credentials, parameters, and triggers in one step. Use \`credentials(action="setup")\` only when the user explicitly asks to create a credential outside of any workflow context. Never call both tools for the same workflow. Never describe workflow setup as something the user starts from the canvas or editor. Setup cards are only open while the setup call is pending — once it returns a result, the card is resolved: describe the outcome (e.g. credentials selected and ready), never that a card is open or that the user still needs to authorize. When a node in \`nodesStillNeedingSetup\` carries \`parameterIssues\`, the connected credential can't reach the value that was configured (e.g. a model outside what the credential allows) — fix the value, then tell the user plainly which value didn't work and what you set instead. Never silently swap a model or other parameter without saying so. Nodes listed under \`skippedByUser\` are different: the user chose to skip them, so never re-open the setup card for those — say what stays unconfigured and offer to set it up later.
- **Error workflows are per workflow** — n8n has no global/instance-wide error workflow setting. Mention that only when the user explicitly asks about global error workflow behavior; build/assign steps live in \`workflow-builder\` and \`post-build-flow\`.
@@ -310,6 +310,7 @@ describe('mapAgentChunkToEvent', () => {
suggestedName: 'Slack API',
},
],
requireUserSelection: true,
projectId: 'project-1',
inputType: 'plan-review',
questions: [
@@ -363,6 +364,7 @@ describe('mapAgentChunkToEvent', () => {
suggestedName: 'Slack API',
},
],
requireUserSelection: true,
projectId: 'project-1',
inputType: 'plan-review',
domainAccess: { url: 'https://example.com/api', host: 'example.com' },
@@ -399,6 +401,24 @@ describe('mapAgentChunkToEvent', () => {
});
});
it.each([false, 'true', 1])(
'drops a non-true credential selection requirement (%s)',
(requireUserSelection) => {
const event = map({
type: 'tool-call-suspended',
toolCallId: 'tc-1',
toolName: 'setup-credentials',
suspendPayload: {
requestId: 'request-1',
requireUserSelection,
},
});
expect(event).toMatchObject({ type: 'confirmation-request' });
expect(event).not.toHaveProperty('payload.requireUserSelection');
},
);
it('defaults optional suspension values and filters invalid structured payloads', () => {
const result = map({
type: 'tool-call-suspended',
@@ -329,6 +329,7 @@ function mapSuspendedChunk(
suspendPayload.credentialRequests,
credentialRequestSchema,
);
const requireUserSelection = suspendPayload.requireUserSelection === true;
const projectId = presentString(suspendPayload.projectId);
const inputType = parseInputType(suspendPayload.inputType);
const questions = parseSchemaArray(suspendPayload.questions, questionItemSchema);
@@ -377,6 +378,7 @@ function mapSuspendedChunk(
: 'Confirmation required',
...(targetApproval ? { targetApproval } : {}),
...(credentialRequests ? { credentialRequests } : {}),
...(requireUserSelection ? { requireUserSelection } : {}),
...(projectId ? { projectId } : {}),
...(inputType ? { inputType } : {}),
...(domainAccess ? { domainAccess } : {}),
@@ -85,6 +85,19 @@ describe('credentials tool', () => {
expect(getDescription(tool)).toContain('set up new credentials');
});
it('should require requireUserSelection to be a boolean when provided', () => {
const tool = createCredentialsTool(createMockContext());
const schema = getInputSchema(tool);
const setupInput = {
action: 'setup',
credentials: [{ credentialType: 'slackApi', reason: 'Send Slack messages' }],
};
expect(schema.safeParse({ ...setupInput, requireUserSelection: true }).success).toBe(true);
expect(schema.safeParse({ ...setupInput, requireUserSelection: false }).success).toBe(true);
expect(schema.safeParse({ ...setupInput, requireUserSelection: 'true' }).success).toBe(false);
});
it('should describe only explicitly allowed actions', () => {
const tool = createCredentialsTool(createMockContext(), {
allowedActions: builderCredentialActions,
@@ -810,6 +823,45 @@ describe('credentials tool', () => {
],
}),
);
expect(suspendFn.mock.calls[0][0]).not.toHaveProperty('requireUserSelection');
});
it('should propagate requireUserSelection when explicitly enabled', async () => {
const context = createMockContext();
const suspendFn = vi.fn();
const tool = createCredentialsTool(context);
await executeTool(
tool,
{
action: 'setup' as const,
credentials: [{ credentialType: 'openRouterApi', reason: 'Set up a new account' }],
requireUserSelection: true,
},
suspendCtx(suspendFn),
);
expect(suspendFn.mock.calls[0][0]).toEqual(
expect.objectContaining({ requireUserSelection: true }),
);
});
it('should omit requireUserSelection when explicitly disabled', async () => {
const context = createMockContext();
const suspendFn = vi.fn();
const tool = createCredentialsTool(context);
await executeTool(
tool,
{
action: 'setup' as const,
credentials: [{ credentialType: 'slackApi', reason: 'Set up Slack' }],
requireUserSelection: false,
},
suspendCtx(suspendFn),
);
expect(suspendFn.mock.calls[0][0]).not.toHaveProperty('requireUserSelection');
});
it('should include suggestedName in credentialRequests when provided', async () => {
@@ -302,6 +302,12 @@ const setupAction = z.object({
}),
)
.describe('List of credentials to set up'),
requireUserSelection: z
.boolean()
.optional()
.describe(
'Set true only for standalone setup when the user explicitly asks to create a new, separate, or different credential, or explicitly asks to see the setup card or choose a credential even if one already exists. Keeps the card open for an explicit user choice instead of automatically accepting a sole existing credential. Omit otherwise.',
),
credentialFlow: z
.object({
stage: z.enum(['generic', 'finalize']),
@@ -428,6 +434,7 @@ const suspendSchema = z.object({
severity: instanceAiConfirmationSeveritySchema,
credentialRequests: z.array(credentialRequestSchema).optional(),
projectId: z.string().optional(),
requireUserSelection: z.boolean().optional(),
credentialFlow: z.object({ stage: z.enum(['generic', 'finalize']) }).optional(),
});
@@ -689,6 +696,7 @@ async function handleSetup(
severity: 'info' as const,
credentialRequests,
...(context.projectId ? { projectId: context.projectId } : {}),
...(input.requireUserSelection === true ? { requireUserSelection: true } : {}),
...(input.credentialFlow ? { credentialFlow: input.credentialFlow } : {}),
});
}
@@ -78,8 +78,16 @@ vi.mock('../components/GatewayResourceDecision.vue', () => ({
}));
vi.mock('../components/InstanceAiCredentialSetup.vue', () => ({
default: {
template: '<div />',
props: ['requestId', 'credentialRequests', 'message', 'projectId', 'credentialFlow'],
template:
'<div data-test-id="mock-credential-setup" :data-project-id="projectId" :data-require-user-selection="String(requireUserSelection)" />',
props: [
'requestId',
'credentialRequests',
'message',
'projectId',
'credentialFlow',
'requireUserSelection',
],
},
}));
vi.mock('../workflowSetup/InstanceAiWorkflowSetup.vue', () => ({
@@ -493,6 +501,31 @@ describe('InstanceAiConfirmationPanel telemetry', () => {
});
});
describe('credential setup', () => {
it('passes requireUserSelection and falls back to the thread project', () => {
thread.projectId = 'thread-project';
injectPendingConfirmation(thread, {
requestId: 'req-credential',
severity: 'info',
message: 'Set up credentials',
credentialRequests: [
{
credentialType: 'slackApi',
reason: 'Post a message',
existingCredentials: [],
},
],
requireUserSelection: true,
});
const { getByTestId } = renderComponent({ props: { kind: 'inline' } });
const credentialSetup = getByTestId('mock-credential-setup');
expect(credentialSetup).toHaveAttribute('data-require-user-selection', 'true');
expect(credentialSetup).toHaveAttribute('data-project-id', 'thread-project');
});
});
describe('text input confirmation', () => {
it('tracks text submit with input_type and question', async () => {
injectPendingConfirmation(thread, {
@@ -10,6 +10,7 @@ import { useInstanceAiStore, type ThreadRuntime } from '../instanceAi.store';
import { useInstanceAiSettingsStore } from '../instanceAiSettings.store';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import type { ICredentialsResponse } from '@/features/credentials/credentials.types';
import * as credentialsApi from '@/features/credentials/credentials.api';
import { useUIStore } from '@/app/stores/ui.store';
import { INSTANCE_AI_BROWSER_USE_SETUP_MODAL_KEY } from '../constants';
@@ -536,6 +537,66 @@ describe('InstanceAiCredentialSetup', () => {
expect(getByText('instanceAi.credential.allSelected')).toBeTruthy();
});
it('waits for Continue when a sole existing credential requires user selection', async () => {
const requests = makeCredentialRequestsWithSingleExisting(1);
const confirmSpy = vi.spyOn(thread, 'confirmAction').mockResolvedValue(true);
const { getByTestId } = renderComponent({
props: {
requestId: 'req-1',
credentialRequests: requests,
message: 'Set up credentials',
requireUserSelection: true,
},
});
await nextTick();
await nextTick();
expect(confirmSpy).not.toHaveBeenCalled();
await userEvent.click(getByTestId('instance-ai-credential-continue-button'));
expect(confirmSpy).toHaveBeenCalledWith('req-1', {
kind: 'credentialSelection',
credentials: { type1: 'existing-1' },
});
});
it('waits for Continue after creating a credential when user selection is required', async () => {
const credentialsStore = useCredentialsStore();
const requests = makeCredentialRequestsWithSingleExisting(1);
const confirmSpy = vi.spyOn(thread, 'confirmAction').mockResolvedValue(true);
vi.spyOn(credentialsApi, 'createNewCredential').mockResolvedValue({
id: 'created-1',
name: 'Created Cred',
type: 'type1',
} as ICredentialsResponse);
const { getByTestId } = renderComponent({
props: {
requestId: 'req-1',
credentialRequests: requests,
message: 'Set up credentials',
requireUserSelection: true,
},
});
await credentialsStore.createNewCredential({
id: '',
name: 'Created Cred',
type: 'type1',
data: {},
});
await nextTick();
await nextTick();
expect(confirmSpy).not.toHaveBeenCalled();
await userEvent.click(getByTestId('instance-ai-credential-continue-button'));
expect(confirmSpy).toHaveBeenCalledWith('req-1', {
kind: 'credentialSelection',
credentials: { type1: 'created-1' },
});
});
it('keeps a sole generic auth credential preselected but does not auto-submit', async () => {
const requests: InstanceAiCredentialRequest[] = [
{
@@ -634,6 +695,41 @@ describe('InstanceAiCredentialSetup', () => {
});
});
it('waits for Continue when the remaining step is skipped and user selection is required', async () => {
const requests: InstanceAiCredentialRequest[] = [
{
credentialType: 'slackApi',
reason: 'Post the message',
existingCredentials: [{ id: 'existing-slack', name: 'Slack account' }],
},
{
credentialType: 'notionApi',
reason: 'Create the page',
existingCredentials: [],
},
];
const confirmSpy = vi.spyOn(thread, 'confirmAction').mockResolvedValue(true);
const { getByText, getByTestId } = renderComponent({
props: {
requestId: 'req-1',
credentialRequests: requests,
message: 'Set up credentials',
requireUserSelection: true,
},
});
await vi.waitFor(() => expect(getByText('2 of 2')).toBeTruthy());
await userEvent.click(getByText('instanceAi.credential.deny'));
expect(confirmSpy).not.toHaveBeenCalled();
await userEvent.click(getByTestId('instance-ai-credential-continue-button'));
expect(confirmSpy).toHaveBeenCalledWith('req-1', {
kind: 'credentialSelection',
credentials: { slackApi: 'existing-slack' },
});
});
it('shows deferred state after skip', async () => {
const requests = makeCredentialRequests(1);
vi.spyOn(thread, 'confirmAction').mockResolvedValue(true);
@@ -452,6 +452,7 @@ function handlePlanDeny(conf: InstanceAiConfirmation, numTasks: number) {
:message="chunk.item.toolCall.confirmation.message"
:project-id="chunk.item.toolCall.confirmation.projectId ?? thread.projectId"
:credential-flow="chunk.item.toolCall.confirmation.credentialFlow"
:require-user-selection="chunk.item.toolCall.confirmation.requireUserSelection"
/>
<!-- Structured questions -->
@@ -37,6 +37,7 @@ const props = defineProps<{
message: string;
projectId?: string;
credentialFlow?: InstanceAiCredentialFlow;
requireUserSelection?: boolean;
}>();
const i18n = useI18n();
@@ -193,13 +194,15 @@ watch(
/**
* A generic auth type (bearer, header, query, basic, digest, custom, OAuth) never
* identifies a service, so its credential must not be attached to whatever URL the
* workflow points at unless the user says so. Whenever the card carries one, the
* Continue button is the only path that may submit — every automatic path bails out.
* workflow points at unless the user says so. The agent can also require explicit
* confirmation for the card. In both cases, only Continue may submit the card.
*/
const requiresExplicitContinue = computed(() =>
props.credentialRequests.some((request) =>
GENERIC_AUTH_CREDENTIAL_TYPES.has(request.credentialType),
),
const requiresExplicitContinue = computed(
() =>
props.requireUserSelection === true ||
props.credentialRequests.some((request) =>
GENERIC_AUTH_CREDENTIAL_TYPES.has(request.credentialType),
),
);
// Auto-continue once every step is handled (selected or skipped) and at