feat: Computer use tools to safely create credentials

This commit is contained in:
Dimitri Lavrenük 2026-05-12 15:08:07 +02:00
parent 6f9b99a3cf
commit a989db90ba
No known key found for this signature in database
20 changed files with 769 additions and 19 deletions

View File

@ -341,6 +341,7 @@ export {
InstanceAiAdminSettingsUpdateRequest,
InstanceAiUserPreferencesUpdateRequest,
InstanceAiGatewayCapabilitiesDto,
InstanceAiGatewayCreateCredentialDto,
InstanceAiFilesystemResponseDto,
applyBranchReadOnlyOverrides,
} from './schemas/instance-ai.schema';

View File

@ -528,6 +528,13 @@ export class InstanceAiGatewayCapabilitiesDto extends Z.class({
}) {}
export type InstanceAiGatewayCapabilities = InstanceType<typeof InstanceAiGatewayCapabilitiesDto>;
export class InstanceAiGatewayCreateCredentialDto extends Z.class({
name: z.string().min(1).max(128),
type: z.string().min(1).max(128),
data: z.record(z.unknown()),
projectId: z.string().optional(),
}) {}
// ---------------------------------------------------------------------------
// Filesystem bridge payloads (browser ↔ server round-trip)
// ---------------------------------------------------------------------------

View File

@ -77,7 +77,7 @@ function makeSession(overrides: Partial<GatewaySession> = {}): jest.Mocked<Gatew
setDir: jest.fn(),
getGroupMode: jest.fn().mockReturnValue('allow'),
allowForSession: jest.fn(),
clearSessionRules: jest.fn(),
clearSession: jest.fn(),
alwaysAllow: jest.fn(),
alwaysDeny: jest.fn(),
flush: jest.fn().mockResolvedValue(undefined),

View File

