mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
fix(core): Handle browser not being available on computer use gracefully, better pause-for-user tool (no-changelog) (#29995)
Some checks failed
CI: Master (Build, Test, Lint) / Build for Github Cache (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (22.x) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (24.14.1) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (25.x) (push) Waiting to run
CI: Master (Build, Test, Lint) / Lint (push) Waiting to run
CI: Master (Build, Test, Lint) / Performance (push) Waiting to run
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Blocked by required conditions
Release: Schedule Patch Release PRs / Create patch release PR (${{ matrix.track }}) (beta) (push) Has been cancelled
Release: Schedule Patch Release PRs / Create patch release PR (${{ matrix.track }}) (stable) (push) Has been cancelled
Release: Schedule Patch Release PRs / Create patch release PR (${{ matrix.track }}) (v1) (push) Has been cancelled
Some checks failed
CI: Master (Build, Test, Lint) / Build for Github Cache (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (22.x) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (24.14.1) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (25.x) (push) Waiting to run
CI: Master (Build, Test, Lint) / Lint (push) Waiting to run
CI: Master (Build, Test, Lint) / Performance (push) Waiting to run
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Blocked by required conditions
Release: Schedule Patch Release PRs / Create patch release PR (${{ matrix.track }}) (beta) (push) Has been cancelled
Release: Schedule Patch Release PRs / Create patch release PR (${{ matrix.track }}) (stable) (push) Has been cancelled
Release: Schedule Patch Release PRs / Create patch release PR (${{ matrix.track }}) (v1) (push) Has been cancelled
This commit is contained in:
parent
820128196c
commit
73dae68663
|
|
@ -226,8 +226,9 @@ describe('isDisplayableConfirmationRequest', () => {
|
|||
questions: true,
|
||||
'plan-review': true,
|
||||
'resource-decision': true,
|
||||
continue: true,
|
||||
} satisfies Record<InstanceAiConfirmationInputType, true>;
|
||||
|
||||
expect(Object.keys(handled)).toHaveLength(5);
|
||||
expect(Object.keys(handled)).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -324,6 +324,7 @@ export const confirmationInputTypeSchema = z.enum([
|
|||
'questions',
|
||||
'plan-review',
|
||||
'resource-decision',
|
||||
'continue',
|
||||
]);
|
||||
export type InstanceAiConfirmationInputType = z.infer<typeof confirmationInputTypeSchema>;
|
||||
|
||||
|
|
@ -350,7 +351,8 @@ export const confirmationRequestPayloadSchema = z.object({
|
|||
.describe(
|
||||
'UI mode: approval (default) shows approve/deny, text shows a text input, ' +
|
||||
'questions shows structured Q&A wizard, plan-review shows plan approval with feedback, ' +
|
||||
'resource-decision shows 5-option gateway permission dialog',
|
||||
'resource-decision shows 5-option gateway permission dialog, ' +
|
||||
'continue shows a single primary button (used by pause-for-user)',
|
||||
),
|
||||
questions: z
|
||||
.array(
|
||||
|
|
@ -427,6 +429,7 @@ export function isDisplayableConfirmationRequest(
|
|||
switch (inputType) {
|
||||
case 'approval':
|
||||
case 'text':
|
||||
case 'continue':
|
||||
return isNonEmptyString(payload.message);
|
||||
case 'questions':
|
||||
return hasItems(payload.questions);
|
||||
|
|
@ -691,7 +694,7 @@ export interface InstanceAiConfirmation {
|
|||
message: string;
|
||||
credentialRequests?: InstanceAiCredentialRequest[];
|
||||
projectId?: string;
|
||||
inputType?: 'approval' | 'text' | 'questions' | 'plan-review' | 'resource-decision';
|
||||
inputType?: 'approval' | 'text' | 'questions' | 'plan-review' | 'resource-decision' | 'continue';
|
||||
domainAccess?: DomainAccessMeta;
|
||||
webSearch?: WebSearchMeta;
|
||||
credentialFlow?: InstanceAiCredentialFlow;
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ export function mapMastraChunkToEvent(
|
|||
const projectId =
|
||||
typeof suspendPayload.projectId === 'string' ? suspendPayload.projectId : undefined;
|
||||
|
||||
// Extract optional inputType (e.g., 'text' for ask-user, 'questions', 'plan-review', 'resource-decision')
|
||||
// Extract optional inputType (e.g., 'text' for ask-user, 'questions', 'plan-review', 'resource-decision', 'continue')
|
||||
const rawInputType =
|
||||
typeof suspendPayload.inputType === 'string' ? suspendPayload.inputType : undefined;
|
||||
const validInputTypes = [
|
||||
|
|
@ -197,6 +197,7 @@ export function mapMastraChunkToEvent(
|
|||
'questions',
|
||||
'plan-review',
|
||||
'resource-decision',
|
||||
'continue',
|
||||
] as const;
|
||||
const inputType = (validInputTypes as readonly string[]).includes(rawInputType ?? '')
|
||||
? (rawInputType as (typeof validInputTypes)[number])
|
||||
|
|
|
|||
|
|
@ -95,8 +95,9 @@ Help the user complete the credential setup flow privately and identify where th
|
|||
You may ONLY stop when ONE of these is true:
|
||||
- You have called pause-for-user telling the user where to find the ACTUAL credential values and to enter them privately into the n8n credential form
|
||||
- An unrecoverable error occurred (e.g., the service is down)
|
||||
- A browser_* tool returned an error containing "permanently denied" — the user has blocked browser access. Stop immediately. Do NOT call pause-for-user. Do NOT fabricate manual setup instructions. Return a short message explaining that browser access was denied; the orchestrator will guide the user from there.
|
||||
|
||||
**If you have NOT yet called pause-for-user with private-entry instructions for the credential values, you are NOT done. Keep going.**
|
||||
**If you have NOT yet called pause-for-user with private-entry instructions for the credential values, you are NOT done. Keep going.** (Exception: the browser-denied case above.)
|
||||
|
||||
You must NOT stop just because you:
|
||||
- Read the docs
|
||||
|
|
|
|||
|
|
@ -33,6 +33,50 @@ import { createAskUserTool } from '../shared/ask-user.tool';
|
|||
|
||||
export { buildBrowserAgentPrompt, type BrowserToolSource } from './browser-credential-setup.prompt';
|
||||
|
||||
const PERMANENT_DENIAL_MARKER = 'User permanently denied access to';
|
||||
const BROWSER_DENIED_RESULT =
|
||||
'Browser access was denied by the user. Provide manual setup guidance in chat — do not try the browser flow again in this turn.';
|
||||
|
||||
const browserToolErrorResultSchema = z.object({
|
||||
isError: z.literal(true),
|
||||
structuredContent: z.object({ error: z.string().optional() }).optional(),
|
||||
content: z.array(z.object({ text: z.string() }).passthrough()).optional(),
|
||||
});
|
||||
|
||||
function isPermanentDenialResult(result: unknown): boolean {
|
||||
const parsed = browserToolErrorResultSchema.safeParse(result);
|
||||
if (!parsed.success) return false;
|
||||
const messages = [
|
||||
parsed.data.structuredContent?.error ?? '',
|
||||
...(parsed.data.content?.map((c) => c.text) ?? []),
|
||||
];
|
||||
return messages.some((m) => m.includes(PERMANENT_DENIAL_MARKER));
|
||||
}
|
||||
|
||||
type ToolExecuteFn = (input: unknown, ctx: unknown) => Promise<unknown>;
|
||||
|
||||
function wrapToolForDenialDetection<T extends ToolsInput[string]>(
|
||||
tool: T,
|
||||
onDenied: () => void,
|
||||
): T {
|
||||
const originalExecute = tool.execute as ToolExecuteFn | undefined;
|
||||
if (!originalExecute) return tool;
|
||||
const observingExecute: ToolExecuteFn = async (input, ctx) => {
|
||||
const result = await originalExecute(input, ctx);
|
||||
if (isPermanentDenialResult(result)) onDenied();
|
||||
return result;
|
||||
};
|
||||
return { ...tool, execute: observingExecute as T['execute'] };
|
||||
}
|
||||
|
||||
function wrapBrowserToolsForDenialDetection(tools: ToolsInput, onDenied: () => void): ToolsInput {
|
||||
const wrapped: ToolsInput = {};
|
||||
for (const [name, tool] of Object.entries(tools)) {
|
||||
wrapped[name] = name.startsWith('browser_') ? wrapToolForDenialDetection(tool, onDenied) : tool;
|
||||
}
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
function createPauseForUserTool() {
|
||||
return createTool({
|
||||
id: 'pause-for-user',
|
||||
|
|
@ -47,6 +91,7 @@ function createPauseForUserTool() {
|
|||
requestId: z.string(),
|
||||
message: z.string(),
|
||||
severity: instanceAiConfirmationSeveritySchema,
|
||||
inputType: z.literal('continue'),
|
||||
}),
|
||||
resumeSchema: browserCredentialSetupResumeSchema,
|
||||
execute: async (input: z.infer<typeof browserCredentialSetupInputSchema>, ctx) => {
|
||||
|
|
@ -60,6 +105,7 @@ function createPauseForUserTool() {
|
|||
requestId: nanoid(),
|
||||
message: input.message,
|
||||
severity: 'info' as const,
|
||||
inputType: 'continue' as const,
|
||||
});
|
||||
return { continued: false };
|
||||
}
|
||||
|
|
@ -148,13 +194,21 @@ export function createBrowserCredentialSetupTool(context: OrchestrationContext)
|
|||
};
|
||||
}
|
||||
|
||||
let browserPermanentlyDenied = false;
|
||||
const browserToolsWithDenialDetection = wrapBrowserToolsForDenialDetection(
|
||||
browserTools,
|
||||
() => {
|
||||
browserPermanentlyDenied = true;
|
||||
},
|
||||
);
|
||||
|
||||
// Add interaction tools
|
||||
browserTools['pause-for-user'] = createPauseForUserTool();
|
||||
browserTools['ask-user'] = createAskUserTool();
|
||||
browserToolsWithDenialDetection['pause-for-user'] = createPauseForUserTool();
|
||||
browserToolsWithDenialDetection['ask-user'] = createAskUserTool();
|
||||
|
||||
// Add consolidated research tool (web-search + fetch-url) from the domain context
|
||||
if (context.domainContext) {
|
||||
browserTools.research = createResearchTool(context.domainContext);
|
||||
browserToolsWithDenialDetection.research = createResearchTool(context.domainContext);
|
||||
}
|
||||
|
||||
const subAgentId = `agent-browser-${nanoid(6)}`;
|
||||
|
|
@ -167,7 +221,7 @@ export function createBrowserCredentialSetupTool(context: OrchestrationContext)
|
|||
payload: {
|
||||
parentId: context.orchestratorAgentId,
|
||||
role: 'credential-setup-browser-agent',
|
||||
tools: Object.keys(browserTools),
|
||||
tools: Object.keys(browserToolsWithDenialDetection),
|
||||
},
|
||||
});
|
||||
let traceRun: Awaited<ReturnType<typeof startSubAgentTrace>>;
|
||||
|
|
@ -196,7 +250,7 @@ export function createBrowserCredentialSetupTool(context: OrchestrationContext)
|
|||
});
|
||||
const tracedBrowserTools = traceSubAgentTools(
|
||||
context,
|
||||
browserTools,
|
||||
browserToolsWithDenialDetection,
|
||||
'credential-setup-browser-agent',
|
||||
);
|
||||
const browserPrompt = buildBrowserAgentPrompt(toolSource);
|
||||
|
|
@ -321,6 +375,13 @@ export function createBrowserCredentialSetupTool(context: OrchestrationContext)
|
|||
throw new Error('Run cancelled while waiting for confirmation');
|
||||
}
|
||||
|
||||
// If the user has permanently denied browser access, stop here.
|
||||
// Don't nudge into pause-for-user — return a structured result so
|
||||
// the orchestrator can offer manual guidance via plain chat text.
|
||||
if (browserPermanentlyDenied) {
|
||||
return BROWSER_DENIED_RESULT;
|
||||
}
|
||||
|
||||
if (lastSuspendedToolName !== 'pause-for-user' && nudgeCount < MAX_NUDGES) {
|
||||
// Agent ended without a final pause-for-user confirmation.
|
||||
// Replay the prior conversation + a nudge so the sub-agent
|
||||
|
|
|
|||
|
|
@ -5344,6 +5344,7 @@
|
|||
"instanceAi.confirmation.pendingInline": "Waiting for approval",
|
||||
"instanceAi.confirmation.agentContext": "{agent} needs approval",
|
||||
"instanceAi.confirmation.approveAll": "Approve all",
|
||||
"instanceAi.confirmation.continue": "Continue",
|
||||
"instanceAi.gatewayConfirmation.allowForSession": "Allow for session",
|
||||
"instanceAi.gatewayConfirmation.allowOnce": "Allow once",
|
||||
"instanceAi.gatewayConfirmation.denyOnce": "Deny once",
|
||||
|
|
|
|||
|
|
@ -300,6 +300,44 @@ describe('InstanceAiConfirmationPanel telemetry', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('continue confirmation', () => {
|
||||
it('renders a single primary button and resolves as approved', async () => {
|
||||
injectPendingConfirmation(store, {
|
||||
requestId: 'req-continue',
|
||||
severity: 'info',
|
||||
message: 'Enter the values privately into n8n.',
|
||||
inputType: 'continue',
|
||||
});
|
||||
const confirmSpy = vi.spyOn(store, 'confirmAction').mockResolvedValue(true);
|
||||
|
||||
const { getByTestId, queryByTestId } = renderComponent();
|
||||
|
||||
expect(queryByTestId('instance-ai-panel-confirm-approve')).toBeNull();
|
||||
expect(queryByTestId('instance-ai-panel-confirm-deny')).toBeNull();
|
||||
|
||||
await userEvent.click(getByTestId('instance-ai-panel-continue'));
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalledWith('req-continue', {
|
||||
kind: 'approval',
|
||||
approved: true,
|
||||
});
|
||||
expect(mockTelemetryTrack).toHaveBeenCalledWith(
|
||||
'User finished providing input',
|
||||
expect.objectContaining({
|
||||
type: 'continue',
|
||||
provided_inputs: [
|
||||
{
|
||||
label: 'Enter the values privately into n8n.',
|
||||
options: ['continue'],
|
||||
option_chosen: 'continue',
|
||||
},
|
||||
],
|
||||
skipped_inputs: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('questions confirmation', () => {
|
||||
const questionsConfirmation: InstanceAiConfirmation = {
|
||||
requestId: 'req-q',
|
||||
|
|
|
|||
|
|
@ -194,6 +194,17 @@ function handleTextSkip(conf: InstanceAiConfirmation) {
|
|||
void store.confirmAction(conf.requestId, { kind: 'approval', approved: false });
|
||||
}
|
||||
|
||||
function handleContinue(conf: InstanceAiConfirmation) {
|
||||
if (store.resolvedConfirmationIds.has(conf.requestId)) return;
|
||||
trackInputCompleted(
|
||||
conf,
|
||||
[{ label: conf.message, options: ['continue'], option_chosen: 'continue' }],
|
||||
[],
|
||||
);
|
||||
store.resolveConfirmation(conf.requestId, 'approved');
|
||||
void store.confirmAction(conf.requestId, { kind: 'approval', approved: true });
|
||||
}
|
||||
|
||||
function handleQuestionsSubmit(conf: InstanceAiConfirmation, answers: QuestionAnswer[]) {
|
||||
const questionsById = new Map((conf.questions ?? []).map((q) => [q.id, q]));
|
||||
const provided: Array<{
|
||||
|
|
@ -388,6 +399,26 @@ function isAllGenericApproval(items: PendingConfirmationItem[]): boolean {
|
|||
</div>
|
||||
</N8nCard>
|
||||
</div>
|
||||
<!-- Continue (pause-for-user) — single-button acknowledgement -->
|
||||
<div
|
||||
v-else-if="chunk.item.toolCall.confirmation.inputType === 'continue'"
|
||||
:key="'continue-' + chunk.item.toolCall.confirmation.requestId"
|
||||
:class="$style.confirmation"
|
||||
>
|
||||
<N8nCard :class="$style.textCard">
|
||||
<N8nText tag="div">{{ chunk.item.toolCall.confirmation!.message }}</N8nText>
|
||||
<div :class="$style.continueRow">
|
||||
<N8nButton
|
||||
data-test-id="instance-ai-panel-continue"
|
||||
size="medium"
|
||||
variant="solid"
|
||||
@click="handleContinue(chunk.item.toolCall.confirmation)"
|
||||
>
|
||||
{{ i18n.baseText('instanceAi.confirmation.continue') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
</N8nCard>
|
||||
</div>
|
||||
<!-- Resource-access decision (gateway permission mode) -->
|
||||
<GatewayResourceDecision
|
||||
v-else-if="
|
||||
|
|
@ -547,6 +578,12 @@ function isAllGenericApproval(items: PendingConfirmationItem[]): boolean {
|
|||
margin-top: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.continueRow {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.generic {
|
||||
padding: var(--spacing--sm);
|
||||
border-bottom: var(--border);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user