From 74e6df3b2ea4677899e1da32e272b4671ac2215e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dimitri=20Lavren=C3=BCk?= <20122620+dlavrenuek@users.noreply.github.com> Date: Thu, 2 Apr 2026 18:49:01 +0200 Subject: [PATCH] feat: Computer use HITL confirmations in Instance AI (#27910) --- .../instance-ai-confirm-request.dto.ts | 1 + packages/@n8n/api-types/src/index.ts | 3 + .../src/schemas/agent-run-reducer.ts | 1 + .../src/schemas/instance-ai.schema.ts | 31 ++- packages/@n8n/fs-proxy/joke.txt | 3 + packages/@n8n/fs-proxy/package.json | 2 +- packages/@n8n/fs-proxy/spec/technical-spec.md | 53 +++- packages/@n8n/fs-proxy/src/config.ts | 18 +- packages/@n8n/fs-proxy/src/gateway-client.ts | 56 ++++- packages/@n8n/fs-proxy/src/logger.test.ts | 1 + .../fs-proxy/src/startup-config-cli.test.ts | 1 + packages/@n8n/fs-proxy/src/tools/types.ts | 12 + .../src/runtime/run-state-registry.ts | 2 + .../@n8n/instance-ai/src/stream/map-chunk.ts | 30 ++- .../create-tools-from-mcp-server.test.ts | 230 +++++++++++++++++ .../create-tools-from-mcp-server.ts | 132 +++++++++- packages/@n8n/instance-ai/src/types.ts | 1 + .../local-gateway/src/main/settings-store.ts | 1 + .../__tests__/instance-ai.controller.test.ts | 16 ++ .../__tests__/local-gateway.test.ts | 37 ++- .../instance-ai/filesystem/local-gateway.ts | 23 +- .../instance-ai/instance-ai.controller.ts | 1 + .../instance-ai/instance-ai.service.ts | 2 +- .../frontend/@n8n/i18n/src/locales/en.json | 6 + .../__tests__/instanceAi.store.test.ts | 87 ++++++- .../components/GatewayResourceDecision.vue | 235 ++++++++++++++++++ .../InstanceAiConfirmationPanel.vue | 17 +- .../features/ai/instanceAi/instanceAi.api.ts | 2 + .../ai/instanceAi/instanceAi.store.ts | 19 ++ 29 files changed, 975 insertions(+), 48 deletions(-) create mode 100644 packages/@n8n/fs-proxy/joke.txt create mode 100644 packages/@n8n/instance-ai/src/tools/filesystem/__tests__/create-tools-from-mcp-server.test.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/components/GatewayResourceDecision.vue diff --git a/packages/@n8n/api-types/src/dto/instance-ai/instance-ai-confirm-request.dto.ts b/packages/@n8n/api-types/src/dto/instance-ai/instance-ai-confirm-request.dto.ts index e1d41091ec5..d5f2ec00853 100644 --- a/packages/@n8n/api-types/src/dto/instance-ai/instance-ai-confirm-request.dto.ts +++ b/packages/@n8n/api-types/src/dto/instance-ai/instance-ai-confirm-request.dto.ts @@ -24,4 +24,5 @@ export class InstanceAiConfirmRequestDto extends Z.class({ }), ) .optional(), + resourceDecision: z.string().optional(), }) {} diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index 354e169e408..f2ec1e8d433 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -280,6 +280,8 @@ export { domainAccessActionSchema, domainAccessMetaSchema, credentialFlowSchema, + gatewayConfirmationRequiredPayloadSchema, + GATEWAY_CONFIRMATION_REQUIRED_PREFIX, InstanceAiSendMessageRequest, instanceAiGatewayKeySchema, InstanceAiGatewayEventsQuery, @@ -350,6 +352,7 @@ export type { DomainAccessAction, DomainAccessMeta, InstanceAiCredentialFlow, + GatewayConfirmationRequiredPayload, ToolCategory, InstanceAiWorkflowSetupNode, } from './schemas/instance-ai.schema'; diff --git a/packages/@n8n/api-types/src/schemas/agent-run-reducer.ts b/packages/@n8n/api-types/src/schemas/agent-run-reducer.ts index 65059fa1930..799646b72ce 100644 --- a/packages/@n8n/api-types/src/schemas/agent-run-reducer.ts +++ b/packages/@n8n/api-types/src/schemas/agent-run-reducer.ts @@ -312,6 +312,7 @@ export function reduceEvent(state: AgentRunState, event: InstanceAiEvent): Agent questions: event.payload.questions, introMessage: event.payload.introMessage, tasks: event.payload.tasks, + resourceDecision: event.payload.resourceDecision, }; } break; diff --git a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts index c9c6b383e74..03d6bcbf62b 100644 --- a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts +++ b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts @@ -258,6 +258,25 @@ export const taskListSchema = z.object({ export type TaskList = z.infer; +// ── Gateway resource confirmation (instance permission mode) ───────────────── + +/** Protocol prefix used by the daemon to signal a resource-access confirmation is required. */ +export const GATEWAY_CONFIRMATION_REQUIRED_PREFIX = 'GATEWAY_CONFIRMATION_REQUIRED::'; + +export const gatewayConfirmationRequiredPayloadSchema = z.object({ + toolGroup: z.string(), + resource: z.string(), + description: z.string(), + /** Available decision options. */ + options: z.array(z.string()), +}); + +export type GatewayConfirmationRequiredPayload = z.infer< + typeof gatewayConfirmationRequiredPayloadSchema +>; + +// --------------------------------------------------------------------------- + export const confirmationRequestPayloadSchema = z.object({ requestId: z.string(), toolCallId: z.string().describe('Correlates to the tool-call that needs approval'), @@ -273,11 +292,12 @@ export const confirmationRequestPayloadSchema = z.object({ 'Target project ID — used to scope actions (e.g. credential creation) to the correct project', ), inputType: z - .enum(['approval', 'text', 'questions', 'plan-review']) + .enum(['approval', 'text', 'questions', 'plan-review', 'resource-decision']) .optional() .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', + 'questions shows structured Q&A wizard, plan-review shows plan approval with feedback, ' + + 'resource-decision shows 5-option gateway permission dialog', ), questions: z .array( @@ -307,6 +327,9 @@ export const confirmationRequestPayloadSchema = z.object({ .optional() .describe('Per-node setup cards for workflow credential/parameter configuration'), workflowId: z.string().optional().describe('Workflow ID for setup-workflow tool'), + resourceDecision: gatewayConfirmationRequiredPayloadSchema + .optional() + .describe('Gateway resource-access decision data (inputType=resource-decision)'), }); export const statusPayloadSchema = z.object({ @@ -557,6 +580,7 @@ export interface InstanceAiConfirmResponse { autoSetup?: { credentialType: string }; userInput?: string; domainAccessAction?: DomainAccessAction; + resourceDecision?: string; action?: 'apply' | 'test-trigger'; nodeParameters?: Record>; testTriggerNode?: string; @@ -586,7 +610,7 @@ export interface InstanceAiToolCallState { message: string; credentialRequests?: InstanceAiCredentialRequest[]; projectId?: string; - inputType?: 'approval' | 'text' | 'questions' | 'plan-review'; + inputType?: 'approval' | 'text' | 'questions' | 'plan-review' | 'resource-decision'; domainAccess?: DomainAccessMeta; credentialFlow?: InstanceAiCredentialFlow; setupRequests?: InstanceAiWorkflowSetupNode[]; @@ -599,6 +623,7 @@ export interface InstanceAiToolCallState { }>; introMessage?: string; tasks?: TaskList; + resourceDecision?: GatewayConfirmationRequiredPayload; }; confirmationStatus?: 'pending' | 'approved' | 'denied'; startedAt?: string; diff --git a/packages/@n8n/fs-proxy/joke.txt b/packages/@n8n/fs-proxy/joke.txt new file mode 100644 index 00000000000..3f4f2ef7603 --- /dev/null +++ b/packages/@n8n/fs-proxy/joke.txt @@ -0,0 +1,3 @@ +Why don't programmers like nature? + +It has too many bugs. \ No newline at end of file diff --git a/packages/@n8n/fs-proxy/package.json b/packages/@n8n/fs-proxy/package.json index 2aca8859375..56cd6018fdd 100644 --- a/packages/@n8n/fs-proxy/package.json +++ b/packages/@n8n/fs-proxy/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/fs-proxy", - "version": "0.1.0-rc2", + "version": "0.1.0-rc3", "description": "Local AI gateway for n8n Instance AI — filesystem, shell, screenshots, mouse/keyboard, and browser automation", "bin": { "n8n-fs-proxy": "dist/cli.js" diff --git a/packages/@n8n/fs-proxy/spec/technical-spec.md b/packages/@n8n/fs-proxy/spec/technical-spec.md index 9801f832892..596a084c849 100644 --- a/packages/@n8n/fs-proxy/spec/technical-spec.md +++ b/packages/@n8n/fs-proxy/spec/technical-spec.md @@ -269,12 +269,14 @@ interface UserGatewayState { ## 6. Tool Call Dispatch +### 6.1 Normal tool call (no confirmation required) + When the AI agent needs to invoke a local tool the call flows through `LocalGateway`: ```mermaid sequenceDiagram - participant A as AI Agent + participant A as AI Agent (Mastra tool) participant GW as LocalGateway participant SRV as Controller (SSE) participant D as fs-proxy Daemon @@ -297,6 +299,55 @@ the agent receives a tool-error event. If the gateway disconnects while requests are pending, `LocalGateway.disconnect()` rejects all outstanding promises immediately with `"Local gateway disconnected"`. +### 6.2 Tool call with resource-access confirmation + +When a tool group operates in `Ask` mode and no stored rule matches the +resource, the daemon returns a `GATEWAY_CONFIRMATION_REQUIRED` error instead +of a result. The Mastra tool layer handles this by suspending the agent — +persisting its state to the database — and resuming it after the user +responds. This means the confirmation survives page reloads and server +restarts. + +```mermaid +sequenceDiagram + participant FE as Browser (Frontend) + participant SRV as n8n Server + participant DB as Database + participant D as fs-proxy Daemon + + Note over SRV: First invocation — tool execute() called by Mastra + SRV->>D: callTool({ name, args }) via LocalGateway + D-->>SRV: { isError: true, content: ["GATEWAY_CONFIRMATION_REQUIRED::..."] } + SRV->>SRV: parse GatewayConfirmationRequiredPayload + SRV->>DB: suspend() — persist agent snapshot + confirmation payload + SRV-->>FE: SSE confirmation-request event
{ inputType: "resource-decision", resourceDecision: { resource, description, options: [...] } } + + FE->>FE: show GatewayResourceDecision panel + Note over FE: User clicks a decision button (e.g. Allow for session) + FE->>SRV: POST /confirm/:requestId { approved: true, resourceDecision: "allowForSession" } + SRV->>DB: load agent snapshot, resume with resumeData + + Note over SRV: Second invocation — tool execute() called with resumeData + SRV->>D: callTool({ name, args, _confirmation: "allowForSession" }) via LocalGateway + D->>D: apply decision, execute tool + D-->>SRV: { content: [...], isError: false } + SRV-->>FE: SSE tool-result / text-delta events +``` + +**Key properties of this design:** + +- Agent state is persisted to the database on suspension — the confirmation + dialog survives page reloads and server restarts. +- The daemon returns `options` as a plain list of decision names (e.g. + `["allowOnce", "allowForSession", "alwaysAllow", "denyOnce", "alwaysDeny"]`). + The user's choice is sent back as the decision string directly — no token + indirection. +- `_confirmation` is always stripped from LLM-provided args on the first-call + path, so the agent cannot bypass the HITL flow by injecting a decision. +- If the user denies without providing a decision, `resumeData.resourceDecision` + is absent and the tool returns an access-denied error to the agent + without re-calling the daemon. + --- ## 7. Disconnect & Reconnect diff --git a/packages/@n8n/fs-proxy/src/config.ts b/packages/@n8n/fs-proxy/src/config.ts index 18bc6303c5a..6bb397da331 100644 --- a/packages/@n8n/fs-proxy/src/config.ts +++ b/packages/@n8n/fs-proxy/src/config.ts @@ -63,6 +63,8 @@ export interface GatewayConfig { }; /** Startup permission overrides (ENV/CLI). Merged with persistent settings in SettingsStore. */ permissions: Partial>; + /** Where resource access confirmation prompts are displayed. */ + permissionConfirmation: 'client' | 'instance'; } // --------------------------------------------------------------------------- @@ -111,6 +113,7 @@ const structuralConfigSchema = z.object({ defaultBrowser: z.string().default('chrome'), }) .default({}), + permissionConfirmation: z.enum(['client', 'instance']).default('instance'), }); // --------------------------------------------------------------------------- @@ -177,6 +180,9 @@ function buildEnvConfig(): PartialStructural { const defaultBrowser = envString('BROWSER_DEFAULT'); if (defaultBrowser) config.browser = { defaultBrowser }; + const permissionConfirmation = envString('PERMISSION_CONFIRMATION'); + if (permissionConfirmation) config.permissionConfirmation = permissionConfirmation; + return config as PartialStructural; } @@ -199,6 +205,9 @@ function buildCliConfig(args: yargsParser.Arguments): PartialStructural { if (args['browser-default']) config.browser = { defaultBrowser: args['browser-default'] as string }; + if (args['permission-confirmation']) + config.permissionConfirmation = args['permission-confirmation']; + return config as PartialStructural; } @@ -273,7 +282,14 @@ export function parseConfig(argv = process.argv.slice(2)): ParsedArgs { const permissionFlags = Object.values(TOOL_GROUP_DEFINITIONS).map((o) => o.cliFlag); const args = yargsParser(rawArgs, { - string: ['log-level', 'filesystem-dir', 'browser-default', 'allow-origin', ...permissionFlags], + string: [ + 'log-level', + 'filesystem-dir', + 'browser-default', + 'allow-origin', + 'permission-confirmation', + ...permissionFlags, + ], boolean: ['auto-confirm', 'non-interactive', 'help'], number: ['port', 'computer-shell-timeout'], alias: { h: 'help', p: 'port' }, diff --git a/packages/@n8n/fs-proxy/src/gateway-client.ts b/packages/@n8n/fs-proxy/src/gateway-client.ts index 2b47c8025d0..57c3686d303 100644 --- a/packages/@n8n/fs-proxy/src/gateway-client.ts +++ b/packages/@n8n/fs-proxy/src/gateway-client.ts @@ -17,12 +17,15 @@ import type { SettingsStore } from './settings-store'; import type { BrowserModule } from './tools/browser'; import { filesystemReadTools, filesystemWriteTools } from './tools/filesystem'; import { ShellModule } from './tools/shell'; -import type { - AffectedResource, - CallToolResult, - ConfirmResourceAccess, - McpTool, - ToolDefinition, +import { + type AffectedResource, + type CallToolResult, + type ConfirmResourceAccess, + type McpTool, + type ResourceDecision, + type ToolDefinition, + GATEWAY_CONFIRMATION_REQUIRED_PREFIX, + RESOURCE_DECISION_KEYS, } from './tools/types'; import { formatErrorResult } from './tools/utils'; @@ -395,30 +398,57 @@ export class GatewayClient { await this.getAllDefinitions(); const def = this.definitionMap.get(name); if (!def) throw new Error(`Unknown tool: ${name}`); - const typedArgs: unknown = def.inputSchema.parse(args); + + // Strip _confirmation from args before schema validation — the agent must + // never be able to inject a decision directly into tool arguments. + const { _confirmation, ...cleanArgs } = args; + const decision = + typeof _confirmation === 'string' ? (_confirmation as ResourceDecision) : undefined; + + const typedArgs: unknown = def.inputSchema.parse(cleanArgs); const context = { dir: this.dir }; const resources = await def.getAffectedResources(typedArgs, context); - await this.checkPermissions(resources); + await this.checkPermissions(resources, decision); return await def.execute(typedArgs, context); } - private async checkPermissions(resources: AffectedResource[]): Promise { - const { settingsStore, confirmResourceAccess } = this.options; + private async checkPermissions( + resources: AffectedResource[], + decision?: ResourceDecision, + ): Promise { + const { settingsStore, confirmResourceAccess, config } = this.options; for (const resource of resources) { const rule = settingsStore.check(resource.toolGroup, resource.resource); if (rule === 'deny') { - throw new Error(`User denied access to ${resource.toolGroup}: ${resource.resource}`); + throw new Error( + `User permanently denied access to ${resource.toolGroup}: ${resource.resource}`, + ); } if (rule === 'allow') continue; - const decision = await confirmResourceAccess(resource); + let resolvedDecision: ResourceDecision; - switch (decision) { + if (decision) { + resolvedDecision = decision; + } else if (config.permissionConfirmation === 'instance') { + throw new Error( + `${GATEWAY_CONFIRMATION_REQUIRED_PREFIX}${JSON.stringify({ + toolGroup: resource.toolGroup, + resource: resource.resource, + description: resource.description, + options: RESOURCE_DECISION_KEYS, + })}`, + ); + } else { + resolvedDecision = await confirmResourceAccess(resource); + } + + switch (resolvedDecision) { case 'allowOnce': break; case 'allowForSession': diff --git a/packages/@n8n/fs-proxy/src/logger.test.ts b/packages/@n8n/fs-proxy/src/logger.test.ts index 480c23a4fc9..f2cf383c8c5 100644 --- a/packages/@n8n/fs-proxy/src/logger.test.ts +++ b/packages/@n8n/fs-proxy/src/logger.test.ts @@ -11,6 +11,7 @@ const BASE_CONFIG: GatewayConfig = { defaultBrowser: 'chrome', }, permissions: {}, + permissionConfirmation: 'instance', }; /** Find the message logged for a specific module by inspecting the meta argument. */ diff --git a/packages/@n8n/fs-proxy/src/startup-config-cli.test.ts b/packages/@n8n/fs-proxy/src/startup-config-cli.test.ts index 68c1606d8b2..0c4cb52601f 100644 --- a/packages/@n8n/fs-proxy/src/startup-config-cli.test.ts +++ b/packages/@n8n/fs-proxy/src/startup-config-cli.test.ts @@ -11,6 +11,7 @@ const BASE_CONFIG: GatewayConfig = { defaultBrowser: 'chrome', }, permissions: {}, + permissionConfirmation: 'instance', }; describe('resolveTemplateName', () => { diff --git a/packages/@n8n/fs-proxy/src/tools/types.ts b/packages/@n8n/fs-proxy/src/tools/types.ts index 41b0ca34206..0bb0057729d 100644 --- a/packages/@n8n/fs-proxy/src/tools/types.ts +++ b/packages/@n8n/fs-proxy/src/tools/types.ts @@ -47,6 +47,18 @@ export type ResourceDecision = | 'denyOnce' | 'alwaysDeny'; +/** Ordered list of all ResourceDecision values — used for iteration (e.g. token generation). */ +export const RESOURCE_DECISION_KEYS: ResourceDecision[] = [ + 'allowOnce', + 'allowForSession', + 'alwaysAllow', + 'denyOnce', + 'alwaysDeny', +]; + +/** Prefix used to signal a gateway confirmation is required (instance mode). */ +export const GATEWAY_CONFIRMATION_REQUIRED_PREFIX = 'GATEWAY_CONFIRMATION_REQUIRED::'; + export type ConfirmResourceAccess = ( resource: AffectedResource, ) => ResourceDecision | Promise; diff --git a/packages/@n8n/instance-ai/src/runtime/run-state-registry.ts b/packages/@n8n/instance-ai/src/runtime/run-state-registry.ts index 3363e9b3fa5..6bc26b97318 100644 --- a/packages/@n8n/instance-ai/src/runtime/run-state-registry.ts +++ b/packages/@n8n/instance-ai/src/runtime/run-state-registry.ts @@ -37,6 +37,8 @@ export interface ConfirmationData { customText?: string; skipped?: boolean; }>; + /** User's resource-access decision (e.g. 'allowForSession'). */ + resourceDecision?: string; } export interface PendingConfirmation { diff --git a/packages/@n8n/instance-ai/src/stream/map-chunk.ts b/packages/@n8n/instance-ai/src/stream/map-chunk.ts index f057fe001a9..c8d18c14491 100644 --- a/packages/@n8n/instance-ai/src/stream/map-chunk.ts +++ b/packages/@n8n/instance-ai/src/stream/map-chunk.ts @@ -1,9 +1,15 @@ -import { credentialRequestSchema, workflowSetupNodeSchema, taskListSchema } from '@n8n/api-types'; +import { + credentialRequestSchema, + workflowSetupNodeSchema, + taskListSchema, + gatewayConfirmationRequiredPayloadSchema, +} from '@n8n/api-types'; import type { InstanceAiCredentialRequest, InstanceAiEvent, InstanceAiWorkflowSetupNode, TaskList, + GatewayConfirmationRequiredPayload, } from '@n8n/api-types'; import { z } from 'zod'; @@ -183,10 +189,16 @@ export function mapMastraChunkToEvent( const projectId = typeof suspendPayload.projectId === 'string' ? suspendPayload.projectId : undefined; - // Extract optional inputType (e.g., 'text' for ask-user, 'questions', 'plan-review') + // Extract optional inputType (e.g., 'text' for ask-user, 'questions', 'plan-review', 'resource-decision') const rawInputType = typeof suspendPayload.inputType === 'string' ? suspendPayload.inputType : undefined; - const validInputTypes = ['approval', 'text', 'questions', 'plan-review'] as const; + const validInputTypes = [ + 'approval', + 'text', + 'questions', + 'plan-review', + 'resource-decision', + ] as const; const inputType = (validInputTypes as readonly string[]).includes(rawInputType ?? '') ? (rawInputType as (typeof validInputTypes)[number]) : undefined; @@ -257,6 +269,17 @@ export function mapMastraChunkToEvent( const workflowId = typeof suspendPayload.workflowId === 'string' ? suspendPayload.workflowId : undefined; + // Extract optional resourceDecision for gateway permission gating (inputType=resource-decision) + let resourceDecision: GatewayConfirmationRequiredPayload | undefined; + if (isRecord(suspendPayload.resourceDecision)) { + const parsed = gatewayConfirmationRequiredPayloadSchema.safeParse( + suspendPayload.resourceDecision, + ); + if (parsed.success) { + resourceDecision = parsed.data; + } + } + return { type: 'confirmation-request', runId, @@ -281,6 +304,7 @@ export function mapMastraChunkToEvent( ...(questions ? { questions } : {}), ...(introMessage ? { introMessage } : {}), ...(tasks ? { tasks } : {}), + ...(resourceDecision ? { resourceDecision } : {}), }, }; } diff --git a/packages/@n8n/instance-ai/src/tools/filesystem/__tests__/create-tools-from-mcp-server.test.ts b/packages/@n8n/instance-ai/src/tools/filesystem/__tests__/create-tools-from-mcp-server.test.ts new file mode 100644 index 00000000000..9d9c3ca49b2 --- /dev/null +++ b/packages/@n8n/instance-ai/src/tools/filesystem/__tests__/create-tools-from-mcp-server.test.ts @@ -0,0 +1,230 @@ +import { GATEWAY_CONFIRMATION_REQUIRED_PREFIX } from '@n8n/api-types'; +import type { McpTool, McpToolCallResult } from '@n8n/api-types'; + +import type { LocalMcpServer } from '../../../types'; +import { createToolsFromLocalMcpServer } from '../create-tools-from-mcp-server'; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const SAMPLE_TOOL: McpTool = { + name: 'write_file', + description: 'Write a file', + inputSchema: { type: 'object', properties: { filePath: { type: 'string' } } }, +}; + +const CONFIRMATION_PAYLOAD = { + toolGroup: 'filesystemWrite', + resource: 'write_file', + description: 'Write to file: test.ts', + options: ['allowOnce', 'allowForSession', 'alwaysAllow', 'denyOnce', 'alwaysDeny'], +}; + +const PLAIN_CONFIRMATION_ERROR: McpToolCallResult = { + content: [ + { + type: 'text', + text: `${GATEWAY_CONFIRMATION_REQUIRED_PREFIX}${JSON.stringify(CONFIRMATION_PAYLOAD)}`, + }, + ], + isError: true, +}; + +const JSON_ENVELOPE_CONFIRMATION_ERROR: McpToolCallResult = { + content: [ + { + type: 'text', + text: JSON.stringify({ + error: `${GATEWAY_CONFIRMATION_REQUIRED_PREFIX}${JSON.stringify(CONFIRMATION_PAYLOAD)}`, + }), + }, + ], + isError: true, +}; + +const SUCCESS_RESULT: McpToolCallResult = { + content: [{ type: 'text', text: 'file written' }], +}; + +const GENERIC_ERROR_RESULT: McpToolCallResult = { + content: [{ type: 'text', text: 'Permission denied' }], + isError: true, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeMockServer(tools: McpTool[] = [SAMPLE_TOOL]): jest.Mocked { + return { + getAvailableTools: jest.fn().mockReturnValue(tools), + getToolsByCategory: jest.fn().mockReturnValue([]), + callTool: jest.fn(), + }; +} + +/** Build the tool and return its execute function. */ +function getExecute(server: LocalMcpServer, toolName = 'write_file') { + const tools = createToolsFromLocalMcpServer(server); + const tool = tools[toolName]; + if (!tool?.execute) throw new Error(`Tool '${toolName}' has no execute function`); + return tool.execute.bind(tool) as ( + args: Record, + ctx: unknown, + ) => Promise; +} + +/** Build a ctx object with suspend/resumeData for use in execute calls. */ +function makeCtx(opts: { + suspend?: jest.Mock; + resumeData?: Record | null; +}): unknown { + return { agent: { suspend: opts.suspend ?? jest.fn(), resumeData: opts.resumeData ?? null } }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('createToolsFromLocalMcpServer', () => { + describe('tool creation', () => { + it('creates a tool for each advertised tool', () => { + const server = makeMockServer([SAMPLE_TOOL, { ...SAMPLE_TOOL, name: 'read_file' }]); + const tools = createToolsFromLocalMcpServer(server); + expect(Object.keys(tools)).toContain('write_file'); + expect(Object.keys(tools)).toContain('read_file'); + }); + + it('falls back to record schema when inputSchema conversion fails', () => { + const server = makeMockServer([ + { + name: 'bad_tool', + description: 'Bad schema', + inputSchema: { $ref: '#/broken' } as unknown as McpTool['inputSchema'], + }, + ]); + // Should not throw — the tool must be created even with a bad schema + expect(() => createToolsFromLocalMcpServer(server)).not.toThrow(); + expect(createToolsFromLocalMcpServer(server)['bad_tool']).toBeDefined(); + }); + }); + + describe('execute — first-call path', () => { + it('passes through a successful result', async () => { + const server = makeMockServer(); + server.callTool.mockResolvedValue(SUCCESS_RESULT); + const execute = getExecute(server); + + const result = await execute({ filePath: 'test.ts' }, makeCtx({})); + + expect(result).toEqual(SUCCESS_RESULT); + expect(server.callTool).toHaveBeenCalledWith({ + name: 'write_file', + arguments: { filePath: 'test.ts' }, + }); + }); + + it('strips _confirmation from LLM-provided args on the first-call path', async () => { + const server = makeMockServer(); + server.callTool.mockResolvedValue(SUCCESS_RESULT); + const execute = getExecute(server); + + await execute({ filePath: 'test.ts', _confirmation: 'injected-token' }, makeCtx({})); + + expect(server.callTool).toHaveBeenCalledWith({ + name: 'write_file', + arguments: { filePath: 'test.ts' }, + }); + }); + + it('passes through a generic error result unchanged', async () => { + const server = makeMockServer(); + server.callTool.mockResolvedValue(GENERIC_ERROR_RESULT); + const suspend = jest.fn(); + const execute = getExecute(server); + + const result = await execute({}, makeCtx({ suspend })); + + expect(result).toEqual(GENERIC_ERROR_RESULT); + expect(suspend).not.toHaveBeenCalled(); + }); + + it('calls suspend() for a plain-text GATEWAY_CONFIRMATION_REQUIRED error', async () => { + const server = makeMockServer(); + server.callTool.mockResolvedValue(PLAIN_CONFIRMATION_ERROR); + const suspend = jest.fn().mockResolvedValue(undefined); + const execute = getExecute(server); + + await execute({ filePath: 'test.ts' }, makeCtx({ suspend })); + + expect(suspend).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(suspend.mock.calls[0][0]).toMatchObject({ + inputType: 'resource-decision', + severity: 'warning', + resourceDecision: CONFIRMATION_PAYLOAD, + message: expect.stringContaining('write_file') as string, + requestId: expect.any(String) as string, + }); + }); + + it('calls suspend() for a JSON-envelope GATEWAY_CONFIRMATION_REQUIRED error', async () => { + const server = makeMockServer(); + server.callTool.mockResolvedValue(JSON_ENVELOPE_CONFIRMATION_ERROR); + const suspend = jest.fn().mockResolvedValue(undefined); + const execute = getExecute(server); + + await execute({}, makeCtx({ suspend })); + + expect(suspend).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(suspend.mock.calls[0][0]).toMatchObject({ + inputType: 'resource-decision', + resourceDecision: CONFIRMATION_PAYLOAD, + }); + }); + + it('does NOT call suspend() when ctx.agent is absent', async () => { + const server = makeMockServer(); + server.callTool.mockResolvedValue(PLAIN_CONFIRMATION_ERROR); + const execute = getExecute(server); + + // ctx without agent — suspend is unavailable + const result = await execute({}, {}); + + // Returns the raw error result unchanged + expect(result).toEqual(PLAIN_CONFIRMATION_ERROR); + }); + }); + + describe('execute — resume path', () => { + it('re-calls the daemon with _confirmation decision when decision is present', async () => { + const server = makeMockServer(); + server.callTool.mockResolvedValue(SUCCESS_RESULT); + const execute = getExecute(server); + + const result = await execute( + { filePath: 'test.ts' }, + makeCtx({ resumeData: { approved: true, resourceDecision: 'allowForSession' } }), + ); + + expect(result).toEqual(SUCCESS_RESULT); + expect(server.callTool).toHaveBeenCalledWith({ + name: 'write_file', + arguments: { filePath: 'test.ts', _confirmation: 'allowForSession' }, + }); + }); + + it('returns access-denied error when resumeData has no token (user denied)', async () => { + const server = makeMockServer(); + const execute = getExecute(server); + + const result = await execute({}, makeCtx({ resumeData: { approved: false } })); + + expect(result.isError).toBe(true); + expect((result.content[0] as { type: string; text: string }).text).toContain('denied'); + expect(server.callTool).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/@n8n/instance-ai/src/tools/filesystem/create-tools-from-mcp-server.ts b/packages/@n8n/instance-ai/src/tools/filesystem/create-tools-from-mcp-server.ts index 14ebb950804..bef45823e0a 100644 --- a/packages/@n8n/instance-ai/src/tools/filesystem/create-tools-from-mcp-server.ts +++ b/packages/@n8n/instance-ai/src/tools/filesystem/create-tools-from-mcp-server.ts @@ -1,5 +1,13 @@ import type { ToolsInput } from '@mastra/core/agent'; import { createTool } from '@mastra/core/tools'; +import { + GATEWAY_CONFIRMATION_REQUIRED_PREFIX, + gatewayConfirmationRequiredPayloadSchema, + instanceAiConfirmationSeveritySchema, + type GatewayConfirmationRequiredPayload, + type McpToolCallResult, +} from '@n8n/api-types'; +import { nanoid } from 'nanoid'; import { z } from 'zod'; import { convertJsonSchemaToZod } from 'zod-from-json-schema-v3'; import type { JSONSchema } from 'zod-from-json-schema-v3'; @@ -7,6 +15,68 @@ import type { JSONSchema } from 'zod-from-json-schema-v3'; import { sanitizeMcpToolSchemas } from '../../agent/sanitize-mcp-schemas'; import type { LocalMcpServer } from '../../types'; +// --------------------------------------------------------------------------- +// Schemas shared across all gateway-gated tools +// --------------------------------------------------------------------------- + +const gatewayConfirmationSuspendSchema = z.object({ + requestId: z.string(), + message: z.string(), + severity: instanceAiConfirmationSeveritySchema, + inputType: z.literal('resource-decision'), + resourceDecision: gatewayConfirmationRequiredPayloadSchema, +}); + +const gatewayConfirmationResumeSchema = z.object({ + approved: z.boolean(), + resourceDecision: z.string().optional(), +}); + +// --------------------------------------------------------------------------- +// Helper +// --------------------------------------------------------------------------- + +function tryParseGatewayConfirmationRequired( + result: McpToolCallResult, +): GatewayConfirmationRequiredPayload | null { + if (!result.isError) return null; + const raw = result.content + .filter((c) => c.type === 'text') + .map((c) => c.text ?? '') + .join(''); + + // Unwrap JSON envelope `{"error":"GATEWAY_CONFIRMATION_REQUIRED::..."}` if present + let candidate = raw; + try { + const parsed = JSON.parse(raw) as unknown; + if ( + typeof parsed === 'object' && + parsed !== null && + 'error' in parsed && + typeof (parsed as Record).error === 'string' + ) { + candidate = (parsed as Record).error as string; + } + } catch { + // Not JSON — use raw text as-is + } + + if (!candidate.startsWith(GATEWAY_CONFIRMATION_REQUIRED_PREFIX)) return null; + try { + const json = JSON.parse( + candidate.slice(GATEWAY_CONFIRMATION_REQUIRED_PREFIX.length), + ) as unknown; + const parsed = gatewayConfirmationRequiredPayloadSchema.safeParse(json); + return parsed.success ? parsed.data : null; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + /** * Build Mastra tools dynamically from the MCP tools advertised by a connected * local MCP server (e.g. the fs-proxy daemon). @@ -15,9 +85,14 @@ import type { LocalMcpServer } from '../../types'; * to a Zod schema so the LLM receives accurate parameter information. Falls back * to `z.record(z.unknown())` if conversion fails for a particular tool. * - * The execute function forwards the call via `server.callTool()` and returns - * the full MCP result. A `toModelOutput` callback converts MCP content blocks - * (text and image) into the AI SDK's multimodal format so the LLM receives images. + * When the daemon responds with `GATEWAY_CONFIRMATION_REQUIRED`, the tool + * suspends the agent via Mastra's native `suspend()` mechanism. This persists + * the confirmation request to the database, so it survives page reloads and + * server restarts. On resume, the tool re-calls the daemon with the selected + * decision token. + * + * The `toModelOutput` callback converts MCP content blocks (text and image) + * into the AI SDK's multimodal format so the LLM receives images. */ export function createToolsFromLocalMcpServer(server: LocalMcpServer): ToolsInput { const tools: ToolsInput = {}; @@ -38,15 +113,52 @@ export function createToolsFromLocalMcpServer(server: LocalMcpServer): ToolsInpu inputSchema = z.record(z.unknown()); } - tools[toolName] = createTool({ + const tool = createTool({ id: toolName, description, inputSchema, - execute: async (args: Record) => { - const result = await server.callTool({ - name: toolName, - arguments: args, - }); + suspendSchema: gatewayConfirmationSuspendSchema, + resumeSchema: gatewayConfirmationResumeSchema, + execute: async (args: Record, ctx) => { + const { resumeData, suspend } = ctx?.agent ?? {}; + + // Resume path: user has made a resource-access decision + if (resumeData !== undefined && resumeData !== null) { + if (!resumeData.resourceDecision) { + // User denied — no decision provided + return { + content: [{ type: 'text', text: JSON.stringify({ error: 'Access denied by user' }) }], + isError: true, + }; + } + // Re-call the daemon with the user's decision + return await server.callTool({ + name: toolName, + arguments: { ...args, _confirmation: resumeData.resourceDecision }, + }); + } + + // First-call path: strip any LLM-provided _confirmation key so the agent + // cannot bypass the human confirmation flow by supplying its own token. + const { _confirmation: _stripped, ...safeArgs } = args; + const result = await server.callTool({ name: toolName, arguments: safeArgs }); + + // If the daemon requires a resource-access confirmation, suspend the agent + if (result.isError && suspend) { + const payload = tryParseGatewayConfirmationRequired(result); + if (payload) { + await suspend({ + requestId: nanoid(), + message: `${toolName}: ${payload.description}`, + severity: 'warning', + inputType: 'resource-decision', + resourceDecision: payload, + }); + // suspend() never resolves — this line is unreachable but satisfies the type checker + return result; + } + } + return result; }, toModelOutput: (result: unknown) => { @@ -89,6 +201,8 @@ export function createToolsFromLocalMcpServer(server: LocalMcpServer): ToolsInpu return { type: 'content', value }; }, }); + + tools[toolName] = tool; } return sanitizeMcpToolSchemas(tools); diff --git a/packages/@n8n/instance-ai/src/types.ts b/packages/@n8n/instance-ai/src/types.ts index b3123864571..2299bb5302f 100644 --- a/packages/@n8n/instance-ai/src/types.ts +++ b/packages/@n8n/instance-ai/src/types.ts @@ -834,6 +834,7 @@ export interface OrchestrationContext { autoSetup?: { credentialType: string }; userInput?: string; domainAccessAction?: string; + resourceDecision?: string; answers?: Array<{ questionId: string; selectedOptions: string[]; diff --git a/packages/@n8n/local-gateway/src/main/settings-store.ts b/packages/@n8n/local-gateway/src/main/settings-store.ts index 439241de631..e95801381a8 100644 --- a/packages/@n8n/local-gateway/src/main/settings-store.ts +++ b/packages/@n8n/local-gateway/src/main/settings-store.ts @@ -86,6 +86,7 @@ export class SettingsStore { computer: s.screenshotEnabled || s.mouseKeyboardEnabled ? 'ask' : 'deny', browser: s.browserEnabled ? 'ask' : 'deny', }, + permissionConfirmation: 'instance', }; } diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts index a1275084876..2f2aabfa5d5 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts @@ -345,6 +345,22 @@ describe('InstanceAiController', () => { ); }); + it('should pass resourceDecision through to resolveConfirmation', async () => { + instanceAiService.resolveConfirmation.mockResolvedValue(true); + const body = mock({ + approved: true, + resourceDecision: 'allowOnce', + }); + + await controller.confirm(req, res, 'req-1', body); + + expect(instanceAiService.resolveConfirmation).toHaveBeenCalledWith( + USER_ID, + 'req-1', + expect.objectContaining({ resourceDecision: 'allowOnce' }), + ); + }); + it('should throw NotFoundError when confirmation not found', async () => { instanceAiService.resolveConfirmation.mockResolvedValue(false); const body = mock({ approved: false }); diff --git a/packages/cli/src/modules/instance-ai/__tests__/local-gateway.test.ts b/packages/cli/src/modules/instance-ai/__tests__/local-gateway.test.ts index d92787db174..f3545ae2a8b 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/local-gateway.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/local-gateway.test.ts @@ -123,7 +123,10 @@ describe('LocalGateway', () => { isError: true, }); - await expect(callPromise).rejects.toThrow('File too large'); + // isError results are passed through so the tool layer can inspect them + const result = await callPromise; + expect(result.isError).toBe(true); + expect((result.content[0] as { type: 'text'; text: string }).text).toBe('File too large'); }); it('should throw when gateway is not connected', async () => { @@ -176,6 +179,38 @@ describe('LocalGateway', () => { }); }); + describe('resolveRequest with isError results', () => { + const CONFIRMATION_PAYLOAD = { + toolGroup: 'filesystemWrite', + resource: 'write_file', + description: 'Write to file: test.ts', + options: { allowOnce: 'token-allow-once', denyOnce: 'token-deny-once' }, + }; + const rawErrorText = `GATEWAY_CONFIRMATION_REQUIRED::${JSON.stringify(CONFIRMATION_PAYLOAD)}`; + + it('should resolve with isError result so the tool layer can inspect it', async () => { + gateway.init(EMPTY_CAPABILITIES); + + const requestEvents: LocalGatewayEvent[] = []; + gateway.onRequest((e) => requestEvents.push(e)); + + const callPromise = gateway.callTool({ + name: 'write_file', + arguments: { filePath: 'test.ts' }, + }); + + const errorResult = { + content: [{ type: 'text' as const, text: rawErrorText }], + isError: true as const, + }; + gateway.resolveRequest(requestEvents[0].payload.requestId, errorResult); + + const result = await callPromise; + expect(result.isError).toBe(true); + expect((result.content[0] as { type: 'text'; text: string }).text).toBe(rawErrorText); + }); + }); + describe('getStatus', () => { it('should return disconnected status by default', () => { const status = gateway.getStatus(); diff --git a/packages/cli/src/modules/instance-ai/filesystem/local-gateway.ts b/packages/cli/src/modules/instance-ai/filesystem/local-gateway.ts index 2fff61f46b6..aafa373bfca 100644 --- a/packages/cli/src/modules/instance-ai/filesystem/local-gateway.ts +++ b/packages/cli/src/modules/instance-ai/filesystem/local-gateway.ts @@ -16,6 +16,7 @@ interface PendingRequest { resolve: (result: McpToolCallResult) => void; reject: (error: Error) => void; timer: NodeJS.Timeout; + toolCall: McpToolCallRequest; } export interface LocalGatewayEvent { @@ -39,6 +40,9 @@ export interface LocalGatewayEvent { * 3. callTool() → emits filesystem-request via SSE * 4. Client executes locally, POSTs MCP result to /instance-ai/gateway/response/:requestId * 5. resolveRequest() resolves the pending promise → caller gets McpToolCallResult + * + * Resource-access confirmations (GATEWAY_CONFIRMATION_REQUIRED) are handled at the + * tool layer via Mastra's suspend()/resumeData mechanism — not here. */ export class LocalGateway { private readonly pendingRequests = new Map(); @@ -105,18 +109,13 @@ export class LocalGateway { if (error) { pending.reject(new Error(error)); - } else if (result?.isError === true) { - pending.reject( - new Error( - result.content - .filter((c): c is { type: 'text'; text: string } => c.type === 'text') - .map((c) => c.text) - .join('\n'), - ), - ); - } else { - pending.resolve(result ?? { content: [] }); + return true; } + + // Resolve with the result as-is (including isError responses) so the tool + // layer (create-tools-from-mcp-server.ts) can inspect GATEWAY_CONFIRMATION_REQUIRED + // errors and handle them via Mastra suspend(). + pending.resolve(result ?? { content: [] }); return true; } @@ -170,7 +169,7 @@ export class LocalGateway { reject(new Error(`Local gateway request timed out after ${REQUEST_TIMEOUT_MS}ms`)); }, REQUEST_TIMEOUT_MS); - this.pendingRequests.set(requestId, { resolve, reject, timer }); + this.pendingRequests.set(requestId, { resolve, reject, timer, toolCall }); this.emitter.emit('filesystem-request', { type: 'filesystem-request', diff --git a/packages/cli/src/modules/instance-ai/instance-ai.controller.ts b/packages/cli/src/modules/instance-ai/instance-ai.controller.ts index 568640a9b43..4cfeb6e2932 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.controller.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.controller.ts @@ -270,6 +270,7 @@ export class InstanceAiController { nodeParameters: body.nodeParameters, testTriggerNode: body.testTriggerNode, answers: body.answers, + resourceDecision: body.resourceDecision, }); if (!resolved) { throw new NotFoundError('Confirmation request not found or not authorized'); diff --git a/packages/cli/src/modules/instance-ai/instance-ai.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.service.ts index f0a66ef0c25..e206b1234d9 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.service.ts @@ -119,7 +119,6 @@ export class InstanceAiService { string, { threadId: string; messageGroupId?: string; tracing: InstanceAiTraceContext } >(); - /** Active sandboxes keyed by thread ID — persisted across messages within a conversation. */ private readonly sandboxes = new Map< string, @@ -1816,6 +1815,7 @@ export class InstanceAiService { ...(data.nodeParameters ? { nodeParameters: data.nodeParameters } : {}), ...(data.testTriggerNode ? { testTriggerNode: data.testTriggerNode } : {}), ...(data.answers ? { answers: data.answers } : {}), + ...(data.resourceDecision ? { resourceDecision: data.resourceDecision } : {}), }; void this.processResumedStream(agent, resumeData, { diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 219d508c76c..a6c3d27fff7 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -5032,6 +5032,12 @@ "instanceAi.confirmation.pendingInline": "Waiting for approval", "instanceAi.confirmation.agentContext": "{agent} needs approval", "instanceAi.confirmation.approveAll": "Approve all", + "instanceAi.gatewayConfirmation.allowForSession": "Allow for session", + "instanceAi.gatewayConfirmation.allowOnce": "Allow once", + "instanceAi.gatewayConfirmation.alwaysAllow": "Always allow", + "instanceAi.gatewayConfirmation.alwaysDeny": "Always deny", + "instanceAi.gatewayConfirmation.denyOnce": "Deny once", + "instanceAi.gatewayConfirmation.prompt": "n8n AI wants to access '{resources}' on Computer Use", "instanceAi.askUser.placeholder": "Type your answer...", "instanceAi.askUser.submit": "Submit", "instanceAi.askUser.skip": "Skip", diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAi.store.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAi.store.test.ts index 972225cc23d..26951e17b5b 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAi.store.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAi.store.test.ts @@ -1,7 +1,7 @@ import { setActivePinia, createPinia } from 'pinia'; -import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, test, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { fetchThreadMessages, fetchThreadStatus } from '../instanceAi.memory.api'; -import { ensureThread, postMessage } from '../instanceAi.api'; +import { ensureThread, postMessage, postConfirmation } from '../instanceAi.api'; import { useInstanceAiStore } from '../instanceAi.store'; // --------------------------------------------------------------------------- @@ -134,6 +134,7 @@ const mockFetchThreadMessages = vi.mocked(fetchThreadMessages); const mockFetchThreadStatus = vi.mocked(fetchThreadStatus); const mockEnsureThread = vi.mocked(ensureThread); const mockPostMessage = vi.mocked(postMessage); +const mockPostConfirmation = vi.mocked(postConfirmation); describe('useInstanceAiStore - onSSEMessage', () => { let store: ReturnType; @@ -597,3 +598,85 @@ describe('useInstanceAiStore - feedback integration', () => { }); }); }); + +// --------------------------------------------------------------------------- +// confirmResourceDecision / confirmAction (resource-decision token) +// --------------------------------------------------------------------------- + +describe('useInstanceAiStore - gateway resource-decision confirmation', () => { + let store: ReturnType; + + beforeEach(async () => { + setActivePinia(createPinia()); + capturedOnMessage = null; + store = useInstanceAiStore(); + store.newThread(); + await vi.waitFor(() => { + expect(capturedOnMessage).not.toBeNull(); + }); + mockPostConfirmation.mockResolvedValue(undefined); + }); + + afterEach(() => { + store.closeSSE(); + vi.clearAllMocks(); + }); + + it('confirmAction passes resourceDecision to postConfirmation', async () => { + await store.confirmAction( + 'req-1', + true, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + 'allowOnce', + ); + + expect(mockPostConfirmation).toHaveBeenCalledOnce(); + expect(mockPostConfirmation).toHaveBeenCalledWith( + expect.anything(), + 'req-1', + true, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + 'allowOnce', + ); + }); + + it('confirmResourceDecision calls postConfirmation with approved=true and the decision', async () => { + await store.confirmResourceDecision('req-2', 'allowForSession'); + + expect(mockPostConfirmation).toHaveBeenCalledOnce(); + expect(mockPostConfirmation).toHaveBeenCalledWith( + expect.anything(), + 'req-2', + true, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + 'allowForSession', + ); + }); + + it('confirmResourceDecision does not call postConfirmation when confirmAction throws', async () => { + mockPostConfirmation.mockRejectedValueOnce(new Error('network error')); + + await store.confirmResourceDecision('req-3', 'denyOnce'); + + // postConfirmation was called once (inside confirmAction) but threw + expect(mockPostConfirmation).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/GatewayResourceDecision.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/GatewayResourceDecision.vue new file mode 100644 index 00000000000..1d1435d0707 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/GatewayResourceDecision.vue @@ -0,0 +1,235 @@ + + + + + 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 cf3e8b337be..e6fa279018c 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 @@ -5,6 +5,7 @@ import { computed, ref } from 'vue'; import { useInstanceAiStore, type PendingConfirmationItem } from '../instanceAi.store'; import { useToolLabel } from '../toolLabels'; import DomainAccessApproval from './DomainAccessApproval.vue'; +import GatewayResourceDecision from './GatewayResourceDecision.vue'; import InstanceAiCredentialSetup from './InstanceAiCredentialSetup.vue'; import type { QuestionAnswer } from './InstanceAiQuestions.vue'; import InstanceAiQuestions from './InstanceAiQuestions.vue'; @@ -137,7 +138,7 @@ function handlePlanRequestChanges(requestId: string, feedback: string) { void store.confirmAction(requestId, false, undefined, undefined, undefined, feedback); } -/** True when every item in the approval-wrapped group is a generic approval (not domain access). */ +/** True when every item in the group is a generic approval (not domain/cred/text). */ function isAllGenericApproval(items: PendingConfirmationItem[]): boolean { return items.every((item) => !item.toolCall.confirmation!.domainAccess); } @@ -248,6 +249,20 @@ function isAllGenericApproval(items: PendingConfirmationItem[]): boolean { + + diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.api.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.api.ts index be0239e2d43..84d9c773e45 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.api.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.api.ts @@ -88,6 +88,7 @@ export async function postConfirmation( testTriggerNode?: string; }, answers?: InstanceAiConfirmResponse['answers'], + resourceDecision?: string, ): Promise { const payload: InstanceAiConfirmResponse = { approved, @@ -111,6 +112,7 @@ export async function postConfirmation( ? { testTriggerNode: setupWorkflowData.testTriggerNode } : {}), ...(answers ? { answers } : {}), + ...(resourceDecision ? { resourceDecision } : {}), }; await makeRestApiRequest(context, 'POST', `/instance-ai/confirm/${requestId}`, payload); } diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.store.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.store.ts index 872c2d37ea2..0ae2e5a3db3 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.store.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.store.ts @@ -786,6 +786,7 @@ export const useInstanceAiStore = defineStore('instanceAi', () => { testTriggerNode?: string; }, answers?: InstanceAiConfirmResponse['answers'], + resourceDecision?: string, ): Promise { try { await postConfirmation( @@ -799,6 +800,7 @@ export const useInstanceAiStore = defineStore('instanceAi', () => { domainAccessAction, setupWorkflowData, answers, + resourceDecision, ); return true; } catch { @@ -807,6 +809,22 @@ export const useInstanceAiStore = defineStore('instanceAi', () => { } } + async function confirmResourceDecision(requestId: string, decision: string): Promise { + resolveConfirmation(requestId, 'approved'); + await confirmAction( + requestId, + true, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + decision, + ); + } + function toggleResearchMode(): void { researchMode.value = !researchMode.value; localStorage.setItem('instanceAi.researchMode', String(researchMode.value)); @@ -956,6 +974,7 @@ export const useInstanceAiStore = defineStore('instanceAi', () => { amendAgent, toggleResearchMode, confirmAction, + confirmResourceDecision, resolveConfirmation, findToolCallByRequestId, copyFullTrace,