@ -21,6 +21,7 @@ import {
type AffectedResource,
type CallToolResult,
type ConfirmResourceAccess,
type CreateCredentialPayload,
type McpTool,
type ResourceDecision,
type ToolDefinition,
@ -137,7 +138,7 @@ export class GatewayClient {
if (this.disconnected) return;
this.disconnected = true;
this.shouldReconnect = false;
this.options.session.clearSessionRules();
this.options.session.clearSession();
const notifyServer = options.notifyServer ?? true;
if (notifyServer) {
@ -439,7 +440,34 @@ export class GatewayClient {
typeof _confirmation === 'string' ? (_confirmation as ResourceDecision) : undefined;
const typedArgs: unknown = def.inputSchema.parse(cleanArgs);
const context = { dir: this.dir };
const session = this.options.session;
const instanceUrl = this.options.url;
const gatewayKey = this.apiKey;
const context = {
dir: this.dir,
secretsBuffer: {
capture: (k: string, f: string, v: string) => session.captureSecret(k, f, v),
getFields: (k: string) => session.getSecretFields(k),
clear: (k: string) => session.clearSecrets(k),
},
createCredential: async (payload: CreateCredentialPayload) => {
const url = `${instanceUrl}/rest/instance-ai/gateway/credentials`;
const headers = new Headers();
headers.set('Content-Type', 'application/json');
headers.set('X-Gateway-Key', gatewayKey);
const res = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(payload),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Credential creation failed: ${res.status} ${text}`);
}
const body = (await res.json()) as { data: { credentialId: string } };
return { credentialId: body.data.credentialId };
},
};
const resources = await def.getAffectedResources(typedArgs, context);
await this.checkPermissions(resources, decision);

View File

@ -281,8 +281,8 @@ describe('GatewaySession', () => {
// Session-level allow rules
// ---------------------------------------------------------------------------
describe('allowForSession / clearSessionRules', () => {
it('allow for session is cleared after clearSessionRules', () => {
describe('allowForSession / clearSession', () => {
it('allow for session is cleared after clearSession', () => {
const store = makeStore();
const session = new GatewaySession(
{ permissions: buildDefaultPermissions({ shell: 'ask' }), dir: '/' },
@ -290,7 +290,7 @@ describe('GatewaySession', () => {
);
session.allowForSession('shell', 'npm');
expect(session.check('shell', 'npm')).toBe('allow');
session.clearSessionRules();
session.clearSession();
expect(session.check('shell', 'npm')).toBe('ask');
});
@ -340,4 +340,54 @@ describe('GatewaySession', () => {
expect(store.flush).toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Secrets buffer
// ---------------------------------------------------------------------------
describe('captureSecret / getSecretFields / clearSecrets', () => {
function makeSession() {
return new GatewaySession(
{ permissions: FULL_ALLOW_PERMISSIONS, dir: '/' },
makeStore() as unknown as SettingsStore,
);
}
it('stores a captured field and retrieves it', () => {
const session = makeSession();
session.captureSecret('k1', 'apiKey', 'secret');
const fields = session.getSecretFields('k1');
expect(fields?.get('apiKey')).toBe('secret');
});
it('accumulates multiple fields under the same key', () => {
const session = makeSession();
session.captureSecret('k1', 'clientId', 'id-value');
session.captureSecret('k1', 'clientSecret', 'secret-value');
const fields = session.getSecretFields('k1');
expect(fields?.get('clientId')).toBe('id-value');
expect(fields?.get('clientSecret')).toBe('secret-value');
});
it('returns undefined for an unknown key', () => {
const session = makeSession();
expect(session.getSecretFields('no-such-key')).toBeUndefined();
});
it('clears only the specified key', () => {
const session = makeSession();
session.captureSecret('k1', 'field', 'value');
session.captureSecret('k2', 'other', 'other-value');
session.clearSecrets('k1');
expect(session.getSecretFields('k1')).toBeUndefined();
expect(session.getSecretFields('k2')?.get('other')).toBe('other-value');
});
it('clearSession also wipes the secrets buffer', () => {
const session = makeSession();
session.captureSecret('k1', 'field', 'value');
session.clearSession();
expect(session.getSecretFields('k1')).toBeUndefined();
});
});
});

View File

@ -14,6 +14,7 @@ export class GatewaySession {
private _dir: string;
private _permissions: Record<ToolGroup, PermissionMode>;
private readonly sessionAllows: Map<ToolGroup, Set<string>> = new Map();
private readonly _secretsBuffer: Map<string, Map<string, string>> = new Map();
constructor(
defaults: { permissions: Record<ToolGroup, PermissionMode>; dir: string },
@ -100,8 +101,30 @@ export class GatewaySession {
set.add(resource);
}
clearSessionRules(): void {
clearSession(): void {
this.sessionAllows.clear();
this._secretsBuffer.clear();
}
// ---------------------------------------------------------------------------
// Secrets buffer
// ---------------------------------------------------------------------------
captureSecret(credentialsKey: string, field: string, value: string): void {
let fields = this._secretsBuffer.get(credentialsKey);
if (!fields) {
fields = new Map();
this._secretsBuffer.set(credentialsKey, fields);
}
fields.set(field, value);
}
getSecretFields(credentialsKey: string): Map<string, string> | undefined {
return this._secretsBuffer.get(credentialsKey);
}
clearSecrets(credentialsKey: string): void {
this._secretsBuffer.delete(credentialsKey);
}
// ---------------------------------------------------------------------------

View File

@ -1,9 +1,10 @@
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import type { CreateCredentialPayload, SecretsBuffer } from '@n8n/mcp-browser';
import type { z } from 'zod';
import type { ToolGroup } from '../config';
export type { CallToolResult };
export type { CallToolResult, CreateCredentialPayload, SecretsBuffer };
export interface McpTool {
name: string;
@ -19,6 +20,8 @@ export interface McpTool {
export interface ToolContext {
/** Base filesystem directory (used by filesystem tools) */
dir: string;
secretsBuffer?: SecretsBuffer;
createCredential?: (payload: CreateCredentialPayload) => Promise<{ credentialId: string }>;
}
export interface ToolAnnotations {

View File

@ -52,6 +52,7 @@
"langsmith": "catalog:",
"@mozilla/readability": "^0.6.0",
"@n8n/api-types": "workspace:*",
"@n8n/mcp-browser": "workspace:*",
"@n8n/sandbox-client": "0.0.1",
"@n8n/utils": "workspace:*",
"@n8n/workflow-sdk": "workspace:*",

View File

@ -7,6 +7,7 @@ import {
type GatewayConfirmationRequiredPayload,
type McpToolCallResult,
} from '@n8n/api-types';
import { browserCreateCredentialSchema } from '@n8n/mcp-browser/dist/tools/credential';
import { nanoid } from 'nanoid';
import { z } from 'zod';
import { convertJsonSchemaToZod } from 'zod-from-json-schema-v3';
@ -186,11 +187,20 @@ export function createToolsFromLocalMcpServer(server: LocalMcpServer, logger?: L
let inputSchema: z.ZodTypeAny;
try {
// Convert JSON Schema → Zod (v3) so the LLM sees the actual parameter shapes.
// McpTool.inputSchema properties are typed as Record<string, unknown> to
// accommodate arbitrary JSON Schema values; the cast is safe here because
// the daemon always sends valid JSON Schema objects.
inputSchema = convertJsonSchemaToZod(mcpTool.inputSchema as JSONSchema);
if (toolName === 'browser_create_credential') {
// when converting json schema the `inputSchema` has the correct shape and parsed to correct output
// but during execution all unspecified key from `data` and `resolveData` are stripped.
// somewhere in mastra core the inputSchema is converted multiple times back and forth and
// gets transformed to jsonSchema with `additionalProperties=false`
// this does not happen when passing the schema directly
inputSchema = browserCreateCredentialSchema;
} else {
// Convert JSON Schema → Zod (v3) so the LLM sees the actual parameter shapes.
// McpTool.inputSchema properties are typed as Record<string, unknown> to
// accommodate arbitrary JSON Schema values; the cast is safe here because
// the daemon always sends valid JSON Schema objects.
inputSchema = convertJsonSchemaToZod(mcpTool.inputSchema as JSONSchema);
}
} catch {
// Fallback: accept any object if conversion fails
inputSchema = z.record(z.unknown());

View File

@ -606,4 +606,14 @@ export class AgentBrowserAdapter implements Adapter {
getPageUrl(pageId: string): string | undefined {
return this.urlCache.get(pageId);
}
async getElementValue(pageId: string, target: ElementTarget): Promise<string> {
await this.switchToTab(pageId);
const ref = this.resolveTarget(target);
const valueResp = await this.run(['get', 'value', ref]);
const value = (valueResp.data as { value?: string } | undefined)?.value ?? '';
if (value !== '') return value;
const textResp = await this.run(['get', 'text', ref]);
return (textResp.data as { text?: string } | undefined)?.text ?? '';
}
}

View File

@ -998,6 +998,10 @@ export class PlaywrightAdapter {
}
}
getElementValue(_pageId: string, _target: ElementTarget): never {
throw new Error('Not implemented');
}
private async resolveLocator(pageId: string, target: ElementTarget): Promise<Locator> {
if ('ref' in target) {
return (await this.resolveRef(pageId, target.ref)) as Locator;

View File

@ -12,9 +12,11 @@ export type {
ConnectResult,
ConnectionState,
Cookie,
CreateCredentialPayload,
ElementTarget,
PageInfo,
ResolvedConfig,
SecretsBuffer,
ToolContext,
ToolDefinition,
CallToolResult,

View File

@ -0,0 +1,277 @@
import type { SecretsBuffer, ToolContext } from '../types';
import { createCredentialTools } from './credential';
import { createMockConnection, findTool, structuredOf } from './test-helpers';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeBuffer(): jest.Mocked<SecretsBuffer> & { _store: Map<string, Map<string, string>> } {
const store = new Map<string, Map<string, string>>();
return {
_store: store,
capture: jest.fn((key: string, field: string, value: string) => {
if (!store.has(key)) store.set(key, new Map());
store.get(key)!.set(field, value);
}),
getFields: jest.fn((key: string) => store.get(key)),
clear: jest.fn((key: string) => {
store.delete(key);
}),
};
}
function makeContext(
overrides: Partial<Pick<ToolContext, 'secretsBuffer' | 'createCredential'>> = {},
): ToolContext {
return { dir: '/test', ...overrides };
}
// ---------------------------------------------------------------------------
// browser_capture_secret
// ---------------------------------------------------------------------------
describe('browser_capture_secret', () => {
let mockConn: ReturnType<typeof createMockConnection>;
let buffer: ReturnType<typeof makeBuffer>;
beforeEach(() => {
mockConn = createMockConnection();
buffer = makeBuffer();
mockConn.adapter.getElementValue.mockResolvedValue('secret-value');
});
const getTool = () =>
findTool(createCredentialTools(mockConn.connection), 'browser_capture_secret');
it('captures element value into the buffer', async () => {
await getTool().execute(
{ credentialsKey: 'k1', field: 'apiKey', sourceRef: 'e42' },
makeContext({ secretsBuffer: buffer }),
);
expect(buffer.capture).toHaveBeenCalledWith('k1', 'apiKey', 'secret-value');
});
it('does NOT include the secret value in the response', async () => {
const result = await getTool().execute(
{ credentialsKey: 'k1', field: 'apiKey', sourceRef: 'e42' },
makeContext({ secretsBuffer: buffer }),
);
const text = JSON.stringify(result);
expect(text).not.toContain('secret-value');
});
it('returns ok:true with fieldsCaptured', async () => {
const result = await getTool().execute(
{ credentialsKey: 'k1', field: 'apiKey', sourceRef: 'e42' },
makeContext({ secretsBuffer: buffer }),
);
expect(structuredOf(result)).toMatchObject({ ok: true, fieldsCaptured: ['apiKey'] });
});
it('passes sourceRef as the element ref to getElementValue', async () => {
await getTool().execute(
{ credentialsKey: 'k1', field: 'apiKey', sourceRef: 'e99' },
makeContext({ secretsBuffer: buffer }),
);
expect(mockConn.adapter.getElementValue).toHaveBeenCalledWith('page1', { ref: 'e99' });
});
it('returns an error result when secretsBuffer is missing from context', async () => {
const result = await getTool().execute(
{ credentialsKey: 'k1', field: 'apiKey', sourceRef: 'e1' },
makeContext(),
);
expect(result.isError).toBe(true);
});
});
// ---------------------------------------------------------------------------
// browser_create_credential
// ---------------------------------------------------------------------------
describe('browser_create_credential', () => {
let mockConn: ReturnType<typeof createMockConnection>;
let buffer: ReturnType<typeof makeBuffer>;
let createCredential: jest.MockedFunction<
(p: {
name: string;
type: string;
data: Record<string, unknown>;
projectId?: string;
}) => Promise<{ credentialId: string }>
>;
beforeEach(() => {
mockConn = createMockConnection();
buffer = makeBuffer();
// Pre-populate buffer with some captured secrets
buffer.capture('k1', 'clientId', 'client-id-value');
buffer.capture('k1', 'clientSecret', 'client-secret-value');
createCredential = jest.fn().mockResolvedValue({ credentialId: 'cred-123' });
});
const getTool = () =>
findTool(createCredentialTools(mockConn.connection), 'browser_create_credential');
const ctx = () => makeContext({ secretsBuffer: buffer, createCredential });
describe('resolveData', () => {
it('resolves flat leaf values to captured secrets', async () => {
await getTool().execute(
{
credentialsKey: 'k1',
type: 'googleApi',
name: 'My Cred',
resolveData: { clientId: 'clientId', clientSecret: 'clientSecret' },
},
ctx(),
);
expect(createCredential).toHaveBeenCalledWith(
expect.objectContaining({
data: { clientId: 'client-id-value', clientSecret: 'client-secret-value' },
}),
);
});
it('resolves nested resolveData', async () => {
buffer.capture('k1', 'token', 'tok-value');
await getTool().execute(
{
credentialsKey: 'k1',
type: 'googleApi',
name: 'My Cred',
resolveData: { oauth: { token: 'token' } },
},
ctx(),
);
expect(createCredential).toHaveBeenCalledWith(
expect.objectContaining({ data: { oauth: { token: 'tok-value' } } }),
);
});
it('throws when a resolveData field name is not in the buffer', async () => {
await expect(
getTool().execute(
{
credentialsKey: 'k1',
type: 'googleApi',
name: 'My Cred',
resolveData: { missing: 'noSuchField' },
},
ctx(),
),
).rejects.toThrow(/noSuchField/);
});
it('deep-merges data and resolveData (resolved wins on collision)', async () => {
await getTool().execute(
{
credentialsKey: 'k1',
type: 'googleApi',
name: 'My Cred',
data: { scopes: 'email', clientId: 'literal-id' },
resolveData: { clientId: 'clientId' },
},
ctx(),
);
expect(createCredential).toHaveBeenCalledWith(
expect.objectContaining({
data: { scopes: 'email', clientId: 'client-id-value' },
}),
);
});
});
describe('credential creation', () => {
it('returns ok:true with the new credentialId', async () => {
const result = await getTool().execute(
{ credentialsKey: 'k1', type: 'googleApi', name: 'My Cred' },
ctx(),
);
expect(structuredOf(result)).toMatchObject({ ok: true, credentialId: 'cred-123' });
});
it('does not include captured secret values in the result', async () => {
const result = await getTool().execute(
{
credentialsKey: 'k1',
type: 'googleApi',
name: 'My Cred',
resolveData: { clientId: 'clientId' },
},
ctx(),
);
expect(JSON.stringify(result)).not.toContain('client-id-value');
});
it('passes name, type, and projectId to createCredential', async () => {
await getTool().execute(
{ credentialsKey: 'k1', type: 'googleApi', name: 'My Cred', projectId: 'proj-1' },
ctx(),
);
expect(createCredential).toHaveBeenCalledWith(
expect.objectContaining({ name: 'My Cred', type: 'googleApi', projectId: 'proj-1' }),
);
});
});
describe('buffer clearing', () => {
it('clears the buffer on success by default', async () => {
await getTool().execute({ credentialsKey: 'k1', type: 'googleApi', name: 'My Cred' }, ctx());
expect(buffer.clear).toHaveBeenCalledWith('k1');
});
it('clears the buffer when clear is explicitly true', async () => {
await getTool().execute(
{ credentialsKey: 'k1', type: 'googleApi', name: 'My Cred', clear: true },
ctx(),
);
expect(buffer.clear).toHaveBeenCalledWith('k1');
});
it('retains the buffer when clear is false', async () => {
await getTool().execute(
{ credentialsKey: 'k1', type: 'googleApi', name: 'My Cred', clear: false },
ctx(),
);
expect(buffer.clear).not.toHaveBeenCalled();
});
});
describe('missing context', () => {
it('throws when secretsBuffer is absent', async () => {
await expect(
getTool().execute(
{ credentialsKey: 'k1', type: 'googleApi', name: 'My Cred' },
makeContext({ createCredential }),
),
).rejects.toThrow();
});
it('throws when createCredential is absent', async () => {
await expect(
getTool().execute(
{ credentialsKey: 'k1', type: 'googleApi', name: 'My Cred' },
makeContext({ secretsBuffer: buffer }),
),
).rejects.toThrow();
});
it('throws when credentialsKey has no captured fields', async () => {
await expect(
getTool().execute(
{ credentialsKey: 'no-such-key', type: 'googleApi', name: 'My Cred' },
ctx(),
),
).rejects.toThrow(/no-such-key/);
});
});
});

View File

@ -0,0 +1,203 @@
import { z } from 'zod';
import type { BrowserConnection } from '../connection';
import type {
AffectedResource,
CreateCredentialPayload,
SecretsBuffer,
ToolContext,
ToolDefinition,
} from '../types';
import { formatCallToolResult } from '../utils';
import { createConnectedTool, pageIdField } from './helpers';
export function createCredentialTools(connection: BrowserConnection): ToolDefinition[] {
return [browserCaptureSecret(connection), browserCreateCredential(connection)];
}
const CREATE_CREDENTIAL_RESOURCE: AffectedResource = {
toolGroup: 'browser',
resource: 'credentials',
description: 'Browser: credentials',
};
// ---------------------------------------------------------------------------
// browser_capture_secret
// ---------------------------------------------------------------------------
const browserCaptureSecretSchema = z
.object({
credentialsKey: z
.string()
.describe('Key grouping related fields for one credential (e.g. "gcp-setup")'),
field: z.string().describe('Field name to store the captured value under (e.g. "clientId")'),
sourceRef: z
.string()
.describe('Element ref from browser_snapshot whose value will be captured'),
pageId: pageIdField,
})
.describe('Capture a secret value from a DOM element into the session buffer');
function browserCaptureSecret(
connection: BrowserConnection,
): ToolDefinition<typeof browserCaptureSecretSchema> {
return createConnectedTool(
connection,
'browser_capture_secret',
'Read a secret value from a DOM element (identified by a snapshot ref) and store it in the session buffer. The value is never returned to the LLM. Use browser_create_credential to assemble buffered secrets into a credential.',
browserCaptureSecretSchema,
async (state, args, pageId, context) => {
requireSecretsBuffer(context);
const value = await state.adapter.getElementValue(pageId, { ref: args.sourceRef });
context.secretsBuffer.capture(args.credentialsKey, args.field, value);
return formatCallToolResult({ ok: true, fieldsCaptured: [args.field] });
},
undefined,
{ skipEnrichment: true },
);
}
// ---------------------------------------------------------------------------
// browser_create_credential
// ---------------------------------------------------------------------------
export const browserCreateCredentialSchema = z
.object({
credentialsKey: z
.string()
.describe('Key identifying the buffered secrets group to use (e.g. "gcp-setup")'),
type: z.string().describe('n8n credential type (e.g. "anthropicApi", "googleApi")'),
name: z.string().describe('Display name for the new credential'),
data: z
.record(z.unknown())
.optional()
.describe('Literal (non-secret) credential fields, may be nested'),
resolveData: z
.record(z.unknown())
.optional()
.describe(
'Same nested shape as data, but leaf string values are field names from the captured buffer. All leaves must resolve.',
),
projectId: z.string().optional().describe('Project to create the credential in'),
clear: z
.boolean()
.optional()
.describe('Clear the session buffer for credentialsKey after success (default: true)'),
})
.describe('Assemble buffered secrets into an n8n credential');
function browserCreateCredential(
_connection: BrowserConnection,
): ToolDefinition<typeof browserCreateCredentialSchema> {
return {
name: 'browser_create_credential',
description:
'Assemble secrets captured with browser_capture_secret into a new n8n credential. Literal fields go in `data`; fields that must come from the buffer go in `resolveData` (leaf values are buffer field names). The buffer is cleared after success unless clear=false.',
inputSchema: browserCreateCredentialSchema,
async execute(args, context: ToolContext) {
requireSecretsBuffer(context);
requireCreateCredential(context);
const captured = context.secretsBuffer.getFields(args.credentialsKey);
if (!captured) {
throw new Error(
`No captured fields found for credentialsKey "${args.credentialsKey}". Call browser_capture_secret first.`,
);
}
const resolvedSecrets = args.resolveData ? resolveSecrets(args.resolveData, captured) : {};
const mergedData = deepMerge(args.data ?? {}, resolvedSecrets);
const credential = await context.createCredential({
name: args.name,
type: args.type,
data: mergedData,
projectId: args.projectId,
} satisfies CreateCredentialPayload);
if (args.clear !== false) {
context.secretsBuffer.clear(args.credentialsKey);
}
return formatCallToolResult({ ok: true, credentialId: credential.credentialId });
},
getAffectedResources() {
return [CREATE_CREDENTIAL_RESOURCE];
},
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function requireSecretsBuffer(
context: ToolContext,
): asserts context is ToolContext & { secretsBuffer: SecretsBuffer } {
if (!context.secretsBuffer) {
throw new Error('This tool is only available when running inside the n8n gateway context.');
}
}
function requireCreateCredential(context: ToolContext): asserts context is ToolContext & {
createCredential: (payload: CreateCredentialPayload) => Promise<{ credentialId: string }>;
} {
if (!context.createCredential) {
throw new Error('This tool is only available when running inside the n8n gateway context.');
}
}
/**
* Recursively walk `resolveData`. Every leaf string value is a field name to
* look up in `captured`. Throws if a field name is not found.
*/
function resolveSecrets(
resolveData: Record<string, unknown>,
captured: Map<string, string>,
): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(resolveData)) {
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
result[key] = resolveSecrets(value as Record<string, unknown>, captured);
} else if (typeof value === 'string') {
if (!captured.has(value)) {
throw new Error(
`resolveData references field "${value}" which was not captured. Call browser_capture_secret with field="${value}" first.`,
);
}
result[key] = captured.get(value);
} else {
throw new Error(
`resolveData leaf values must be strings (field names). Got ${typeof value} for key "${key}".`,
);
}
}
return result;
}
/** Deep merge two plain objects. Values from `override` win on collision. */
function deepMerge(
base: Record<string, unknown>,
override: Record<string, unknown>,
): Record<string, unknown> {
const result: Record<string, unknown> = { ...base };
for (const [key, overrideVal] of Object.entries(override)) {
const baseVal = result[key];
if (
overrideVal !== null &&
typeof overrideVal === 'object' &&
!Array.isArray(overrideVal) &&
baseVal !== null &&
typeof baseVal === 'object' &&
!Array.isArray(baseVal)
) {
result[key] = deepMerge(
baseVal as Record<string, unknown>,
overrideVal as Record<string, unknown>,
);
} else {
result[key] = overrideVal;
}
}
return result;
}

View File

@ -74,7 +74,12 @@ export function createConnectedTool<
name: string,
description: string,
inputSchema: TSchema,
fn: (state: ConnectionState, input: z.infer<TSchema>, pageId: string) => Promise<CallToolResult>,
fn: (
state: ConnectionState,
input: z.infer<TSchema>,
pageId: string,
context: ToolContext,
) => Promise<CallToolResult>,
outputSchema?: z.ZodObject<z.ZodRawShape>,
options?: ConnectedToolOptions,
getResourceFromArgs?: (args: z.infer<TSchema>) => string,
@ -84,7 +89,7 @@ export function createConnectedTool<
description,
inputSchema,
outputSchema,
async execute(args: z.infer<TSchema>, _context: ToolContext) {
async execute(args: z.infer<TSchema>, context: ToolContext) {
try {
const { state, pageId } = resolvePageContext(connection, args);
@ -96,8 +101,11 @@ export function createConnectedTool<
}
const result = options?.waitForCompletion
? await state.adapter.waitForCompletion(pageId, async () => await fn(state, args, pageId))
: await fn(state, args, pageId);
? await state.adapter.waitForCompletion(
pageId,
async () => await fn(state, args, pageId, context),
)
: await fn(state, args, pageId, context);
if (!options?.skipEnrichment) {
// Re-resolve: tab-creating actions (tab_open) update activePageId

View File

@ -1,5 +1,6 @@
import { BrowserConnection } from '../connection';
import type { BrowserToolkit, Config, ToolDefinition } from '../types';
import { createCredentialTools } from './credential';
import { createInspectionTools } from './inspection';
import { createInteractionTools } from './interaction';
import { createNavigationTools } from './navigation';
@ -19,6 +20,7 @@ export function createBrowserTools(config?: Partial<Config>): BrowserToolkit {
...createInspectionTools(connection),
...createWaitTools(connection),
...createStateTools(connection),
...createCredentialTools(connection),
];
return { tools, connection };

View File

@ -94,6 +94,9 @@ export function createMockAdapter() {
setStorage: jest.fn().mockResolvedValue(undefined),
clearStorage: jest.fn().mockResolvedValue(undefined),
// Credential helpers
getElementValue: jest.fn().mockResolvedValue(''),
// URL lookup
getPageUrl: jest.fn().mockReturnValue('http://test.com'),

View File

@ -120,6 +120,8 @@ export interface Adapter {
clearStorage(pageId: string, kind: 'local' | 'session'): Promise<void>;
// Sync helpers used by tool helpers
getPageUrl(pageId: string): string | undefined;
// Credential capture
getElementValue(pageId: string, target: ElementTarget): Promise<string>;
}
export interface ConnectionState {
@ -237,9 +239,24 @@ export interface WaitOptions {
export type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
export interface SecretsBuffer {
capture(credentialsKey: string, field: string, value: string): void;
getFields(credentialsKey: string): Map<string, string> | undefined;
clear(credentialsKey: string): void;
}
export interface CreateCredentialPayload {
name: string;
type: string;
data: Record<string, unknown>;
projectId?: string;
}
export interface ToolContext {
/** Base filesystem directory (used by filesystem tools) */
dir: string;
secretsBuffer?: SecretsBuffer;
createCredential?: (payload: CreateCredentialPayload) => Promise<{ credentialId: string }>;
}
export interface ToolDefinition<TSchema extends z.ZodType = z.ZodType> {

View File

@ -48,7 +48,7 @@ import type {
} from '@n8n/api-types';
import type { ModuleRegistry } from '@n8n/backend-common';
import type { GlobalConfig } from '@n8n/config';
import type { AuthenticatedRequest } from '@n8n/db';
import type { AuthenticatedRequest, User, UserRepository } from '@n8n/db';
import { ControllerRegistryMetadata } from '@n8n/decorators';
import { Container } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
@ -58,6 +58,7 @@ import { mock } from 'jest-mock-extended';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import type { CredentialsService } from '@/credentials/credentials.service';
import type { Push } from '@/push';
import type { UrlService } from '@/services/url.service';
@ -99,6 +100,8 @@ describe('InstanceAiController', () => {
});
const subAgentEvalService = mock<SubAgentEvalService>();
const userRepository = mock<UserRepository>();
const credentialsService = mock<CredentialsService>();
const controller = new InstanceAiController(
instanceAiService,
@ -110,6 +113,8 @@ describe('InstanceAiController', () => {
moduleRegistry,
push,
urlService,
userRepository,
credentialsService,
globalConfig,
);
@ -1153,6 +1158,69 @@ describe('InstanceAiController', () => {
});
});
describe('gatewayCreateCredential', () => {
const makeGatewayReq = (key: string) =>
({ headers: { 'x-gateway-key': key } }) as unknown as Request;
const payload = {
name: 'My Slack Cred',
type: 'slackApi',
data: { accessToken: 'xoxb-token' },
};
it('should have no access scope (skipAuth)', () => {
expect(scopeOf('gatewayCreateCredential')).toBeUndefined();
});
it('should create credential and return credentialId', async () => {
const user = mock<User>({ id: USER_ID });
instanceAiService.getUserIdForApiKey.mockReturnValue(USER_ID);
userRepository.findOne.mockResolvedValue(user);
credentialsService.createUnmanagedCredential.mockResolvedValue(
mock<Awaited<ReturnType<CredentialsService['createUnmanagedCredential']>>>({
id: 'cred-1',
}),
);
const result = await controller.gatewayCreateCredential(
makeGatewayReq('session-key'),
res,
payload,
);
expect(result).toEqual({ credentialId: 'cred-1' });
expect(credentialsService.createUnmanagedCredential).toHaveBeenCalledWith(payload, user);
});
it('should throw ForbiddenError when using the static env-var key', async () => {
const gatewayReq = makeGatewayReq('static-key');
await expect(controller.gatewayCreateCredential(gatewayReq, res, payload)).rejects.toThrow(
ForbiddenError,
);
});
it('should throw ForbiddenError when the user is not found', async () => {
instanceAiService.getUserIdForApiKey.mockReturnValue(USER_ID);
userRepository.findOne.mockResolvedValue(null);
await expect(
controller.gatewayCreateCredential(makeGatewayReq('session-key'), res, payload),
).rejects.toThrow(ForbiddenError);
});
it('should throw ForbiddenError when the gateway is disabled', async () => {
const user = mock<User>({ id: USER_ID });
instanceAiService.getUserIdForApiKey.mockReturnValue(USER_ID);
userRepository.findOne.mockResolvedValue(user);
settingsService.isLocalGatewayDisabledForUser.mockResolvedValue(true);
await expect(
controller.gatewayCreateCredential(makeGatewayReq('session-key'), res, payload),
).rejects.toThrow(ForbiddenError);
});
});
describe('getGatewayKeyHeader', () => {
it('should extract first element from array header', () => {
instanceAiService.getUserIdForApiKey.mockReturnValue(USER_ID);

View File

@ -2,6 +2,7 @@ import {
InstanceAiConfirmRequestDto,
InstanceAiFeedbackRequestDto,
InstanceAiGatewayCapabilitiesDto,
InstanceAiGatewayCreateCredentialDto,
InstanceAiFilesystemResponseDto,
InstanceAiRenameThreadRequestDto,
InstanceAiSendMessageRequest,
@ -18,7 +19,7 @@ import {
import type { InstanceAiAgentNode } from '@n8n/api-types';
import { ModuleRegistry } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import { AuthenticatedRequest } from '@n8n/db';
import { AuthenticatedRequest, User, UserRepository } from '@n8n/db';
import {
RestController,
GlobalScope,
@ -43,6 +44,7 @@ import { InProcessEventBus } from './event-bus/in-process-event-bus';
import { InstanceAiMemoryService } from './instance-ai-memory.service';
import { InstanceAiSettingsService } from './instance-ai-settings.service';
import { InstanceAiService } from './instance-ai.service';
import { CredentialsService } from '@/credentials/credentials.service';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ConflictError } from '@/errors/response-errors/conflict.error';
@ -99,6 +101,8 @@ export class InstanceAiController {
private readonly moduleRegistry: ModuleRegistry,
private readonly push: Push,
private readonly urlService: UrlService,
private readonly userRepository: UserRepository,
private readonly credentialsService: CredentialsService,
globalConfig: GlobalConfig,
) {
this.gatewayApiKey = globalConfig.instanceAi.gatewayApiKey;
@ -788,6 +792,18 @@ export class InstanceAiController {
return { ok: true };
}
@Post('/gateway/credentials', { skipAuth: true })
async gatewayCreateCredential(
req: Request,
_res: Response,
@Body payload: InstanceAiGatewayCreateCredentialDto,
) {
const user = await this.resolveGatewayUser(this.getGatewayKeyHeader(req));
await this.assertGatewayEnabled(user.id);
const credential = await this.credentialsService.createUnmanagedCredential(payload, user);
return { credentialId: credential.id };
}
@Get('/gateway/status')
@GlobalScope('instanceAi:gateway')
async gatewayStatus(req: AuthenticatedRequest) {
@ -882,6 +898,23 @@ export class InstanceAiController {
throw new ForbiddenError('Invalid API key');
}
/**
* Resolve a gateway API key to its associated User. Requires a user-scoped
* key the static env-var key (which has no associated DB user) is rejected.
*/
private async resolveGatewayUser(key: string | undefined): Promise<User> {
const userId = this.validateGatewayApiKey(key);
if (userId === 'env-gateway') {
throw new ForbiddenError('Credential creation requires a user-scoped gateway key');
}
const user = await this.userRepository.findOne({
where: { id: userId },
relations: ['role', 'role.scopes'],
});
if (!user) throw new ForbiddenError('Invalid API key');
return user;
}
private writeSseEvent(res: FlushableResponse, stored: StoredEvent): void {
// No `event:` field — events are discriminated by data.type per streaming-protocol.md
res.write(`id: ${stored.id}\ndata: ${JSON.stringify(stored.event)}\n\n`);