mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-01 05:38:33 +08:00
feat: Computer use HITL confirmations in Instance AI (#27910)
This commit is contained in:
@@ -24,4 +24,5 @@ export class InstanceAiConfirmRequestDto extends Z.class({
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
resourceDecision: z.string().optional(),
|
||||
}) {}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -258,6 +258,25 @@ export const taskListSchema = z.object({
|
||||
|
||||
export type TaskList = z.infer<typeof taskListSchema>;
|
||||
|
||||
// ── 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<string, Record<string, unknown>>;
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
Why don't programmers like nature?
|
||||
|
||||
It has too many bugs.
|
||||
@@ -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"
|
||||
|
||||
@@ -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<br/>{ 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
|
||||
|
||||
@@ -63,6 +63,8 @@ export interface GatewayConfig {
|
||||
};
|
||||
/** Startup permission overrides (ENV/CLI). Merged with persistent settings in SettingsStore. */
|
||||
permissions: Partial<Record<ToolGroup, PermissionMode>>;
|
||||
/** 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' },
|
||||
|
||||
@@ -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<void> {
|
||||
const { settingsStore, confirmResourceAccess } = this.options;
|
||||
private async checkPermissions(
|
||||
resources: AffectedResource[],
|
||||
decision?: ResourceDecision,
|
||||
): Promise<void> {
|
||||
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':
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -11,6 +11,7 @@ const BASE_CONFIG: GatewayConfig = {
|
||||
defaultBrowser: 'chrome',
|
||||
},
|
||||
permissions: {},
|
||||
permissionConfirmation: 'instance',
|
||||
};
|
||||
|
||||
describe('resolveTemplateName', () => {
|
||||
|
||||
@@ -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<ResourceDecision>;
|
||||
|
||||
@@ -37,6 +37,8 @@ export interface ConfirmationData {
|
||||
customText?: string;
|
||||
skipped?: boolean;
|
||||
}>;
|
||||
/** User's resource-access decision (e.g. 'allowForSession'). */
|
||||
resourceDecision?: string;
|
||||
}
|
||||
|
||||
export interface PendingConfirmation {
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+230
@@ -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<LocalMcpServer> {
|
||||
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<string, unknown>,
|
||||
ctx: unknown,
|
||||
) => Promise<McpToolCallResult>;
|
||||
}
|
||||
|
||||
/** Build a ctx object with suspend/resumeData for use in execute calls. */
|
||||
function makeCtx(opts: {
|
||||
suspend?: jest.Mock;
|
||||
resumeData?: Record<string, unknown> | 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>).error === 'string'
|
||||
) {
|
||||
candidate = (parsed as Record<string, unknown>).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<string, unknown>) => {
|
||||
const result = await server.callTool({
|
||||
name: toolName,
|
||||
arguments: args,
|
||||
});
|
||||
suspendSchema: gatewayConfirmationSuspendSchema,
|
||||
resumeSchema: gatewayConfirmationResumeSchema,
|
||||
execute: async (args: Record<string, unknown>, 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);
|
||||
|
||||
@@ -834,6 +834,7 @@ export interface OrchestrationContext {
|
||||
autoSetup?: { credentialType: string };
|
||||
userInput?: string;
|
||||
domainAccessAction?: string;
|
||||
resourceDecision?: string;
|
||||
answers?: Array<{
|
||||
questionId: string;
|
||||
selectedOptions: string[];
|
||||
|
||||
@@ -86,6 +86,7 @@ export class SettingsStore {
|
||||
computer: s.screenshotEnabled || s.mouseKeyboardEnabled ? 'ask' : 'deny',
|
||||
browser: s.browserEnabled ? 'ask' : 'deny',
|
||||
},
|
||||
permissionConfirmation: 'instance',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -345,6 +345,22 @@ describe('InstanceAiController', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass resourceDecision through to resolveConfirmation', async () => {
|
||||
instanceAiService.resolveConfirmation.mockResolvedValue(true);
|
||||
const body = mock<InstanceAiConfirmRequestDto>({
|
||||
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<InstanceAiConfirmRequestDto>({ approved: false });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string, PendingRequest>();
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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",
|
||||
|
||||
+85
-2
@@ -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<typeof useInstanceAiStore>;
|
||||
@@ -597,3 +598,85 @@ describe('useInstanceAiStore - feedback integration', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// confirmResourceDecision / confirmAction (resource-decision token)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('useInstanceAiStore - gateway resource-decision confirmation', () => {
|
||||
let store: ReturnType<typeof useInstanceAiStore>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
<script lang="ts" setup>
|
||||
import { N8nActionDropdown, N8nButton, N8nIconButton } from '@n8n/design-system';
|
||||
import type { ActionDropdownItem } from '@n8n/design-system/types';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useInstanceAiStore } from '../instanceAi.store';
|
||||
|
||||
const props = defineProps<{
|
||||
requestId: string;
|
||||
resource: string;
|
||||
description: string;
|
||||
options: string[];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const store = useInstanceAiStore();
|
||||
|
||||
interface OptionEntry {
|
||||
decision: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const DECISION_LABELS: Record<string, string> = {
|
||||
allowOnce: i18n.baseText('instanceAi.gatewayConfirmation.allowOnce'),
|
||||
allowForSession: i18n.baseText('instanceAi.gatewayConfirmation.allowForSession'),
|
||||
alwaysAllow: i18n.baseText('instanceAi.gatewayConfirmation.alwaysAllow'),
|
||||
denyOnce: i18n.baseText('instanceAi.gatewayConfirmation.denyOnce'),
|
||||
alwaysDeny: i18n.baseText('instanceAi.gatewayConfirmation.alwaysDeny'),
|
||||
};
|
||||
|
||||
const KNOWN_DECISIONS = new Set(Object.keys(DECISION_LABELS));
|
||||
|
||||
function getDecisionLabel(decision: string): string {
|
||||
return DECISION_LABELS[decision] ?? decision;
|
||||
}
|
||||
|
||||
function optionEntry(decision: string): OptionEntry {
|
||||
return { decision, label: getDecisionLabel(decision) };
|
||||
}
|
||||
|
||||
const denyPrimary = computed(() =>
|
||||
props.options.includes('denyOnce') ? optionEntry('denyOnce') : undefined,
|
||||
);
|
||||
|
||||
const denyDropdownItems = computed(() => {
|
||||
const items: Array<ActionDropdownItem<string>> = [];
|
||||
if (props.options.includes('alwaysDeny'))
|
||||
items.push({ id: 'alwaysDeny', label: getDecisionLabel('alwaysDeny') });
|
||||
return items;
|
||||
});
|
||||
|
||||
const approvePrimary = computed(() =>
|
||||
props.options.includes('allowForSession') ? optionEntry('allowForSession') : undefined,
|
||||
);
|
||||
|
||||
const approveDropdownItems = computed(() => {
|
||||
const items: Array<ActionDropdownItem<string>> = [];
|
||||
if (props.options.includes('allowOnce'))
|
||||
items.push({ id: 'allowOnce', label: getDecisionLabel('allowOnce') });
|
||||
if (props.options.includes('alwaysAllow'))
|
||||
items.push({ id: 'alwaysAllow', label: getDecisionLabel('alwaysAllow') });
|
||||
return items;
|
||||
});
|
||||
|
||||
const otherOptions = computed<OptionEntry[]>(() =>
|
||||
props.options.filter((d) => !KNOWN_DECISIONS.has(d)).map(optionEntry),
|
||||
);
|
||||
|
||||
async function confirm(decision: string) {
|
||||
await store.confirmResourceDecision(props.requestId, decision);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.root">
|
||||
<div :class="$style.body">
|
||||
<div :class="$style.message">
|
||||
{{
|
||||
i18n.baseText('instanceAi.gatewayConfirmation.prompt', {
|
||||
interpolate: { resources: props.resource },
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
<div :class="$style.preview">{{ props.description }}</div>
|
||||
</div>
|
||||
|
||||
<div :class="$style.actions">
|
||||
<!-- Unknown options not in the standard set -->
|
||||
<N8nButton
|
||||
v-for="opt in otherOptions"
|
||||
:key="opt.decision"
|
||||
variant="outline"
|
||||
size="small"
|
||||
:label="opt.label"
|
||||
@click="confirm(opt.decision)"
|
||||
/>
|
||||
|
||||
<!-- Deny side -->
|
||||
<template v-if="denyPrimary">
|
||||
<div v-if="denyDropdownItems.length" :class="$style.splitButton">
|
||||
<N8nButton
|
||||
variant="outline"
|
||||
size="small"
|
||||
:label="denyPrimary.label"
|
||||
:class="$style.splitButtonMain"
|
||||
data-test-id="gateway-decision-deny"
|
||||
@click="confirm(denyPrimary.decision)"
|
||||
/>
|
||||
<N8nActionDropdown
|
||||
:items="denyDropdownItems"
|
||||
:class="$style.splitButtonDropdown"
|
||||
placement="bottom-start"
|
||||
@select="confirm"
|
||||
>
|
||||
<template #activator>
|
||||
<N8nIconButton
|
||||
variant="outline"
|
||||
icon="chevron-down"
|
||||
:class="$style.splitButtonCaret"
|
||||
aria-label="More deny options"
|
||||
size="small"
|
||||
/>
|
||||
</template>
|
||||
</N8nActionDropdown>
|
||||
</div>
|
||||
<N8nButton
|
||||
v-else
|
||||
variant="outline"
|
||||
size="small"
|
||||
:label="denyPrimary.label"
|
||||
data-test-id="gateway-decision-deny"
|
||||
@click="confirm(denyPrimary.decision)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Approve side -->
|
||||
<template v-if="approvePrimary">
|
||||
<div v-if="approveDropdownItems.length" :class="$style.splitButton">
|
||||
<N8nButton
|
||||
variant="solid"
|
||||
size="small"
|
||||
:label="approvePrimary.label"
|
||||
:class="$style.splitButtonMain"
|
||||
data-test-id="gateway-decision-approve"
|
||||
@click="confirm(approvePrimary.decision)"
|
||||
/>
|
||||
<N8nActionDropdown
|
||||
:items="approveDropdownItems"
|
||||
:class="$style.splitButtonDropdown"
|
||||
placement="bottom-start"
|
||||
@select="confirm"
|
||||
>
|
||||
<template #activator>
|
||||
<N8nIconButton
|
||||
variant="solid"
|
||||
icon="chevron-down"
|
||||
:class="$style.splitButtonCaret"
|
||||
aria-label="More approve options"
|
||||
size="small"
|
||||
/>
|
||||
</template>
|
||||
</N8nActionDropdown>
|
||||
</div>
|
||||
<N8nButton
|
||||
v-else
|
||||
variant="solid"
|
||||
size="small"
|
||||
:label="approvePrimary.label"
|
||||
data-test-id="gateway-decision-approve"
|
||||
@click="confirm(approvePrimary.decision)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
border: var(--border);
|
||||
border-radius: var(--radius--lg);
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: var(--spacing--sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--3xs);
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: var(--font-size--2xs);
|
||||
color: var(--color--text);
|
||||
font-weight: var(--font-weight--medium);
|
||||
}
|
||||
|
||||
.preview {
|
||||
font-family: monospace;
|
||||
font-size: var(--font-size--3xs);
|
||||
color: var(--color--text--tint-1);
|
||||
word-break: break-all;
|
||||
padding: var(--spacing--2xs);
|
||||
background: var(--color--background);
|
||||
border-radius: var(--radius);
|
||||
border: var(--border);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--spacing--2xs);
|
||||
justify-content: flex-end;
|
||||
border-top: var(--border);
|
||||
padding: var(--spacing--xs) var(--spacing--sm);
|
||||
}
|
||||
|
||||
.splitButton {
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.splitButtonMain {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.splitButtonDropdown {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.splitButtonCaret {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
border-left: 1px solid var(--color--foreground--tint-2);
|
||||
}
|
||||
</style>
|
||||
+16
-1
@@ -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 {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Resource-access decision (gateway permission mode) -->
|
||||
<GatewayResourceDecision
|
||||
v-else-if="
|
||||
chunk.item.toolCall.confirmation!.inputType === 'resource-decision' &&
|
||||
chunk.item.toolCall.confirmation!.resourceDecision
|
||||
"
|
||||
:key="'rd-' + chunk.item.toolCall.confirmation!.requestId"
|
||||
:class="$style.confirmation"
|
||||
data-test-id="instance-ai-gateway-confirmation-panel"
|
||||
:request-id="chunk.item.toolCall.confirmation!.requestId"
|
||||
:resource="chunk.item.toolCall.confirmation!.resourceDecision.resource"
|
||||
:description="chunk.item.toolCall.confirmation!.resourceDecision.description"
|
||||
:options="chunk.item.toolCall.confirmation!.resourceDecision.options"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- ============ Approval-wrapped group ============ -->
|
||||
|
||||
@@ -88,6 +88,7 @@ export async function postConfirmation(
|
||||
testTriggerNode?: string;
|
||||
},
|
||||
answers?: InstanceAiConfirmResponse['answers'],
|
||||
resourceDecision?: string,
|
||||
): Promise<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -786,6 +786,7 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
|
||||
testTriggerNode?: string;
|
||||
},
|
||||
answers?: InstanceAiConfirmResponse['answers'],
|
||||
resourceDecision?: string,
|
||||
): Promise<boolean> {
|
||||
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<void> {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user